diff --git a/mobile/apps/photos/lib/l10n/intl_en.arb b/mobile/apps/photos/lib/l10n/intl_en.arb index 477cd4f257..84391ae647 100644 --- a/mobile/apps/photos/lib/l10n/intl_en.arb +++ b/mobile/apps/photos/lib/l10n/intl_en.arb @@ -1866,7 +1866,7 @@ "@deletePhotosWithSize": { "placeholders": { "count": { - "type": "int" + "type": "String" }, "size": { "type": "String" @@ -1941,5 +1941,8 @@ "findingSimilarImages": "Finding similar images", "almostDone": "Almost done", "processingLocally": "Processing locally", - "useMLToFindSimilarImages": "Use ML to find images that look similar to each other." -} \ No newline at end of file + "useMLToFindSimilarImages": "Review and remove images that look similar to each other.", + "all": "All", + "similar": "Similar", + "identical": "Identical" +} 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 791518e343..0f9e43a593 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -2,6 +2,7 @@ import "dart:async"; import "package:flutter/foundation.dart" show kDebugMode; import 'package:flutter/material.dart'; +import "package:intl/intl.dart"; import 'package:logging/logging.dart'; import "package:photos/core/configuration.dart"; import 'package:photos/core/constants.dart'; @@ -12,13 +13,15 @@ import "package:photos/models/similar_files.dart"; import "package:photos/service_locator.dart"; import "package:photos/services/collections_service.dart"; import "package:photos/services/machine_learning/similar_images_service.dart"; +import "package:photos/theme/colors.dart"; import 'package:photos/theme/ente_theme.dart'; -import 'package:photos/ui/components/action_sheet_widget.dart'; +import "package:photos/theme/text_style.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"; 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/utils/delete_file_util.dart"; import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/navigation_util.dart"; @@ -37,6 +40,12 @@ enum SortKey { count, } +enum TabFilter { + all, + similar, + identical, +} + class SimilarImagesPage extends StatefulWidget { final bool debugScreen; @@ -49,6 +58,8 @@ class SimilarImagesPage extends StatefulWidget { class _SimilarImagesPageState extends State { static const crossAxisCount = 3; static const crossAxisSpacing = 12.0; + static const double _similarThreshold = 0.02; + static const double _identicalThreshold = 0.0001; final _logger = Logger("SimilarImagesPage"); bool _isDisposed = false; @@ -56,14 +67,29 @@ class _SimilarImagesPageState extends State { SimilarImagesPageState _pageState = SimilarImagesPageState.setup; double _distanceThreshold = 0.04; // Default value List _similarFilesList = []; + SortKey _sortKey = SortKey.distanceAsc; bool _exactSearch = false; bool _fullRefresh = false; - bool _isSelectionSheetOpen = false; + TabFilter _selectedTab = TabFilter.all; late SelectedFiles _selectedFiles; late ValueNotifier _deleteProgress; + List get _filteredGroups { + if (_selectedTab == TabFilter.all) { + return _similarFilesList; + } + + final threshold = _selectedTab == TabFilter.similar + ? _similarThreshold + : _identicalThreshold; + + return _similarFilesList.where((group) { + return group.furthestDistance <= threshold; + }).toList(); + } + @override void initState() { super.initState(); @@ -318,78 +344,122 @@ class _SimilarImagesPageState extends State { return Column( children: [ + _buildTabBar(), Expanded( - child: ListView.builder( - cacheExtent: 400, - itemCount: _similarFilesList.length + 1, // +1 for header - itemBuilder: (context, index) { - if (index == 0) { - return RepaintBoundary( - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: crossAxisSpacing, - vertical: 12, - ), - padding: const EdgeInsets.all(crossAxisSpacing), - 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( - AppLocalizations.of(context).similarGroupsFound( - count: _similarFilesList.length, - ), - style: textTheme.bodyBold, - ), - const SizedBox(height: 4), - Text( - AppLocalizations.of(context) - .reviewAndRemoveSimilarImages, - style: textTheme.miniMuted, - ), - ], - ), - ), - ], - ), - ), - ); - } - - // Similar files groups (index - 1 because first item is header) - final similarFiles = _similarFilesList[index - 1]; - return RepaintBoundary( - child: _buildSimilarFilesGroup(similarFiles), - ); - }, - ), + child: _filteredGroups.isEmpty + ? const EmptyState() + : ListView.builder( + cacheExtent: 400, + itemCount: _filteredGroups.length, + itemBuilder: (context, index) { + final similarFiles = _filteredGroups[index]; + return RepaintBoundary( + child: _buildSimilarFilesGroup(similarFiles), + ); + }, + ), ), - _getBottomActionButtons(), + if (_filteredGroups.isNotEmpty) _getBottomActionButtons(), ], ); } + Widget _buildTabBar() { + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + _buildTabButton( + TabFilter.all, + AppLocalizations.of(context).all, + colorScheme, + textTheme, + ), + const SizedBox(width: crossAxisSpacing), + _buildTabButton( + TabFilter.similar, + AppLocalizations.of(context).similar, + colorScheme, + textTheme, + ), + const SizedBox(width: crossAxisSpacing), + _buildTabButton( + TabFilter.identical, + AppLocalizations.of(context).identical, + colorScheme, + textTheme, + ), + ], + ), + ); + } + + Widget _buildTabButton( + TabFilter tab, + String label, + EnteColorScheme colorScheme, + EnteTextTheme textTheme, + ) { + final isSelected = _selectedTab == tab; + + return GestureDetector( + onTap: () => _onTabChanged(tab), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary700 : colorScheme.fillFaint, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: isSelected + ? textTheme.smallBold.copyWith(color: Colors.white) + : textTheme.smallBold, + ), + ), + ); + } + + void _onTabChanged(TabFilter newTab) { + setState(() { + _selectedTab = newTab; + + final newSelection = {}; + for (final group in _filteredGroups) { + for (int i = 1; i < group.files.length; i++) { + newSelection.add(group.files[i]); + } + } + _selectedFiles.clearAll(); + _selectedFiles.selectAll(newSelection); + }); + } + Widget _getBottomActionButtons() { return ListenableBuilder( listenable: _selectedFiles, builder: (context, _) { - final selectedCount = _selectedFiles.files.length; + final selectedFiles = _selectedFiles.files; + final selectedCount = selectedFiles.length; final hasSelectedFiles = selectedCount > 0; + final eligibleFilteredFiles = {}; + for (final group in _filteredGroups) { + for (int i = 1; i < group.files.length; i++) { + eligibleFilteredFiles.add(group.files[i]); + } + } + + final selectedFilteredFiles = + selectedFiles.intersection(eligibleFilteredFiles); + final allFilteredSelected = eligibleFilteredFiles.isNotEmpty && + selectedFilteredFiles.length == eligibleFilteredFiles.length; + int totalSize = 0; - for (final file in _selectedFiles.files) { + for (final file in selectedFilteredFiles) { totalSize += file.fileSize ?? 0; } @@ -424,7 +494,7 @@ class _SimilarImagesPageState extends State { ), ); }, - child: hasSelectedFiles && !_isSelectionSheetOpen + child: hasSelectedFiles ? Column( key: const ValueKey('delete_section'), children: [ @@ -433,7 +503,7 @@ class _SimilarImagesPageState extends State { child: ButtonWidget( labelText: AppLocalizations.of(context) .deletePhotosWithSize( - count: selectedCount, + count: NumberFormat().format(selectedFilteredFiles.length), size: formatBytes(totalSize), ), buttonType: ButtonType.critical, @@ -441,7 +511,7 @@ class _SimilarImagesPageState extends State { shouldShowSuccessConfirmation: false, onTap: () async { await _deleteFiles( - _selectedFiles.files, + selectedFilteredFiles, showDialog: true, showUIFeedback: true, ); @@ -453,27 +523,20 @@ class _SimilarImagesPageState extends State { ) : const SizedBox.shrink(key: ValueKey('no_delete')), ), - if (!_isSelectionSheetOpen) - SizedBox( - width: double.infinity, - child: ButtonWidget( - labelText: AppLocalizations.of(context).selectionOptions, - buttonType: ButtonType.secondary, - shouldSurfaceExecutionStates: false, - shouldShowSuccessConfirmation: false, - onTap: () async { - setState(() { - _isSelectionSheetOpen = true; - }); - await _showSelectionOptionsSheet(); - if (mounted) { - setState(() { - _isSelectionSheetOpen = false; - }); - } - }, - ), + SizedBox( + width: double.infinity, + child: ButtonWidget( + labelText: allFilteredSelected + ? AppLocalizations.of(context).unselectAll + : AppLocalizations.of(context).selectAll, + buttonType: ButtonType.secondary, + shouldSurfaceExecutionStates: false, + shouldShowSuccessConfirmation: false, + onTap: () async { + _toggleSelectAll(); + }, ), + ), ], ), ), @@ -482,6 +545,25 @@ class _SimilarImagesPageState extends State { ); } + void _toggleSelectAll() { + final eligibleFiles = {}; + for (final group in _filteredGroups) { + for (int i = 1; i < group.files.length; i++) { + eligibleFiles.add(group.files[i]); + } + } + + final currentSelected = _selectedFiles.files.intersection(eligibleFiles); + final allSelected = eligibleFiles.isNotEmpty && + currentSelected.length == eligibleFiles.length; + + if (allSelected) { + _selectedFiles.unSelectAll(eligibleFiles); + } else { + _selectedFiles.selectAll(eligibleFiles); + } + } + Future _findSimilarImages() async { if (_isDisposed) return; setState(() { @@ -489,7 +571,6 @@ class _SimilarImagesPageState extends State { }); try { - // You can use _toggleValue here for advanced mode features _logger.info("exact mode: $_exactSearch"); final similarFiles = await SimilarImagesService.instance.getSimilarFiles( @@ -505,6 +586,14 @@ class _SimilarImagesPageState extends State { _pageState = SimilarImagesPageState.results; _sortSimilarFiles(); + for (final group in _similarFilesList) { + if (group.files.length > 1) { + for (int i = 1; i < group.files.length; i++) { + _selectedFiles.toggleSelection(group.files[i]); + } + } + } + if (_isDisposed) return; setState(() {}); @@ -557,114 +646,6 @@ class _SimilarImagesPageState extends State { setState(() {}); } - void _selectFilesByThreshold(double threshold) { - final filesToSelect = {}; - - for (final similarFilesGroup in _similarFilesList) { - if (similarFilesGroup.furthestDistance <= threshold) { - for (int i = 1; i < similarFilesGroup.files.length; i++) { - filesToSelect.add(similarFilesGroup.files[i]); - } - } - } - - if (filesToSelect.isNotEmpty) { - _selectedFiles.clearAll(fireEvent: false); - _selectedFiles.selectAll(filesToSelect); - } else { - _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; - } - } - - // Always show counts, even when 0 - final String exactLabel = - AppLocalizations.of(context).selectExactWithCount(count: exactFiles); - - final String similarLabel = AppLocalizations.of(context) - .selectSimilarWithCount(count: similarFiles); - - final String allLabel = - AppLocalizations.of(context).selectAllWithCount(count: allFiles); - - await showActionSheet( - context: context, - title: AppLocalizations.of(context).selectSimilarImagesTitle, - body: AppLocalizations.of(context).chooseSimilarImagesToSelect, - buttons: [ - ButtonWidget( - labelText: exactLabel, - buttonType: ButtonType.neutral, - buttonSize: ButtonSize.large, - shouldStickToDarkTheme: true, - isInAlert: true, - buttonAction: ButtonAction.first, - shouldSurfaceExecutionStates: false, - isDisabled: exactFiles == 0, - onTap: () async { - _selectFilesByThreshold(0.0); - }, - ), - ButtonWidget( - labelText: similarLabel, - buttonType: ButtonType.neutral, - buttonSize: ButtonSize.large, - shouldStickToDarkTheme: true, - isInAlert: true, - buttonAction: ButtonAction.second, - shouldSurfaceExecutionStates: false, - isDisabled: similarFiles == 0, - onTap: () async { - _selectFilesByThreshold(0.02); - }, - ), - ButtonWidget( - labelText: allLabel, - buttonType: ButtonType.neutral, - buttonSize: ButtonSize.large, - shouldStickToDarkTheme: true, - isInAlert: true, - buttonAction: ButtonAction.third, - shouldSurfaceExecutionStates: false, - isDisabled: allFiles == 0, - onTap: () async { - _selectFilesByThreshold(0.05); - }, - ), - ButtonWidget( - labelText: AppLocalizations.of(context).clearSelection, - 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( @@ -975,15 +956,15 @@ class _SimilarImagesPageState extends State { _similarFilesList.remove(group); } + final int collectionCnt = collectionToFilesToAddMap.keys.length; if (createSymlink) { final userID = Configuration.instance.getUserID(); - final int collectionCnt = collectionToFilesToAddMap.keys.length; int progress = 0; for (final collectionID in collectionToFilesToAddMap.keys) { if (!mounted) { return; } - if (collectionCnt > 0 && showUIFeedback) { + if (collectionCnt > 2 && showUIFeedback) { progress++; // calculate progress percentage upto 2 decimal places final double percentage = (progress / collectionCnt) * 100; @@ -1004,7 +985,7 @@ class _SimilarImagesPageState extends State { } } } - if (showUIFeedback) { + if (collectionCnt > 2 && showUIFeedback) { _deleteProgress.value = ""; } @@ -1013,7 +994,7 @@ class _SimilarImagesPageState extends State { await deleteFilesFromRemoteOnly(context, allDeleteFiles.toList()); // Show congratulations popup - if (allDeleteFiles.isNotEmpty && mounted && showUIFeedback) { + if (allDeleteFiles.length > 100 && mounted && showUIFeedback) { final int totalSize = allDeleteFiles.fold( 0, (sum, file) => sum + (file.fileSize ?? 0),