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 {
final SearchApi _api;
const SearchApiRepository(this._api);
Future<SearchResponseDto?> 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,

View file

@ -178,6 +178,7 @@ class SearchFilter {
String? description;
String? ocr;
String? language;
String? assetId;
Set<PersonDto> 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<PersonDto>? 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 ^

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/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<TabShellPage> {
}
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();

View file

@ -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>(TextSearchType.context);
final searchHintText = useState<String>('sunrise_on_the_beach'.t(context: context));
final textSearchController = useTextEditingController();
final preFilter = ref.watch(searchPreFilterProvider);
final filter = useState<SearchFilter>(
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);
}
});
}

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/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>(
(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
/// [DriftSearchPage]
class DriftSearchRoute extends PageRouteInfo<DriftSearchRouteArgs> {
DriftSearchRoute({
Key? key,
SearchFilter? preFilter,
List<PageRouteInfo>? children,
}) : super(
DriftSearchRoute.name,
args: DriftSearchRouteArgs(key: key, preFilter: preFilter),
initialChildren: children,
);
class DriftSearchRoute extends PageRouteInfo<void> {
const DriftSearchRoute({List<PageRouteInfo>? children})
: super(DriftSearchRoute.name, initialChildren: children);
static const String name = 'DriftSearchRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftSearchRouteArgs>(
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<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/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),
};
}
}

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', () {
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<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) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
@ -808,7 +860,7 @@ void main() {
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else {
final widget = buttonType.buildButton(context);
final widget = buttonType.buildButton(buttonContext);
expect(widget, isA<Widget>());
}
}