From ad396940266644dc60a5a343b770e40ed4435773 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 14 Aug 2025 19:06:03 +0530 Subject: [PATCH 01/26] report changes at correct place --- mobile/apps/photos/scripts/internal_changes.txt | 1 + mobile/apps/photos/scripts/store_changes.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/apps/photos/scripts/internal_changes.txt b/mobile/apps/photos/scripts/internal_changes.txt index f6341c95b3..ca54d7c400 100644 --- a/mobile/apps/photos/scripts/internal_changes.txt +++ b/mobile/apps/photos/scripts/internal_changes.txt @@ -1,3 +1,4 @@ +- Similar images debug screen (Settings > Backup > Free up space > Similar images) - (prtk) Upgrade Flutter version to 3.32.8 - (prtk) Run FFMpeg in an isolate - Neeraj: Handle custom domain links diff --git a/mobile/apps/photos/scripts/store_changes.txt b/mobile/apps/photos/scripts/store_changes.txt index 1d6ce14986..f1baf26cae 100644 --- a/mobile/apps/photos/scripts/store_changes.txt +++ b/mobile/apps/photos/scripts/store_changes.txt @@ -1,4 +1,3 @@ -- Added similar images debug screen (Settings > Backup > Free up space > Similar images) - Added support for custom domain links - Image editor fixes: - Fixed bottom navigation bar color in light theme From b3535393285cf491b635b66268404b154629b5ef Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Mon, 18 Aug 2025 09:54:31 +0530 Subject: [PATCH 02/26] Sort by highest size first --- .../lib/services/machine_learning/similar_images_service.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index fd1b0e9d1c..9fd88262c5 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -127,8 +127,9 @@ class SimilarImagesService { for (final file in similarFilesList) { alreadyUsedFileIDs.add(file.uploadedFileID!); } + // show highest quality files first similarFilesList.sort((a, b) { - return a.displayName.length.compareTo(b.displayName.length); + return (b.fileSize ?? 0).compareTo(a.fileSize ?? 0); }); final similarFiles = SimilarFiles( similarFilesList, From 5729e0cf3e3d92c02736067ffc57c2bbe5b72875 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Mon, 18 Aug 2025 10:11:04 +0530 Subject: [PATCH 03/26] no shared or hidden files --- .../lib/services/machine_learning/similar_images_service.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index 9fd88262c5..d13949a748 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -6,8 +6,8 @@ import "package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart" import 'package:logging/logging.dart'; import "package:photos/db/ml/db.dart"; import "package:photos/extensions/stop_watch.dart"; +import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; -import "package:photos/models/file/file_type.dart"; import "package:photos/models/similar_files.dart"; import "package:photos/services/machine_learning/ml_computer.dart"; import "package:photos/services/machine_learning/ml_result.dart"; @@ -58,7 +58,7 @@ class SimilarImagesService { final allFileIdsToFile = {}; final fileIDs = []; for (final file in allFiles) { - if (file.uploadedFileID != null && file.fileType != FileType.video) { + if (file.uploadedFileID != null && file.isOwner && !file.isVideo) { allFileIdsToFile[file.uploadedFileID!] = file; fileIDs.add(file.uploadedFileID!); } From e6bf64548cd6c67aa43f33298112cc5d62962f45 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Mon, 18 Aug 2025 10:21:28 +0530 Subject: [PATCH 04/26] Use cached files --- .../photos/lib/services/deduplication_service.dart | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/mobile/apps/photos/lib/services/deduplication_service.dart b/mobile/apps/photos/lib/services/deduplication_service.dart index 8ec6d59203..dc584fc314 100644 --- a/mobile/apps/photos/lib/services/deduplication_service.dart +++ b/mobile/apps/photos/lib/services/deduplication_service.dart @@ -1,12 +1,12 @@ import 'package:logging/logging.dart'; -import "package:photos/core/configuration.dart"; import 'package:photos/core/network/network.dart'; -import 'package:photos/db/files_db.dart'; import 'package:photos/models/duplicate_files.dart'; +import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; import "package:photos/models/file/file_type.dart"; import "package:photos/services/collections_service.dart"; import "package:photos/services/files_service.dart"; +import "package:photos/services/search_service.dart"; class DeduplicationService { final _logger = Logger("DeduplicationService"); @@ -39,16 +39,13 @@ class DeduplicationService { final Set allowedCollectionIDs = CollectionsService.instance.nonHiddenOwnedCollections(); - final List allFiles = await FilesDB.instance.getAllFilesFromDB( - CollectionsService.instance.getHiddenCollectionIds(), - dedupeByUploadId: false, - ); - final int ownerID = Configuration.instance.getUserID()!; + final List allFiles = + await SearchService.instance.getAllFilesForSearch(); final List filteredFiles = []; for (final file in allFiles) { if (!file.isUploaded || (file.hash ?? '').isEmpty || - (file.ownerID ?? 0) != ownerID || + !file.isOwner || (!allowedCollectionIDs.contains(file.collectionID!))) { continue; } From b46e51f64d127a10ff16c81b322bfa761821181a Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Mon, 18 Aug 2025 10:25:06 +0530 Subject: [PATCH 05/26] Prefer smaller names --- .../lib/services/machine_learning/similar_images_service.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index d13949a748..4cad21888a 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -129,7 +129,9 @@ class SimilarImagesService { } // show highest quality files first similarFilesList.sort((a, b) { - return (b.fileSize ?? 0).compareTo(a.fileSize ?? 0); + final sizeComparison = (b.fileSize ?? 0).compareTo(a.fileSize ?? 0); + if (sizeComparison != 0) return sizeComparison; + return a.displayName.compareTo(b.displayName); }); final similarFiles = SimilarFiles( similarFilesList, From cad8613e81adf5e5973b99edcea24017ceb07d96 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Mon, 18 Aug 2025 10:28:57 +0530 Subject: [PATCH 06/26] Remove "select extra" button --- .../lib/ui/tools/similar_images_page.dart | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index 89d50f6a2d..c04e07bf63 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -469,30 +469,11 @@ class _SimilarImagesPageState extends State { .toSet(); final bool allFilesFromGroupSelected = groupSelectedFiles.length == similarFiles.length; - final hasAnySelection = _selectedFiles.files.isNotEmpty; - final allGroupFilesSelected = similarFiles.files.every( - (file) => _selectedFiles.isFileSelected(file), - ); - if (groupSelectedFiles.isNotEmpty) { return _getSmallDeleteButton( groupSelectedFiles, allFilesFromGroupSelected, ); - } else if (hasAnySelection) { - return _getSmallSelectButton(allGroupFilesSelected, () { - if (allGroupFilesSelected) { - // Unselect all files in this group - _selectedFiles.unSelectAll( - similarFiles.files.toSet(), - ); - } else { - // Select all files in this group - _selectedFiles.selectAll( - similarFiles.files.sublist(1).toSet(), - ); - } - }); } return const SizedBox.shrink(); }, @@ -784,35 +765,6 @@ class _SimilarImagesPageState extends State { await deleteFilesFromRemoteOnly(context, allDeleteFiles.toList()); } - Widget _getSmallSelectButton(bool unselectAll, void Function() onTap) { - final colorScheme = getEnteColorScheme(context); - final textTheme = getEnteTextTheme(context); - - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - decoration: BoxDecoration( - color: unselectAll - ? getEnteColorScheme(context).primary500 - : getEnteColorScheme(context).strokeFaint, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - unselectAll - ? "Unselect all" // TODO: lau: extract string - : "Select extra", // TODO: lau: extract string - style: textTheme.smallMuted.copyWith( - color: unselectAll ? Colors.white : colorScheme.textMuted, - ), - ), - ), - ); - } - Widget _getSortMenu() { final textTheme = getEnteTextTheme(context); final colorScheme = getEnteColorScheme(context); From be1bf28cd8c790fd3484dbd172dea352606470f0 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Mon, 18 Aug 2025 17:25:44 +0530 Subject: [PATCH 07/26] Add cacheExtend for smoother scroll --- mobile/apps/photos/lib/ui/tools/deduplicate_page.dart | 1 + mobile/apps/photos/lib/ui/tools/similar_images_page.dart | 1 + 2 files changed, 2 insertions(+) diff --git a/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart b/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart index 81887b955f..a8e39b58c7 100644 --- a/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart +++ b/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart @@ -134,6 +134,7 @@ class _DeduplicatePageState extends State { children: [ Expanded( child: ListView.builder( + cacheExtent: 400, itemBuilder: (context, index) { if (index == 0) { return const SizedBox.shrink(); diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index c04e07bf63..976324d340 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -254,6 +254,7 @@ class _SimilarImagesPageState extends State { children: [ Expanded( child: ListView.builder( + cacheExtent: 400, itemCount: _similarFilesList.length + 1, // +1 for header itemBuilder: (context, index) { if (index == 0) { From 37c1d0f6a8bee6e9f5fd5439eaeeb2a58a42447c Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Mon, 18 Aug 2025 18:23:14 +0530 Subject: [PATCH 08/26] Unify UI --- .../photos/lib/ui/tools/deduplicate_page.dart | 287 +++++++----------- 1 file changed, 115 insertions(+), 172 deletions(-) diff --git a/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart b/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart index a8e39b58c7..eef6864ef4 100644 --- a/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart +++ b/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart @@ -4,13 +4,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/ente_theme_data.dart'; + import 'package:photos/events/user_details_changed_event.dart'; import "package:photos/generated/l10n.dart"; import 'package:photos/models/duplicate_files.dart'; import 'package:photos/models/file/file.dart'; import 'package:photos/services/collections_service.dart'; import "package:photos/theme/ente_theme.dart"; +import 'package:photos/ui/components/buttons/button_widget.dart'; +import "package:photos/ui/components/models/button_type.dart"; import 'package:photos/ui/viewer/file/detail_page.dart'; import 'package:photos/ui/viewer/file/thumbnail_widget.dart'; import 'package:photos/ui/viewer/gallery/empty_state.dart'; @@ -31,7 +33,6 @@ class DeduplicatePage extends StatefulWidget { class _DeduplicatePageState extends State { static const crossAxisCount = 3; static const crossAxisSpacing = 12.0; - static const headerRowCount = 3; final Set selectedGrids = {}; @@ -68,47 +69,7 @@ class _DeduplicatePageState extends State { appBar: AppBar( elevation: 0, title: Text(S.of(context).deduplicateFiles), - actions: [ - PopupMenuButton( - constraints: const BoxConstraints(minWidth: 180), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(8), - ), - ), - onSelected: (dynamic value) { - setState(() { - selectedGrids.clear(); - }); - }, - offset: const Offset(0, 50), - itemBuilder: (BuildContext context) => [ - PopupMenuItem( - value: true, - height: 32, - child: Row( - children: [ - const Icon( - Icons.remove_circle_outline, - size: 20, - ), - const SizedBox(width: 12), - Padding( - padding: const EdgeInsets.only(bottom: 1), - child: Text( - S.of(context).deselectAll, - style: Theme.of(context) - .textTheme - .titleMedium! - .copyWith(fontWeight: FontWeight.w600), - ), - ), - ], - ), - ), - ], - ), - ], + actions: _duplicates.isNotEmpty ? [_getSortMenu()] : null, ), body: _getBody(), ); @@ -133,34 +94,25 @@ class _DeduplicatePageState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: ListView.builder( - cacheExtent: 400, - itemBuilder: (context, index) { - if (index == 0) { - return const SizedBox.shrink(); - } else if (index == 1) { - return const SizedBox.shrink(); - } else if (index == 2) { - if (_duplicates.isNotEmpty) { - return _getSortMenu(context); - } else { - return const Padding( - padding: EdgeInsets.only(top: 32), - child: EmptyState(), - ); - } - } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: _getGridView( - _duplicates[index - headerRowCount], - index - headerRowCount, + child: _duplicates.isNotEmpty + ? ListView.builder( + cacheExtent: 400, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _getGridView( + _duplicates[index], + index, + ), + ); + }, + itemCount: _duplicates.length, + shrinkWrap: true, + ) + : const Padding( + padding: EdgeInsets.only(top: 32), + child: EmptyState(), ), - ); - }, - itemCount: _duplicates.length + headerRowCount, - shrinkWrap: true, - ), ), selectedGrids.isEmpty ? const SizedBox.shrink() @@ -190,7 +142,8 @@ class _DeduplicatePageState extends State { ); } - Widget _getSortMenu(BuildContext context) { + Widget _getSortMenu() { + final textTheme = getEnteTextTheme(context); Text sortOptionText(SortKey key) { String text = key.toString(); switch (key) { @@ -203,63 +156,44 @@ class _DeduplicatePageState extends State { } return Text( text, - style: Theme.of(context).textTheme.titleMedium!.copyWith( - fontSize: 14, - color: Theme.of(context).iconTheme.color!.withValues(alpha: 0.7), - ), + style: textTheme.miniBold, ); } - return Row( - // h4ck to align PopupMenuItems to end - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const SizedBox.shrink(), - PopupMenuButton( - initialValue: sortKey.index, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 6, 24, 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - sortOptionText(sortKey), - const Padding(padding: EdgeInsets.only(left: 4)), - Icon( - Icons.sort, - color: Theme.of(context).colorScheme.iconColor, - size: 20, - ), - ], - ), - ), - onSelected: (int index) { - setState(() { - final newKey = SortKey.values[index]; - if (newKey == sortKey) { - return; - } else { - sortKey = newKey; - if (selectedGrids.length != _duplicates.length) { - selectedGrids.clear(); - } - } - }); - }, - itemBuilder: (context) { - return List.generate(SortKey.values.length, (index) { - return PopupMenuItem( - value: index, - child: Align( - alignment: Alignment.centerLeft, - child: sortOptionText(SortKey.values[index]), - ), - ); - }); - }, + return PopupMenuButton( + initialValue: sortKey.index, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 6, 24, 6), + child: Icon( + Icons.sort, + color: getEnteColorScheme(context).strokeBase, + size: 20, ), - ], + ), + onSelected: (int index) { + setState(() { + final newKey = SortKey.values[index]; + if (newKey == sortKey) { + return; + } else { + sortKey = newKey; + if (selectedGrids.length != _duplicates.length) { + selectedGrids.clear(); + } + } + }); + }, + itemBuilder: (context) { + return List.generate(SortKey.values.length, (index) { + return PopupMenuItem( + value: index, + child: Text( + sortOptionText(SortKey.values[index]).data!, + style: textTheme.miniBold, + ), + ); + }); + }, ); } @@ -273,55 +207,60 @@ class _DeduplicatePageState extends State { totalSize += toDeleteCount * _duplicates[index].size; } } - final String text = S.of(context).deleteItemCount(fileCount); - return SizedBox( - width: double.infinity, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: crossAxisSpacing), - child: TextButton( - style: OutlinedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.inverseBackgroundColor, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Padding(padding: EdgeInsets.all(4)), - Text( - text, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: Theme.of(context).colorScheme.inverseTextColor, - ), - textAlign: TextAlign.center, + final hasSelectedFiles = fileCount > 0; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: hasSelectedFiles + ? SafeArea( + child: Container( + key: const ValueKey('bottom_buttons'), + padding: const EdgeInsets.symmetric( + horizontal: crossAxisSpacing, + vertical: 8, ), - const Padding(padding: EdgeInsets.all(2)), - Text( - formatBytes(totalSize), - style: TextStyle( - color: Theme.of(context) - .colorScheme - .inverseTextColor - .withValues(alpha: 0.7), - fontSize: 12, - ), + decoration: BoxDecoration( + color: getEnteColorScheme(context).backgroundBase, ), - const Padding(padding: EdgeInsets.all(2)), - ], - ), - onPressed: () async { - try { - await deleteDuplicates(totalSize); - } catch (e) { - log("Failed to delete duplicates", error: e); - showGenericErrorDialog(context: context, error: e).ignore(); - } - }, - ), - ), - ), + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: ButtonWidget( + labelText: + "${S.of(context).deleteItemCount(fileCount)} (${formatBytes(totalSize)})", + buttonType: ButtonType.critical, + onTap: () async { + try { + await deleteDuplicates(totalSize); + } catch (e) { + log("Failed to delete duplicates", error: e); + showGenericErrorDialog(context: context, error: e) + .ignore(); + } + }, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ButtonWidget( + labelText: "Unselect all", // TODO: lau: extract string + buttonType: ButtonType.secondary, + onTap: () async { + setState(() { + selectedGrids.clear(); + }); + }, + ), + ), + ], + ), + ), + ) + : const SizedBox.shrink(key: ValueKey('empty')), ); } @@ -377,7 +316,11 @@ class _DeduplicatePageState extends State { children: [ Padding( padding: const EdgeInsets.fromLTRB( - crossAxisSpacing, 4, crossAxisSpacing, 12,), + crossAxisSpacing, + 4, + crossAxisSpacing, + 12, + ), child: GestureDetector( onTap: () { if (selectedGrids.contains(itemIndex)) { From 65f7e3f6c6d01231f235ea86ce5ac493a2a92da2 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Tue, 19 Aug 2025 11:39:03 +0530 Subject: [PATCH 09/26] Exclude rust_builder in linter --- mobile/apps/photos/analysis_options.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mobile/apps/photos/analysis_options.yaml b/mobile/apps/photos/analysis_options.yaml index 2d1fb835f0..c444f8262e 100644 --- a/mobile/apps/photos/analysis_options.yaml +++ b/mobile/apps/photos/analysis_options.yaml @@ -22,7 +22,6 @@ linter: - use_key_in_widget_constructors - cancel_subscriptions - - avoid_empty_else - exhaustive_cases @@ -63,7 +62,6 @@ analyzer: unrelated_type_equality_checks: error unnecessary_cast: info - unawaited_futures: warning # convert to warning after fixing existing issues invalid_dependency: info use_build_context_synchronously: ignore # experimental lint, requires many changes @@ -74,3 +72,4 @@ analyzer: exclude: - thirdparty/** - lib/generated/** + - rust_builder/** From 58182cc8ab639ee214fad2b7995674451e20372c Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Tue, 19 Aug 2025 17:11:12 +0530 Subject: [PATCH 10/26] Remove slider interface in release --- .../photos/lib/ui/settings/backup/free_space_options.dart | 3 ++- mobile/apps/photos/lib/ui/tools/similar_images_page.dart | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/mobile/apps/photos/lib/ui/settings/backup/free_space_options.dart b/mobile/apps/photos/lib/ui/settings/backup/free_space_options.dart index 95f61344b8..b43b69709b 100644 --- a/mobile/apps/photos/lib/ui/settings/backup/free_space_options.dart +++ b/mobile/apps/photos/lib/ui/settings/backup/free_space_options.dart @@ -1,6 +1,7 @@ import "dart:async"; import "dart:io"; +import "package:flutter/foundation.dart" show kDebugMode; import 'package:flutter/material.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/models/backup_status.dart"; @@ -207,7 +208,7 @@ class _FreeUpSpaceOptionsScreenState extends State { onTap: () async { await routeToPage( context, - const SimilarImagesPage(), + const SimilarImagesPage(debugScreen: kDebugMode,), ); }, ), diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index 976324d340..37e8c61369 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -37,7 +37,9 @@ enum SortKey { } class SimilarImagesPage extends StatefulWidget { - const SimilarImagesPage({super.key}); + final bool debugScreen; + + const SimilarImagesPage({super.key, this.debugScreen = false}); @override State createState() => _SimilarImagesPageState(); @@ -63,6 +65,10 @@ class _SimilarImagesPageState extends State { void initState() { super.initState(); _selectedFiles = SelectedFiles(); + + if (!widget.debugScreen) { + _findSimilarImages(); + } } @override From 19eb342f59be193c38f6af7abe0f6b4259815152 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Wed, 20 Aug 2025 09:47:36 +0530 Subject: [PATCH 11/26] Move internal things to debug mode only --- .../photos/lib/ui/tools/similar_images_page.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index 37e8c61369..6e4e77deaf 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -1,5 +1,6 @@ import "dart:async"; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/constants.dart'; @@ -84,7 +85,7 @@ class _SimilarImagesPageState extends State { appBar: AppBar( elevation: 0, title: const Text("Similar images"), // TODO: lau: extract string - actions: _pageState == SimilarImagesPageState.results + actions: _pageState == SimilarImagesPageState.results && kDebugMode ? [_getSortMenu()] : null, ), @@ -264,19 +265,18 @@ class _SimilarImagesPageState extends State { itemCount: _similarFilesList.length + 1, // +1 for header itemBuilder: (context, index) { if (index == 0) { - // Header item - if (flagService.internalUser) { + if (kDebugMode) { return Container( padding: const EdgeInsets.all(16), child: Column( children: [ Text( - "(I) Found ${_similarFilesList.length} groups of similar images", // TODO: lau: extract string + "(I) Found ${_similarFilesList.length} groups of similar images", style: textTheme.bodyBold, ), const SizedBox(height: 4), Text( - "(I) Threshold: ${_distanceThreshold.toStringAsFixed(2)}", // TODO: lau: extract string + "(I) Threshold: ${_distanceThreshold.toStringAsFixed(2)}", style: textTheme.miniMuted, ), ], @@ -459,7 +459,7 @@ class _SimilarImagesPageState extends State { children: [ Text( "${similarFiles.files.length} similar images" + - (flagService.internalUser + (kDebugMode ? " (I: d: ${similarFiles.furthestDistance.toStringAsFixed(3)})" : ""), // TODO: lau: extract string style: textTheme.smallMuted.copyWith( From 28a842b006c43e522c360ca060ac9b44c486efc7 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Wed, 20 Aug 2025 09:51:27 +0530 Subject: [PATCH 12/26] Show size --- mobile/apps/photos/lib/ui/tools/similar_images_page.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index 6e4e77deaf..7693c80bfc 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -305,6 +305,11 @@ class _SimilarImagesPageState extends State { final selectedCount = _selectedFiles.files.length; final hasSelectedFiles = selectedCount > 0; + int totalSize = 0; + for (final file in _selectedFiles.files) { + totalSize += file.fileSize ?? 0; + } + return AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOut, @@ -326,7 +331,7 @@ class _SimilarImagesPageState extends State { width: double.infinity, child: ButtonWidget( labelText: - "Delete $selectedCount photos", // TODO: lau: extract string + "Delete $selectedCount photos (${formatBytes(totalSize)})", // TODO: lau: extract string buttonType: ButtonType.critical, onTap: () async { await _deleteFiles( From a8ae0727a886c0e912a7f939cec059b58891e98c Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Wed, 20 Aug 2025 18:24:21 +0530 Subject: [PATCH 13/26] Fix potential duplicates --- .../lib/services/machine_learning/similar_images_service.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index 4cad21888a..7ab1c62132 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -121,6 +121,7 @@ class SimilarImagesService { if (!setsAreEqual(personIDs, otherPersonIDs)) continue; similarFilesList.add(otherFile); furthestDistance = max(furthestDistance, distance); + alreadyUsedFileIDs.add(otherFileID); } if (similarFilesList.isNotEmpty) { similarFilesList.add(firstLoopFile); From f202fef26660412d8773b2589cdf783147b53149 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 11:35:12 +0530 Subject: [PATCH 14/26] JSON caching of similar files --- .../apps/photos/lib/models/similar_files.dart | 129 +++++++++++++++++- 1 file changed, 125 insertions(+), 4 deletions(-) diff --git a/mobile/apps/photos/lib/models/similar_files.dart b/mobile/apps/photos/lib/models/similar_files.dart index ee946c499a..eadbc34cb1 100644 --- a/mobile/apps/photos/lib/models/similar_files.dart +++ b/mobile/apps/photos/lib/models/similar_files.dart @@ -1,18 +1,21 @@ +import "dart:convert"; + import "package:photos/models/file/file.dart"; +import "package:photos/services/search_service.dart"; class SimilarFiles { final List files; final Set fileIds; - final double furthestDistance; + double furthestDistance; SimilarFiles( this.files, this.furthestDistance, - ) : fileIds = files.map((file) => file.uploadedFileID!).toSet(); + ) : fileIds = files.map((file) => file.uploadedFileID!).toSet(); - int get totalSize => - files.fold(0, (sum, file) => sum + (file.fileSize ?? 0)); + int get totalSize => files.fold(0, (sum, file) => sum + (file.fileSize ?? 0)); + // TODO: lau: check if we're not using this wrong bool get isEmpty => files.isEmpty; int get length => files.length; @@ -26,7 +29,125 @@ class SimilarFiles { fileIds.remove(file.uploadedFileID); } + void addFile(EnteFile file) { + files.add(file); + fileIds.add(file.uploadedFileID!); + } + bool containsFile(EnteFile file) { return fileIds.contains(file.uploadedFileID); } + + Map toJson() { + return { + 'fileIDs': fileIds.toList(), + 'distance': furthestDistance, + }; + } + + String toJsonString() { + return jsonEncode(toJson()); + } + + factory SimilarFiles.fromJson( + Map json, + Map fileMap, + ) { + final fileIds = List.from(json['fileIDs']); + final furthestDistance = json['distance'].toDouble(); + + final files = []; + for (final fileId in fileIds) { + final file = fileMap[fileId]; + if (file == null) continue; + files.add(file); + } + + return SimilarFiles( + files, + furthestDistance, + ); + } + + static SimilarFiles fromJsonString( + String jsonString, + Map fileMap, + ) { + return SimilarFiles.fromJson(jsonDecode(jsonString), fileMap); + } +} + +class SimilarFilesCache { + final List similarFilesJsonStringList; + final Set allCheckedFileIDs; + final double distanceThreshold; + final bool exact; + + List? _similarFilesList; + + /// Milliseconds since epoch + final int cachedTime; + + SimilarFilesCache({ + required this.similarFilesJsonStringList, + required this.allCheckedFileIDs, + required this.distanceThreshold, + required this.exact, + required this.cachedTime, + }); + + Future> similarFilesList() async { + final allFiles = await SearchService.instance.getAllFilesForSearch(); + final fileMap = {}; + for (final file in allFiles) { + if (file.uploadedFileID == null) continue; + fileMap[file.uploadedFileID!] = file; + } + _similarFilesList ??= similarFilesJsonStringList.map((jsonString) { + return SimilarFiles.fromJson(jsonDecode(jsonString), fileMap); + }).toList(); + return _similarFilesList!; + } + + Future> getGroupedFileIDs() async { + final similarFiles = await similarFilesList(); + final groupedFileIDs = {}; + for (final files in similarFiles) { + groupedFileIDs.addAll(files.fileIds); + } + return groupedFileIDs; + } + + factory SimilarFilesCache.fromJson( + Map json, + ) { + return SimilarFilesCache( + similarFilesJsonStringList: + json['similarFilesJsonStringList'] as List, + allCheckedFileIDs: Set.from(json['allCheckedFileIDs']), + distanceThreshold: json['distanceThreshold'] as double, + exact: json['exact'] as bool, + cachedTime: json['cachedTime'] as int, + ); + } + + Map toJson() { + return { + 'similarFilesJsonStringList': similarFilesJsonStringList, + 'allCheckedFileIDs': allCheckedFileIDs.toList(), + 'distanceThreshold': distanceThreshold, + 'exact': exact, + 'cachedTime': cachedTime, + }; + } + + static String encodeToJsonString(SimilarFilesCache cache) { + return jsonEncode(cache.toJson()); + } + + static SimilarFilesCache decodeFromJsonString( + String jsonString, + ) { + return SimilarFilesCache.fromJson(jsonDecode(jsonString)); + } } From a11f66b51d56eb518a3896b85d3e299a2217d207 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 11:35:42 +0530 Subject: [PATCH 15/26] Caching and partial compute logic for similar files calculation --- .../similar_images_service.dart | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index 7ab1c62132..00abdfd98d 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -4,6 +4,7 @@ import "package:flutter/foundation.dart" show kDebugMode; import "package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart" show Uint64List; import 'package:logging/logging.dart'; +import "package:path_provider/path_provider.dart"; import "package:photos/db/ml/db.dart"; import "package:photos/extensions/stop_watch.dart"; import "package:photos/models/file/extensions/file_props.dart"; @@ -12,6 +13,7 @@ import "package:photos/models/similar_files.dart"; import "package:photos/services/machine_learning/ml_computer.dart"; import "package:photos/services/machine_learning/ml_result.dart"; import "package:photos/services/search_service.dart"; +import "package:photos/utils/cache_util.dart"; class SimilarImagesService { final _logger = Logger("SimilarImagesService"); @@ -84,6 +86,208 @@ class SimilarImagesService { } w?.log("getFileIDToPersonIDs"); + // Load cached data + final SimilarFilesCache? cachedData = await _readCachedSimilarFiles(); + + // Determine if we need full refresh + bool needsFullRefresh = false; + if (cachedData != null) { + final Set cachedFileIDs = cachedData.allCheckedFileIDs; + final currentFileIDs = fileIDs.toSet(); + + // Check condition 1: New files > 20% of total files + final newFileIDs = currentFileIDs.difference(cachedFileIDs); + if (newFileIDs.length > currentFileIDs.length * 0.2) { + needsFullRefresh = true; + } + + // Check condition 2: 20+% of grouped files deleted + if (!needsFullRefresh) { + final Set cacheGroupedFileIDs = + await cachedData.getGroupedFileIDs(); + final deletedFromGroups = cacheGroupedFileIDs + .intersection(cachedFileIDs.difference(currentFileIDs)); + final totalInGroups = cacheGroupedFileIDs.length; + if (totalInGroups > 0 && + deletedFromGroups.length > totalInGroups * 0.2) { + needsFullRefresh = true; + } + } + } + + if (cachedData == null || needsFullRefresh) { + final result = await _performFullSearch( + potentialKeys, + allFileIdsToFile, + fileIDToPersonIDs, + distanceThreshold, + exact, + ); + await _cacheSimilarFiles( + result, + fileIDs.toSet(), + distanceThreshold, + exact, + DateTime.now().millisecondsSinceEpoch, + ); + return result; + } else { + return await _performIncrementalUpdate( + cachedData, + potentialKeys, + allFileIdsToFile, + fileIDToPersonIDs, + distanceThreshold, + exact, + ); + } + } + + Future> _performIncrementalUpdate( + SimilarFilesCache cachedData, + Uint64List currentFileIDs, + Map allFileIdsToFile, + Map> fileIDToPersonIDs, + double distanceThreshold, + bool exact, + ) async { + final existingGroups = await cachedData.similarFilesList(); + final cachedFileIDs = cachedData.allCheckedFileIDs; + final currentFileIDsSet = currentFileIDs.map((id) => id.toInt()).toSet(); + final deletedFiles = currentFileIDsSet.difference(cachedFileIDs); + + // Step 1: Clean up deleted files from existing groups + if (deletedFiles.isNotEmpty) { + for (final group in existingGroups) { + final filesInGroupToDelete = []; + for (final fileInGroup in group.files) { + if (deletedFiles.contains(fileInGroup.uploadedFileID ?? -1)) { + filesInGroupToDelete.add(fileInGroup); + } + } + for (final fileToDelete in filesInGroupToDelete) { + group.removeFile(fileToDelete); + } + } + } + // Remove empty groups + existingGroups.removeWhere((group) => group.length <= 1); + + // Step 2: Identify new files + final newFileIDs = currentFileIDsSet.difference(cachedFileIDs); + if (newFileIDs.isEmpty) { + return existingGroups; + } + + // Step 3: Search only new files + final newFileIDsList = Uint64List.fromList(newFileIDs.toList()); + final (keys, vectorKeys, distances) = + await MLComputer.instance.bulkVectorSearchWithKeys( + newFileIDsList, + exact, + ); + final keysList = keys.map((key) => key.toInt()).toList(); + + // Step 4: Try to assign new files to existing groups + final unassignedNewFilesIndices = {}; + final unassignedNewFileIDs = {}; + for (int i = 0; i < keysList.length; i++) { + final newFileID = keysList[i].toInt(); + final newFile = allFileIdsToFile[newFileID]; + if (newFile == null) continue; + final similarFileIDs = vectorKeys[i]; + final fileDistances = distances[i]; + final newFilePersonIDs = fileIDToPersonIDs[newFileID] ?? {}; + bool assigned = false; + for (final group in existingGroups) { + for (int j = 0; j < similarFileIDs.length; j++) { + final otherFileID = similarFileIDs[j].toInt(); + if (otherFileID == newFileID) continue; + final distance = fileDistances[j]; + if (distance > distanceThreshold) break; + if (group.fileIds.contains(otherFileID)) { + final otherPersonIDs = fileIDToPersonIDs[otherFileID] ?? {}; + if (setsAreEqual(newFilePersonIDs, otherPersonIDs)) { + group.addFile(newFile); + group.furthestDistance = max(group.furthestDistance, distance); + group.files.sort((a, b) { + final sizeComparison = + (b.fileSize ?? 0).compareTo(a.fileSize ?? 0); + if (sizeComparison != 0) return sizeComparison; + return a.displayName.compareTo(b.displayName); + }); + assigned = true; + break; + } + } + } + if (assigned) break; + } + if (!assigned) { + unassignedNewFilesIndices.add(i); + unassignedNewFileIDs.add(newFileID); + } + } + + // Step 5: Check if unassigned new files form groups among themselves + if (unassignedNewFilesIndices.isNotEmpty) { + final alreadyUsedNewFiles = {}; + for (final searchIndex in unassignedNewFilesIndices) { + final newFileID = keysList[searchIndex]; + if (alreadyUsedNewFiles.contains(newFileID)) continue; + final newFile = allFileIdsToFile[newFileID]; + if (newFile == null) continue; + final similarFileIDs = vectorKeys[searchIndex]; + final fileDistances = distances[searchIndex]; + final newFilePersonIDs = fileIDToPersonIDs[newFileID] ?? {}; + final similarNewFiles = []; + double furthestDistance = 0.0; + for (int j = 0; j < similarFileIDs.length; j++) { + final otherFileID = similarFileIDs[j].toInt(); + if (otherFileID == newFileID) continue; + if (!unassignedNewFileIDs.contains(otherFileID)) continue; + if (alreadyUsedNewFiles.contains(otherFileID)) continue; + final distance = fileDistances[j]; + if (distance > distanceThreshold) break; + final otherFile = allFileIdsToFile[otherFileID]; + if (otherFile == null) continue; + final otherPersonIDs = fileIDToPersonIDs[otherFileID] ?? {}; + if (!setsAreEqual(newFilePersonIDs, otherPersonIDs)) continue; + similarNewFiles.add(otherFile); + alreadyUsedNewFiles.add(otherFileID); + furthestDistance = max(furthestDistance, distance); + } + if (similarNewFiles.isNotEmpty) { + similarNewFiles.add(newFile); + alreadyUsedNewFiles.add(newFileID); + similarNewFiles.sort((a, b) { + final sizeComparison = (b.fileSize ?? 0).compareTo(a.fileSize ?? 0); + if (sizeComparison != 0) return sizeComparison; + return a.displayName.compareTo(b.displayName); + }); + existingGroups.add(SimilarFiles(similarNewFiles, furthestDistance)); + } + } + } + await _cacheSimilarFiles( + existingGroups, + currentFileIDsSet, + distanceThreshold, + exact, + cachedData.cachedTime, + ); + + return existingGroups; + } + + Future> _performFullSearch( + Uint64List potentialKeys, + Map allFileIdsToFile, + Map> fileIDToPersonIDs, + double distanceThreshold, + bool exact, + ) async { + final w = (kDebugMode ? EnteWatch('getSimilarFiles') : null)?..start(); // Run bulk vector search final (keys, vectorKeys, distances) = await MLComputer.instance.bulkVectorSearchWithKeys( @@ -145,6 +349,44 @@ class SimilarImagesService { return allSimilarFiles; } + + Future _getCachePath() async { + return (await getApplicationSupportDirectory()).path + + "/cache/similar_images_cache"; + } + + Future _cacheSimilarFiles( + List similarGroups, + Set allCheckedFileIDs, + double distanceThreshold, + bool exact, + int cachedTimeOfOriginalComputation, + ) async { + final cachePath = await _getCachePath(); + final similarGroupsJsonStringList = + similarGroups.map((group) => group.toJsonString()).toList(); + final cacheObject = SimilarFilesCache( + similarFilesJsonStringList: similarGroupsJsonStringList, + allCheckedFileIDs: allCheckedFileIDs, + distanceThreshold: distanceThreshold, + exact: exact, + cachedTime: cachedTimeOfOriginalComputation, + ); + await writeToJsonFile( + cachePath, + cacheObject, + SimilarFilesCache.encodeToJsonString, + ); + } + + Future _readCachedSimilarFiles() async { + _logger.info("Reading similar files cache result from disk"); + final cache = decodeJsonFile( + await _getCachePath(), + SimilarFilesCache.decodeFromJsonString, + ); + return cache; + } } bool setsAreEqual(Set set1, Set set2) { From e824c02d7fb47b9e8bf9ab754018eb45961acf8a Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 11:45:08 +0530 Subject: [PATCH 16/26] Fix bug --- .../lib/services/machine_learning/similar_images_service.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index 00abdfd98d..34e3c5dfb7 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -154,7 +154,7 @@ class SimilarImagesService { final existingGroups = await cachedData.similarFilesList(); final cachedFileIDs = cachedData.allCheckedFileIDs; final currentFileIDsSet = currentFileIDs.map((id) => id.toInt()).toSet(); - final deletedFiles = currentFileIDsSet.difference(cachedFileIDs); + final deletedFiles = cachedFileIDs.difference(currentFileIDsSet); // Step 1: Clean up deleted files from existing groups if (deletedFiles.isNotEmpty) { @@ -192,7 +192,7 @@ class SimilarImagesService { final unassignedNewFilesIndices = {}; final unassignedNewFileIDs = {}; for (int i = 0; i < keysList.length; i++) { - final newFileID = keysList[i].toInt(); + final newFileID = keysList[i]; final newFile = allFileIdsToFile[newFileID]; if (newFile == null) continue; final similarFileIDs = vectorKeys[i]; From 99c0194c0f5e8208369f2f62c1872e6000cab28e Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 11:54:10 +0530 Subject: [PATCH 17/26] Check cache parameters --- .../machine_learning/similar_images_service.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index 34e3c5dfb7..c619f5a8a3 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -95,12 +95,19 @@ class SimilarImagesService { final Set cachedFileIDs = cachedData.allCheckedFileIDs; final currentFileIDs = fileIDs.toSet(); - // Check condition 1: New files > 20% of total files - final newFileIDs = currentFileIDs.difference(cachedFileIDs); - if (newFileIDs.length > currentFileIDs.length * 0.2) { + if (cachedData.distanceThreshold != distanceThreshold || + cachedData.exact != exact) { needsFullRefresh = true; } + // Check condition 1: New files > 20% of total files + if (!needsFullRefresh) { + final newFileIDs = currentFileIDs.difference(cachedFileIDs); + if (newFileIDs.length > currentFileIDs.length * 0.2) { + needsFullRefresh = true; + } + } + // Check condition 2: 20+% of grouped files deleted if (!needsFullRefresh) { final Set cacheGroupedFileIDs = From e26b4796d3cf08765b3f520575569d4c1b0a3bc4 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 12:04:31 +0530 Subject: [PATCH 18/26] Match to closest group --- .../similar_images_service.dart | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index c619f5a8a3..da050a0153 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -100,7 +100,13 @@ class SimilarImagesService { needsFullRefresh = true; } - // Check condition 1: New files > 20% of total files + // Check condition: cache is older than a month + if (DateTime.fromMillisecondsSinceEpoch(cachedData.cachedTime) + .isBefore(DateTime.now().subtract(const Duration(days: 30)))) { + needsFullRefresh = true; + } + + // Check condition: new files > 20% of total files if (!needsFullRefresh) { final newFileIDs = currentFileIDs.difference(cachedFileIDs); if (newFileIDs.length > currentFileIDs.length * 0.2) { @@ -108,7 +114,7 @@ class SimilarImagesService { } } - // Check condition 2: 20+% of grouped files deleted + // Check condition: 20+% of grouped files deleted if (!needsFullRefresh) { final Set cacheGroupedFileIDs = await cachedData.getGroupedFileIDs(); @@ -206,12 +212,12 @@ class SimilarImagesService { final fileDistances = distances[i]; final newFilePersonIDs = fileIDToPersonIDs[newFileID] ?? {}; bool assigned = false; - for (final group in existingGroups) { - for (int j = 0; j < similarFileIDs.length; j++) { - final otherFileID = similarFileIDs[j].toInt(); - if (otherFileID == newFileID) continue; - final distance = fileDistances[j]; - if (distance > distanceThreshold) break; + for (int j = 0; j < similarFileIDs.length; j++) { + final otherFileID = similarFileIDs[j].toInt(); + if (otherFileID == newFileID) continue; + final distance = fileDistances[j]; + if (distance > distanceThreshold) break; + for (final group in existingGroups) { if (group.fileIds.contains(otherFileID)) { final otherPersonIDs = fileIDToPersonIDs[otherFileID] ?? {}; if (setsAreEqual(newFilePersonIDs, otherPersonIDs)) { From c6734a5cb7a95d6e46b3be4af94cc9851788d608 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 12:15:37 +0530 Subject: [PATCH 19/26] More logging --- .../machine_learning/similar_images_service.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index da050a0153..f9d9fceb02 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -88,6 +88,13 @@ class SimilarImagesService { // Load cached data final SimilarFilesCache? cachedData = await _readCachedSimilarFiles(); + if (cachedData == null) { + _logger.warning("No cached similar files found"); + } else { + _logger.info( + "Cached similar files found with ${cachedData.similarFilesJsonStringList.length} groups", + ); + } // Determine if we need full refresh bool needsFullRefresh = false; @@ -164,6 +171,7 @@ class SimilarImagesService { double distanceThreshold, bool exact, ) async { + _logger.info("Performing incremental update for similar files"); final existingGroups = await cachedData.similarFilesList(); final cachedFileIDs = cachedData.allCheckedFileIDs; final currentFileIDsSet = currentFileIDs.map((id) => id.toInt()).toSet(); @@ -300,6 +308,7 @@ class SimilarImagesService { double distanceThreshold, bool exact, ) async { + _logger.info("Performing full search for similar files"); final w = (kDebugMode ? EnteWatch('getSimilarFiles') : null)?..start(); // Run bulk vector search final (keys, vectorKeys, distances) = From 41ef85a2942a7230c1091aa7d749ef869014e0d5 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 13:08:51 +0530 Subject: [PATCH 20/26] Add scroll bars --- .../photos/lib/ui/tools/deduplicate_page.dart | 42 +++++++---- .../lib/ui/tools/similar_images_page.dart | 74 +++++++++++-------- 2 files changed, 74 insertions(+), 42 deletions(-) diff --git a/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart b/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart index eef6864ef4..e58cc2780e 100644 --- a/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart +++ b/mobile/apps/photos/lib/ui/tools/deduplicate_page.dart @@ -16,6 +16,7 @@ import "package:photos/ui/components/models/button_type.dart"; import 'package:photos/ui/viewer/file/detail_page.dart'; import 'package:photos/ui/viewer/file/thumbnail_widget.dart'; import 'package:photos/ui/viewer/gallery/empty_state.dart'; +import "package:photos/ui/viewer/gallery/scrollbar/scroll_bar_with_use_notifier.dart"; import 'package:photos/utils/delete_file_util.dart'; import "package:photos/utils/dialog_util.dart"; import 'package:photos/utils/navigation_util.dart'; @@ -40,11 +41,15 @@ class _DeduplicatePageState extends State { SortKey sortKey = SortKey.size; late ValueNotifier _deleteProgress; + late ScrollController _scrollController; + late ValueNotifier _scrollbarInUseNotifier; @override void initState() { _duplicates = widget.duplicates; _deleteProgress = ValueNotifier(""); + _scrollController = ScrollController(); + _scrollbarInUseNotifier = ValueNotifier(false); _selectAllGrids(); super.initState(); } @@ -52,6 +57,8 @@ class _DeduplicatePageState extends State { @override void dispose() { _deleteProgress.dispose(); + _scrollController.dispose(); + _scrollbarInUseNotifier.dispose(); super.dispose(); } @@ -95,19 +102,28 @@ class _DeduplicatePageState extends State { children: [ Expanded( child: _duplicates.isNotEmpty - ? ListView.builder( - cacheExtent: 400, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: _getGridView( - _duplicates[index], - index, - ), - ); - }, - itemCount: _duplicates.length, - shrinkWrap: true, + ? ScrollbarWithUseNotifer( + controller: _scrollController, + inUseNotifier: _scrollbarInUseNotifier, + minScrollbarLength: 36.0, + interactive: true, + thickness: 8, + radius: const Radius.circular(4), + child: ListView.builder( + controller: _scrollController, + cacheExtent: 400, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _getGridView( + _duplicates[index], + index, + ), + ); + }, + itemCount: _duplicates.length, + shrinkWrap: true, + ), ) : const Padding( padding: EdgeInsets.only(top: 32), diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index 7693c80bfc..c1222f7fcb 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -19,6 +19,7 @@ import "package:photos/ui/components/models/button_type.dart"; import "package:photos/ui/components/toggle_switch_widget.dart"; import "package:photos/ui/viewer/file/detail_page.dart"; import "package:photos/ui/viewer/file/thumbnail_widget.dart"; +import "package:photos/ui/viewer/gallery/scrollbar/scroll_bar_with_use_notifier.dart"; import "package:photos/utils/delete_file_util.dart"; import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/navigation_util.dart"; @@ -61,11 +62,15 @@ class _SimilarImagesPageState extends State { bool _exactSearch = false; late SelectedFiles _selectedFiles; + late ScrollController _scrollController; + late ValueNotifier _scrollbarInUseNotifier; @override void initState() { super.initState(); _selectedFiles = SelectedFiles(); + _scrollController = ScrollController(); + _scrollbarInUseNotifier = ValueNotifier(false); if (!widget.debugScreen) { _findSimilarImages(); @@ -76,6 +81,8 @@ class _SimilarImagesPageState extends State { void dispose() { _isDisposed = true; _selectedFiles.dispose(); + _scrollController.dispose(); + _scrollbarInUseNotifier.dispose(); super.dispose(); } @@ -260,37 +267,46 @@ class _SimilarImagesPageState extends State { return Column( children: [ Expanded( - child: ListView.builder( - cacheExtent: 400, - itemCount: _similarFilesList.length + 1, // +1 for header - itemBuilder: (context, index) { - if (index == 0) { - if (kDebugMode) { - return Container( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Text( - "(I) Found ${_similarFilesList.length} groups of similar images", - style: textTheme.bodyBold, - ), - const SizedBox(height: 4), - Text( - "(I) Threshold: ${_distanceThreshold.toStringAsFixed(2)}", - style: textTheme.miniMuted, - ), - ], - ), - ); - } else { - return const SizedBox.shrink(); + child: ScrollbarWithUseNotifer( + controller: _scrollController, + inUseNotifier: _scrollbarInUseNotifier, + minScrollbarLength: 36.0, + interactive: true, + thickness: 8, + radius: const Radius.circular(4), + child: ListView.builder( + controller: _scrollController, + cacheExtent: 400, + itemCount: _similarFilesList.length + 1, // +1 for header + itemBuilder: (context, index) { + if (index == 0) { + if (kDebugMode) { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + "(I) Found ${_similarFilesList.length} groups of similar images", + style: textTheme.bodyBold, + ), + const SizedBox(height: 4), + Text( + "(I) Threshold: ${_distanceThreshold.toStringAsFixed(2)}", + style: textTheme.miniMuted, + ), + ], + ), + ); + } else { + return const SizedBox.shrink(); + } } - } - // Similar files groups (index - 1 because first item is header) - final similarFiles = _similarFilesList[index - 1]; - return _buildSimilarFilesGroup(similarFiles); - }, + // Similar files groups (index - 1 because first item is header) + final similarFiles = _similarFilesList[index - 1]; + return _buildSimilarFilesGroup(similarFiles); + }, + ), ), ), _getBottomActionButtons(), From 4d4cce091f11c1a97c7f9830525feebbb9ea1e14 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 14:13:49 +0530 Subject: [PATCH 21/26] Fix json decoding issue --- mobile/apps/photos/lib/models/similar_files.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/apps/photos/lib/models/similar_files.dart b/mobile/apps/photos/lib/models/similar_files.dart index eadbc34cb1..e0c2a14a9f 100644 --- a/mobile/apps/photos/lib/models/similar_files.dart +++ b/mobile/apps/photos/lib/models/similar_files.dart @@ -54,7 +54,7 @@ class SimilarFiles { Map fileMap, ) { final fileIds = List.from(json['fileIDs']); - final furthestDistance = json['distance'].toDouble(); + final furthestDistance = (json['distance'] as num).toDouble(); final files = []; for (final fileId in fileIds) { @@ -123,9 +123,9 @@ class SimilarFilesCache { ) { return SimilarFilesCache( similarFilesJsonStringList: - json['similarFilesJsonStringList'] as List, - allCheckedFileIDs: Set.from(json['allCheckedFileIDs']), - distanceThreshold: json['distanceThreshold'] as double, + List.from(json['similarFilesJsonStringList']), + allCheckedFileIDs: Set.from(json['allCheckedFileIDs']), + distanceThreshold: (json['distanceThreshold'] as num).toDouble(), exact: json['exact'] as bool, cachedTime: json['cachedTime'] as int, ); From 9600b26359faf12d79ebdf0cb63b2b2d6dadf23d Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 15:10:40 +0530 Subject: [PATCH 22/26] More stable sort --- .../lib/ui/tools/similar_images_page.dart | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index c1222f7fcb..01a4434f0c 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -424,12 +424,24 @@ class _SimilarImagesPageState extends State { _similarFilesList.sort((a, b) => b.totalSize.compareTo(a.totalSize)); break; case SortKey.distanceAsc: - _similarFilesList - .sort((a, b) => a.furthestDistance.compareTo(b.furthestDistance)); + _similarFilesList.sort((a, b) { + final distanceComparison = + a.furthestDistance.compareTo(b.furthestDistance); + if (distanceComparison != 0) { + return distanceComparison; + } + return b.totalSize.compareTo(a.totalSize); + }); break; case SortKey.distanceDesc: - _similarFilesList - .sort((a, b) => b.furthestDistance.compareTo(a.furthestDistance)); + _similarFilesList.sort((a, b) { + final distanceComparison = + b.furthestDistance.compareTo(a.furthestDistance); + if (distanceComparison != 0) { + return distanceComparison; + } + return b.totalSize.compareTo(a.totalSize); + }); break; case SortKey.count: _similarFilesList From 70f4325c714cb71ee76014fc56fe12e5719948f1 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 15:18:05 +0530 Subject: [PATCH 23/26] Refresh for small size --- .../machine_learning/similar_images_service.dart | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index f9d9fceb02..efadcc1cb3 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -107,6 +107,11 @@ class SimilarImagesService { needsFullRefresh = true; } + // Check condition: less than 1000 files + if (currentFileIDs.length < 1000) { + needsFullRefresh = true; + } + // Check condition: cache is older than a month if (DateTime.fromMillisecondsSinceEpoch(cachedData.cachedTime) .isBefore(DateTime.now().subtract(const Duration(days: 30)))) { @@ -177,7 +182,7 @@ class SimilarImagesService { final currentFileIDsSet = currentFileIDs.map((id) => id.toInt()).toSet(); final deletedFiles = cachedFileIDs.difference(currentFileIDsSet); - // Step 1: Clean up deleted files from existing groups + // Clean up deleted files from existing groups if (deletedFiles.isNotEmpty) { for (final group in existingGroups) { final filesInGroupToDelete = []; @@ -194,13 +199,13 @@ class SimilarImagesService { // Remove empty groups existingGroups.removeWhere((group) => group.length <= 1); - // Step 2: Identify new files + // Identify new files final newFileIDs = currentFileIDsSet.difference(cachedFileIDs); if (newFileIDs.isEmpty) { return existingGroups; } - // Step 3: Search only new files + // Search only new files final newFileIDsList = Uint64List.fromList(newFileIDs.toList()); final (keys, vectorKeys, distances) = await MLComputer.instance.bulkVectorSearchWithKeys( @@ -209,7 +214,7 @@ class SimilarImagesService { ); final keysList = keys.map((key) => key.toInt()).toList(); - // Step 4: Try to assign new files to existing groups + // Try to assign new files to existing groups final unassignedNewFilesIndices = {}; final unassignedNewFileIDs = {}; for (int i = 0; i < keysList.length; i++) { @@ -250,7 +255,7 @@ class SimilarImagesService { } } - // Step 5: Check if unassigned new files form groups among themselves + // Check if unassigned new files form groups among themselves if (unassignedNewFilesIndices.isNotEmpty) { final alreadyUsedNewFiles = {}; for (final searchIndex in unassignedNewFilesIndices) { From add2f0c8de027826f9c460c5764efd5f1a9176c8 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 15:34:05 +0530 Subject: [PATCH 24/26] Debug option for full refresh --- .../similar_images_service.dart | 22 +++++++++++++- .../lib/ui/tools/similar_images_page.dart | 29 +++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index efadcc1cb3..77fbb309c4 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -27,11 +27,12 @@ class SimilarImagesService { Future> getSimilarFiles( double distanceThreshold, { bool exact = false, + bool forceRefresh = false, }) async { try { final now = DateTime.now(); final List result = - await _getSimilarFiles(distanceThreshold, exact); + await _getSimilarFiles(distanceThreshold, exact, forceRefresh); final duration = DateTime.now().difference(now); _logger.info( "Found ${result.length} similar files in ${duration.inSeconds} seconds for threshold $distanceThreshold and exact $exact", @@ -46,6 +47,7 @@ class SimilarImagesService { Future> _getSimilarFiles( double distanceThreshold, bool exact, + bool forceRefresh, ) async { final w = (kDebugMode ? EnteWatch('getSimilarFiles') : null)?..start(); final mlDataDB = MLDataDB.instance; @@ -86,6 +88,24 @@ class SimilarImagesService { } w?.log("getFileIDToPersonIDs"); + if (forceRefresh) { + final result = await _performFullSearch( + potentialKeys, + allFileIdsToFile, + fileIDToPersonIDs, + distanceThreshold, + exact, + ); + await _cacheSimilarFiles( + result, + fileIDs.toSet(), + distanceThreshold, + exact, + DateTime.now().millisecondsSinceEpoch, + ); + return result; + } + // Load cached data final SimilarFilesCache? cachedData = await _readCachedSimilarFiles(); if (cachedData == null) { diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index 01a4434f0c..6df710e09c 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -60,6 +60,7 @@ class _SimilarImagesPageState extends State { List _similarFilesList = []; SortKey _sortKey = SortKey.distanceAsc; bool _exactSearch = false; + bool _fullRefresh = false; late SelectedFiles _selectedFiles; late ScrollController _scrollController; @@ -185,7 +186,7 @@ class _SimilarImagesPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Exact search", // TODO: lau: extract string + "Exact search", style: textTheme.bodyBold, ), ToggleSwitchWidget( @@ -199,6 +200,25 @@ class _SimilarImagesPageState extends State { ), ], ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Full refresh", + style: textTheme.bodyBold, + ), + ToggleSwitchWidget( + value: () => _fullRefresh, + onChanged: () async { + if (_isDisposed) return; + setState(() { + _fullRefresh = !_fullRefresh; + }); + }, + ), + ], + ), const SizedBox(height: 32), ButtonWidget( labelText: "Find similar images", // TODO: lau: extract string @@ -389,8 +409,11 @@ class _SimilarImagesPageState extends State { // You can use _toggleValue here for advanced mode features _logger.info("exact mode: $_exactSearch"); - final similarFiles = await SimilarImagesService.instance - .getSimilarFiles(_distanceThreshold, exact: _exactSearch); + final similarFiles = await SimilarImagesService.instance.getSimilarFiles( + _distanceThreshold, + exact: _exactSearch, + forceRefresh: _fullRefresh, + ); _logger.info( "Found ${similarFiles.length} groups of similar images", ); From 3862644dd553a3bcbe4dbd11e9ce40f2a7abd149 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 16:07:52 +0530 Subject: [PATCH 25/26] Show info at top --- .../lib/ui/tools/similar_images_page.dart | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index 6df710e09c..842aefef65 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -1,6 +1,6 @@ import "dart:async"; -import 'package:flutter/foundation.dart' show kDebugMode; +import "package:flutter/foundation.dart" show kDebugMode; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/constants.dart'; @@ -93,7 +93,7 @@ class _SimilarImagesPageState extends State { appBar: AppBar( elevation: 0, title: const Text("Similar images"), // TODO: lau: extract string - actions: _pageState == SimilarImagesPageState.results && kDebugMode + actions: _pageState == SimilarImagesPageState.results ? [_getSortMenu()] : null, ), @@ -300,26 +300,43 @@ class _SimilarImagesPageState extends State { itemCount: _similarFilesList.length + 1, // +1 for header itemBuilder: (context, index) { if (index == 0) { - if (kDebugMode) { - return Container( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Text( - "(I) Found ${_similarFilesList.length} groups of similar images", - style: textTheme.bodyBold, + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.fillFaint, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.photo_library_outlined, + size: 20, + color: colorScheme.textMuted, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${_similarFilesList.length} ${_similarFilesList.length == 1 ? 'group' : 'groups'} found", // TODO: lau: extract string + style: textTheme.bodyBold, + ), + const SizedBox(height: 4), + Text( + "Review and remove similar images", // TODO: lau: extract string + style: textTheme.miniMuted, + ), + ], ), - const SizedBox(height: 4), - Text( - "(I) Threshold: ${_distanceThreshold.toStringAsFixed(2)}", - style: textTheme.miniMuted, - ), - ], - ), - ); - } else { - return const SizedBox.shrink(); - } + ), + ], + ), + ); } // Similar files groups (index - 1 because first item is header) @@ -838,10 +855,10 @@ class _SimilarImagesPageState extends State { text = "Size"; // TODO: lau: extract string break; case SortKey.distanceAsc: - text = "Distance ascending"; // TODO: lau: extract string + text = "Similarity (Desc.)"; // TODO: lau: extract string break; case SortKey.distanceDesc: - text = "Distance descending"; // TODO: lau: extract string + text = "Similarity (Asc.)"; // TODO: lau: extract string break; case SortKey.count: text = "Count"; // TODO: lau: extract string From 6f6770d677d58f35be4ab2cb4ea4ca05321abd10 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 21 Aug 2025 17:58:43 +0530 Subject: [PATCH 26/26] Selection options --- .../lib/ui/tools/similar_images_page.dart | 213 +++++++++++++----- 1 file changed, 152 insertions(+), 61 deletions(-) diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index 842aefef65..9c18f93b98 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -14,6 +14,7 @@ import "package:photos/services/collections_service.dart"; import "package:photos/services/machine_learning/similar_images_service.dart"; import 'package:photos/theme/ente_theme.dart'; import "package:photos/ui/common/loading_widget.dart"; +import 'package:photos/ui/components/action_sheet_widget.dart'; import 'package:photos/ui/components/buttons/button_widget.dart'; import "package:photos/ui/components/models/button_type.dart"; import "package:photos/ui/components/toggle_switch_widget.dart"; @@ -50,7 +51,6 @@ class SimilarImagesPage extends StatefulWidget { class _SimilarImagesPageState extends State { static const crossAxisCount = 3; static const crossAxisSpacing = 12.0; - static const autoSelectDistanceThreshold = 0.01; final _logger = Logger("SimilarImagesPage"); bool _isDisposed = false; @@ -61,6 +61,7 @@ class _SimilarImagesPageState extends State { SortKey _sortKey = SortKey.distanceAsc; bool _exactSearch = false; bool _fullRefresh = false; + bool _isSelectionSheetOpen = false; late SelectedFiles _selectedFiles; late ScrollController _scrollController; @@ -363,54 +364,65 @@ class _SimilarImagesPageState extends State { totalSize += file.fileSize ?? 0; } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: hasSelectedFiles - ? SafeArea( - child: Container( - key: const ValueKey('bottom_buttons'), - padding: const EdgeInsets.symmetric( - horizontal: crossAxisSpacing, - vertical: 8, + return SafeArea( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: crossAxisSpacing, + vertical: 8, + ), + decoration: BoxDecoration( + color: getEnteColorScheme(context).backgroundBase, + ), + child: AnimatedSwitcher( + duration: Duration.zero, + child: Column( + key: ValueKey(hasSelectedFiles), + children: [ + if (hasSelectedFiles && !_isSelectionSheetOpen) ...[ + SizedBox( + width: double.infinity, + child: ButtonWidget( + labelText: + "Delete $selectedCount photos (${formatBytes(totalSize)})", // TODO: lau: extract string + buttonType: ButtonType.critical, + shouldSurfaceExecutionStates: false, + shouldShowSuccessConfirmation: false, + onTap: () async { + await _deleteFiles( + _selectedFiles.files, + showDialog: true, + ); + }, + ), ), - decoration: BoxDecoration( - color: getEnteColorScheme(context).backgroundBase, + const SizedBox(height: 8), + ], + if (!_isSelectionSheetOpen) + SizedBox( + width: double.infinity, + child: ButtonWidget( + labelText: + "Selection options", // TODO: lau: extract string + buttonType: ButtonType.secondary, + shouldSurfaceExecutionStates: false, + shouldShowSuccessConfirmation: false, + onTap: () async { + setState(() { + _isSelectionSheetOpen = true; + }); + await _showSelectionOptionsSheet(); + if (mounted) { + setState(() { + _isSelectionSheetOpen = false; + }); + } + }, + ), ), - child: Column( - children: [ - SizedBox( - width: double.infinity, - child: ButtonWidget( - labelText: - "Delete $selectedCount photos (${formatBytes(totalSize)})", // TODO: lau: extract string - buttonType: ButtonType.critical, - onTap: () async { - await _deleteFiles( - _selectedFiles.files, - showDialog: true, - ); - }, - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: ButtonWidget( - labelText: - "Unselect all", // TODO: lau: extract string - buttonType: ButtonType.secondary, - onTap: () async { - _selectedFiles.clearAll(fireEvent: false); - }, - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink(key: ValueKey('empty')), + ], + ), + ), + ), ); }, ); @@ -438,7 +450,6 @@ class _SimilarImagesPageState extends State { _similarFilesList = similarFiles; _pageState = SimilarImagesPageState.results; _sortSimilarFiles(); - _autoSelectSimilarFiles(); if (_isDisposed) return; setState(() {}); @@ -492,16 +503,11 @@ class _SimilarImagesPageState extends State { setState(() {}); } - void _autoSelectSimilarFiles() { + void _selectFilesByThreshold(double threshold) { final filesToSelect = {}; - int groupsProcessed = 0; - int groupsAutoSelected = 0; for (final similarFilesGroup in _similarFilesList) { - groupsProcessed++; - if (similarFilesGroup.furthestDistance < autoSelectDistanceThreshold) { - groupsAutoSelected++; - // Skip the first file (keep it unselected) and select the rest + if (similarFilesGroup.furthestDistance <= threshold) { for (int i = 1; i < similarFilesGroup.files.length; i++) { filesToSelect.add(similarFilesGroup.files[i]); } @@ -509,17 +515,102 @@ class _SimilarImagesPageState extends State { } if (filesToSelect.isNotEmpty) { + _selectedFiles.clearAll(fireEvent: false); _selectedFiles.selectAll(filesToSelect); - _logger.info( - "Auto-selected ${filesToSelect.length} files from $groupsAutoSelected/$groupsProcessed groups (threshold: $autoSelectDistanceThreshold)", - ); } else { - _logger.info( - "No files auto-selected from $groupsProcessed groups (threshold: $autoSelectDistanceThreshold)", - ); + _selectedFiles.clearAll(fireEvent: false); } } + Future _showSelectionOptionsSheet() async { + // Calculate how many files fall into each category + int exactFiles = 0; + int similarFiles = 0; + int allFiles = 0; + + for (final group in _similarFilesList) { + final duplicateCount = group.files.length - 1; // Exclude the first file + allFiles += duplicateCount; + + if (group.furthestDistance <= 0.0) { + exactFiles += duplicateCount; + similarFiles += duplicateCount; + } else if (group.furthestDistance <= 0.02) { + similarFiles += duplicateCount; + } + } + + final String exactLabel = exactFiles > 0 + ? "Select exact ($exactFiles)" // TODO: lau: extract string + : "Select exact"; // TODO: lau: extract string + + final String similarLabel = similarFiles > 0 + ? "Select similar ($similarFiles)" // TODO: lau: extract string + : "Select similar"; // TODO: lau: extract string + + final String allLabel = allFiles > 0 + ? "Select all ($allFiles)" // TODO: lau: extract string + : "Select all"; // TODO: lau: extract string + + await showActionSheet( + context: context, + title: "Select similar images", // TODO: lau: extract string + body: + "Choose which similar images to select for deletion", // TODO: lau: extract string + buttons: [ + ButtonWidget( + labelText: exactLabel, + buttonType: ButtonType.neutral, + buttonSize: ButtonSize.large, + shouldStickToDarkTheme: true, + isInAlert: true, + buttonAction: ButtonAction.first, + shouldSurfaceExecutionStates: false, + onTap: () async { + _selectFilesByThreshold(0.0); + }, + ), + ButtonWidget( + labelText: similarLabel, + buttonType: ButtonType.neutral, + buttonSize: ButtonSize.large, + shouldStickToDarkTheme: true, + isInAlert: true, + buttonAction: ButtonAction.second, + shouldSurfaceExecutionStates: false, + onTap: () async { + _selectFilesByThreshold(0.02); + }, + ), + ButtonWidget( + labelText: allLabel, + buttonType: ButtonType.neutral, + buttonSize: ButtonSize.large, + shouldStickToDarkTheme: true, + isInAlert: true, + buttonAction: ButtonAction.third, + shouldSurfaceExecutionStates: false, + onTap: () async { + _selectFilesByThreshold(0.05); + }, + ), + ButtonWidget( + labelText: "Clear selection", // TODO: lau: extract string + buttonType: ButtonType.secondary, + buttonSize: ButtonSize.large, + shouldStickToDarkTheme: true, + isInAlert: true, + buttonAction: ButtonAction.cancel, + shouldSurfaceExecutionStates: false, + onTap: () async { + _selectedFiles.clearAll(fireEvent: false); + }, + ), + ], + actionSheetType: ActionSheetType.defaultActionSheet, + ); + } + Widget _buildSimilarFilesGroup(SimilarFiles similarFiles) { final textTheme = getEnteTextTheme(context); return Padding(