diff --git a/lib/services/deduplication_service.dart b/lib/services/deduplication_service.dart index a569e6138..4fef9ea82 100644 --- a/lib/services/deduplication_service.dart +++ b/lib/services/deduplication_service.dart @@ -24,40 +24,67 @@ class DeduplicationService { ids.addAll(dupe.fileIDs); } final fileMap = await FilesDB.instance.getFilesFromIDs(ids); - return _filterDuplicatesByCreationTime(dupes, fileMap); + final result = []; + final missingFileIDs = []; + for (final dupe in dupes.duplicates) { + final files = []; + for (final id in dupe.fileIDs) { + final file = fileMap[id]; + if (file != null) { + files.add(file); + } else { + missingFileIDs.add(id); + } + } + // Place files that are available locally at first to minimize the chances + // of a deletion followed by a re-upload + files.sort((first, second) { + if (first.localID != null && second.localID == null) { + return -1; + } else if (first.localID == null && second.localID != null) { + return 1; + } + return 0; + }); + if (files.length > 1) { + result.add(DuplicateFiles(files, dupe.size)); + } + } + if (missingFileIDs.isNotEmpty) { + _logger.severe( + "Missing files", + InvalidStateError("Could not find " + + missingFileIDs.length.toString() + + " files in local DB: " + + missingFileIDs.toString())); + } + return result; } catch (e) { _logger.severe(e); rethrow; } } - List _filterDuplicatesByCreationTime( - DuplicateFilesResponse dupes, Map fileMap) { + List clubDuplicatesByTime(List dupes) { final result = []; - final missingFileIDs = []; - for (final dupe in dupes.duplicates) { + for (final dupe in dupes) { final files = []; final Map creationTimeCounter = {}; int mostFrequentCreationTime = 0, mostFrequentCreationTimeCount = 0; // Counts the frequency of creationTimes within the supposed duplicates - for (final id in dupe.fileIDs) { - final file = fileMap[id]; - if (file != null) { - if (creationTimeCounter.containsKey(file.creationTime)) { - creationTimeCounter[file.creationTime]++; - } else { - creationTimeCounter[file.creationTime] = 0; - } - if (creationTimeCounter[file.creationTime] > - mostFrequentCreationTimeCount) { - mostFrequentCreationTimeCount = - creationTimeCounter[file.creationTime]; - mostFrequentCreationTime = file.creationTime; - } - files.add(file); + for (final file in dupe.files) { + if (creationTimeCounter.containsKey(file.creationTime)) { + creationTimeCounter[file.creationTime]++; } else { - missingFileIDs.add(id); + creationTimeCounter[file.creationTime] = 0; } + if (creationTimeCounter[file.creationTime] > + mostFrequentCreationTimeCount) { + mostFrequentCreationTimeCount = + creationTimeCounter[file.creationTime]; + mostFrequentCreationTime = file.creationTime; + } + files.add(file); } // Ignores those files that were not created within the most common creationTime final incorrectDuplicates = {}; @@ -67,28 +94,10 @@ class DeduplicationService { } } files.removeWhere((file) => incorrectDuplicates.contains(file)); - // Place files that are available locally at first to minimize the chances - // of a deletion followed by a re-upload - files.sort((first, second) { - if (first.localID != null && second.localID == null) { - return -1; - } else if (first.localID == null && second.localID != null) { - return 1; - } - return 0; - }); if (files.length > 1) { result.add(DuplicateFiles(files, dupe.size)); } } - if (missingFileIDs.isNotEmpty) { - _logger.severe( - "Missing files", - InvalidStateError("Could not find " + - missingFileIDs.length.toString() + - " files in local DB: " + - missingFileIDs.toString())); - } return result; } diff --git a/lib/ui/deduplicate_page.dart b/lib/ui/deduplicate_page.dart index a734cac3d..ce3d3b022 100644 --- a/lib/ui/deduplicate_page.dart +++ b/lib/ui/deduplicate_page.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/events/user_details_changed_event.dart'; import 'package:photos/models/duplicate_files.dart'; import 'package:photos/models/file.dart'; +import 'package:photos/services/deduplication_service.dart'; +import 'package:photos/ui/common_elements.dart'; import 'package:photos/ui/detail_page.dart'; import 'package:photos/ui/thumbnail_widget.dart'; import 'package:photos/utils/data_util.dart'; @@ -23,7 +24,7 @@ class DeduplicatePage extends StatefulWidget { } class _DeduplicatePageState extends State { - static final kHeaderRowCount = 2; + static final kHeaderRowCount = 3; static final kDeleteIconOverlay = Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -51,13 +52,23 @@ class _DeduplicatePageState extends State { final Set _selectedFiles = {}; final Map _fileSizeMap = {}; + List _duplicates; + bool _shouldClubByCaptureTime = true; SortKey sortKey = SortKey.size; @override void initState() { super.initState(); - for (final duplicate in widget.duplicates) { + _duplicates = + DeduplicationService.instance.clubDuplicatesByTime(widget.duplicates); + _selectAllFilesButFirst(); + showToast("long-press on an item to view in full-screen"); + } + + void _selectAllFilesButFirst() { + _selectedFiles.clear(); + for (final duplicate in _duplicates) { for (int index = 0; index < duplicate.files.length; index++) { // Select all items but the first if (index != 0) { @@ -67,7 +78,6 @@ class _DeduplicatePageState extends State { _fileSizeMap[duplicate.files[index].uploadedFileID] = duplicate.size; } } - showToast("long-press on an item to view in full-screen"); } @override @@ -93,7 +103,7 @@ class _DeduplicatePageState extends State { } void _sortDuplicates() { - widget.duplicates.sort((first, second) { + _duplicates.sort((first, second) { if (sortKey == SortKey.size) { final aSize = first.files.length * first.size; final bSize = second.files.length * second.size; @@ -117,15 +127,24 @@ class _DeduplicatePageState extends State { if (index == 0) { return _getHeader(); } else if (index == 1) { - return _getSortMenu(); + return _getClubbingConfig(); + } else if (index == 2) { + if (_duplicates.isNotEmpty) { + return _getSortMenu(); + } else { + return Padding( + padding: EdgeInsets.only(top: 32), + child: nothingToSeeHere, + ); + } } return Padding( padding: const EdgeInsets.only(top: 10, bottom: 10), - child: _getGridView(widget.duplicates[index - kHeaderRowCount], + child: _getGridView(_duplicates[index - kHeaderRowCount], index - kHeaderRowCount), ); }, - itemCount: widget.duplicates.length + kHeaderRowCount, + itemCount: _duplicates.length + kHeaderRowCount, shrinkWrap: true, ), ), @@ -140,7 +159,8 @@ class _DeduplicatePageState extends State { child: Column( children: [ Text( - "the following files were clubbed based on their sizes and capture times", + "the following files were clubbed based on their sizes" + + (_shouldClubByCaptureTime ? " and capture times" : ""), style: TextStyle( color: Colors.white.withOpacity(0.6), height: 1.2, @@ -156,11 +176,42 @@ class _DeduplicatePageState extends State { height: 1.2, ), ), + Padding( + padding: EdgeInsets.all(12), + ), + Divider( + height: 0, + ), ], ), ); } + Widget _getClubbingConfig() { + return Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, 4), + child: CheckboxListTile( + value: _shouldClubByCaptureTime, + onChanged: (value) { + _shouldClubByCaptureTime = value; + _resetEntriesAndSelection(); + setState(() {}); + }, + title: Text("club by capture time"), + ), + ); + } + + void _resetEntriesAndSelection() { + if (_shouldClubByCaptureTime) { + _duplicates = + DeduplicationService.instance.clubDuplicatesByTime(_duplicates); + } else { + _duplicates = widget.duplicates; + } + _selectAllFilesButFirst(); + } + Widget _getSortMenu() { Text sortOptionText(SortKey key) { String text = key.toString(); @@ -324,7 +375,7 @@ class _DeduplicatePageState extends State { }, onLongPress: () { HapticFeedback.lightImpact(); - final files = widget.duplicates[index].files; + final files = _duplicates[index].files; routeToPage( context, DetailPage( diff --git a/pubspec.yaml b/pubspec.yaml index e477b7683..a0320ef2c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: ente photos application # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.4.11+271 +version: 0.4.12+272 environment: sdk: ">=2.10.0 <3.0.0"