From 319b46851967dae9b2c13d9d9bfabb727c036f49 Mon Sep 17 00:00:00 2001 From: Thomas Way Date: Sun, 5 Apr 2026 03:34:46 +0100 Subject: [PATCH] chore(mobile): use declarative ui in search The search page makes use of imperative state, which is buggy and unergonomic - it fights against how flutter wants widgets to be written. Using declarative state simplifies the code and fixes bugs. --- .../repositories/search_api.repository.dart | 14 +- .../lib/models/search/date_filter.model.dart | 91 ++++++ .../models/search/search_filter.model.dart | 25 +- .../places/places_collection.page.dart | 3 +- mobile/lib/pages/search/search.page.dart | 20 +- .../pages/search/drift_search.page.dart | 262 ++++++------------ .../similar_photos_action_button.widget.dart | 3 +- .../widgets/search/quick_date_picker.dart | 90 +----- mobile/lib/widgets/search/explore_grid.dart | 3 +- 9 files changed, 221 insertions(+), 290 deletions(-) create mode 100644 mobile/lib/models/search/date_filter.model.dart diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index bcfddfce6e..cdb829164d 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -16,6 +16,8 @@ class SearchApiRepository extends ApiRepository { type = AssetTypeEnum.VIDEO; } + final dateRange = filter.date.asDateTimeRange(); + if ((filter.context != null && filter.context!.isNotEmpty) || (filter.assetId != null && filter.assetId!.isNotEmpty)) { return _api.searchSmart( @@ -28,14 +30,14 @@ class SearchApiRepository extends ApiRepository { city: filter.location.city, make: filter.camera.make, model: filter.camera.model, - takenAfter: filter.date.takenAfter, - takenBefore: filter.date.takenBefore, + takenAfter: dateRange?.start, + takenBefore: dateRange?.end, visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline, rating: filter.rating.rating, isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), - tagIds: filter.tagIds, + tagIds: filter.tags.map((t) => t.id).toList(), type: type, page: page, size: 100, @@ -53,14 +55,14 @@ class SearchApiRepository extends ApiRepository { city: filter.location.city, make: filter.camera.make, model: filter.camera.model, - takenAfter: filter.date.takenAfter, - takenBefore: filter.date.takenBefore, + takenAfter: dateRange?.start, + takenBefore: dateRange?.end, visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline, rating: filter.rating.rating, isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), - tagIds: filter.tagIds, + tagIds: filter.tags.map((t) => t.id).toList(), type: type, page: page, size: 1000, diff --git a/mobile/lib/models/search/date_filter.model.dart b/mobile/lib/models/search/date_filter.model.dart new file mode 100644 index 0000000000..7b765bb191 --- /dev/null +++ b/mobile/lib/models/search/date_filter.model.dart @@ -0,0 +1,91 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; + +sealed class DateFilterInputModel { + const DateFilterInputModel(); + bool get isEmpty => asDateTimeRange() == null; + DateTimeRange? asDateTimeRange(); + + String asHumanReadable(BuildContext context) { + final date = asDateTimeRange(); + if (date == null) return ''; + if (date.end.difference(date.start).inHours < 24) { + return DateFormat.yMMMd().format(date.start.toLocal()); + } else { + return 'search_filter_date_interval'.t( + context: context, + args: { + "start": DateFormat.yMMMd().format(date.start.toLocal()), + "end": DateFormat.yMMMd().format(date.end.toLocal()), + }, + ); + } + } +} + +class RecentMonthRangeFilter extends DateFilterInputModel { + final int monthDelta; + + const RecentMonthRangeFilter(this.monthDelta); + + @override + DateTimeRange asDateTimeRange() { + final now = DateTime.now(); + // Note that DateTime's constructor properly handles month overflow. + final from = DateTime(now.year, now.month - monthDelta, 1); + return DateTimeRange(start: from, end: now); + } + + @override + String asHumanReadable(BuildContext context) { + return 'last_months'.t(context: context, args: {"count": monthDelta.toString()}); + } +} + +class YearFilter extends DateFilterInputModel { + final int year; + const YearFilter(this.year); + + @override + DateTimeRange asDateTimeRange() { + final now = DateTime.now(); + final from = DateTime(year, 1, 1); + + if (now.year == year) { + // To not go beyond today if the user picks the current year + return DateTimeRange(start: from, end: now); + } + + final to = DateTime(year, 12, 31, 23, 59, 59); + return DateTimeRange(start: from, end: to); + } + + @override + String asHumanReadable(BuildContext context) { + return 'in_year'.tr(namedArgs: {"year": year.toString()}); + } +} + +class EmptyDateFilter extends DateFilterInputModel { + const EmptyDateFilter(); + + @override + DateTimeRange? asDateTimeRange() => null; +} + +class CustomDateFilter extends DateFilterInputModel { + final DateTime start; + final DateTime end; + + const CustomDateFilter._(this.start, this.end); + + factory CustomDateFilter.fromRange(DateTimeRange range) { + return CustomDateFilter._(range.start, range.end.add(const Duration(hours: 23, minutes: 59, seconds: 59))); + } + + @override + DateTimeRange asDateTimeRange() { + return DateTimeRange(start: start, end: end); + } +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 1b730e0c68..eb3e2aa8f7 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -2,7 +2,9 @@ import 'dart:convert'; import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/models/search/date_filter.model.dart'; class SearchLocationFilter { String? country; @@ -214,11 +216,11 @@ class SearchFilter { String? ocr; String? language; String? assetId; - List? tagIds; + List tags; Set people; SearchLocationFilter location; SearchCameraFilter camera; - SearchDateFilter date; + DateFilterInputModel date; SearchRatingFilter rating; SearchDisplayFilters display; @@ -232,7 +234,7 @@ class SearchFilter { this.ocr, this.language, this.assetId, - this.tagIds, + this.tags = const [], required this.people, required this.location, required this.camera, @@ -248,15 +250,14 @@ class SearchFilter { (description == null || (description!.isEmpty)) && (assetId == null || (assetId!.isEmpty)) && (ocr == null || (ocr!.isEmpty)) && - (tagIds ?? []).isEmpty && + tags.isEmpty && people.isEmpty && location.country == null && location.state == null && location.city == null && camera.make == null && camera.model == null && - date.takenBefore == null && - date.takenAfter == null && + date.isEmpty && display.isNotInAlbum == false && display.isArchive == false && display.isFavorite == false && @@ -272,10 +273,10 @@ class SearchFilter { String? ocr, String? assetId, Set? people, - List? tagIds, + List? tags, SearchLocationFilter? location, SearchCameraFilter? camera, - SearchDateFilter? date, + DateFilterInputModel? date, SearchDisplayFilters? display, SearchRatingFilter? rating, AssetType? mediaType, @@ -294,13 +295,13 @@ class SearchFilter { display: display ?? this.display, rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, - tagIds: tagIds ?? this.tagIds, + tags: tags ?? this.tags, ); } @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tags: $tags, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; } @override @@ -314,7 +315,7 @@ class SearchFilter { other.ocr == ocr && other.assetId == assetId && other.people == people && - other.tagIds == tagIds && + other.tags == tags && other.location == location && other.camera == camera && other.date == date && @@ -332,7 +333,7 @@ class SearchFilter { ocr.hashCode ^ assetId.hashCode ^ people.hashCode ^ - tagIds.hashCode ^ + tags.hashCode ^ location.hashCode ^ camera.hashCode ^ date.hashCode ^ diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart index a4a6f66915..7b2b73c6dd 100644 --- a/mobile/lib/pages/library/places/places_collection.page.dart +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.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/models/search/date_filter.model.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; @@ -111,7 +112,7 @@ class PlaceTile extends StatelessWidget { people: {}, location: SearchLocationFilter(city: name), camera: SearchCameraFilter(), - date: SearchDateFilter(), + date: const EmptyDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), rating: SearchRatingFilter(), mediaType: AssetType.other, diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index dbd32ac94b..b865fdf5bc 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -10,6 +10,7 @@ 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/models/search/date_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'; @@ -40,7 +41,7 @@ class SearchPage extends HookConsumerWidget { people: prefilter?.people ?? {}, location: prefilter?.location ?? SearchLocationFilter(), camera: prefilter?.camera ?? SearchCameraFilter(), - date: prefilter?.date ?? SearchDateFilter(), + date: prefilter?.date ?? const EmptyDateFilter(), display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), mediaType: prefilter?.mediaType ?? AssetType.other, rating: prefilter?.rating ?? SearchRatingFilter(), @@ -242,15 +243,17 @@ class SearchPage extends HookConsumerWidget { final firstDate = DateTime(1900); final lastDate = DateTime.now(); + final stored = filter.value.date.asDateTimeRange(); + final dateRange = stored != null + ? DateTimeRange(start: DateUtils.dateOnly(stored.start), end: DateUtils.dateOnly(stored.end)) + : DateTimeRange(start: lastDate, end: lastDate); + 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, - ), + initialDateRange: dateRange, helpText: 'search_filter_date_title'.tr(), cancelText: 'cancel'.tr(), confirmText: 'select'.tr(), @@ -264,7 +267,7 @@ class SearchPage extends HookConsumerWidget { ); if (date == null) { - filter.value = filter.value.copyWith(date: SearchDateFilter()); + filter.value = filter.value.copyWith(date: const EmptyDateFilter()); dateRangeCurrentFilterWidget.value = null; unawaited(search()); @@ -272,10 +275,7 @@ class SearchPage extends HookConsumerWidget { } filter.value = filter.value.copyWith( - date: SearchDateFilter( - takenAfter: date.start, - takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)), - ), + date: CustomDateFilter.fromRange(date), ); // If date range is less than 24 hours, set the end date to the end of the day diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 7e47a742ae..dcbb6f6d0a 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; +import 'package:immich_mobile/models/search/date_filter.model.dart'; import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -57,26 +58,14 @@ class DriftSearchPage extends HookConsumerWidget { people: {}, location: SearchLocationFilter(), camera: SearchCameraFilter(), - date: SearchDateFilter(), + date: const EmptyDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), rating: SearchRatingFilter(), mediaType: AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", - tagIds: [], ), ); - final dateInputFilter = useState(null); - - final peopleCurrentFilterWidget = useState(null); - final dateRangeCurrentFilterWidget = useState(null); - final cameraCurrentFilterWidget = useState(null); - final locationCurrentFilterWidget = useState(null); - final tagCurrentFilterWidget = useState(null); - final mediaTypeCurrentFilterWidget = useState(null); - final ratingCurrentFilterWidget = useState(null); - final displayOptionCurrentFilterWidget = useState(null); - final userPreferences = ref.watch(userMetadataPreferencesProvider); search(SearchFilter f) { @@ -107,14 +96,60 @@ class DriftSearchPage extends HookConsumerWidget { Future.microtask(() { textSearchController.clear(); search(preFilter); - if (preFilter.location.city != null) { - locationCurrentFilterWidget.value = Text(preFilter.location.city!, style: context.textTheme.labelLarge); - } }); return null; }, [preFilter]); + Widget? chipLabel(String text) => text.isEmpty ? null : Text(text, style: context.textTheme.labelLarge); + + Widget? peopleChip() { + final label = filter.value.people.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '); + return chipLabel(label); + } + + Widget? locationChip() { + final l = filter.value.location; + final parts = [if (l.country != null) l.country!, if (l.state != null) l.state!, if (l.city != null) l.city!]; + return chipLabel(parts.join(', ')); + } + + Widget? tagChip() { + final label = filter.value.tags.map((t) => t.value).join(', '); + return chipLabel(label); + } + + Widget? cameraChip() { + final c = filter.value.camera; + return chipLabel('${c.make ?? ''} ${c.model ?? ''}'.trim()); + } + + Widget? dateChip() { + final d = filter.value.date; + return d.isEmpty ? null : chipLabel(d.asHumanReadable(context)); + } + + Widget? mediaTypeChip() { + final mt = filter.value.mediaType; + if (mt == AssetType.other) return null; + return chipLabel(mt == AssetType.image ? 'image'.t(context: context) : 'video'.t(context: context)); + } + + Widget? ratingChip() { + final r = filter.value.rating.rating; + return r == null ? null : chipLabel('rating_count'.t(args: {'count': r})); + } + + Widget? displayChip() { + final d = filter.value.display; + final parts = [ + if (d.isNotInAlbum) 'search_filter_display_option_not_in_album'.t(context: context), + if (d.isArchive) 'archive'.t(context: context), + if (d.isFavorite) 'favorite'.t(context: context), + ]; + return chipLabel(parts.join(', ')); + } + showPeoplePicker() { var people = filter.value.people; @@ -122,17 +157,6 @@ class DriftSearchPage extends HookConsumerWidget { people = value; } - handleClear() { - peopleCurrentFilterWidget.value = null; - search(filter.value.copyWith(people: {})); - } - - handleApply() { - final label = people.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '); - peopleCurrentFilterWidget.value = label.isNotEmpty ? Text(label, style: context.textTheme.labelLarge) : null; - search(filter.value.copyWith(people: people)); - } - showFilterBottomSheet( context: context, isScrollControlled: true, @@ -141,8 +165,8 @@ class DriftSearchPage extends HookConsumerWidget { child: FilterBottomSheetScaffold( title: 'search_filter_people_title'.t(context: context), expanded: true, - onSearch: handleApply, - onClear: handleClear, + onSearch: () => search(filter.value.copyWith(people: people)), + onClear: () => search(filter.value.copyWith(people: {})), child: PeoplePicker(onSelect: handleOnSelect, filter: filter.value.people), ), ), @@ -150,22 +174,10 @@ class DriftSearchPage extends HookConsumerWidget { } showTagPicker() { - var tagIds = filter.value.tagIds ?? []; - String tagLabel = ''; + var tags = filter.value.tags; - handleOnSelect(Iterable tags) { - tagIds = tags.map((t) => t.id).toList(); - tagLabel = tags.map((t) => t.value).join(', '); - } - - handleClear() { - tagCurrentFilterWidget.value = null; - search(filter.value.copyWith(tagIds: [])); - } - - handleApply() { - tagCurrentFilterWidget.value = tagLabel.isNotEmpty ? Text(tagLabel, style: context.textTheme.labelLarge) : null; - search(filter.value.copyWith(tagIds: tagIds)); + handleOnSelect(Iterable selected) { + tags = selected.toList(); } showFilterBottomSheet( @@ -176,9 +188,9 @@ class DriftSearchPage extends HookConsumerWidget { child: FilterBottomSheetScaffold( title: 'search_filter_tags_title'.t(context: context), expanded: true, - onSearch: handleApply, - onClear: handleClear, - child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), + onSearch: () => search(filter.value.copyWith(tags: tags)), + onClear: () => search(filter.value.copyWith(tags: [])), + child: TagPicker(onSelect: handleOnSelect, filter: filter.value.tags.map((t) => t.id).toSet()), ), ), ); @@ -191,31 +203,14 @@ class DriftSearchPage extends HookConsumerWidget { location = SearchLocationFilter(country: value['country'], city: value['city'], state: value['state']); } - handleClear() { - locationCurrentFilterWidget.value = null; - search(filter.value.copyWith(location: SearchLocationFilter())); - } - - handleApply() { - final locationText = [ - if (location.country != null) location.country!, - if (location.state != null) location.state!, - if (location.city != null) location.city!, - ]; - locationCurrentFilterWidget.value = locationText.isNotEmpty - ? Text(locationText.join(', '), style: context.textTheme.labelLarge) - : null; - search(filter.value.copyWith(location: location)); - } - showFilterBottomSheet( context: context, isScrollControlled: true, isDismissible: true, child: FilterBottomSheetScaffold( title: 'search_filter_location_title'.t(context: context), - onSearch: handleApply, - onClear: handleClear, + onSearch: () => search(filter.value.copyWith(location: location)), + onClear: () => search(filter.value.copyWith(location: SearchLocationFilter())), child: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: Container( @@ -237,28 +232,14 @@ class DriftSearchPage extends HookConsumerWidget { camera = SearchCameraFilter(make: value['make'], model: value['model']); } - handleClear() { - cameraCurrentFilterWidget.value = null; - search(filter.value.copyWith(camera: SearchCameraFilter())); - } - - handleApply() { - final make = camera.make ?? ''; - final model = camera.model ?? ''; - cameraCurrentFilterWidget.value = (make.isNotEmpty || model.isNotEmpty) - ? Text('$make $model', style: context.textTheme.labelLarge) - : null; - search(filter.value.copyWith(camera: camera)); - } - showFilterBottomSheet( context: context, isScrollControlled: true, isDismissible: true, child: FilterBottomSheetScaffold( title: 'search_filter_camera_title'.t(context: context), - onSearch: handleApply, - onClear: handleClear, + onSearch: () => search(filter.value.copyWith(camera: camera)), + onClear: () => search(filter.value.copyWith(camera: SearchCameraFilter())), child: Padding( padding: const EdgeInsets.all(16.0), child: CameraPicker(onSelect: handleOnSelect, filter: filter.value.camera), @@ -268,42 +249,17 @@ class DriftSearchPage extends HookConsumerWidget { } datePicked(DateFilterInputModel? selectedDate) { - dateInputFilter.value = selectedDate; - if (selectedDate == null) { - dateRangeCurrentFilterWidget.value = null; - search(filter.value.copyWith(date: SearchDateFilter())); - return; - } - - final date = selectedDate.asDateTimeRange(); - dateRangeCurrentFilterWidget.value = Text( - selectedDate.asHumanReadable(context), - style: context.textTheme.labelLarge, - ); - search( - filter.value.copyWith( - date: SearchDateFilter( - takenAfter: date.start, - takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)), - ), - ), - ); + search(filter.value.copyWith(date: selectedDate ?? const EmptyDateFilter())); } showDatePicker() async { final firstDate = DateTime(1900); final lastDate = DateTime.now(); - var dateRange = DateTimeRange( - start: filter.value.date.takenAfter ?? lastDate, - end: filter.value.date.takenBefore ?? lastDate, - ); - - // datePicked() may increase the date, this will make the date picker fail an assertion - // Fixup the end date to be at most now. - if (dateRange.end.isAfter(lastDate)) { - dateRange = DateTimeRange(start: dateRange.start, end: lastDate); - } + final stored = filter.value.date.asDateTimeRange(); + final dateRange = stored != null + ? DateTimeRange(start: DateUtils.dateOnly(stored.start), end: DateUtils.dateOnly(stored.end)) + : DateTimeRange(start: lastDate, end: lastDate); final date = await showDateRangePicker( context: context, @@ -338,7 +294,7 @@ class DriftSearchPage extends HookConsumerWidget { expanded: true, onClear: () => datePicked(null), child: QuickDatePicker( - currentInput: dateInputFilter.value, + currentInput: filter.value.date, onRequestPicker: () { context.pop(); showDatePicker(); @@ -360,27 +316,12 @@ class DriftSearchPage extends HookConsumerWidget { mediaType = assetType; } - handleClear() { - mediaTypeCurrentFilterWidget.value = null; - search(filter.value.copyWith(mediaType: AssetType.other)); - } - - handleApply() { - mediaTypeCurrentFilterWidget.value = mediaType != AssetType.other - ? Text( - mediaType == AssetType.image ? 'image'.t(context: context) : 'video'.t(context: context), - style: context.textTheme.labelLarge, - ) - : null; - search(filter.value.copyWith(mediaType: mediaType)); - } - showFilterBottomSheet( context: context, child: FilterBottomSheetScaffold( title: 'search_filter_media_type_title'.t(context: context), - onSearch: handleApply, - onClear: handleClear, + onSearch: () => search(filter.value.copyWith(mediaType: mediaType)), + onClear: () => search(filter.value.copyWith(mediaType: AssetType.other)), child: MediaTypePicker(onSelect: handleOnSelected, filter: filter.value.mediaType), ), ); @@ -394,25 +335,13 @@ class DriftSearchPage extends HookConsumerWidget { rating = value; } - handleClear() { - ratingCurrentFilterWidget.value = null; - search(filter.value.copyWith(rating: SearchRatingFilter(rating: null))); - } - - handleApply() { - ratingCurrentFilterWidget.value = rating.rating != null - ? Text('rating_count'.t(args: {'count': rating.rating!}), style: context.textTheme.labelLarge) - : null; - search(filter.value.copyWith(rating: rating)); - } - showFilterBottomSheet( context: context, isScrollControlled: true, child: FilterBottomSheetScaffold( title: 'rating'.t(context: context), - onSearch: handleApply, - onClear: handleClear, + onSearch: () => search(filter.value.copyWith(rating: rating)), + onClear: () => search(filter.value.copyWith(rating: SearchRatingFilter(rating: null))), child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating), ), ); @@ -430,33 +359,16 @@ class DriftSearchPage extends HookConsumerWidget { ); } - handleClear() { - displayOptionCurrentFilterWidget.value = null; - search( - filter.value.copyWith( - display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), - ), - ); - } - - handleApply() { - final filterText = [ - if (display.isNotInAlbum) 'search_filter_display_option_not_in_album'.t(context: context), - if (display.isArchive) 'archive'.t(context: context), - if (display.isFavorite) 'favorite'.t(context: context), - ]; - displayOptionCurrentFilterWidget.value = filterText.isNotEmpty - ? Text(filterText.join(', '), style: context.textTheme.labelLarge) - : null; - search(filter.value.copyWith(display: display)); - } - showFilterBottomSheet( context: context, child: FilterBottomSheetScaffold( title: 'display_options'.t(context: context), - onSearch: handleApply, - onClear: handleClear, + onSearch: () => search(filter.value.copyWith(display: display)), + onClear: () => search( + filter.value.copyWith( + display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + ), + ), child: DisplayOptionPicker(onSelect: handleOnSelect, filter: filter.value.display), ), ); @@ -631,52 +543,52 @@ class DriftSearchPage extends HookConsumerWidget { icon: Icons.people_alt_outlined, onTap: showPeoplePicker, label: 'people'.t(context: context), - currentFilter: peopleCurrentFilterWidget.value, + currentFilter: peopleChip(), ), SearchFilterChip( icon: Icons.location_on_outlined, onTap: showLocationPicker, label: 'search_filter_location'.t(context: context), - currentFilter: locationCurrentFilterWidget.value, + currentFilter: locationChip(), ), if (userPreferences.valueOrNull?.tagsEnabled ?? false) SearchFilterChip( icon: Icons.sell_outlined, onTap: showTagPicker, label: 'tags'.t(context: context), - currentFilter: tagCurrentFilterWidget.value, + currentFilter: tagChip(), ), SearchFilterChip( icon: Icons.camera_alt_outlined, onTap: showCameraPicker, label: 'camera'.t(context: context), - currentFilter: cameraCurrentFilterWidget.value, + currentFilter: cameraChip(), ), SearchFilterChip( icon: Icons.date_range_outlined, onTap: showQuickDatePicker, label: 'search_filter_date'.t(context: context), - currentFilter: dateRangeCurrentFilterWidget.value, + currentFilter: dateChip(), ), SearchFilterChip( key: const Key('media_type_chip'), icon: Icons.video_collection_outlined, onTap: showMediaTypePicker, label: 'search_filter_media_type'.t(context: context), - currentFilter: mediaTypeCurrentFilterWidget.value, + currentFilter: mediaTypeChip(), ), if (userPreferences.valueOrNull?.ratingsEnabled ?? false) SearchFilterChip( icon: Icons.star_outline_rounded, onTap: showStarRatingPicker, label: 'search_filter_star_rating'.t(context: context), - currentFilter: ratingCurrentFilterWidget.value, + currentFilter: ratingChip(), ), SearchFilterChip( icon: Icons.display_settings_outlined, onTap: showDisplayOptionPicker, label: 'search_filter_display_options'.t(context: context), - currentFilter: displayOptionCurrentFilterWidget.value, + currentFilter: displayChip(), ), ], ), diff --git a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart index bb42140d0a..9e6601988d 100644 --- a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/models/search/date_filter.model.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; @@ -34,7 +35,7 @@ class SimilarPhotosActionButton extends ConsumerWidget { people: {}, location: SearchLocationFilter(), camera: SearchCameraFilter(), - date: SearchDateFilter(), + date: const EmptyDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), rating: SearchRatingFilter(), mediaType: AssetType.image, diff --git a/mobile/lib/presentation/widgets/search/quick_date_picker.dart b/mobile/lib/presentation/widgets/search/quick_date_picker.dart index 09b1cee700..83e1742651 100644 --- a/mobile/lib/presentation/widgets/search/quick_date_picker.dart +++ b/mobile/lib/presentation/widgets/search/quick_date_picker.dart @@ -2,85 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; - -sealed class DateFilterInputModel { - DateTimeRange asDateTimeRange(); - - String asHumanReadable(BuildContext context) { - // General implementation for arbitrary date and time ranges - // If date range is less than 24 hours, set the end date to the end of the day - final date = asDateTimeRange(); - if (date.end.difference(date.start).inHours < 24) { - return DateFormat.yMMMd().format(date.start.toLocal()); - } else { - return 'search_filter_date_interval'.t( - context: context, - args: { - "start": DateFormat.yMMMd().format(date.start.toLocal()), - "end": DateFormat.yMMMd().format(date.end.toLocal()), - }, - ); - } - } -} - -class RecentMonthRangeFilter extends DateFilterInputModel { - final int monthDelta; - RecentMonthRangeFilter(this.monthDelta); - - @override - DateTimeRange asDateTimeRange() { - final now = DateTime.now(); - // Note that DateTime's constructor properly handles month overflow. - final from = DateTime(now.year, now.month - monthDelta, 1); - return DateTimeRange(start: from, end: now); - } - - @override - String asHumanReadable(BuildContext context) { - return 'last_months'.t(context: context, args: {"count": monthDelta.toString()}); - } -} - -class YearFilter extends DateFilterInputModel { - final int year; - YearFilter(this.year); - - @override - DateTimeRange asDateTimeRange() { - final now = DateTime.now(); - final from = DateTime(year, 1, 1); - - if (now.year == year) { - // To not go beyond today if the user picks the current year - return DateTimeRange(start: from, end: now); - } - - final to = DateTime(year, 12, 31, 23, 59, 59); - return DateTimeRange(start: from, end: to); - } - - @override - String asHumanReadable(BuildContext context) { - return 'in_year'.tr(namedArgs: {"year": year.toString()}); - } -} - -class CustomDateFilter extends DateFilterInputModel { - final DateTime start; - final DateTime end; - - CustomDateFilter(this.start, this.end); - - factory CustomDateFilter.fromRange(DateTimeRange range) { - return CustomDateFilter(range.start, range.end); - } - - @override - DateTimeRange asDateTimeRange() { - return DateTimeRange(start: start, end: end); - } -} +import 'package:immich_mobile/models/search/date_filter.model.dart'; enum _QuickPickerType { last1Month, last3Months, last9Months, year, custom } @@ -102,7 +24,7 @@ class QuickDatePicker extends HookWidget { }); static int _initialYearFromModel(DateFilterInputModel? model) { - return model?.asDateTimeRange().start.year ?? DateTime.now().year; + return model?.asDateTimeRange()?.start.year ?? DateTime.now().year; } static _QuickPickerType? _selectionFromModel(DateFilterInputModel? model) { @@ -149,7 +71,7 @@ class QuickDatePicker extends HookWidget { // Even if it's already toggled it should always open the full date picker, RadioListTiles don't do that by default // so we wrap it in a InkWell Widget _exactPicker(BuildContext context) { - final hasPreviousInput = currentInput != null && currentInput is CustomDateFilter; + final hasPreviousInput = currentInput is CustomDateFilter; return InkWell( onTap: onRequestPicker, @@ -182,9 +104,9 @@ class QuickDatePicker extends HookWidget { if (value == null) return; final _ = switch (value) { _QuickPickerType.custom => onRequestPicker(), - _QuickPickerType.last1Month => onSelect(RecentMonthRangeFilter(1)), - _QuickPickerType.last3Months => onSelect(RecentMonthRangeFilter(3)), - _QuickPickerType.last9Months => onSelect(RecentMonthRangeFilter(9)), + _QuickPickerType.last1Month => onSelect(const RecentMonthRangeFilter(1)), + _QuickPickerType.last3Months => onSelect(const RecentMonthRangeFilter(3)), + _QuickPickerType.last9Months => onSelect(const RecentMonthRangeFilter(9)), // When a year is selected the combobox triggers onSelect() on its own. // Here we handle the radio button being selected which can only ever be the initial year _QuickPickerType.year => onSelect(YearFilter(_initialYear)), diff --git a/mobile/lib/widgets/search/explore_grid.dart b/mobile/lib/widgets/search/explore_grid.dart index 6af20df029..e33efbad96 100644 --- a/mobile/lib/widgets/search/explore_grid.dart +++ b/mobile/lib/widgets/search/explore_grid.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/search/search_curated_content.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/models/search/date_filter.model.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; @@ -53,7 +54,7 @@ class ExploreGrid extends StatelessWidget { people: {}, location: SearchLocationFilter(city: content.label), camera: SearchCameraFilter(), - date: SearchDateFilter(), + date: const EmptyDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), rating: SearchRatingFilter(), mediaType: AssetType.other,