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>
This commit is contained in:
Viktor Mykhailiv 2025-10-28 21:17:26 +00:00 committed by GitHub
parent 9098717c55
commit 12bb39a111
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 155 additions and 38 deletions

View file

@ -5,6 +5,7 @@ import 'package:openapi/api.dart';
class SearchApiRepository extends ApiRepository { class SearchApiRepository extends ApiRepository {
final SearchApi _api; final SearchApi _api;
const SearchApiRepository(this._api); const SearchApiRepository(this._api);
Future<SearchResponseDto?> search(SearchFilter filter, int page) { Future<SearchResponseDto?> search(SearchFilter filter, int page) {
@ -15,10 +16,12 @@ class SearchApiRepository extends ApiRepository {
type = AssetTypeEnum.VIDEO; 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( return _api.searchSmart(
SmartSearchDto( SmartSearchDto(
query: filter.context!, query: filter.context,
queryAssetId: filter.assetId,
language: filter.language, language: filter.language,
country: filter.location.country, country: filter.location.country,
state: filter.location.state, state: filter.location.state,

View file

@ -178,6 +178,7 @@ class SearchFilter {
String? description; String? description;
String? ocr; String? ocr;
String? language; String? language;
String? assetId;
Set<PersonDto> people; Set<PersonDto> people;
SearchLocationFilter location; SearchLocationFilter location;
SearchCameraFilter camera; SearchCameraFilter camera;
@ -193,6 +194,7 @@ class SearchFilter {
this.description, this.description,
this.ocr, this.ocr,
this.language, this.language,
this.assetId,
required this.people, required this.people,
required this.location, required this.location,
required this.camera, required this.camera,
@ -205,6 +207,7 @@ class SearchFilter {
return (context == null || (context != null && context!.isEmpty)) && return (context == null || (context != null && context!.isEmpty)) &&
(filename == null || (filename!.isEmpty)) && (filename == null || (filename!.isEmpty)) &&
(description == null || (description!.isEmpty)) && (description == null || (description!.isEmpty)) &&
(assetId == null || (assetId!.isEmpty)) &&
(ocr == null || (ocr!.isEmpty)) && (ocr == null || (ocr!.isEmpty)) &&
people.isEmpty && people.isEmpty &&
location.country == null && location.country == null &&
@ -226,6 +229,7 @@ class SearchFilter {
String? description, String? description,
String? language, String? language,
String? ocr, String? ocr,
String? assetId,
Set<PersonDto>? people, Set<PersonDto>? people,
SearchLocationFilter? location, SearchLocationFilter? location,
SearchCameraFilter? camera, SearchCameraFilter? camera,
@ -239,6 +243,7 @@ class SearchFilter {
description: description ?? this.description, description: description ?? this.description,
language: language ?? this.language, language: language ?? this.language,
ocr: ocr ?? this.ocr, ocr: ocr ?? this.ocr,
assetId: assetId ?? this.assetId,
people: people ?? this.people, people: people ?? this.people,
location: location ?? this.location, location: location ?? this.location,
camera: camera ?? this.camera, camera: camera ?? this.camera,
@ -250,7 +255,7 @@ class SearchFilter {
@override @override
String toString() { 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 @override
@ -262,6 +267,7 @@ class SearchFilter {
other.description == description && other.description == description &&
other.language == language && other.language == language &&
other.ocr == ocr && other.ocr == ocr &&
other.assetId == assetId &&
other.people == people && other.people == people &&
other.location == location && other.location == location &&
other.camera == camera && other.camera == camera &&
@ -277,6 +283,7 @@ class SearchFilter {
description.hashCode ^ description.hashCode ^
language.hashCode ^ language.hashCode ^
ocr.hashCode ^ ocr.hashCode ^
assetId.hashCode ^
people.hashCode ^ people.hashCode ^
location.hashCode ^ location.hashCode ^
camera.hashCode ^ camera.hashCode ^

View file

@ -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/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
@ -77,7 +78,7 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
} }
return AutoTabsRouter( return AutoTabsRouter(
routes: [const MainTimelineRoute(), DriftSearchRoute(), const DriftAlbumsRoute(), const DriftLibraryRoute()], routes: const [MainTimelineRoute(), DriftSearchRoute(), DriftAlbumsRoute(), DriftLibraryRoute()],
duration: const Duration(milliseconds: 600), duration: const Duration(milliseconds: 600),
transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child), transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child),
builder: (context, child) { builder: (context, child) {
@ -114,6 +115,10 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
ref.invalidate(driftMemoryFutureProvider); ref.invalidate(driftMemoryFutureProvider);
} }
if (router.activeIndex != 1 && index == 1) {
ref.read(searchPreFilterProvider.notifier).clear();
}
// On Search page tapped // On Search page tapped
if (router.activeIndex == 1 && index == 1) { if (router.activeIndex == 1 && index == 1) {
ref.read(searchInputFocusProvider).requestFocus(); ref.read(searchInputFocusProvider).requestFocus();

View file

@ -32,15 +32,14 @@ import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.d
@RoutePage() @RoutePage()
class DriftSearchPage extends HookConsumerWidget { class DriftSearchPage extends HookConsumerWidget {
const DriftSearchPage({super.key, this.preFilter}); const DriftSearchPage({super.key});
final SearchFilter? preFilter;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final textSearchType = useState<TextSearchType>(TextSearchType.context); final textSearchType = useState<TextSearchType>(TextSearchType.context);
final searchHintText = useState<String>('sunrise_on_the_beach'.t(context: context)); final searchHintText = useState<String>('sunrise_on_the_beach'.t(context: context));
final textSearchController = useTextEditingController(); final textSearchController = useTextEditingController();
final preFilter = ref.watch(searchPreFilterProvider);
final filter = useState<SearchFilter>( final filter = useState<SearchFilter>(
SearchFilter( SearchFilter(
people: preFilter?.people ?? {}, people: preFilter?.people ?? {},
@ -50,6 +49,7 @@ class DriftSearchPage extends HookConsumerWidget {
display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
mediaType: preFilter?.mediaType ?? AssetType.other, mediaType: preFilter?.mediaType ?? AssetType.other,
language: "${context.locale.languageCode}-${context.locale.countryCode}", language: "${context.locale.languageCode}-${context.locale.countryCode}",
assetId: preFilter?.assetId,
), ),
); );
@ -110,8 +110,8 @@ class DriftSearchPage extends HookConsumerWidget {
Future.delayed(Duration.zero, () { Future.delayed(Duration.zero, () {
search(); search();
if (preFilter!.location.city != null) { if (preFilter.location.city != null) {
locationCurrentFilterWidget.value = Text(preFilter!.location.city!, style: context.textTheme.labelLarge); locationCurrentFilterWidget.value = Text(preFilter.location.city!, style: context.textTheme.labelLarge);
} }
}); });
} }

View file

@ -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/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
final searchPreFilterProvider = NotifierProvider<SearchFilterProvider, SearchFilter?>(SearchFilterProvider.new);
class SearchFilterProvider extends Notifier<SearchFilter?> {
@override
SearchFilter? build() {
return null;
}
void setFilter(SearchFilter? filter) {
state = filter;
}
void clear() {
state = null;
}
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>( final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
); );

View file

@ -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,
);
}
}

View file

@ -1458,43 +1458,20 @@ class DriftRecentlyTakenRoute extends PageRouteInfo<void> {
/// generated route for /// generated route for
/// [DriftSearchPage] /// [DriftSearchPage]
class DriftSearchRoute extends PageRouteInfo<DriftSearchRouteArgs> { class DriftSearchRoute extends PageRouteInfo<void> {
DriftSearchRoute({ const DriftSearchRoute({List<PageRouteInfo>? children})
Key? key, : super(DriftSearchRoute.name, initialChildren: children);
SearchFilter? preFilter,
List<PageRouteInfo>? children,
}) : super(
DriftSearchRoute.name,
args: DriftSearchRouteArgs(key: key, preFilter: preFilter),
initialChildren: children,
);
static const String name = 'DriftSearchRoute'; static const String name = 'DriftSearchRoute';
static PageInfo page = PageInfo( static PageInfo page = PageInfo(
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<DriftSearchRouteArgs>( return const DriftSearchPage();
orElse: () => const DriftSearchRouteArgs(),
);
return DriftSearchPage(key: args.key, preFilter: args.preFilter);
}, },
); );
} }
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 /// generated route for
/// [DriftTrashPage] /// [DriftTrashPage]
class DriftTrashRoute extends PageRouteInfo<void> { class DriftTrashRoute extends PageRouteInfo<void> {

View file

@ -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/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_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/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/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/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
@ -59,7 +60,8 @@ enum ActionButtonType {
upload, upload,
removeFromAlbum, removeFromAlbum,
unstack, unstack,
likeActivity; likeActivity,
similarPhotos;
bool shouldShow(ActionButtonContext context) { bool shouldShow(ActionButtonContext context) {
return switch (this) { return switch (this) {
@ -123,6 +125,9 @@ enum ActionButtonType {
context.currentAlbum != null && context.currentAlbum != null &&
context.currentAlbum!.isActivityEnabled && context.currentAlbum!.isActivityEnabled &&
context.currentAlbum!.isShared, context.currentAlbum!.isShared,
ActionButtonType.similarPhotos =>
!context.isInLockedView && //
context.asset.hasRemote,
}; };
} }
@ -147,6 +152,7 @@ enum ActionButtonType {
), ),
ActionButtonType.likeActivity => const LikeActivityActionButton(), ActionButtonType.likeActivity => const LikeActivityActionButton(),
ActionButtonType.unstack => UnStackActionButton(source: context.source), ActionButtonType.unstack => UnStackActionButton(source: context.source),
ActionButtonType.similarPhotos => SimilarPhotosActionButton(assetId: (context.asset as RemoteAsset).id),
}; };
} }
} }

View file

@ -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', () { group('trash button', () {
test('should show when owner, not locked, has remote, and trash enabled', () { test('should show when owner, not locked, has remote, and trash enabled', () {
final remoteAsset = createRemoteAsset(); final remoteAsset = createRemoteAsset();
@ -777,6 +813,8 @@ void main() {
test('should build correct widget for each button type', () { test('should build correct widget for each button type', () {
for (final buttonType in ActionButtonType.values) { for (final buttonType in ActionButtonType.values) {
var buttonContext = context;
if (buttonType == ActionButtonType.removeFromAlbum) { if (buttonType == ActionButtonType.removeFromAlbum) {
final album = createRemoteAlbum(); final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext( final contextWithAlbum = ActionButtonContext(
@ -792,6 +830,20 @@ void main() {
); );
final widget = buttonType.buildButton(contextWithAlbum); final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>()); expect(widget, isA<Widget>());
} 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<Widget>());
} else if (buttonType == ActionButtonType.unstack) { } else if (buttonType == ActionButtonType.unstack) {
final album = createRemoteAlbum(); final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext( final contextWithAlbum = ActionButtonContext(
@ -808,7 +860,7 @@ void main() {
final widget = buttonType.buildButton(contextWithAlbum); final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>()); expect(widget, isA<Widget>());
} else { } else {
final widget = buttonType.buildButton(context); final widget = buttonType.buildButton(buttonContext);
expect(widget, isA<Widget>()); expect(widget, isA<Widget>());
} }
} }