diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 87e7b24e34..aa98c2bafd 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -5,10 +5,11 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/person_api.interface.dart'; class SearchLocationFilter { - String? country; - String? state; - String? city; - SearchLocationFilter({ + final String? country; + final String? state; + final String? city; + + const SearchLocationFilter({ this.country, this.state, this.city, @@ -65,9 +66,10 @@ class SearchLocationFilter { } class SearchCameraFilter { - String? make; - String? model; - SearchCameraFilter({ + final String? make; + final String? model; + + const SearchCameraFilter({ this.make, this.model, }); @@ -116,9 +118,10 @@ class SearchCameraFilter { } class SearchDateFilter { - DateTime? takenBefore; - DateTime? takenAfter; - SearchDateFilter({ + final DateTime? takenBefore; + final DateTime? takenAfter; + + const SearchDateFilter({ this.takenBefore, this.takenAfter, }); @@ -172,10 +175,11 @@ class SearchDateFilter { } class SearchDisplayFilters { - bool isNotInAlbum = false; - bool isArchive = false; - bool isFavorite = false; - SearchDisplayFilters({ + final bool isNotInAlbum; + final bool isArchive; + final bool isFavorite; + + const SearchDisplayFilters({ required this.isNotInAlbum, required this.isArchive, required this.isFavorite, @@ -233,19 +237,19 @@ class SearchDisplayFilters { } class SearchFilter { - String? context; - String? filename; - String? description; - Set people; - SearchLocationFilter location; - SearchCameraFilter camera; - SearchDateFilter date; - SearchDisplayFilters display; + final String? context; + final String? filename; + final String? description; + final Set people; + final SearchLocationFilter location; + final SearchCameraFilter camera; + final SearchDateFilter date; + final SearchDisplayFilters display; // Enum - AssetType mediaType; + final AssetType mediaType; - SearchFilter({ + const SearchFilter({ this.context, this.filename, this.description, diff --git a/mobile/lib/models/search/search_result.model.dart b/mobile/lib/models/search/search_result.model.dart index f51353ad61..458a9b4abc 100644 --- a/mobile/lib/models/search/search_result.model.dart +++ b/mobile/lib/models/search/search_result.model.dart @@ -6,7 +6,7 @@ class SearchResult { final List assets; final int? nextPage; - SearchResult({ + const SearchResult({ required this.assets, this.nextPage, }); diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index fcae1fb586..77abfd719a 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -6,520 +6,49 @@ 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/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/pages/search/search_body.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; +import 'package:immich_mobile/providers/search/search_filters.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/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; + const SearchPage({super.key, this.prefilter}); + @override Widget build(BuildContext context, WidgetRef ref) { final textSearchType = useState(TextSearchType.context); final searchHintText = useState('contextual_search'.tr()); final textSearchController = useTextEditingController(); - final filter = useState( - SearchFilter( - people: prefilter?.people ?? {}, - location: prefilter?.location ?? SearchLocationFilter(), - camera: prefilter?.camera ?? SearchCameraFilter(), - date: prefilter?.date ?? SearchDateFilter(), - display: prefilter?.display ?? - SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - mediaType: prefilter?.mediaType ?? AssetType.other, - ), - ); - - final previousFilter = useState(null); - - final peopleCurrentFilterWidget = useState(null); - final dateRangeCurrentFilterWidget = useState(null); - final cameraCurrentFilterWidget = useState(null); - final locationCurrentFilterWidget = useState(null); - final mediaTypeCurrentFilterWidget = useState(null); - final displayOptionCurrentFilterWidget = useState(null); - - final isSearching = useState(false); - - SnackBar searchInfoSnackBar(String message) { - return SnackBar( - content: Text( - message, - style: context.textTheme.labelLarge, - ), - showCloseIcon: true, - behavior: SnackBarBehavior.fixed, - closeIconColor: context.colorScheme.onSurface, - ); - } - - search() async { - if (filter.value.isEmpty) { - return; - } - - if (prefilter == null && filter.value == previousFilter.value) { - return; - } - - isSearching.value = true; - ref.watch(paginatedSearchProvider.notifier).clear(); - final hasResult = await ref - .watch(paginatedSearchProvider.notifier) - .search(filter.value); - - if (!hasResult) { - context.showSnackBar( - searchInfoSnackBar('search_no_result'.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 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 value) { - filter.value = filter.value.copyWith( - location: SearchLocationFilter( - country: value['country'], - city: value['city'], - state: value['state'], - ), - ); - - final locationText = []; - if (value['country'] != null) { - locationText.add(value['country']!); - } - - if (value['state'] != null) { - locationText.add(value['state']!); - } - - if (value['city'] != null) { - locationText.add(value['city']!); - } - - locationCurrentFilterWidget.value = Text( - locationText.join(', '), - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - location: SearchLocationFilter(), - ); - - locationCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: true, - child: FilterBottomSheetScaffold( - title: 'search_filter_location_title'.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 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: 'action_common_cancel'.tr(), - confirmText: 'action_common_select'.tr(), - saveText: 'action_common_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 - ? 'search_filter_media_type_image'.tr() - : assetType == AssetType.video - ? 'search_filter_media_type_video'.tr() - : 'search_filter_media_type_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 value) { - final filterText = []; - value.forEach((key, value) { - switch (key) { - case DisplayOption.notInAlbum: - filter.value = filter.value.copyWith( - display: filter.value.display.copyWith( - isNotInAlbum: value, - ), - ); - if (value) { - filterText - .add('search_filter_display_option_not_in_album'.tr()); - } - break; - case DisplayOption.archive: - filter.value = filter.value.copyWith( - display: filter.value.display.copyWith( - isArchive: value, - ), - ); - if (value) { - filterText.add('search_filter_display_option_archive'.tr()); - } - break; - case DisplayOption.favorite: - filter.value = filter.value.copyWith( - display: filter.value.display.copyWith( - isFavorite: value, - ), - ); - if (value) { - filterText.add('search_filter_display_option_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: 'search_filter_display_options_title'.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( + final filter = ref.read(searchFiltersProvider); + ref.read(searchFiltersProvider.notifier).value = + switch (textSearchType.value) { + TextSearchType.context => filter.copyWith( filename: '', context: value, description: '', - ); - - break; - case TextSearchType.filename: - filter.value = filter.value.copyWith( + ), + TextSearchType.filename => filter.copyWith( filename: value, context: '', description: '', - ); - - break; - case TextSearchType.description: - filter.value = filter.value.copyWith( + ), + TextSearchType.description => filter.copyWith( filename: '', context: '', description: value, - ); - break; - } - - search(); - } - - IconData getSearchPrefixIcon() { - switch (textSearchType.value) { - case TextSearchType.context: - return Icons.image_search_rounded; - case TextSearchType.filename: - return Icons.abc_rounded; - case TextSearchType.description: - return Icons.text_snippet_outlined; - default: - return Icons.search_rounded; - } + ), + }; + ref.read(searchFiltersProvider.notifier).search(); } return Scaffold( @@ -530,16 +59,14 @@ class SearchPage extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(right: 16.0), child: MenuAnchor( - style: MenuStyle( - elevation: const WidgetStatePropertyAll(1), - shape: WidgetStateProperty.all( + style: const MenuStyle( + elevation: WidgetStatePropertyAll(1), + shape: WidgetStatePropertyAll( RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.all(Radius.circular(24)), ), ), - padding: const WidgetStatePropertyAll( - EdgeInsets.all(4), - ), + padding: WidgetStatePropertyAll(EdgeInsets.all(4)), ), builder: ( BuildContext context, @@ -625,13 +152,13 @@ class SearchPage extends HookConsumerWidget { ), ), ], - title: Container( + title: DecoratedBox( decoration: BoxDecoration( border: Border.all( color: context.colorScheme.onSurface.withAlpha(0), width: 0, ), - borderRadius: BorderRadius.circular(24), + borderRadius: const BorderRadius.all(Radius.circular(24)), gradient: LinearGradient( colors: [ context.colorScheme.primary.withOpacity(0.075), @@ -652,7 +179,7 @@ class SearchPage extends HookConsumerWidget { prefixIcon: prefilter != null ? null : Icon( - getSearchPrefixIcon(), + getSearchPrefixIcon(textSearchType.value), color: context.colorScheme.primary, ), hintText: searchHintText.value, @@ -660,25 +187,20 @@ class SearchPage extends HookConsumerWidget { color: context.themeData.colorScheme.onSurfaceSecondary, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(25), - borderSide: BorderSide( - color: context.colorScheme.surfaceDim, - ), + borderRadius: const BorderRadius.all(Radius.circular(25)), + borderSide: BorderSide(color: context.colorScheme.surfaceDim), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(25), - borderSide: BorderSide( - color: context.colorScheme.surfaceContainer, - ), + borderRadius: const BorderRadius.all(Radius.circular(25)), + borderSide: + BorderSide(color: context.colorScheme.surfaceContainer), ), disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(25), - borderSide: BorderSide( - color: context.colorScheme.surfaceDim, - ), + borderRadius: const BorderRadius.all(Radius.circular(25)), + borderSide: BorderSide(color: context.colorScheme.surfaceDim), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(25), + borderRadius: const BorderRadius.all(Radius.circular(25)), borderSide: BorderSide( color: context.colorScheme.primary.withAlpha(100), ), @@ -690,72 +212,35 @@ class SearchPage extends HookConsumerWidget { ), ), ), - 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_rounded, - onTap: showPeoplePicker, - label: 'search_filter_people'.tr(), - currentFilter: peopleCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.location_pin, - onTap: showLocationPicker, - label: 'search_filter_location'.tr(), - currentFilter: locationCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.camera_alt_rounded, - onTap: showCameraPicker, - label: 'search_filter_camera'.tr(), - currentFilter: cameraCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.date_range_rounded, - 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.adaptive()), - ) - else - SearchResultGrid( - onScrollEnd: loadMoreSearchResult, - isSearching: isSearching.value, - ), - ], - ), + body: const SearchBody(), ); } + + SnackBar searchInfoSnackBar( + String message, + TextStyle? textStyle, + Color closeIconColor, + ) { + return SnackBar( + content: Text(message, style: textStyle), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + closeIconColor: closeIconColor, + ); + } + + IconData getSearchPrefixIcon(TextSearchType textSearchType) { + switch (textSearchType) { + case TextSearchType.context: + return Icons.image_search_rounded; + case TextSearchType.filename: + return Icons.abc_rounded; + case TextSearchType.description: + return Icons.text_snippet_outlined; + default: + return Icons.search_rounded; + } + } } class SearchResultGrid extends StatelessWidget { @@ -798,12 +283,15 @@ class SearchResultGrid extends StatelessWidget { editEnabled: true, favoriteEnabled: true, stackEnabled: false, - emptyIndicator: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: !isSearching - ? const SearchEmptyContent() - : const SizedBox.shrink(), - ), + emptyIndicator: isSearching + ? const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox.shrink(), + ) + : const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: SearchEmptyContent(), + ), ), ), ), @@ -822,14 +310,19 @@ class SearchEmptyContent extends StatelessWidget { shrinkWrap: false, children: [ const SizedBox(height: 40), - Center( - child: Image.asset( - context.isDarkTheme - ? 'assets/polaroid-dark.png' - : 'assets/polaroid-light.png', - height: 125, - ), - ), + context.isDarkTheme + ? const Center( + child: Image( + image: AssetImage('assets/polaroid-dark.png'), + height: 125, + ), + ) + : const Center( + child: Image( + image: AssetImage('assets/polaroid-light.png'), + height: 125, + ), + ), const SizedBox(height: 16), Center( child: Text( @@ -850,9 +343,9 @@ class QuickLinkList extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return DecoratedBox( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), + borderRadius: const BorderRadius.all(Radius.circular(20)), border: Border.all( color: context.colorScheme.outline.withAlpha(10), width: 1, @@ -912,21 +405,34 @@ class QuickLink extends StatelessWidget { @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), - ); + final shape = switch ((isTop, isBottom)) { + (true, false) => const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + (false, true) => const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + (true, true) => const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + (false, false) => + const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + }; return ListTile( - shape: RoundedRectangleBorder( - borderRadius: borderRadius, - ), - leading: Icon( - icon, - size: 26, - ), + shape: shape, + leading: Icon(icon, size: 26), title: Text( title, style: context.textTheme.titleSmall?.copyWith( diff --git a/mobile/lib/pages/search/search_body.dart b/mobile/lib/pages/search/search_body.dart new file mode 100644 index 0000000000..54c1144bef --- /dev/null +++ b/mobile/lib/pages/search/search_body.dart @@ -0,0 +1,306 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/search/show_camera_picker.dart'; +import 'package:immich_mobile/pages/search/show_date_picker.dart'; +import 'package:immich_mobile/pages/search/show_display_option_picker.dart'; +import 'package:immich_mobile/pages/search/show_location_picker.dart'; +import 'package:immich_mobile/pages/search/show_media_type_picker.dart'; +import 'package:immich_mobile/pages/search/show_people_picker.dart'; +import 'package:immich_mobile/providers/search/is_searching.provider.dart'; +import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; +import 'package:immich_mobile/providers/search/search_filters.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; + +class SearchBody extends HookConsumerWidget { + const SearchBody({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSearching = ref.watch(isSearchingProvider); + + loadMoreSearchResult() async { + final filter = ref.read(searchFiltersProvider); + final hasResult = + await ref.read(paginatedSearchProvider.notifier).search(filter); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar( + 'search_no_more_result'.tr(), + context.textTheme.labelLarge, + context.colorScheme.onSurface, + ), + ); + } + } + + const pickers = Padding( + padding: EdgeInsets.only(top: 12.0), + child: SizedBox( + height: 50, + child: ListView.custom( + key: Key('search_filter_chip_list'), + shrinkWrap: true, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: 16), + childrenDelegate: SliverChildListDelegate.fixed( + [ + ShowPeoplePicker(), + ShowLocationPicker(), + ShowCameraPicker(), + ShowDatePicker(), + ShowMediaTypePicker(), + ShowDisplayOptionsPicker(), + ], + addAutomaticKeepAlives: true, + addRepaintBoundaries: true, + addSemanticIndexes: true, + ), + ), + ), + ); + + // TODO: extend render list without discarding the existing result grid + return isSearching + ? const Column( + children: [ + pickers, + Expanded( + child: Center(child: CircularProgressIndicator.adaptive()), + ), + ], + ) + : Column( + children: [ + pickers, + SearchResultGrid(onScrollEnd: loadMoreSearchResult), + ], + ); + } + + SnackBar searchInfoSnackBar( + String message, + TextStyle? textStyle, + Color closeIconColor, + ) { + return SnackBar( + content: Text(message, style: textStyle), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + closeIconColor: closeIconColor, + ); + } + + IconData getSearchPrefixIcon(TextSearchType textSearchType) { + switch (textSearchType) { + case TextSearchType.context: + return Icons.image_search_rounded; + case TextSearchType.filename: + return Icons.abc_rounded; + case TextSearchType.description: + return Icons.text_snippet_outlined; + default: + return Icons.search_rounded; + } + } +} + +class SearchResultGrid extends StatelessWidget { + final VoidCallback onScrollEnd; + + const SearchResultGrid({ + super.key, + required this.onScrollEnd, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: NotificationListener( + 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, + emptyIndicator: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: SearchEmptyContent(), + ), + ), + ), + ), + ); + } +} + +class SearchEmptyContent extends StatelessWidget { + const SearchEmptyContent({super.key}); + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (_) => true, + child: ListView( + shrinkWrap: false, + children: [ + const SizedBox(height: 40), + context.isDarkTheme + ? const Center( + child: Image( + image: AssetImage('assets/polaroid-dark.png'), + height: 125, + ), + ) + : const Center( + child: Image( + image: AssetImage('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 DecoratedBox( + 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_added'.tr(), + icon: const Icon(Icons.schedule_outlined, size: 26), + isTop: true, + onTap: () => context.pushRoute(const RecentlyAddedRoute()), + ), + QuickLink( + title: 'videos'.tr(), + icon: const Icon(Icons.play_circle_outline_rounded, size: 26), + onTap: () => context.pushRoute(const AllVideosRoute()), + ), + QuickLink( + title: 'favorites'.tr(), + icon: const Icon(Icons.favorite_border_rounded, size: 26), + isBottom: true, + onTap: () => context.pushRoute(const FavoritesRoute()), + ), + ], + ), + ); + } +} + +class QuickLink extends StatelessWidget { + final String title; + final Icon 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 shape = switch ((isTop, isBottom)) { + (true, false) => const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + (false, true) => const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + (true, true) => const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + (false, false) => + const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + }; + + return ListTile( + shape: shape, + leading: icon, + title: Text( + title, + style: + context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), + ), + onTap: onTap, + ); + } +} diff --git a/mobile/lib/pages/search/show_camera_picker.dart b/mobile/lib/pages/search/show_camera_picker.dart new file mode 100644 index 0000000000..4b702970cb --- /dev/null +++ b/mobile/lib/pages/search/show_camera_picker.dart @@ -0,0 +1,64 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/is_searching.provider.dart'; +import 'package:immich_mobile/providers/search/search_filters.provider.dart'; +import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.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'; + +class ShowCameraPicker extends ConsumerWidget { + const ShowCameraPicker({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(searchFiltersProvider); + + showCameraPicker() { + handleOnSelect(Map value) { + ref.read(searchFiltersProvider.notifier).camera = SearchCameraFilter( + make: value['make'], + model: value['model'], + ); + } + + handleClear() { + ref.read(searchFiltersProvider.notifier).value = filter.copyWith( + camera: const SearchCameraFilter(), + ); + ref.read(searchFiltersProvider.notifier).search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + child: FilterBottomSheetScaffold( + title: 'search_filter_camera_title'.tr(), + onSearch: () => ref.read(isSearchingProvider.notifier).value = true, + onClear: handleClear, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CameraPicker( + onSelect: handleOnSelect, + filter: filter.camera, + ), + ), + ), + ); + } + + return SearchFilterChip( + icon: Icons.camera_alt_rounded, + onTap: showCameraPicker, + label: 'search_filter_camera'.tr(), + currentFilter: Text( + '${filter.camera.make ?? ''} ${filter.camera.model ?? ''}', + style: context.textTheme.labelLarge, + ), + ); + } +} diff --git a/mobile/lib/pages/search/show_date_picker.dart b/mobile/lib/pages/search/show_date_picker.dart new file mode 100644 index 0000000000..543f66df14 --- /dev/null +++ b/mobile/lib/pages/search/show_date_picker.dart @@ -0,0 +1,88 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/search_filters.provider.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; + +class ShowDatePicker extends ConsumerWidget { + const ShowDatePicker({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final date = + ref.watch(searchFiltersProvider.select((filters) => filters.date)); + + showDatePicker() async { + final firstDate = DateTime(1900); + final lastDate = DateTime.now(); + + final dateRange = await showDateRangePicker( + context: context, + firstDate: firstDate, + lastDate: lastDate, + currentDate: DateTime.now(), + initialDateRange: DateTimeRange( + start: date.takenAfter ?? lastDate, + end: date.takenBefore ?? lastDate, + ), + helpText: 'search_filter_date_title'.tr(), + cancelText: 'action_common_cancel'.tr(), + confirmText: 'action_common_select'.tr(), + saveText: 'action_common_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 (dateRange == null) { + ref.read(searchFiltersProvider.notifier).date = + const SearchDateFilter(); + return; + } + + ref.read(searchFiltersProvider.notifier).date = SearchDateFilter( + takenAfter: dateRange.start, + takenBefore: dateRange.end.add( + const Duration( + hours: 23, + minutes: 59, + seconds: 59, + ), + ), + ); + ref.read(searchFiltersProvider.notifier).search(); + } + + return SearchFilterChip( + icon: Icons.date_range_rounded, + onTap: showDatePicker, + label: 'search_filter_date'.tr(), + currentFilter: Text( + getFormattedText(date.takenAfter, date.takenBefore), + style: context.textTheme.labelLarge, + ), + ); + } + + String getFormattedText(DateTime? start, DateTime? end) { + if (start == null || end == null) { + return ''; + } + + if (end.difference(start).inHours < 24) { + return DateFormat.yMMMd().format(start.toLocal()); + } + + return 'search_filter_date_interval'.tr( + namedArgs: { + "start": DateFormat.yMMMd().format(start.toLocal()), + "end": DateFormat.yMMMd().format(end.toLocal()), + }, + ); + } +} diff --git a/mobile/lib/pages/search/show_display_option_picker.dart b/mobile/lib/pages/search/show_display_option_picker.dart new file mode 100644 index 0000000000..65bfd68f32 --- /dev/null +++ b/mobile/lib/pages/search/show_display_option_picker.dart @@ -0,0 +1,75 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/is_searching.provider.dart'; +import 'package:immich_mobile/providers/search/search_filters.provider.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/search_filter_chip.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; + +class ShowDisplayOptionsPicker extends ConsumerWidget { + const ShowDisplayOptionsPicker({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final display = + ref.watch(searchFiltersProvider.select((filters) => filters.display)); + + showDisplayOptionPicker() { + handleOnSelect(Map value) { + value.forEach((key, value) { + ref.read(searchFiltersProvider.notifier).display = switch (key) { + DisplayOption.notInAlbum => display.copyWith(isNotInAlbum: value), + DisplayOption.archive => display.copyWith(isArchive: value), + DisplayOption.favorite => display.copyWith(isFavorite: value), + }; + }); + } + + handleClear() { + ref.read(searchFiltersProvider.notifier).display = + const SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ); + ref.read(searchFiltersProvider.notifier).search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_display_options_title'.tr(), + onSearch: () => ref.read(isSearchingProvider.notifier).value = true, + onClear: handleClear, + child: DisplayOptionPicker( + onSelect: handleOnSelect, + filter: display, + ), + ), + ); + } + + return SearchFilterChip( + icon: Icons.display_settings_outlined, + onTap: showDisplayOptionPicker, + label: 'search_filter_display_options'.tr(), + currentFilter: Text( + getFormattedText(display), + style: context.textTheme.labelLarge, + ), + ); + } + + getFormattedText(SearchDisplayFilters display) { + return [ + if (display.isNotInAlbum) + 'search_filter_display_option_not_in_album'.tr(), + if (display.isArchive) 'search_filter_display_option_archive'.tr(), + if (display.isFavorite) 'search_filter_display_option_favorite'.tr(), + ].join(', '); + } +} diff --git a/mobile/lib/pages/search/show_location_picker.dart b/mobile/lib/pages/search/show_location_picker.dart new file mode 100644 index 0000000000..70580995e6 --- /dev/null +++ b/mobile/lib/pages/search/show_location_picker.dart @@ -0,0 +1,82 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/is_searching.provider.dart'; +import 'package:immich_mobile/providers/search/search_filters.provider.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/search_filter_chip.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; + +class ShowLocationPicker extends ConsumerWidget { + const ShowLocationPicker({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final location = + ref.watch(searchFiltersProvider.select((filters) => filters.location)); + + showLocationPicker() { + handleOnSelect(Map value) { + ref.read(searchFiltersProvider.notifier).location = + SearchLocationFilter( + country: value['country'], + city: value['city'], + state: value['state'], + ); + } + + handleClear() { + ref.read(searchFiltersProvider.notifier).location = + const SearchLocationFilter(); + ref.read(searchFiltersProvider.notifier).search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + child: FilterBottomSheetScaffold( + title: 'search_filter_location_title'.tr(), + onSearch: () => ref.read(isSearchingProvider.notifier).value = true, + 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: location, + ), + ), + ), + ), + ), + ); + } + + return SearchFilterChip( + icon: Icons.location_pin, + onTap: showLocationPicker, + label: 'search_filter_location'.tr(), + currentFilter: Text( + getFormattedText(location.city, location.state, location.country), + style: context.textTheme.labelLarge, + ), + ); + } + + String getFormattedText(String? city, String? state, String? country) { + return [ + if (city != null) city, + if (state != null) state, + if (country != null) country, + ].join(', '); + } +} diff --git a/mobile/lib/pages/search/show_media_type_picker.dart b/mobile/lib/pages/search/show_media_type_picker.dart new file mode 100644 index 0000000000..41fd79e6c9 --- /dev/null +++ b/mobile/lib/pages/search/show_media_type_picker.dart @@ -0,0 +1,66 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/search/is_searching.provider.dart'; +import 'package:immich_mobile/providers/search/search_filters.provider.dart'; +import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart'; +import 'package:immich_mobile/widgets/search/search_filter/media_type_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'; + +class ShowMediaTypePicker extends ConsumerWidget { + const ShowMediaTypePicker({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mediaType = + ref.watch(searchFiltersProvider.select((filters) => filters.mediaType)); + + showMediaTypePicker() { + handleOnSelected(AssetType assetType) { + ref.read(searchFiltersProvider.notifier).mediaType = assetType; + } + + handleClear() { + ref.read(searchFiltersProvider.notifier).mediaType = AssetType.other; + ref.read(searchFiltersProvider.notifier).search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_media_type_title'.tr(), + onSearch: () => ref.read(isSearchingProvider.notifier).value = true, + onClear: handleClear, + child: MediaTypePicker( + onSelect: handleOnSelected, + filter: mediaType, + ), + ), + ); + } + + return SearchFilterChip( + icon: Icons.video_collection_outlined, + onTap: showMediaTypePicker, + label: 'search_filter_media_type'.tr(), + currentFilter: Text( + getFormattedText(mediaType), + style: context.textTheme.labelLarge, + ), + ); + } + + String getFormattedText(AssetType mediaType) { + switch (mediaType) { + case AssetType.image: + return 'search_filter_media_type_image'.tr(); + case AssetType.video: + return 'search_filter_media_type_video'.tr(); + default: + return 'search_filter_media_type_all'.tr(); + } + } +} diff --git a/mobile/lib/pages/search/show_people_picker.dart b/mobile/lib/pages/search/show_people_picker.dart new file mode 100644 index 0000000000..d4901b7d1f --- /dev/null +++ b/mobile/lib/pages/search/show_people_picker.dart @@ -0,0 +1,65 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/providers/search/is_searching.provider.dart'; +import 'package:immich_mobile/providers/search/search_filters.provider.dart'; +import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.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'; + +class ShowPeoplePicker extends ConsumerWidget { + const ShowPeoplePicker({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final people = + ref.watch(searchFiltersProvider.select((filters) => filters.people)); + + showPeoplePicker() { + handleOnSelect(Set value) { + ref.read(searchFiltersProvider.notifier).people = value; + } + + handleClear() { + ref.read(searchFiltersProvider.notifier).people = const {}; + ref.read(searchFiltersProvider.notifier).search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FractionallySizedBox( + heightFactor: 0.8, + child: FilterBottomSheetScaffold( + title: 'search_filter_people_title'.tr(), + expanded: true, + onSearch: () => ref.read(isSearchingProvider.notifier).value = true, + onClear: handleClear, + child: PeoplePicker( + onSelect: handleOnSelect, + filter: people, + ), + ), + ), + ); + } + + return SearchFilterChip( + icon: Icons.people_alt_rounded, + onTap: showPeoplePicker, + label: 'search_filter_people'.tr(), + currentFilter: Text( + getFormattedText(people), + style: context.textTheme.labelLarge, + ), + ); + } + + String getFormattedText(Set people) { + final noName = 'no_name'.tr(); + return people.map((e) => e.name != '' ? e.name : noName).join(', '); + } +} diff --git a/mobile/lib/providers/search/is_searching.provider.dart b/mobile/lib/providers/search/is_searching.provider.dart new file mode 100644 index 0000000000..89c09c198d --- /dev/null +++ b/mobile/lib/providers/search/is_searching.provider.dart @@ -0,0 +1,22 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Whether to display the video part of a motion photo +final isSearchingProvider = StateNotifierProvider((ref) { + return IsSearching(ref); +}); + +class IsSearching extends StateNotifier { + IsSearching(this.ref) : super(false); + + final Ref ref; + + bool get value => state; + + set value(bool value) { + state = value; + } + + void toggle() { + state = !state; + } +} diff --git a/mobile/lib/providers/search/paginated_search.provider.dart b/mobile/lib/providers/search/paginated_search.provider.dart index 990bd3f74a..c8e5150ab9 100644 --- a/mobile/lib/providers/search/paginated_search.provider.dart +++ b/mobile/lib/providers/search/paginated_search.provider.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/search/search_result.model.dart'; +import 'package:immich_mobile/providers/search/is_searching.provider.dart'; import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; @@ -10,36 +11,41 @@ part 'paginated_search.provider.g.dart'; final paginatedSearchProvider = StateNotifierProvider( - (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), + (ref) => PaginatedSearchNotifier(ref), ); class PaginatedSearchNotifier extends StateNotifier { - final SearchService _searchService; + final Ref ref; - PaginatedSearchNotifier(this._searchService) - : super(SearchResult(assets: [], nextPage: 1)); + PaginatedSearchNotifier(this.ref) + : super(const SearchResult(assets: [], nextPage: 1)); Future search(SearchFilter filter) async { if (state.nextPage == null) { return false; } - final result = await _searchService.search(filter, state.nextPage!); + ref.read(isSearchingProvider.notifier).value = true; + try { + final result = + await ref.read(searchServiceProvider).search(filter, state.nextPage!); + if (result == null) { + return false; + } - if (result == null) { - return false; + state = SearchResult( + assets: [...state.assets, ...result.assets], + nextPage: result.nextPage, + ); + } finally { + ref.read(isSearchingProvider.notifier).value = false; } - state = SearchResult( - assets: [...state.assets, ...result.assets], - nextPage: result.nextPage, - ); - return true; } clear() { - state = SearchResult(assets: [], nextPage: 1); + state = const SearchResult(assets: [], nextPage: 1); } } diff --git a/mobile/lib/providers/search/search_filters.provider.dart b/mobile/lib/providers/search/search_filters.provider.dart new file mode 100644 index 0000000000..573d79f69f --- /dev/null +++ b/mobile/lib/providers/search/search_filters.provider.dart @@ -0,0 +1,81 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/providers/search/is_searching.provider.dart'; +import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; + +final searchFiltersProvider = + StateNotifierProvider((ref) { + return SearchFilterNotifier(ref); +}); + +const searchFiltersDefault = SearchFilter( + people: {}, + location: SearchLocationFilter(), + camera: SearchCameraFilter(), + date: SearchDateFilter(), + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: AssetType.other, +); + +class SearchFilterNotifier extends StateNotifier { + final Ref ref; + + SearchFilterNotifier(this.ref) : super(searchFiltersDefault); + + SearchFilter get value => state; + + set value(SearchFilter value) { + state = value; + } + + void reset() { + state = searchFiltersDefault; + } + + Set get people => state.people; + + SearchLocationFilter get location => state.location; + + SearchCameraFilter get camera => state.camera; + + SearchDateFilter get date => state.date; + + SearchDisplayFilters get display => state.display; + + AssetType get mediaType => state.mediaType; + + set people(Set value) { + state = state.copyWith(people: value); + } + + set location(SearchLocationFilter value) { + state = state.copyWith(location: value); + } + + set camera(SearchCameraFilter value) { + state = state.copyWith(camera: value); + } + + set date(SearchDateFilter value) { + state = state.copyWith(date: value); + } + + set display(SearchDisplayFilters value) { + state = state.copyWith(display: value); + } + + set mediaType(AssetType value) { + state = state.copyWith(mediaType: value); + } + + Future search() { + ref.read(paginatedSearchProvider.notifier).clear(); + return ref.read(paginatedSearchProvider.notifier).search(state); + } +}