You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(mobile): drift search page
This commit is contained in:
		
				
					committed by
					
						 shenlong-tanwen
						shenlong-tanwen
					
				
			
			
				
	
			
			
			
						parent
						
							59e7754bdc
						
					
				
				
					commit
					5e1edc2246
				
			| @@ -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 | ||||
|   | ||||
							
								
								
									
										38
									
								
								mobile/lib/domain/models/search_result.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								mobile/lib/domain/models/search_result.model.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; | ||||
|  | ||||
| class SearchResult { | ||||
|   final List<BaseAsset> assets; | ||||
|   final int? nextPage; | ||||
|  | ||||
|   const SearchResult({ | ||||
|     required this.assets, | ||||
|     this.nextPage, | ||||
|   }); | ||||
|  | ||||
|   int get totalAssets => assets.length; | ||||
|  | ||||
|   SearchResult copyWith({ | ||||
|     List<BaseAsset>? 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; | ||||
| } | ||||
							
								
								
									
										92
									
								
								mobile/lib/domain/services/search.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								mobile/lib/domain/services/search.service.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<List<String>?> 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<SearchResult?> 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'), | ||||
|       }; | ||||
| } | ||||
| @@ -62,6 +62,9 @@ class TimelineFactory { | ||||
|  | ||||
|   TimelineService video(String userId) => | ||||
|       TimelineService(_timelineRepository.video(userId, groupBy)); | ||||
|  | ||||
|   TimelineService fromAssets(List<BaseAsset> assets) => | ||||
|       TimelineService(_timelineRepository.fromAssets(assets)); | ||||
| } | ||||
|  | ||||
| class TimelineService { | ||||
|   | ||||
| @@ -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<SearchResponseDto?> 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<List<String>?> getSearchSuggestions( | ||||
|     SearchSuggestionType type, { | ||||
|     String? country, | ||||
|     String? state, | ||||
|     String? make, | ||||
|     String? model, | ||||
|   }) => | ||||
|       _api.getSearchSuggestions( | ||||
|         type, | ||||
|         country: country, | ||||
|         state: state, | ||||
|         make: make, | ||||
|         model: model, | ||||
|       ); | ||||
| } | ||||
| @@ -251,6 +251,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository { | ||||
|         .get(); | ||||
|   } | ||||
|  | ||||
|   TimelineQuery fromAssets(List<BaseAsset> 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) => | ||||
|   | ||||
| @@ -117,7 +117,7 @@ class TabShellPage extends ConsumerWidget { | ||||
|     return AutoTabsRouter( | ||||
|       routes: [ | ||||
|         const MainTimelineRoute(), | ||||
|         SearchRoute(), | ||||
|         DriftSearchRoute(), | ||||
|         const DriftAlbumsRoute(), | ||||
|         const DriftLibraryRoute(), | ||||
|       ], | ||||
|   | ||||
							
								
								
									
										914
									
								
								mobile/lib/presentation/pages/search/drift_search.page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										914
									
								
								mobile/lib/presentation/pages/search/drift_search.page.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,914 @@ | ||||
| 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/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>(TextSearchType.context); | ||||
|     final searchHintText = useState<String>('sunrise_on_the_beach'.tr()); | ||||
|     final textSearchController = useTextEditingController(); | ||||
|     final filter = useState<SearchFilter>( | ||||
|       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<SearchFilter?>(null); | ||||
|  | ||||
|     final peopleCurrentFilterWidget = useState<Widget?>(null); | ||||
|     final dateRangeCurrentFilterWidget = useState<Widget?>(null); | ||||
|     final cameraCurrentFilterWidget = useState<Widget?>(null); | ||||
|     final locationCurrentFilterWidget = useState<Widget?>(null); | ||||
|     final mediaTypeCurrentFilterWidget = useState<Widget?>(null); | ||||
|     final displayOptionCurrentFilterWidget = useState<Widget?>(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'.tr()), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       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'.tr()), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       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<Person> value) { | ||||
|         filter.value = filter.value.copyWith( | ||||
|           people: value, | ||||
|         ); | ||||
|  | ||||
|         peopleCurrentFilterWidget.value = Text( | ||||
|           value.map((e) => e.name != '' ? e.name : 'no_name'.tr()).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'.tr(), | ||||
|             expanded: true, | ||||
|             onSearch: search, | ||||
|             onClear: handleClear, | ||||
|             child: PeoplePicker( | ||||
|               onSelect: handleOnSelect, | ||||
|               filter: filter.value.people, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     showLocationPicker() { | ||||
|       handleOnSelect(Map<String, String?> value) { | ||||
|         filter.value = filter.value.copyWith( | ||||
|           location: SearchLocationFilter( | ||||
|             country: value['country'], | ||||
|             city: value['city'], | ||||
|             state: value['state'], | ||||
|           ), | ||||
|         ); | ||||
|  | ||||
|         final locationText = <String>[]; | ||||
|         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'.tr(), | ||||
|           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<String, String?> 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'.tr(), | ||||
|           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'.tr(), | ||||
|         cancelText: 'cancel'.tr(), | ||||
|         confirmText: 'select'.tr(), | ||||
|         saveText: 'save'.tr(), | ||||
|         errorFormatText: 'invalid_date_format'.tr(), | ||||
|         errorInvalidText: 'invalid_date'.tr(), | ||||
|         fieldStartHintText: 'start_date'.tr(), | ||||
|         fieldEndHintText: 'end_date'.tr(), | ||||
|         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'.tr( | ||||
|             namedArgs: { | ||||
|               "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'.tr() | ||||
|               : assetType == AssetType.video | ||||
|                   ? 'video'.tr() | ||||
|                   : 'all'.tr(), | ||||
|           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'.tr(), | ||||
|           onSearch: search, | ||||
|           onClear: handleClear, | ||||
|           child: MediaTypePicker( | ||||
|             onSelect: handleOnSelected, | ||||
|             filter: filter.value.mediaType, | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // DISPLAY OPTION | ||||
|     showDisplayOptionPicker() { | ||||
|       handleOnSelect(Map<DisplayOption, bool> value) { | ||||
|         final filterText = <String>[]; | ||||
|         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'.tr()); | ||||
|               } | ||||
|               break; | ||||
|             case DisplayOption.archive: | ||||
|               filter.value = filter.value.copyWith( | ||||
|                 display: filter.value.display.copyWith( | ||||
|                   isArchive: value, | ||||
|                 ), | ||||
|               ); | ||||
|               if (value) { | ||||
|                 filterText.add('archive'.tr()); | ||||
|               } | ||||
|               break; | ||||
|             case DisplayOption.favorite: | ||||
|               filter.value = filter.value.copyWith( | ||||
|                 display: filter.value.display.copyWith( | ||||
|                   isFavorite: value, | ||||
|                 ), | ||||
|               ); | ||||
|               if (value) { | ||||
|                 filterText.add('favorite'.tr()); | ||||
|               } | ||||
|               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'.tr(), | ||||
|           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'.tr(), | ||||
|                       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'.tr(); | ||||
|                   }, | ||||
|                 ), | ||||
|                 MenuItemButton( | ||||
|                   child: ListTile( | ||||
|                     leading: const Icon(Icons.abc_rounded), | ||||
|                     title: Text( | ||||
|                       'search_filter_filename'.tr(), | ||||
|                       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'.tr(); | ||||
|                   }, | ||||
|                 ), | ||||
|                 MenuItemButton( | ||||
|                   child: ListTile( | ||||
|                     leading: const Icon(Icons.text_snippet_outlined), | ||||
|                     title: Text( | ||||
|                       'search_by_description'.tr(), | ||||
|                       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'.tr(); | ||||
|                   }, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|         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'.tr(), | ||||
|                       currentFilter: peopleCurrentFilterWidget.value, | ||||
|                     ), | ||||
|                     SearchFilterChip( | ||||
|                       icon: Icons.location_on_outlined, | ||||
|                       onTap: showLocationPicker, | ||||
|                       label: 'search_filter_location'.tr(), | ||||
|                       currentFilter: locationCurrentFilterWidget.value, | ||||
|                     ), | ||||
|                     SearchFilterChip( | ||||
|                       icon: Icons.camera_alt_outlined, | ||||
|                       onTap: showCameraPicker, | ||||
|                       label: 'camera'.tr(), | ||||
|                       currentFilter: cameraCurrentFilterWidget.value, | ||||
|                     ), | ||||
|                     SearchFilterChip( | ||||
|                       icon: Icons.date_range_outlined, | ||||
|                       onTap: showDatePicker, | ||||
|                       label: 'search_filter_date'.tr(), | ||||
|                       currentFilter: dateRangeCurrentFilterWidget.value, | ||||
|                     ), | ||||
|                     SearchFilterChip( | ||||
|                       key: const Key('media_type_chip'), | ||||
|                       icon: Icons.video_collection_outlined, | ||||
|                       onTap: showMediaTypePicker, | ||||
|                       label: 'search_filter_media_type'.tr(), | ||||
|                       currentFilter: mediaTypeCurrentFilterWidget.value, | ||||
|                     ), | ||||
|                     SearchFilterChip( | ||||
|                       icon: Icons.display_settings_outlined, | ||||
|                       onTap: showDisplayOptionPicker, | ||||
|                       label: 'search_filter_display_options'.tr(), | ||||
|                       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<ScrollEndNotification>( | ||||
|       onNotification: (notification) { | ||||
|         final isBottomSheetNotification = notification.context | ||||
|                 ?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != | ||||
|             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'.tr(), | ||||
|               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'.tr(), | ||||
|             icon: Icons.schedule_outlined, | ||||
|             isTop: true, | ||||
|             onTap: () => context.pushRoute(const RecentlyTakenRoute()), | ||||
|           ), | ||||
|           _QuickLink( | ||||
|             title: 'videos'.tr(), | ||||
|             icon: Icons.play_circle_outline_rounded, | ||||
|             onTap: () => context.pushRoute(const AllVideosRoute()), | ||||
|           ), | ||||
|           _QuickLink( | ||||
|             title: 'favorites'.tr(), | ||||
|             icon: Icons.favorite_border_rounded, | ||||
|             isBottom: true, | ||||
|             onTap: () => context.pushRoute(const FavoritesRoute()), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| 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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -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<PaginatedSearchNotifier, SearchResult>( | ||||
|   (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), | ||||
| ); | ||||
|  | ||||
| class PaginatedSearchNotifier extends StateNotifier<SearchResult> { | ||||
|   final SearchService _searchService; | ||||
|  | ||||
|   PaginatedSearchNotifier(this._searchService) | ||||
|       : super(const SearchResult(assets: [], nextPage: 1)); | ||||
|  | ||||
|   Future<bool> 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); | ||||
|   } | ||||
| } | ||||
| @@ -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<List<Segment>>( | ||||
|     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) { | ||||
|   | ||||
| @@ -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, | ||||
|                   ), | ||||
|               ], | ||||
|             ], | ||||
|           ), | ||||
|   | ||||
							
								
								
									
										12
									
								
								mobile/lib/providers/infrastructure/search.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								mobile/lib/providers/infrastructure/search.provider.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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)), | ||||
| ); | ||||
| @@ -69,24 +69,25 @@ import 'package:immich_mobile/pages/search/person_result.page.dart'; | ||||
| import 'package:immich_mobile/pages/search/recently_taken.page.dart'; | ||||
| import 'package:immich_mobile/pages/search/search.page.dart'; | ||||
| import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart'; | ||||
| import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; | ||||
| 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'; | ||||
| @@ -100,7 +101,6 @@ import 'package:immich_mobile/services/api.service.dart'; | ||||
| import 'package:immich_mobile/services/local_auth.service.dart'; | ||||
| import 'package:immich_mobile/services/secure_storage.service.dart'; | ||||
| import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; | ||||
|  | ||||
| import 'package:maplibre_gl/maplibre_gl.dart'; | ||||
|  | ||||
| part 'router.gr.dart'; | ||||
| @@ -186,7 +186,7 @@ class AppRouter extends RootStackRouter { | ||||
|           guards: [_authGuard, _duplicateGuard], | ||||
|         ), | ||||
|         AutoRoute( | ||||
|           page: SearchRoute.page, | ||||
|           page: DriftSearchRoute.page, | ||||
|           guards: [_authGuard, _duplicateGuard], | ||||
|           maintainState: false, | ||||
|         ), | ||||
|   | ||||
| @@ -869,6 +869,45 @@ 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, | ||||
|         ); | ||||
|  | ||||
|   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); | ||||
|     }, | ||||
|   ); | ||||
| } | ||||
|  | ||||
| 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> { | ||||
|   | ||||
| @@ -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<List<String>?> 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<SearchResult?> 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) { | ||||
|   | ||||
| @@ -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); | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user