deduplicate_page.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  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/common_elements.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/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. DeduplicatePage(this.duplicates, {Key key}) : super(key: key);
  20. @override
  21. _DeduplicatePageState createState() => _DeduplicatePageState();
  22. }
  23. class _DeduplicatePageState extends State<DeduplicatePage> {
  24. static final 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: 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 Padding(
  116. padding: EdgeInsets.only(top: 32),
  117. child: nothingToSeeHere(
  118. textColor: Theme.of(context).colorScheme.defaultTextColor,
  119. ),
  120. );
  121. }
  122. }
  123. return Padding(
  124. padding: const EdgeInsets.only(top: 10, bottom: 10),
  125. child: _getGridView(
  126. _duplicates[index - kHeaderRowCount],
  127. index - kHeaderRowCount,
  128. ),
  129. );
  130. },
  131. itemCount: _duplicates.length + kHeaderRowCount,
  132. shrinkWrap: true,
  133. ),
  134. ),
  135. _selectedFiles.isEmpty ? Container() : _getDeleteButton(),
  136. ],
  137. );
  138. }
  139. Padding _getHeader() {
  140. return Padding(
  141. padding: EdgeInsets.fromLTRB(16, 12, 16, 12),
  142. child: Column(
  143. crossAxisAlignment: CrossAxisAlignment.start,
  144. children: [
  145. Text(
  146. "Following files were clubbed based on their sizes" +
  147. ((_shouldClubByCaptureTime ? " and capture times." : ".")),
  148. style: Theme.of(context).textTheme.subtitle2,
  149. ),
  150. Padding(
  151. padding: EdgeInsets.all(2),
  152. ),
  153. Text(
  154. "Please review and delete the items you believe are duplicates.",
  155. style: Theme.of(context).textTheme.subtitle2,
  156. ),
  157. Padding(
  158. padding: EdgeInsets.all(12),
  159. ),
  160. Divider(
  161. height: 0,
  162. ),
  163. ],
  164. ),
  165. );
  166. }
  167. Widget _getClubbingConfig() {
  168. return Padding(
  169. padding: EdgeInsets.fromLTRB(12, 0, 12, 4),
  170. child: CheckboxListTile(
  171. value: _shouldClubByCaptureTime,
  172. onChanged: (value) {
  173. _shouldClubByCaptureTime = value;
  174. _resetEntriesAndSelection();
  175. setState(() {});
  176. },
  177. title: Text("Club by capture time"),
  178. ),
  179. );
  180. }
  181. void _resetEntriesAndSelection() {
  182. if (_shouldClubByCaptureTime) {
  183. _duplicates =
  184. DeduplicationService.instance.clubDuplicatesByTime(_duplicates);
  185. } else {
  186. _duplicates = widget.duplicates;
  187. }
  188. _selectAllFilesButFirst();
  189. }
  190. Widget _getSortMenu() {
  191. Text sortOptionText(SortKey key) {
  192. String text = key.toString();
  193. switch (key) {
  194. case SortKey.count:
  195. text = "Count";
  196. break;
  197. case SortKey.size:
  198. text = "Total size";
  199. break;
  200. case SortKey.time:
  201. text = "Time";
  202. break;
  203. }
  204. return Text(
  205. text,
  206. style: Theme.of(context).textTheme.subtitle1.copyWith(
  207. fontSize: 14,
  208. color: Theme.of(context).iconTheme.color.withOpacity(0.7),
  209. ),
  210. );
  211. }
  212. return Row(
  213. // h4ck to align PopupMenuItems to end
  214. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  215. crossAxisAlignment: CrossAxisAlignment.end,
  216. children: [
  217. Container(),
  218. PopupMenuButton(
  219. initialValue: sortKey?.index ?? 0,
  220. child: Padding(
  221. padding: const EdgeInsets.fromLTRB(24, 6, 24, 6),
  222. child: Row(
  223. mainAxisAlignment: MainAxisAlignment.start,
  224. crossAxisAlignment: CrossAxisAlignment.center,
  225. children: [
  226. sortOptionText(sortKey),
  227. Padding(padding: EdgeInsets.only(left: 4)),
  228. Icon(
  229. Icons.sort,
  230. color: Theme.of(context).colorScheme.iconColor,
  231. size: 20,
  232. ),
  233. ],
  234. ),
  235. ),
  236. onSelected: (int index) {
  237. setState(() {
  238. sortKey = SortKey.values[index];
  239. });
  240. },
  241. itemBuilder: (context) {
  242. return List.generate(SortKey.values.length, (index) {
  243. return PopupMenuItem(
  244. value: index,
  245. child: Align(
  246. alignment: Alignment.centerLeft,
  247. child: sortOptionText(SortKey.values[index]),
  248. ),
  249. );
  250. });
  251. },
  252. ),
  253. ],
  254. );
  255. }
  256. Widget _getDeleteButton() {
  257. String text;
  258. if (_selectedFiles.length == 1) {
  259. text = "Delete 1 item";
  260. } else {
  261. text = "Delete " + _selectedFiles.length.toString() + " items";
  262. }
  263. int size = 0;
  264. for (final file in _selectedFiles) {
  265. size += _fileSizeMap[file.uploadedFileID];
  266. }
  267. return SizedBox(
  268. width: double.infinity,
  269. child: SafeArea(
  270. child: TextButton(
  271. style: OutlinedButton.styleFrom(
  272. backgroundColor: Colors.red[700],
  273. ),
  274. child: Column(
  275. mainAxisAlignment: MainAxisAlignment.end,
  276. children: [
  277. Padding(padding: EdgeInsets.all(2)),
  278. Text(
  279. text,
  280. style: TextStyle(
  281. fontWeight: FontWeight.bold,
  282. fontSize: 14,
  283. color: Colors.white,
  284. ),
  285. textAlign: TextAlign.center,
  286. ),
  287. Padding(padding: EdgeInsets.all(2)),
  288. Text(
  289. formatBytes(size),
  290. style: TextStyle(
  291. color: Colors.white.withOpacity(0.7),
  292. fontSize: 12,
  293. ),
  294. ),
  295. Padding(padding: EdgeInsets.all(2)),
  296. ],
  297. ),
  298. onPressed: () async {
  299. await deleteFilesFromRemoteOnly(context, _selectedFiles.toList());
  300. Bus.instance.fire(UserDetailsChangedEvent());
  301. Navigator.of(context)
  302. .pop(DeduplicationResult(_selectedFiles.length, size));
  303. },
  304. ),
  305. ),
  306. );
  307. }
  308. Widget _getGridView(DuplicateFiles duplicates, int itemIndex) {
  309. return Column(
  310. crossAxisAlignment: CrossAxisAlignment.start,
  311. children: [
  312. Padding(
  313. padding: const EdgeInsets.fromLTRB(16, 8, 4, 4),
  314. child: Text(
  315. duplicates.files.length.toString() +
  316. " files, " +
  317. formatBytes(duplicates.size) +
  318. " each",
  319. style: Theme.of(context).textTheme.subtitle2,
  320. ),
  321. ),
  322. GridView.builder(
  323. shrinkWrap: true,
  324. physics: NeverScrollableScrollPhysics(),
  325. // to disable GridView's scrolling
  326. itemBuilder: (context, index) {
  327. return _buildFile(context, duplicates.files[index], itemIndex);
  328. },
  329. itemCount: duplicates.files.length,
  330. gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  331. crossAxisCount: 4,
  332. ),
  333. padding: EdgeInsets.all(0),
  334. ),
  335. ],
  336. );
  337. }
  338. Widget _buildFile(BuildContext context, File file, int index) {
  339. return GestureDetector(
  340. onTap: () {
  341. if (_selectedFiles.contains(file)) {
  342. _selectedFiles.remove(file);
  343. } else {
  344. _selectedFiles.add(file);
  345. }
  346. setState(() {});
  347. },
  348. onLongPress: () {
  349. HapticFeedback.lightImpact();
  350. final files = _duplicates[index].files;
  351. routeToPage(
  352. context,
  353. DetailPage(
  354. DetailPageConfiguration(
  355. files,
  356. null,
  357. files.indexOf(file),
  358. "deduplicate_",
  359. mode: DetailPageMode.minimalistic,
  360. ),
  361. ),
  362. forceCustomPageRoute: true,
  363. );
  364. },
  365. child: Container(
  366. margin: const EdgeInsets.all(2.0),
  367. decoration: BoxDecoration(
  368. border: _selectedFiles.contains(file)
  369. ? Border.all(
  370. width: 3,
  371. color: Colors.red[700],
  372. )
  373. : null,
  374. ),
  375. child: Stack(
  376. children: [
  377. Hero(
  378. tag: "deduplicate_" + file.tag(),
  379. child: ThumbnailWidget(
  380. file,
  381. diskLoadDeferDuration: kThumbnailDiskLoadDeferDuration,
  382. serverLoadDeferDuration: kThumbnailServerLoadDeferDuration,
  383. shouldShowLivePhotoOverlay: true,
  384. key: Key("deduplicate_" + file.tag()),
  385. ),
  386. ),
  387. _selectedFiles.contains(file) ? kDeleteIconOverlay : Container(),
  388. ],
  389. ),
  390. ),
  391. );
  392. }
  393. }
  394. enum SortKey {
  395. size,
  396. count,
  397. time,
  398. }
  399. class DeduplicationResult {
  400. final int count;
  401. final int size;
  402. DeduplicationResult(this.count, this.size);
  403. }