deduplicate_page.dart 15 KB

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