mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 02:27:08 -04:00 
			
		
		
		
	* feat(mobile): drift people sync * merge main --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
		
			
				
	
	
		
			912 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			912 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| 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/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/providers/search/paginated_search.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/asset_grid/multiselect_grid.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 SearchPage extends HookConsumerWidget {
 | |
|   const SearchPage({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<PersonDto> 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: Column(
 | |
|         children: [
 | |
|           Padding(
 | |
|             padding: const EdgeInsets.only(top: 12.0),
 | |
|             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 Expanded(
 | |
|               child: Center(child: CircularProgressIndicator()),
 | |
|             )
 | |
|           else
 | |
|             SearchResultGrid(
 | |
|               onScrollEnd: loadMoreSearchResult,
 | |
|               isSearching: isSearching.value,
 | |
|             ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class SearchResultGrid extends StatelessWidget {
 | |
|   final VoidCallback onScrollEnd;
 | |
|   final bool isSearching;
 | |
| 
 | |
|   const SearchResultGrid({
 | |
|     super.key,
 | |
|     required this.onScrollEnd,
 | |
|     this.isSearching = false,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return Expanded(
 | |
|       child: Padding(
 | |
|         padding: const EdgeInsets.only(top: 8.0),
 | |
|         child: 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: MultiselectGrid(
 | |
|             renderListProvider: paginatedSearchRenderListProvider,
 | |
|             archiveEnabled: true,
 | |
|             deleteEnabled: true,
 | |
|             editEnabled: true,
 | |
|             favoriteEnabled: true,
 | |
|             stackEnabled: false,
 | |
|             dragScrollLabelEnabled: false,
 | |
|             emptyIndicator: Padding(
 | |
|               padding: const EdgeInsets.symmetric(horizontal: 16.0),
 | |
|               child: !isSearching
 | |
|                   ? const SearchEmptyContent()
 | |
|                   : const SizedBox.shrink(),
 | |
|             ),
 | |
|           ),
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class SearchEmptyContent extends StatelessWidget {
 | |
|   const SearchEmptyContent({super.key});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return NotificationListener<ScrollNotification>(
 | |
|       onNotification: (_) => true,
 | |
|       child: ListView(
 | |
|         shrinkWrap: false,
 | |
|         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 QuickLinkList(),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class QuickLinkList extends StatelessWidget {
 | |
|   const QuickLinkList({super.key});
 | |
| 
 | |
|   @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({
 | |
|     super.key,
 | |
|     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,
 | |
|     );
 | |
|   }
 | |
| }
 |