deduplicate_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:photos/core/constants.dart';
  4. import 'package:photos/core/event_bus.dart';
  5. import 'package:photos/ente_theme_data.dart';
  6. import 'package:photos/events/user_details_changed_event.dart';
  7. import 'package:photos/models/duplicate_files.dart';
  8. import 'package:photos/models/file.dart';
  9. import 'package:photos/services/collections_service.dart';
  10. import 'package:photos/services/deduplication_service.dart';
  11. import 'package:photos/ui/viewer/file/detail_page.dart';
  12. import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
  13. import 'package:photos/ui/viewer/gallery/empte_state.dart';
  14. import 'package:photos/utils/data_util.dart';
  15. import 'package:photos/utils/delete_file_util.dart';
  16. import 'package:photos/utils/navigation_util.dart';
  17. import 'package:photos/utils/toast_util.dart';
  18. class DeduplicatePage extends StatefulWidget {
  19. final List<DuplicateFiles> duplicates;
  20. const DeduplicatePage(this.duplicates, {Key key}) : super(key: key);
  21. @override
  22. State<DeduplicatePage> createState() => _DeduplicatePageState();
  23. }
  24. class _DeduplicatePageState extends State<DeduplicatePage> {
  25. static const crossAxisCount = 4;
  26. static const crossAxisSpacing = 4.0;
  27. static const headerRowCount = 3;
  28. static final selectedOverlay = Container(
  29. color: Colors.black.withOpacity(0.4),
  30. child: const Align(
  31. alignment: Alignment.bottomRight,
  32. child: Padding(
  33. padding: EdgeInsets.only(right: 4, bottom: 4),
  34. child: Icon(
  35. Icons.check_circle,
  36. size: 24,
  37. color: Colors.white,
  38. ),
  39. ),
  40. ),
  41. );
  42. final Set<File> _selectedFiles = <File>{};
  43. final Map<int, int> _fileSizeMap = {};
  44. List<DuplicateFiles> _duplicates;
  45. bool _shouldClubByCaptureTime = true;
  46. SortKey sortKey = SortKey.size;
  47. @override
  48. void initState() {
  49. super.initState();
  50. _duplicates =
  51. DeduplicationService.instance.clubDuplicatesByTime(widget.duplicates);
  52. _selectAllFilesButFirst();
  53. showToast(context, "Long-press on an item to view in full-screen");
  54. }
  55. void _selectAllFilesButFirst() {
  56. _selectedFiles.clear();
  57. for (final duplicate in _duplicates) {
  58. for (int index = 0; index < duplicate.files.length; index++) {
  59. // Select all items but the first
  60. if (index != 0) {
  61. _selectedFiles.add(duplicate.files[index]);
  62. }
  63. // Maintain a map of fileID to fileSize for quick "space freed" computation
  64. _fileSizeMap[duplicate.files[index].uploadedFileID] = duplicate.size;
  65. }
  66. }
  67. }
  68. @override
  69. Widget build(BuildContext context) {
  70. _sortDuplicates();
  71. return Scaffold(
  72. appBar: AppBar(
  73. elevation: 0,
  74. title: const Text("Deduplicate Files"),
  75. actions: <Widget>[
  76. PopupMenuButton(
  77. constraints: const BoxConstraints(minWidth: 180),
  78. shape: const RoundedRectangleBorder(
  79. borderRadius: BorderRadius.all(
  80. Radius.circular(8),
  81. ),
  82. ),
  83. onSelected: (value) {
  84. setState(() {
  85. _selectedFiles.clear();
  86. });
  87. },
  88. offset: const Offset(0, 50),
  89. itemBuilder: (BuildContext context) => [
  90. PopupMenuItem(
  91. value: true,
  92. height: 32,
  93. child: Row(
  94. children: [
  95. const Icon(
  96. Icons.remove_circle_outline,
  97. size: 20,
  98. ),
  99. const SizedBox(width: 12),
  100. Padding(
  101. padding: const EdgeInsets.only(bottom: 1),
  102. child: Text(
  103. "Deselect all",
  104. style: Theme.of(context)
  105. .textTheme
  106. .subtitle1
  107. .copyWith(fontWeight: FontWeight.w600),
  108. ),
  109. ),
  110. ],
  111. ),
  112. )
  113. ],
  114. )
  115. ],
  116. ),
  117. body: _getBody(),
  118. );
  119. }
  120. void _sortDuplicates() {
  121. _duplicates.sort((first, second) {
  122. if (sortKey == SortKey.size) {
  123. final aSize = first.files.length * first.size;
  124. final bSize = second.files.length * second.size;
  125. return bSize - aSize;
  126. } else if (sortKey == SortKey.count) {
  127. return second.files.length - first.files.length;
  128. } else {
  129. return second.files.first.creationTime - first.files.first.creationTime;
  130. }
  131. });
  132. }
  133. Widget _getBody() {
  134. return Column(
  135. mainAxisAlignment: MainAxisAlignment.center,
  136. crossAxisAlignment: CrossAxisAlignment.center,
  137. children: [
  138. Expanded(
  139. child: ListView.builder(
  140. itemBuilder: (context, index) {
  141. if (index == 0) {
  142. return _getHeader();
  143. } else if (index == 1) {
  144. return _getClubbingConfig();
  145. } else if (index == 2) {
  146. if (_duplicates.isNotEmpty) {
  147. return _getSortMenu();
  148. } else {
  149. return const Padding(
  150. padding: EdgeInsets.only(top: 32),
  151. child: EmptyState(),
  152. );
  153. }
  154. }
  155. return Padding(
  156. padding: const EdgeInsets.symmetric(vertical: 8),
  157. child: _getGridView(
  158. _duplicates[index - headerRowCount],
  159. index - headerRowCount,
  160. ),
  161. );
  162. },
  163. itemCount: _duplicates.length + headerRowCount,
  164. shrinkWrap: true,
  165. ),
  166. ),
  167. _selectedFiles.isEmpty ? const SizedBox.shrink() : _getDeleteButton(),
  168. ],
  169. );
  170. }
  171. Padding _getHeader() {
  172. return Padding(
  173. padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
  174. child: Column(
  175. crossAxisAlignment: CrossAxisAlignment.start,
  176. children: [
  177. Text(
  178. "Following files were clubbed based on their sizes" +
  179. ((_shouldClubByCaptureTime ? " and capture times." : ".")),
  180. style: Theme.of(context).textTheme.subtitle2,
  181. ),
  182. const Padding(
  183. padding: EdgeInsets.all(2),
  184. ),
  185. Text(
  186. "Please review and delete the items you believe are duplicates.",
  187. style: Theme.of(context).textTheme.subtitle2,
  188. ),
  189. const Padding(
  190. padding: EdgeInsets.all(12),
  191. ),
  192. const Divider(
  193. height: 0,
  194. ),
  195. ],
  196. ),
  197. );
  198. }
  199. Widget _getClubbingConfig() {
  200. return Padding(
  201. padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
  202. child: CheckboxListTile(
  203. value: _shouldClubByCaptureTime,
  204. onChanged: (value) {
  205. _shouldClubByCaptureTime = value;
  206. _resetEntriesAndSelection();
  207. setState(() {});
  208. },
  209. title: const Text("Club by capture time"),
  210. ),
  211. );
  212. }
  213. void _resetEntriesAndSelection() {
  214. if (_shouldClubByCaptureTime) {
  215. _duplicates =
  216. DeduplicationService.instance.clubDuplicatesByTime(_duplicates);
  217. } else {
  218. _duplicates = widget.duplicates;
  219. }
  220. _selectAllFilesButFirst();
  221. }
  222. Widget _getSortMenu() {
  223. Text sortOptionText(SortKey key) {
  224. String text = key.toString();
  225. switch (key) {
  226. case SortKey.count:
  227. text = "Count";
  228. break;
  229. case SortKey.size:
  230. text = "Total size";
  231. break;
  232. case SortKey.time:
  233. text = "Time";
  234. break;
  235. }
  236. return Text(
  237. text,
  238. style: Theme.of(context).textTheme.subtitle1.copyWith(
  239. fontSize: 14,
  240. color: Theme.of(context).iconTheme.color.withOpacity(0.7),
  241. ),
  242. );
  243. }
  244. return Row(
  245. // h4ck to align PopupMenuItems to end
  246. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  247. crossAxisAlignment: CrossAxisAlignment.end,
  248. children: [
  249. Container(),
  250. PopupMenuButton(
  251. initialValue: sortKey?.index ?? 0,
  252. child: Padding(
  253. padding: const EdgeInsets.fromLTRB(24, 6, 24, 6),
  254. child: Row(
  255. mainAxisAlignment: MainAxisAlignment.start,
  256. crossAxisAlignment: CrossAxisAlignment.center,
  257. children: [
  258. sortOptionText(sortKey),
  259. const Padding(padding: EdgeInsets.only(left: 4)),
  260. Icon(
  261. Icons.sort,
  262. color: Theme.of(context).colorScheme.iconColor,
  263. size: 20,
  264. ),
  265. ],
  266. ),
  267. ),
  268. onSelected: (int index) {
  269. setState(() {
  270. sortKey = SortKey.values[index];
  271. });
  272. },
  273. itemBuilder: (context) {
  274. return List.generate(SortKey.values.length, (index) {
  275. return PopupMenuItem(
  276. value: index,
  277. child: Align(
  278. alignment: Alignment.centerLeft,
  279. child: sortOptionText(SortKey.values[index]),
  280. ),
  281. );
  282. });
  283. },
  284. ),
  285. ],
  286. );
  287. }
  288. Widget _getDeleteButton() {
  289. String text;
  290. if (_selectedFiles.length == 1) {
  291. text = "Delete 1 item";
  292. } else {
  293. text = "Delete " + _selectedFiles.length.toString() + " items";
  294. }
  295. int size = 0;
  296. for (final file in _selectedFiles) {
  297. size += _fileSizeMap[file.uploadedFileID];
  298. }
  299. return SizedBox(
  300. width: double.infinity,
  301. child: SafeArea(
  302. child: Padding(
  303. padding: const EdgeInsets.symmetric(horizontal: 2),
  304. child: TextButton(
  305. style: OutlinedButton.styleFrom(
  306. backgroundColor:
  307. Theme.of(context).colorScheme.inverseBackgroundColor,
  308. ),
  309. child: Column(
  310. mainAxisAlignment: MainAxisAlignment.end,
  311. children: [
  312. const Padding(padding: EdgeInsets.all(crossAxisSpacing / 2)),
  313. Text(
  314. text,
  315. style: TextStyle(
  316. fontWeight: FontWeight.bold,
  317. fontSize: 14,
  318. color: Theme.of(context).colorScheme.inverseTextColor,
  319. ),
  320. textAlign: TextAlign.center,
  321. ),
  322. const Padding(padding: EdgeInsets.all(2)),
  323. Text(
  324. formatBytes(size),
  325. style: TextStyle(
  326. color: Theme.of(context)
  327. .colorScheme
  328. .inverseTextColor
  329. .withOpacity(0.7),
  330. fontSize: 12,
  331. ),
  332. ),
  333. const Padding(padding: EdgeInsets.all(2)),
  334. ],
  335. ),
  336. onPressed: () async {
  337. await deleteFilesFromRemoteOnly(context, _selectedFiles.toList());
  338. Bus.instance.fire(UserDetailsChangedEvent());
  339. Navigator.of(context)
  340. .pop(DeduplicationResult(_selectedFiles.length, size));
  341. },
  342. ),
  343. ),
  344. ),
  345. );
  346. }
  347. Widget _getGridView(DuplicateFiles duplicates, int itemIndex) {
  348. return Column(
  349. crossAxisAlignment: CrossAxisAlignment.start,
  350. children: [
  351. Padding(
  352. padding: const EdgeInsets.fromLTRB(2, 4, 4, 12),
  353. child: Text(
  354. duplicates.files.length.toString() +
  355. " files, " +
  356. formatBytes(duplicates.size) +
  357. " each",
  358. style: Theme.of(context).textTheme.subtitle2,
  359. ),
  360. ),
  361. Padding(
  362. padding: const EdgeInsets.symmetric(horizontal: crossAxisSpacing / 2),
  363. child: GridView.builder(
  364. shrinkWrap: true,
  365. physics: const NeverScrollableScrollPhysics(),
  366. // to disable GridView's scrolling
  367. itemBuilder: (context, index) {
  368. return _buildFile(context, duplicates.files[index], itemIndex);
  369. },
  370. itemCount: duplicates.files.length,
  371. gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
  372. crossAxisCount: crossAxisCount,
  373. crossAxisSpacing: crossAxisSpacing,
  374. childAspectRatio: 0.75,
  375. ),
  376. padding: const EdgeInsets.all(0),
  377. ),
  378. ),
  379. ],
  380. );
  381. }
  382. Widget _buildFile(BuildContext context, File file, int index) {
  383. return GestureDetector(
  384. onTap: () {
  385. if (_selectedFiles.contains(file)) {
  386. _selectedFiles.remove(file);
  387. } else {
  388. _selectedFiles.add(file);
  389. }
  390. setState(() {});
  391. },
  392. onLongPress: () {
  393. HapticFeedback.lightImpact();
  394. final files = _duplicates[index].files;
  395. routeToPage(
  396. context,
  397. DetailPage(
  398. DetailPageConfiguration(
  399. files,
  400. null,
  401. files.indexOf(file),
  402. "deduplicate_",
  403. mode: DetailPageMode.minimalistic,
  404. ),
  405. ),
  406. forceCustomPageRoute: true,
  407. );
  408. },
  409. child: Column(
  410. crossAxisAlignment: CrossAxisAlignment.start,
  411. children: [
  412. SizedBox(
  413. //the numerator will give the width of the screen excuding the whitespaces in the the grid row
  414. height: (MediaQuery.of(context).size.width -
  415. (crossAxisSpacing * crossAxisCount)) /
  416. crossAxisCount,
  417. child: Stack(
  418. children: [
  419. Hero(
  420. tag: "deduplicate_" + file.tag(),
  421. child: ClipRRect(
  422. borderRadius: BorderRadius.circular(4),
  423. child: ThumbnailWidget(
  424. file,
  425. diskLoadDeferDuration: kThumbnailDiskLoadDeferDuration,
  426. serverLoadDeferDuration:
  427. kThumbnailServerLoadDeferDuration,
  428. shouldShowLivePhotoOverlay: true,
  429. key: Key("deduplicate_" + file.tag()),
  430. ),
  431. ),
  432. ),
  433. _selectedFiles.contains(file)
  434. ? ClipRRect(
  435. borderRadius: BorderRadius.circular(4),
  436. child: selectedOverlay,
  437. )
  438. : const SizedBox.shrink(),
  439. ],
  440. ),
  441. ),
  442. const SizedBox(height: 6),
  443. Padding(
  444. padding: const EdgeInsets.only(right: 2),
  445. child: Text(
  446. CollectionsService.instance
  447. .getCollectionNameByID(file.collectionID),
  448. style: Theme.of(context).textTheme.caption.copyWith(fontSize: 12),
  449. overflow: TextOverflow.ellipsis,
  450. ),
  451. ),
  452. ],
  453. ),
  454. );
  455. }
  456. }
  457. enum SortKey {
  458. size,
  459. count,
  460. time,
  461. }
  462. class DeduplicationResult {
  463. final int count;
  464. final int size;
  465. DeduplicationResult(this.count, this.size);
  466. }