deduplicate_page.dart 12 KB

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