deduplicate_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  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/empty_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. late 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: (dynamic 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! -
  130. first.files.first.creationTime!;
  131. }
  132. });
  133. }
  134. Widget _getBody() {
  135. return Column(
  136. mainAxisAlignment: MainAxisAlignment.center,
  137. crossAxisAlignment: CrossAxisAlignment.center,
  138. children: [
  139. Expanded(
  140. child: ListView.builder(
  141. itemBuilder: (context, index) {
  142. if (index == 0) {
  143. return _getHeader();
  144. } else if (index == 1) {
  145. return _getClubbingConfig();
  146. } else if (index == 2) {
  147. if (_duplicates.isNotEmpty) {
  148. return _getSortMenu();
  149. } else {
  150. return const Padding(
  151. padding: EdgeInsets.only(top: 32),
  152. child: EmptyState(),
  153. );
  154. }
  155. }
  156. return Padding(
  157. padding: const EdgeInsets.symmetric(vertical: 8),
  158. child: _getGridView(
  159. _duplicates[index - headerRowCount],
  160. index - headerRowCount,
  161. ),
  162. );
  163. },
  164. itemCount: _duplicates.length + headerRowCount,
  165. shrinkWrap: true,
  166. ),
  167. ),
  168. _selectedFiles.isEmpty
  169. ? const SizedBox.shrink()
  170. : Column(
  171. children: [
  172. _getDeleteButton(),
  173. const SizedBox(height: crossAxisSpacing / 2),
  174. ],
  175. ),
  176. ],
  177. );
  178. }
  179. Padding _getHeader() {
  180. return Padding(
  181. padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
  182. child: Column(
  183. crossAxisAlignment: CrossAxisAlignment.start,
  184. children: [
  185. Text(
  186. "Following files were clubbed based on their sizes" +
  187. (_shouldClubByCaptureTime! ? " and capture times." : "."),
  188. style: Theme.of(context).textTheme.subtitle2,
  189. ),
  190. const Padding(
  191. padding: EdgeInsets.all(2),
  192. ),
  193. Text(
  194. "Please review and delete the items you believe are duplicates.",
  195. style: Theme.of(context).textTheme.subtitle2,
  196. ),
  197. const Padding(
  198. padding: EdgeInsets.all(12),
  199. ),
  200. const Divider(
  201. height: 0,
  202. ),
  203. ],
  204. ),
  205. );
  206. }
  207. Widget _getClubbingConfig() {
  208. return Padding(
  209. padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
  210. child: CheckboxListTile(
  211. value: _shouldClubByCaptureTime,
  212. onChanged: (value) {
  213. _shouldClubByCaptureTime = value;
  214. _resetEntriesAndSelection();
  215. setState(() {});
  216. },
  217. title: const Text("Club by capture time"),
  218. ),
  219. );
  220. }
  221. void _resetEntriesAndSelection() {
  222. if (_shouldClubByCaptureTime!) {
  223. _duplicates =
  224. DeduplicationService.instance.clubDuplicatesByTime(_duplicates);
  225. } else {
  226. _duplicates = widget.duplicates;
  227. }
  228. _selectAllFilesButFirst();
  229. }
  230. Widget _getSortMenu() {
  231. Text sortOptionText(SortKey key) {
  232. String text = key.toString();
  233. switch (key) {
  234. case SortKey.count:
  235. text = "Count";
  236. break;
  237. case SortKey.size:
  238. text = "Total size";
  239. break;
  240. case SortKey.time:
  241. text = "Time";
  242. break;
  243. }
  244. return Text(
  245. text,
  246. style: Theme.of(context).textTheme.subtitle1!.copyWith(
  247. fontSize: 14,
  248. color: Theme.of(context).iconTheme.color!.withOpacity(0.7),
  249. ),
  250. );
  251. }
  252. return Row(
  253. // h4ck to align PopupMenuItems to end
  254. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  255. crossAxisAlignment: CrossAxisAlignment.end,
  256. children: [
  257. Container(),
  258. PopupMenuButton(
  259. initialValue: sortKey.index,
  260. child: Padding(
  261. padding: const EdgeInsets.fromLTRB(24, 6, 24, 6),
  262. child: Row(
  263. mainAxisAlignment: MainAxisAlignment.start,
  264. crossAxisAlignment: CrossAxisAlignment.center,
  265. children: [
  266. sortOptionText(sortKey),
  267. const Padding(padding: EdgeInsets.only(left: 4)),
  268. Icon(
  269. Icons.sort,
  270. color: Theme.of(context).colorScheme.iconColor,
  271. size: 20,
  272. ),
  273. ],
  274. ),
  275. ),
  276. onSelected: (int index) {
  277. setState(() {
  278. sortKey = SortKey.values[index];
  279. });
  280. },
  281. itemBuilder: (context) {
  282. return List.generate(SortKey.values.length, (index) {
  283. return PopupMenuItem(
  284. value: index,
  285. child: Align(
  286. alignment: Alignment.centerLeft,
  287. child: sortOptionText(SortKey.values[index]),
  288. ),
  289. );
  290. });
  291. },
  292. ),
  293. ],
  294. );
  295. }
  296. Widget _getDeleteButton() {
  297. String text;
  298. if (_selectedFiles.length == 1) {
  299. text = "Delete 1 item";
  300. } else {
  301. text = "Delete " + _selectedFiles.length.toString() + " items";
  302. }
  303. int size = 0;
  304. for (final file in _selectedFiles) {
  305. size += _fileSizeMap[file.uploadedFileID]!;
  306. }
  307. return SizedBox(
  308. width: double.infinity,
  309. child: SafeArea(
  310. child: Padding(
  311. padding: const EdgeInsets.symmetric(horizontal: crossAxisSpacing / 2),
  312. child: TextButton(
  313. style: OutlinedButton.styleFrom(
  314. backgroundColor:
  315. Theme.of(context).colorScheme.inverseBackgroundColor,
  316. ),
  317. child: Column(
  318. mainAxisAlignment: MainAxisAlignment.end,
  319. children: [
  320. const Padding(padding: EdgeInsets.all(4)),
  321. Text(
  322. text,
  323. style: TextStyle(
  324. fontWeight: FontWeight.bold,
  325. fontSize: 14,
  326. color: Theme.of(context).colorScheme.inverseTextColor,
  327. ),
  328. textAlign: TextAlign.center,
  329. ),
  330. const Padding(padding: EdgeInsets.all(2)),
  331. Text(
  332. formatBytes(size),
  333. style: TextStyle(
  334. color: Theme.of(context)
  335. .colorScheme
  336. .inverseTextColor
  337. .withOpacity(0.7),
  338. fontSize: 12,
  339. ),
  340. ),
  341. const Padding(padding: EdgeInsets.all(2)),
  342. ],
  343. ),
  344. onPressed: () async {
  345. await deleteFilesFromRemoteOnly(context, _selectedFiles.toList());
  346. Bus.instance.fire(UserDetailsChangedEvent());
  347. Navigator.of(context)
  348. .pop(DeduplicationResult(_selectedFiles.length, size));
  349. },
  350. ),
  351. ),
  352. ),
  353. );
  354. }
  355. Widget _getGridView(DuplicateFiles duplicates, int itemIndex) {
  356. return Column(
  357. crossAxisAlignment: CrossAxisAlignment.start,
  358. children: [
  359. Padding(
  360. padding: const EdgeInsets.fromLTRB(2, 4, 4, 12),
  361. child: Text(
  362. duplicates.files.length.toString() +
  363. " files, " +
  364. formatBytes(duplicates.size) +
  365. " each",
  366. style: Theme.of(context).textTheme.subtitle2,
  367. ),
  368. ),
  369. Padding(
  370. padding: const EdgeInsets.symmetric(horizontal: crossAxisSpacing / 2),
  371. child: GridView.builder(
  372. shrinkWrap: true,
  373. physics: const NeverScrollableScrollPhysics(),
  374. // to disable GridView's scrolling
  375. itemBuilder: (context, index) {
  376. return _buildFile(context, duplicates.files[index], itemIndex);
  377. },
  378. itemCount: duplicates.files.length,
  379. gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
  380. crossAxisCount: crossAxisCount,
  381. crossAxisSpacing: crossAxisSpacing,
  382. childAspectRatio: 0.75,
  383. ),
  384. padding: const EdgeInsets.all(0),
  385. ),
  386. ),
  387. ],
  388. );
  389. }
  390. Widget _buildFile(BuildContext context, File file, int index) {
  391. return GestureDetector(
  392. onTap: () {
  393. if (_selectedFiles.contains(file)) {
  394. _selectedFiles.remove(file);
  395. } else {
  396. _selectedFiles.add(file);
  397. }
  398. setState(() {});
  399. },
  400. onLongPress: () {
  401. HapticFeedback.lightImpact();
  402. final files = _duplicates[index].files;
  403. routeToPage(
  404. context,
  405. DetailPage(
  406. DetailPageConfiguration(
  407. files,
  408. null,
  409. files.indexOf(file),
  410. "deduplicate_",
  411. mode: DetailPageMode.minimalistic,
  412. ),
  413. ),
  414. forceCustomPageRoute: true,
  415. );
  416. },
  417. child: Column(
  418. crossAxisAlignment: CrossAxisAlignment.start,
  419. children: [
  420. SizedBox(
  421. //the numerator will give the width of the screen excuding the whitespaces in the the grid row
  422. height: (MediaQuery.of(context).size.width -
  423. (crossAxisSpacing * crossAxisCount)) /
  424. crossAxisCount,
  425. child: Stack(
  426. children: [
  427. Hero(
  428. tag: "deduplicate_" + file.tag,
  429. child: ClipRRect(
  430. borderRadius: BorderRadius.circular(4),
  431. child: ThumbnailWidget(
  432. file,
  433. diskLoadDeferDuration: thumbnailDiskLoadDeferDuration,
  434. serverLoadDeferDuration: thumbnailServerLoadDeferDuration,
  435. shouldShowLivePhotoOverlay: true,
  436. key: Key("deduplicate_" + file.tag),
  437. ),
  438. ),
  439. ),
  440. _selectedFiles.contains(file)
  441. ? ClipRRect(
  442. borderRadius: BorderRadius.circular(4),
  443. child: selectedOverlay,
  444. )
  445. : const SizedBox.shrink(),
  446. ],
  447. ),
  448. ),
  449. const SizedBox(height: 6),
  450. Padding(
  451. padding: const EdgeInsets.only(right: 2),
  452. child: Text(
  453. CollectionsService.instance
  454. .getCollectionByID(file.collectionID!)!
  455. .name!,
  456. style:
  457. Theme.of(context).textTheme.caption!.copyWith(fontSize: 12),
  458. overflow: TextOverflow.ellipsis,
  459. ),
  460. ),
  461. ],
  462. ),
  463. );
  464. }
  465. }
  466. enum SortKey {
  467. size,
  468. count,
  469. time,
  470. }
  471. class DeduplicationResult {
  472. final int count;
  473. final int size;
  474. DeduplicationResult(this.count, this.size);
  475. }