diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 2c52595a26..7a03b54a95 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -106,6 +106,7 @@ custom_lint: - lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... - lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database + - lib/domain/services/search.service.dart # refactor - lib/models/map/map_marker.model.dart diff --git a/mobile/lib/domain/models/search_result.model.dart b/mobile/lib/domain/models/search_result.model.dart new file mode 100644 index 0000000000..e8c9429432 --- /dev/null +++ b/mobile/lib/domain/models/search_result.model.dart @@ -0,0 +1,38 @@ +import 'package:collection/collection.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +class SearchResult { + final List assets; + final int? nextPage; + + const SearchResult({ + required this.assets, + this.nextPage, + }); + + int get totalAssets => assets.length; + + SearchResult copyWith({ + List? assets, + int? nextPage, + }) { + return SearchResult( + assets: assets ?? this.assets, + nextPage: nextPage ?? this.nextPage, + ); + } + + @override + String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)'; + + @override + bool operator ==(covariant SearchResult other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.assets, assets) && other.nextPage == nextPage; + } + + @override + int get hashCode => assets.hashCode ^ nextPage.hashCode; +} diff --git a/mobile/lib/domain/services/search.service.dart b/mobile/lib/domain/services/search.service.dart new file mode 100644 index 0000000000..052a2ca9da --- /dev/null +++ b/mobile/lib/domain/services/search.service.dart @@ -0,0 +1,92 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/search_result.model.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart' as api show AssetVisibility; +import 'package:openapi/api.dart' hide AssetVisibility; + +class SearchService { + final _log = Logger("SearchService"); + final SearchApiRepository _searchApiRepository; + + SearchService(this._searchApiRepository); + + Future?> getSearchSuggestions( + SearchSuggestionType type, { + String? country, + String? state, + String? make, + String? model, + }) async { + try { + return await _searchApiRepository.getSearchSuggestions( + type, + country: country, + state: state, + make: make, + model: model, + ); + } catch (e) { + _log.warning("Failed to get search suggestions", e); + } + return []; + } + + Future search(SearchFilter filter, int page) async { + try { + final response = await _searchApiRepository.search(filter, page); + + if (response == null || response.assets.items.isEmpty) { + return null; + } + + return SearchResult( + assets: response.assets.items.map((e) => e.toDto()).toList(), + nextPage: response.assets.nextPage?.toInt(), + ); + } catch (error, stackTrace) { + _log.severe("Failed to search for assets", error, stackTrace); + } + return null; + } +} + +extension on AssetResponseDto { + RemoteAsset toDto() { + return RemoteAsset( + id: id, + name: originalFileName, + checksum: checksum, + createdAt: fileCreatedAt, + updatedAt: updatedAt, + ownerId: ownerId, + visibility: switch (visibility) { + api.AssetVisibility.timeline => AssetVisibility.timeline, + api.AssetVisibility.hidden => AssetVisibility.hidden, + api.AssetVisibility.archive => AssetVisibility.archive, + api.AssetVisibility.locked => AssetVisibility.locked, + _ => AssetVisibility.timeline, + }, + durationInSeconds: duration.toDuration()?.inSeconds ?? 0, + height: exifInfo?.exifImageHeight?.toInt(), + width: exifInfo?.exifImageWidth?.toInt(), + isFavorite: isFavorite, + livePhotoVideoId: livePhotoVideoId, + thumbHash: thumbhash, + localId: null, + type: type.toAssetType(), + ); + } +} + +extension on AssetTypeEnum { + AssetType toAssetType() => switch (this) { + AssetTypeEnum.IMAGE => AssetType.image, + AssetTypeEnum.VIDEO => AssetType.video, + AssetTypeEnum.AUDIO => AssetType.audio, + AssetTypeEnum.OTHER => AssetType.other, + _ => throw Exception('Unknown AssetType value: $this'), + }; +} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 14a854a760..0d31f06e74 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -65,6 +65,9 @@ class TimelineFactory { TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy)); + + TimelineService fromAssets(List assets) => + TimelineService(_timelineRepository.fromAssets(assets)); } class TimelineService { diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart new file mode 100644 index 0000000000..55604b885c --- /dev/null +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -0,0 +1,87 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' + hide AssetVisibility; +import 'package:immich_mobile/infrastructure/repositories/api.repository.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:openapi/api.dart'; + +class SearchApiRepository extends ApiRepository { + final SearchApi _api; + const SearchApiRepository(this._api); + + Future search(SearchFilter filter, int page) { + AssetTypeEnum? type; + if (filter.mediaType.index == AssetType.image.index) { + type = AssetTypeEnum.IMAGE; + } else if (filter.mediaType.index == AssetType.video.index) { + type = AssetTypeEnum.VIDEO; + } + + if (filter.context != null && filter.context!.isNotEmpty) { + return _api.searchSmart( + SmartSearchDto( + query: filter.context!, + language: filter.language, + country: filter.location.country, + state: filter.location.state, + city: filter.location.city, + make: filter.camera.make, + model: filter.camera.model, + takenAfter: filter.date.takenAfter, + takenBefore: filter.date.takenBefore, + visibility: filter.display.isArchive + ? AssetVisibility.archive + : AssetVisibility.timeline, + isFavorite: filter.display.isFavorite ? true : null, + isNotInAlbum: filter.display.isNotInAlbum ? true : null, + personIds: filter.people.map((e) => e.id).toList(), + type: type, + page: page, + size: 1000, + ), + ); + } + + return _api.searchAssets( + MetadataSearchDto( + originalFileName: filter.filename != null && filter.filename!.isNotEmpty + ? filter.filename + : null, + country: filter.location.country, + description: + filter.description != null && filter.description!.isNotEmpty + ? filter.description + : null, + state: filter.location.state, + city: filter.location.city, + make: filter.camera.make, + model: filter.camera.model, + takenAfter: filter.date.takenAfter, + takenBefore: filter.date.takenBefore, + visibility: filter.display.isArchive + ? AssetVisibility.archive + : AssetVisibility.timeline, + isFavorite: filter.display.isFavorite ? true : null, + isNotInAlbum: filter.display.isNotInAlbum ? true : null, + personIds: filter.people.map((e) => e.id).toList(), + type: type, + page: page, + size: 1000, + ), + ); + } + + Future?> getSearchSuggestions( + SearchSuggestionType type, { + String? country, + String? state, + String? make, + String? model, + }) => + _api.getSearchSuggestions( + type, + country: country, + state: state, + make: make, + model: model, + ); +} diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 4ec8a93b8d..a6d89b5e8e 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -302,6 +302,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository { .get(); } + TimelineQuery fromAssets(List assets) => ( + bucketSource: () => Stream.value(_generateBuckets(assets.length)), + assetSource: (offset, count) => + Future.value(assets.skip(offset).take(count).toList()), + ); + TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder( filter: (row) => diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 007dc4c9d6..24efff143f 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -133,7 +133,7 @@ class _TabShellPageState extends ConsumerState { return AutoTabsRouter( routes: [ const MainTimelineRoute(), - SearchRoute(), + DriftSearchRoute(), const DriftAlbumsRoute(), const DriftLibraryRoute(), ], diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index bdb426fe22..2815b2db83 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -22,16 +22,6 @@ final _features = [ icon: Icons.timeline_rounded, onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()), ), - _Feature( - name: 'Video', - icon: Icons.video_collection_outlined, - onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()), - ), - _Feature( - name: 'Recently Taken', - icon: Icons.schedule_outlined, - onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()), - ), _Feature( name: 'Selection Mode Timeline', icon: Icons.developer_mode_rounded, diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart new file mode 100644 index 0000000000..5a5eb44b3b --- /dev/null +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -0,0 +1,925 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.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/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; +import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart'; +import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; + +@RoutePage() +class DriftSearchPage extends HookConsumerWidget { + const DriftSearchPage({super.key, this.preFilter}); + + final SearchFilter? preFilter; + + @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 filter = useState( + SearchFilter( + people: preFilter?.people ?? {}, + location: preFilter?.location ?? SearchLocationFilter(), + camera: preFilter?.camera ?? SearchCameraFilter(), + date: preFilter?.date ?? SearchDateFilter(), + display: preFilter?.display ?? + SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: preFilter?.mediaType ?? AssetType.other, + language: + "${context.locale.languageCode}-${context.locale.countryCode}", + ), + ); + + final previousFilter = useState(null); + + final peopleCurrentFilterWidget = useState(null); + final dateRangeCurrentFilterWidget = useState(null); + final cameraCurrentFilterWidget = useState(null); + final locationCurrentFilterWidget = useState(null); + final mediaTypeCurrentFilterWidget = useState(null); + final displayOptionCurrentFilterWidget = useState(null); + + final isSearching = useState(false); + + SnackBar searchInfoSnackBar(String message) { + return SnackBar( + content: Text( + message, + style: context.textTheme.labelLarge, + ), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + closeIconColor: context.colorScheme.onSurface, + ); + } + + search() async { + if (filter.value.isEmpty) { + return; + } + + if (preFilter == null && filter.value == previousFilter.value) { + return; + } + + isSearching.value = true; + ref.watch(paginatedSearchProvider.notifier).clear(); + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_result'.t(context: context)), + ); + } + + previousFilter.value = filter.value; + isSearching.value = false; + } + + loadMoreSearchResult() async { + isSearching.value = true; + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_more_result'.t(context: context)), + ); + } + + isSearching.value = false; + } + + searchPreFilter() { + if (preFilter != null) { + Future.delayed( + Duration.zero, + () { + search(); + + if (preFilter!.location.city != null) { + locationCurrentFilterWidget.value = Text( + preFilter!.location.city!, + style: context.textTheme.labelLarge, + ); + } + }, + ); + } + } + + useEffect( + () { + Future.microtask( + () => ref.invalidate(paginatedSearchProvider), + ); + searchPreFilter(); + + return null; + }, + [], + ); + + showPeoplePicker() { + handleOnSelect(Set value) { + filter.value = filter.value.copyWith( + people: value, + ); + + peopleCurrentFilterWidget.value = Text( + value + .map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)) + .join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + people: {}, + ); + + peopleCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FractionallySizedBox( + heightFactor: 0.8, + child: FilterBottomSheetScaffold( + title: 'search_filter_people_title'.t(context: context), + expanded: true, + onSearch: search, + onClear: handleClear, + child: PeoplePicker( + onSelect: handleOnSelect, + filter: filter.value.people, + ), + ), + ), + ); + } + + showLocationPicker() { + handleOnSelect(Map value) { + filter.value = filter.value.copyWith( + location: SearchLocationFilter( + country: value['country'], + city: value['city'], + state: value['state'], + ), + ); + + final locationText = []; + if (value['country'] != null) { + locationText.add(value['country']!); + } + + if (value['state'] != null) { + locationText.add(value['state']!); + } + + if (value['city'] != null) { + locationText.add(value['city']!); + } + + locationCurrentFilterWidget.value = Text( + locationText.join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + location: SearchLocationFilter(), + ); + + locationCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + child: FilterBottomSheetScaffold( + title: 'search_filter_location_title'.t(context: context), + onSearch: search, + onClear: handleClear, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Container( + padding: EdgeInsets.only( + bottom: context.viewInsets.bottom, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: LocationPicker( + onSelected: handleOnSelect, + filter: filter.value.location, + ), + ), + ), + ), + ), + ); + } + + showCameraPicker() { + handleOnSelect(Map value) { + filter.value = filter.value.copyWith( + camera: SearchCameraFilter( + make: value['make'], + model: value['model'], + ), + ); + + cameraCurrentFilterWidget.value = Text( + '${value['make'] ?? ''} ${value['model'] ?? ''}', + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + camera: SearchCameraFilter(), + ); + + cameraCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + child: FilterBottomSheetScaffold( + title: 'search_filter_camera_title'.t(context: context), + onSearch: search, + onClear: handleClear, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CameraPicker( + onSelect: handleOnSelect, + filter: filter.value.camera, + ), + ), + ), + ); + } + + showDatePicker() async { + final firstDate = DateTime(1900); + final lastDate = DateTime.now(); + + final date = await showDateRangePicker( + context: context, + firstDate: firstDate, + lastDate: lastDate, + currentDate: DateTime.now(), + initialDateRange: DateTimeRange( + start: filter.value.date.takenAfter ?? lastDate, + end: filter.value.date.takenBefore ?? lastDate, + ), + helpText: 'search_filter_date_title'.t(context: context), + cancelText: 'cancel'.t(context: context), + confirmText: 'select'.t(context: context), + saveText: 'save'.t(context: context), + errorFormatText: 'invalid_date_format'.t(context: context), + errorInvalidText: 'invalid_date'.t(context: context), + fieldStartHintText: 'start_date'.t(context: context), + fieldEndHintText: 'end_date'.t(context: context), + initialEntryMode: DatePickerEntryMode.calendar, + keyboardType: TextInputType.text, + ); + + if (date == null) { + filter.value = filter.value.copyWith( + date: SearchDateFilter(), + ); + + dateRangeCurrentFilterWidget.value = null; + search(); + return; + } + + filter.value = filter.value.copyWith( + date: SearchDateFilter( + takenAfter: date.start, + takenBefore: date.end.add( + const Duration( + hours: 23, + minutes: 59, + seconds: 59, + ), + ), + ), + ); + + // If date range is less than 24 hours, set the end date to the end of the day + if (date.end.difference(date.start).inHours < 24) { + dateRangeCurrentFilterWidget.value = Text( + DateFormat.yMMMd().format(date.start.toLocal()), + style: context.textTheme.labelLarge, + ); + } else { + dateRangeCurrentFilterWidget.value = Text( + 'search_filter_date_interval'.t( + context: context, + args: { + "start": DateFormat.yMMMd().format(date.start.toLocal()), + "end": DateFormat.yMMMd().format(date.end.toLocal()), + }, + ), + style: context.textTheme.labelLarge, + ); + } + + search(); + } + + // MEDIA PICKER + showMediaTypePicker() { + handleOnSelected(AssetType assetType) { + filter.value = filter.value.copyWith( + mediaType: assetType, + ); + + mediaTypeCurrentFilterWidget.value = Text( + assetType == AssetType.image + ? 'image'.t(context: context) + : assetType == AssetType.video + ? 'video'.t(context: context) + : 'all'.t(context: context), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + mediaType: AssetType.other, + ); + + mediaTypeCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_media_type_title'.t(context: context), + onSearch: search, + onClear: handleClear, + child: MediaTypePicker( + onSelect: handleOnSelected, + filter: filter.value.mediaType, + ), + ), + ); + } + + // DISPLAY OPTION + showDisplayOptionPicker() { + handleOnSelect(Map value) { + final filterText = []; + value.forEach((key, value) { + switch (key) { + case DisplayOption.notInAlbum: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isNotInAlbum: value, + ), + ); + if (value) { + filterText.add( + 'search_filter_display_option_not_in_album' + .t(context: context), + ); + } + break; + case DisplayOption.archive: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isArchive: value, + ), + ); + if (value) { + filterText.add('archive'.t(context: context)); + } + break; + case DisplayOption.favorite: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isFavorite: value, + ), + ); + if (value) { + filterText.add('favorite'.t(context: context)); + } + break; + } + }); + + if (filterText.isEmpty) { + displayOptionCurrentFilterWidget.value = null; + return; + } + + displayOptionCurrentFilterWidget.value = Text( + filterText.join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + ); + + displayOptionCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'display_options'.t(context: context), + onSearch: search, + onClear: handleClear, + child: DisplayOptionPicker( + onSelect: handleOnSelect, + filter: filter.value.display, + ), + ), + ); + } + + handleTextSubmitted(String value) { + switch (textSearchType.value) { + case TextSearchType.context: + filter.value = filter.value.copyWith( + filename: '', + context: value, + description: '', + ); + + break; + case TextSearchType.filename: + filter.value = filter.value.copyWith( + filename: value, + context: '', + description: '', + ); + + break; + case TextSearchType.description: + filter.value = filter.value.copyWith( + filename: '', + context: '', + description: value, + ); + break; + } + + search(); + } + + IconData getSearchPrefixIcon() => switch (textSearchType.value) { + TextSearchType.context => Icons.image_search_rounded, + TextSearchType.filename => Icons.abc_rounded, + TextSearchType.description => Icons.text_snippet_outlined, + }; + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + automaticallyImplyLeading: true, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: MenuAnchor( + style: MenuStyle( + elevation: const WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(24), + ), + ), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + builder: ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert_rounded), + tooltip: 'Show text search menu', + ); + }, + menuChildren: [ + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.image_search_rounded), + title: Text( + 'search_by_context'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.context + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.context, + ), + onPressed: () { + textSearchType.value = TextSearchType.context; + searchHintText.value = + 'sunrise_on_the_beach'.t(context: context); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.abc_rounded), + title: Text( + 'search_filter_filename'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.filename + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.filename, + ), + onPressed: () { + textSearchType.value = TextSearchType.filename; + searchHintText.value = + 'file_name_or_extension'.t(context: context); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text( + 'search_by_description'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: + textSearchType.value == TextSearchType.description + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: + textSearchType.value == TextSearchType.description, + ), + onPressed: () { + textSearchType.value = TextSearchType.description; + searchHintText.value = + 'search_by_description_example'.t(context: context); + }, + ), + ], + ), + ), + ], + title: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, + ), + borderRadius: const BorderRadius.all( + Radius.circular(24), + ), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withValues(alpha: 0.075), + context.colorScheme.primary.withValues(alpha: 0.09), + context.colorScheme.primary.withValues(alpha: 0.075), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: SearchField( + hintText: searchHintText.value, + key: const Key('search_text_field'), + controller: textSearchController, + contentPadding: preFilter != null + ? const EdgeInsets.only(left: 24) + : const EdgeInsets.all(8), + prefixIcon: preFilter != null + ? null + : Icon( + getSearchPrefixIcon(), + color: context.colorScheme.primary, + ), + onSubmitted: handleTextSubmitted, + focusNode: ref.watch(searchInputFocusProvider), + ), + ), + ), + body: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(top: 12.0), + sliver: SliverToBoxAdapter( + child: SizedBox( + height: 50, + child: ListView( + key: const Key('search_filter_chip_list'), + shrinkWrap: true, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + SearchFilterChip( + icon: Icons.people_alt_outlined, + onTap: showPeoplePicker, + label: 'people'.t(context: context), + currentFilter: peopleCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.location_on_outlined, + onTap: showLocationPicker, + label: 'search_filter_location'.t(context: context), + currentFilter: locationCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.camera_alt_outlined, + onTap: showCameraPicker, + label: 'camera'.t(context: context), + currentFilter: cameraCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.date_range_outlined, + onTap: showDatePicker, + label: 'search_filter_date'.t(context: context), + currentFilter: dateRangeCurrentFilterWidget.value, + ), + SearchFilterChip( + key: const Key('media_type_chip'), + icon: Icons.video_collection_outlined, + onTap: showMediaTypePicker, + label: 'search_filter_media_type'.t(context: context), + currentFilter: mediaTypeCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.display_settings_outlined, + onTap: showDisplayOptionPicker, + label: + 'search_filter_display_options'.t(context: context), + currentFilter: displayOptionCurrentFilterWidget.value, + ), + ], + ), + ), + ), + ), + if (isSearching.value) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else + _SearchResultGrid(onScrollEnd: loadMoreSearchResult), + ], + ), + ); + } +} + +class _SearchResultGrid extends ConsumerWidget { + final VoidCallback onScrollEnd; + + const _SearchResultGrid({required this.onScrollEnd}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchResult = ref.watch(paginatedSearchProvider); + + if (searchResult.totalAssets == 0) { + return const _SearchEmptyContent(); + } + + return NotificationListener( + onNotification: (notification) { + final isBottomSheetNotification = notification.context + ?.findAncestorWidgetOfExactType() != + null; + + final metrics = notification.metrics; + final isVerticalScroll = metrics.axis == Axis.vertical; + + if (metrics.pixels >= metrics.maxScrollExtent && + isVerticalScroll && + !isBottomSheetNotification) { + onScrollEnd(); + } + + return true; + }, + child: SliverFillRemaining( + child: ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith( + (ref) { + final timelineService = ref + .watch(timelineFactoryProvider) + .fromAssets(searchResult.assets); + ref.onDispose(timelineService.dispose); + return timelineService; + }, + ), + ], + child: Timeline( + key: ValueKey(searchResult.totalAssets), + appBar: null, + groupBy: GroupAssetsBy.none, + ), + ), + ), + ); + } +} + +class _SearchEmptyContent extends StatelessWidget { + const _SearchEmptyContent(); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: ListView( + shrinkWrap: true, + children: [ + const SizedBox(height: 40), + Center( + child: Image.asset( + context.isDarkTheme + ? 'assets/polaroid-dark.png' + : 'assets/polaroid-light.png', + height: 125, + ), + ), + const SizedBox(height: 16), + Center( + child: Text( + 'search_page_search_photos_videos'.t(context: context), + style: context.textTheme.labelLarge, + ), + ), + const SizedBox(height: 32), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: _QuickLinkList(), + ), + ], + ), + ); + } +} + +class _QuickLinkList extends StatelessWidget { + const _QuickLinkList(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(20), + ), + border: Border.all( + color: context.colorScheme.outline.withAlpha(10), + width: 1, + ), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + context.colorScheme.primary.withAlpha(20), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + _QuickLink( + title: 'recently_taken'.t(context: context), + icon: Icons.schedule_outlined, + isTop: true, + onTap: () => context.pushRoute(const DriftRecentlyTakenRoute()), + ), + _QuickLink( + title: 'videos'.t(context: context), + icon: Icons.play_circle_outline_rounded, + onTap: () => context.pushRoute(const DriftVideoRoute()), + ), + _QuickLink( + title: 'favorites'.t(context: context), + icon: Icons.favorite_border_rounded, + isBottom: true, + onTap: () => context.pushRoute(const DriftFavoriteRoute()), + ), + ], + ), + ); + } +} + +class _QuickLink extends StatelessWidget { + final String title; + final IconData icon; + final VoidCallback onTap; + final bool isTop; + final bool isBottom; + + const _QuickLink({ + required this.title, + required this.icon, + required this.onTap, + this.isTop = false, + this.isBottom = false, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.only( + topLeft: Radius.circular(isTop ? 20 : 0), + topRight: Radius.circular(isTop ? 20 : 0), + bottomLeft: Radius.circular(isBottom ? 20 : 0), + bottomRight: Radius.circular(isBottom ? 20 : 0), + ); + + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + ), + leading: Icon( + icon, + size: 26, + ), + title: Text( + title, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + onTap: onTap, + ); + } +} diff --git a/mobile/lib/presentation/pages/search/paginated_search.provider.dart b/mobile/lib/presentation/pages/search/paginated_search.provider.dart new file mode 100644 index 0000000000..84635fd0b9 --- /dev/null +++ b/mobile/lib/presentation/pages/search/paginated_search.provider.dart @@ -0,0 +1,40 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/search_result.model.dart'; +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 paginatedSearchProvider = + StateNotifierProvider( + (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), +); + +class PaginatedSearchNotifier extends StateNotifier { + final SearchService _searchService; + + PaginatedSearchNotifier(this._searchService) + : super(const SearchResult(assets: [], nextPage: 1)); + + Future search(SearchFilter filter) async { + if (state.nextPage == null) { + return false; + } + + final result = await _searchService.search(filter, state.nextPage!); + + if (result == null) { + return false; + } + + state = SearchResult( + assets: [...state.assets, ...result.assets], + nextPage: result.nextPage, + ); + + return true; + } + + clear() { + state = const SearchResult(assets: [], nextPage: 1); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 30a4088ce2..91003fb1a1 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -15,6 +15,7 @@ class TimelineArgs { final double spacing; final int columnCount; final bool showStorageIndicator; + final GroupAssetsBy? groupBy; const TimelineArgs({ required this.maxWidth, @@ -22,6 +23,7 @@ class TimelineArgs { this.spacing = kTimelineSpacing, this.columnCount = kTimelineColumnCount, this.showStorageIndicator = false, + this.groupBy, }); @override @@ -30,7 +32,8 @@ class TimelineArgs { maxWidth == other.maxWidth && maxHeight == other.maxHeight && columnCount == other.columnCount && - showStorageIndicator == other.showStorageIndicator; + showStorageIndicator == other.showStorageIndicator && + groupBy == other.groupBy; } @override @@ -39,7 +42,8 @@ class TimelineArgs { maxHeight.hashCode ^ spacing.hashCode ^ columnCount.hashCode ^ - showStorageIndicator.hashCode; + showStorageIndicator.hashCode ^ + groupBy.hashCode; } class TimelineState { @@ -97,8 +101,9 @@ final timelineSegmentProvider = StreamProvider.autoDispose>( final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); final tileExtent = math.max(0, availableTileWidth) / columnCount; - final groupBy = GroupAssetsBy - .values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)]; + final groupBy = args.groupBy ?? + GroupAssetsBy + .values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)]; final timelineService = ref.watch(timelineServiceProvider); yield* timelineService.watchBuckets().map((buckets) { diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 490f2bcff2..d279937417 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -27,8 +28,13 @@ class Timeline extends StatelessWidget { this.topSliverWidget, this.topSliverWidgetHeight, this.showStorageIndicator = false, - this.appBar, + this.appBar = const ImmichSliverAppBar( + floating: true, + pinned: false, + snap: false, + ), this.bottomSheet = const GeneralBottomSheet(), + this.groupBy, }); final Widget? topSliverWidget; @@ -36,6 +42,8 @@ class Timeline extends StatelessWidget { final bool showStorageIndicator; final Widget? appBar; final Widget? bottomSheet; + final GroupAssetsBy? groupBy; + @override Widget build(BuildContext context) { return Scaffold( @@ -50,6 +58,7 @@ class Timeline extends StatelessWidget { settingsProvider.select((s) => s.get(Setting.tilesPerRow)), ), showStorageIndicator: showStorageIndicator, + groupBy: groupBy, ), ), ], @@ -112,13 +121,17 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { return asyncSegments.widgetWhen( onData: (segments) { final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; - final statusBarHeight = context.padding.top; final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar ? 200 : 0; - final totalAppBarHeight = statusBarHeight + kToolbarHeight; + final topPadding = context.padding.top + + (widget.appBar == null ? 0 : kToolbarHeight) + + 10; + const scrubberBottomPadding = 100.0; + final bottomPadding = context.padding.bottom + + (widget.appBar == null ? 0 : scrubberBottomPadding); return PrimaryScrollController( controller: _scrollController, @@ -127,8 +140,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { Scrubber( layoutSegments: segments, timelineHeight: maxHeight, - topPadding: totalAppBarHeight + 10, - bottomPadding: context.padding.bottom + scrubberBottomPadding, + topPadding: topPadding, + bottomPadding: bottomPadding, monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, child: CustomScrollView( @@ -137,13 +150,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { slivers: [ if (isSelectionMode) const SelectionSliverAppBar() - else - widget.appBar ?? - const ImmichSliverAppBar( - floating: true, - pinned: false, - snap: false, - ), + else if (widget.appBar != null) + widget.appBar!, if (widget.topSliverWidget != null) widget.topSliverWidget!, _SliverSegmentedList( segments: segments, @@ -188,21 +196,22 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { child: _MultiSelectStatusButton(), ), ), - Consumer( - builder: (_, consumerRef, child) { - final isMultiSelectEnabled = consumerRef.watch( - multiSelectProvider.select( - (s) => s.isEnabled, - ), - ); + if (widget.bottomSheet != null) + Consumer( + builder: (_, consumerRef, child) { + final isMultiSelectEnabled = consumerRef.watch( + multiSelectProvider.select( + (s) => s.isEnabled, + ), + ); - if (isMultiSelectEnabled) { - return child!; - } - return const SizedBox.shrink(); - }, - child: widget.bottomSheet, - ), + if (isMultiSelectEnabled) { + return child!; + } + return const SizedBox.shrink(); + }, + child: widget.bottomSheet, + ), ], ], ), diff --git a/mobile/lib/providers/infrastructure/search.provider.dart b/mobile/lib/providers/infrastructure/search.provider.dart new file mode 100644 index 0000000000..cdcd3ee43b --- /dev/null +++ b/mobile/lib/providers/infrastructure/search.provider.dart @@ -0,0 +1,12 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/search.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; + +final searchApiRepositoryProvider = Provider( + (ref) => SearchApiRepository(ref.watch(apiServiceProvider).searchApi), +); + +final searchServiceProvider = Provider( + (ref) => SearchService(ref.watch(searchApiRepositoryProvider)), +); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index a6666ec6eb..94e3437d66 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -91,6 +91,7 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; +import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -189,7 +190,7 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], ), AutoRoute( - page: SearchRoute.page, + page: DriftSearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false, ), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 7412e1dee5..716d5ef89a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -991,6 +991,45 @@ 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, + ); + + 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); + }, + ); +} + +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/services/search.service.dart b/mobile/lib/services/search.service.dart index 5d3b08aaed..aa72a7908b 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/search/search_result.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; @@ -14,15 +15,21 @@ final searchServiceProvider = Provider( (ref) => SearchService( ref.watch(apiServiceProvider), ref.watch(assetRepositoryProvider), + ref.watch(searchApiRepositoryProvider), ), ); class SearchService { final ApiService _apiService; final AssetRepository _assetRepository; + final SearchApiRepository _searchApiRepository; final _log = Logger("SearchService"); - SearchService(this._apiService, this._assetRepository); + SearchService( + this._apiService, + this._assetRepository, + this._searchApiRepository, + ); Future?> getSearchSuggestions( SearchSuggestionType type, { @@ -32,7 +39,7 @@ class SearchService { String? model, }) async { try { - return await _apiService.searchApi.getSearchSuggestions( + return await _searchApiRepository.getSearchSuggestions( type, country: country, state: state, @@ -47,76 +54,15 @@ class SearchService { Future search(SearchFilter filter, int page) async { try { - SearchResponseDto? response; - AssetTypeEnum? type; - if (filter.mediaType == AssetType.image) { - type = AssetTypeEnum.IMAGE; - } else if (filter.mediaType == AssetType.video) { - type = AssetTypeEnum.VIDEO; - } - - if (filter.context != null && filter.context!.isNotEmpty) { - response = await _apiService.searchApi.searchSmart( - SmartSearchDto( - query: filter.context!, - language: filter.language, - country: filter.location.country, - state: filter.location.state, - city: filter.location.city, - make: filter.camera.make, - model: filter.camera.model, - takenAfter: filter.date.takenAfter, - takenBefore: filter.date.takenBefore, - visibility: filter.display.isArchive - ? AssetVisibility.archive - : AssetVisibility.timeline, - isFavorite: filter.display.isFavorite ? true : null, - isNotInAlbum: filter.display.isNotInAlbum ? true : null, - personIds: filter.people.map((e) => e.id).toList(), - type: type, - page: page, - size: 1000, - ), - ); - } else { - response = await _apiService.searchApi.searchAssets( - MetadataSearchDto( - originalFileName: - filter.filename != null && filter.filename!.isNotEmpty - ? filter.filename - : null, - country: filter.location.country, - description: - filter.description != null && filter.description!.isNotEmpty - ? filter.description - : null, - state: filter.location.state, - city: filter.location.city, - make: filter.camera.make, - model: filter.camera.model, - takenAfter: filter.date.takenAfter, - takenBefore: filter.date.takenBefore, - visibility: filter.display.isArchive - ? AssetVisibility.archive - : AssetVisibility.timeline, - isFavorite: filter.display.isFavorite ? true : null, - isNotInAlbum: filter.display.isNotInAlbum ? true : null, - personIds: filter.people.map((e) => e.id).toList(), - type: type, - page: page, - size: 1000, - ), - ); - } + final response = await _searchApiRepository.search(filter, page); if (response == null || response.assets.items.isEmpty) { return null; } return SearchResult( - assets: await _assetRepository.getAllByRemoteId( - response.assets.items.map((e) => e.id), - ), + assets: await _assetRepository + .getAllByRemoteId(response.assets.items.map((e) => e.id)), nextPage: response.assets.nextPage?.toInt(), ); } catch (error, stackTrace) { diff --git a/mobile/lib/utils/provider_utils.dart b/mobile/lib/utils/provider_utils.dart index bf18d24213..7af0e61d3c 100644 --- a/mobile/lib/utils/provider_utils.dart +++ b/mobile/lib/utils/provider_utils.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; @@ -15,4 +16,5 @@ void invalidateAllApiRepositoryProviders(WidgetRef ref) { ref.invalidate(personApiRepositoryProvider); ref.invalidate(assetApiRepositoryProvider); ref.invalidate(timelineRepositoryProvider); + ref.invalidate(searchApiRepositoryProvider); }