From 12bb39a111f447e41ecdc51cf52701da409500e8 Mon Sep 17 00:00:00 2001 From: Viktor Mykhailiv Date: Tue, 28 Oct 2025 21:17:26 +0000 Subject: [PATCH] feat(mobile): view similar photos (#22148) * feat: view similar photos on mobile # Conflicts: # mobile/lib/models/search/search_filter.model.dart # mobile/lib/utils/action_button.utils.dart * fix: bottom sheet is unusable after navigating to search * feat(mobile): open DriftSearchPage as root route * reset search state on tab navigation * fix tests --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../repositories/search_api.repository.dart | 7 ++- .../models/search/search_filter.model.dart | 9 +++- mobile/lib/pages/common/tab_shell.page.dart | 7 ++- .../pages/search/drift_search.page.dart | 10 ++-- .../search/paginated_search.provider.dart | 17 ++++++ .../similar_photos_action_button.widget.dart | 50 +++++++++++++++++ mobile/lib/routing/router.gr.dart | 31 ++--------- mobile/lib/utils/action_button.utils.dart | 8 ++- .../test/utils/action_button_utils_test.dart | 54 ++++++++++++++++++- 9 files changed, 155 insertions(+), 38 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index cb97df72dc..34870dc1b3 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -5,6 +5,7 @@ import 'package:openapi/api.dart'; class SearchApiRepository extends ApiRepository { final SearchApi _api; + const SearchApiRepository(this._api); Future search(SearchFilter filter, int page) { @@ -15,10 +16,12 @@ class SearchApiRepository extends ApiRepository { type = AssetTypeEnum.VIDEO; } - if (filter.context != null && filter.context!.isNotEmpty) { + if ((filter.context != null && filter.context!.isNotEmpty) || + (filter.assetId != null && filter.assetId!.isNotEmpty)) { return _api.searchSmart( SmartSearchDto( - query: filter.context!, + query: filter.context, + queryAssetId: filter.assetId, language: filter.language, country: filter.location.country, state: filter.location.state, diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index bc6a17b265..93322f5031 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -178,6 +178,7 @@ class SearchFilter { String? description; String? ocr; String? language; + String? assetId; Set people; SearchLocationFilter location; SearchCameraFilter camera; @@ -193,6 +194,7 @@ class SearchFilter { this.description, this.ocr, this.language, + this.assetId, required this.people, required this.location, required this.camera, @@ -205,6 +207,7 @@ class SearchFilter { return (context == null || (context != null && context!.isEmpty)) && (filename == null || (filename!.isEmpty)) && (description == null || (description!.isEmpty)) && + (assetId == null || (assetId!.isEmpty)) && (ocr == null || (ocr!.isEmpty)) && people.isEmpty && location.country == null && @@ -226,6 +229,7 @@ class SearchFilter { String? description, String? language, String? ocr, + String? assetId, Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, @@ -239,6 +243,7 @@ class SearchFilter { description: description ?? this.description, language: language ?? this.language, ocr: ocr ?? this.ocr, + assetId: assetId ?? this.assetId, people: people ?? this.people, location: location ?? this.location, camera: camera ?? this.camera, @@ -250,7 +255,7 @@ class SearchFilter { @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType, assetId: $assetId)'; } @override @@ -262,6 +267,7 @@ class SearchFilter { other.description == description && other.language == language && other.ocr == ocr && + other.assetId == assetId && other.people == people && other.location == location && other.camera == camera && @@ -277,6 +283,7 @@ class SearchFilter { description.hashCode ^ language.hashCode ^ ocr.hashCode ^ + assetId.hashCode ^ people.hashCode ^ location.hashCode ^ camera.hashCode ^ diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index b60fe1ddc1..c4bf19fe34 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; @@ -77,7 +78,7 @@ class _TabShellPageState extends ConsumerState { } return AutoTabsRouter( - routes: [const MainTimelineRoute(), DriftSearchRoute(), const DriftAlbumsRoute(), const DriftLibraryRoute()], + routes: const [MainTimelineRoute(), DriftSearchRoute(), DriftAlbumsRoute(), DriftLibraryRoute()], duration: const Duration(milliseconds: 600), transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child), builder: (context, child) { @@ -114,6 +115,10 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { ref.invalidate(driftMemoryFutureProvider); } + if (router.activeIndex != 1 && index == 1) { + ref.read(searchPreFilterProvider.notifier).clear(); + } + // On Search page tapped if (router.activeIndex == 1 && index == 1) { ref.read(searchInputFocusProvider).requestFocus(); diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index d631395465..661c8d127d 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -32,15 +32,14 @@ import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.d @RoutePage() class DriftSearchPage extends HookConsumerWidget { - const DriftSearchPage({super.key, this.preFilter}); - - final SearchFilter? preFilter; + const DriftSearchPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final textSearchType = useState(TextSearchType.context); final searchHintText = useState('sunrise_on_the_beach'.t(context: context)); final textSearchController = useTextEditingController(); + final preFilter = ref.watch(searchPreFilterProvider); final filter = useState( SearchFilter( people: preFilter?.people ?? {}, @@ -50,6 +49,7 @@ class DriftSearchPage extends HookConsumerWidget { display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), mediaType: preFilter?.mediaType ?? AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", + assetId: preFilter?.assetId, ), ); @@ -110,8 +110,8 @@ class DriftSearchPage extends HookConsumerWidget { Future.delayed(Duration.zero, () { search(); - if (preFilter!.location.city != null) { - locationCurrentFilterWidget.value = Text(preFilter!.location.city!, style: context.textTheme.labelLarge); + if (preFilter.location.city != null) { + locationCurrentFilterWidget.value = Text(preFilter.location.city!, style: context.textTheme.labelLarge); } }); } diff --git a/mobile/lib/presentation/pages/search/paginated_search.provider.dart b/mobile/lib/presentation/pages/search/paginated_search.provider.dart index c0c822198d..e37aa7e0af 100644 --- a/mobile/lib/presentation/pages/search/paginated_search.provider.dart +++ b/mobile/lib/presentation/pages/search/paginated_search.provider.dart @@ -4,6 +4,23 @@ import 'package:immich_mobile/domain/services/search.service.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; +final searchPreFilterProvider = NotifierProvider(SearchFilterProvider.new); + +class SearchFilterProvider extends Notifier { + @override + SearchFilter? build() { + return null; + } + + void setFilter(SearchFilter? filter) { + state = filter; + } + + void clear() { + state = null; + } +} + final paginatedSearchProvider = StateNotifierProvider( (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), ); diff --git a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart new file mode 100644 index 0000000000..f9ba31e8be --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SimilarPhotosActionButton extends ConsumerWidget { + final String assetId; + + const SimilarPhotosActionButton({super.key, required this.assetId}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + ref.invalidate(assetViewerProvider); + ref + .read(searchPreFilterProvider.notifier) + .setFilter( + SearchFilter( + assetId: assetId, + people: {}, + location: SearchLocationFilter(), + camera: SearchCameraFilter(), + date: SearchDateFilter(), + display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + mediaType: AssetType.image, + ), + ); + unawaited(context.router.popAndPush(const DriftSearchRoute())); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.compare, + label: "view_similar_photos".t(context: context), + onPressed: () => _onTap(context, ref), + maxWidth: 100, + ); + } +} diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 4e60e4fb6a..146b313c2d 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1458,43 +1458,20 @@ class DriftRecentlyTakenRoute extends PageRouteInfo { /// generated route for /// [DriftSearchPage] -class DriftSearchRoute extends PageRouteInfo { - DriftSearchRoute({ - Key? key, - SearchFilter? preFilter, - List? children, - }) : super( - DriftSearchRoute.name, - args: DriftSearchRouteArgs(key: key, preFilter: preFilter), - initialChildren: children, - ); +class DriftSearchRoute extends PageRouteInfo { + const DriftSearchRoute({List? children}) + : super(DriftSearchRoute.name, initialChildren: children); static const String name = 'DriftSearchRoute'; static PageInfo page = PageInfo( name, builder: (data) { - final args = data.argsAs( - orElse: () => const DriftSearchRouteArgs(), - ); - return DriftSearchPage(key: args.key, preFilter: args.preFilter); + return const DriftSearchPage(); }, ); } -class DriftSearchRouteArgs { - const DriftSearchRouteArgs({this.key, this.preFilter}); - - final Key? key; - - final SearchFilter? preFilter; - - @override - String toString() { - return 'DriftSearchRouteArgs{key: $key, preFilter: $preFilter}'; - } -} - /// generated route for /// [DriftTrashPage] class DriftTrashRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index c5a2583531..96a2ecd6f7 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_al import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; @@ -59,7 +60,8 @@ enum ActionButtonType { upload, removeFromAlbum, unstack, - likeActivity; + likeActivity, + similarPhotos; bool shouldShow(ActionButtonContext context) { return switch (this) { @@ -123,6 +125,9 @@ enum ActionButtonType { context.currentAlbum != null && context.currentAlbum!.isActivityEnabled && context.currentAlbum!.isShared, + ActionButtonType.similarPhotos => + !context.isInLockedView && // + context.asset.hasRemote, }; } @@ -147,6 +152,7 @@ enum ActionButtonType { ), ActionButtonType.likeActivity => const LikeActivityActionButton(), ActionButtonType.unstack => UnStackActionButton(source: context.source), + ActionButtonType.similarPhotos => SimilarPhotosActionButton(assetId: (context.asset as RemoteAsset).id), }; } } diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index 274176ae88..d93d59d3c7 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -383,6 +383,42 @@ void main() { }); }); + group('similar photos button', () { + test('should show when not locked and has remote', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + isStacked: false, + currentAlbum: null, + advancedTroubleshooting: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.similarPhotos.shouldShow(context), isTrue); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + isStacked: false, + advancedTroubleshooting: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.similarPhotos.shouldShow(context), isFalse); + }); + }); + group('trash button', () { test('should show when owner, not locked, has remote, and trash enabled', () { final remoteAsset = createRemoteAsset(); @@ -777,6 +813,8 @@ void main() { test('should build correct widget for each button type', () { for (final buttonType in ActionButtonType.values) { + var buttonContext = context; + if (buttonType == ActionButtonType.removeFromAlbum) { final album = createRemoteAlbum(); final contextWithAlbum = ActionButtonContext( @@ -792,6 +830,20 @@ void main() { ); final widget = buttonType.buildButton(contextWithAlbum); expect(widget, isA()); + } else if (buttonType == ActionButtonType.similarPhotos) { + final contextWithAlbum = ActionButtonContext( + asset: createRemoteAsset(), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + final widget = buttonType.buildButton(contextWithAlbum); + expect(widget, isA()); } else if (buttonType == ActionButtonType.unstack) { final album = createRemoteAlbum(); final contextWithAlbum = ActionButtonContext( @@ -808,7 +860,7 @@ void main() { final widget = buttonType.buildButton(contextWithAlbum); expect(widget, isA()); } else { - final widget = buttonType.buildButton(context); + final widget = buttonType.buildButton(buttonContext); expect(widget, isA()); } }