deduplicate_page.dart 15 KB

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