From 498537ee13edee212f36eabcbb359b71a7b3d591 Mon Sep 17 00:00:00 2001 From: Yaros Date: Wed, 14 Jan 2026 15:07:06 +0100 Subject: [PATCH] feat: rating search filter --- i18n/en.json | 1 + .../repositories/search_api.repository.dart | 2 + .../models/search/search_filter.model.dart | 44 +++++++++++++++++- .../places/places_collection.page.dart | 1 + mobile/lib/pages/search/search.page.dart | 1 + .../pages/search/drift_search.page.dart | 45 +++++++++++++++++++ .../similar_photos_action_button.widget.dart | 1 + mobile/lib/widgets/search/explore_grid.dart | 1 + .../search_filter/star_rating_picker.dart | 35 +++++++++++++++ 9 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 mobile/lib/widgets/search/search_filter/star_rating_picker.dart diff --git a/i18n/en.json b/i18n/en.json index 473bd6f37b..cee1f5e5e0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1868,6 +1868,7 @@ "search_filter_media_type_title": "Select media type", "search_filter_ocr": "Search by OCR", "search_filter_people_title": "Select people", + "search_filter_star_rating": "Star Rating", "search_for": "Search for", "search_for_existing_person": "Search for existing person", "search_no_more_result": "No more results", diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 34870dc1b3..043a42b1a4 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -31,6 +31,7 @@ class SearchApiRepository extends ApiRepository { takenAfter: filter.date.takenAfter, takenBefore: filter.date.takenBefore, 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(), @@ -54,6 +55,7 @@ class SearchApiRepository extends ApiRepository { takenAfter: filter.date.takenAfter, takenBefore: filter.date.takenBefore, 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(), diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 93322f5031..2d45913fcb 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -126,6 +126,41 @@ class SearchDateFilter { int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode; } +class SearchRatingFilter { + int? rating; + SearchRatingFilter({this.rating}); + + SearchRatingFilter copyWith({int? rating}) { + return SearchRatingFilter(rating: rating ?? this.rating); + } + + Map toMap() { + return {'rating': rating}; + } + + factory SearchRatingFilter.fromMap(Map map) { + return SearchRatingFilter(rating: map['rating'] != null ? map['rating'] as int : null); + } + + String toJson() => json.encode(toMap()); + + factory SearchRatingFilter.fromJson(String source) => + SearchRatingFilter.fromMap(json.decode(source) as Map); + + @override + String toString() => 'SearchRatingFilter(rating: $rating)'; + + @override + bool operator ==(covariant SearchRatingFilter other) { + if (identical(this, other)) return true; + + return other.rating == rating; + } + + @override + int get hashCode => rating.hashCode; +} + class SearchDisplayFilters { bool isNotInAlbum = false; bool isArchive = false; @@ -183,6 +218,7 @@ class SearchFilter { SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; + SearchRatingFilter rating; SearchDisplayFilters display; // Enum @@ -200,6 +236,7 @@ class SearchFilter { required this.camera, required this.date, required this.display, + required this.rating, required this.mediaType, }); @@ -220,6 +257,7 @@ class SearchFilter { display.isNotInAlbum == false && display.isArchive == false && display.isFavorite == false && + rating.rating == null && mediaType == AssetType.other; } @@ -235,6 +273,7 @@ class SearchFilter { SearchCameraFilter? camera, SearchDateFilter? date, SearchDisplayFilters? display, + SearchRatingFilter? rating, AssetType? mediaType, }) { return SearchFilter( @@ -249,13 +288,14 @@ class SearchFilter { camera: camera ?? this.camera, date: date ?? this.date, display: display ?? this.display, + rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, ); } @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType, assetId: $assetId)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; } @override @@ -273,6 +313,7 @@ class SearchFilter { other.camera == camera && other.date == date && other.display == display && + other.rating == rating && other.mediaType == mediaType; } @@ -289,6 +330,7 @@ class SearchFilter { camera.hashCode ^ date.hashCode ^ display.hashCode ^ + rating.hashCode ^ mediaType.hashCode; } } diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart index f376709316..d6511cb25b 100644 --- a/mobile/lib/pages/library/places/places_collection.page.dart +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -113,6 +113,7 @@ class PlaceTile extends StatelessWidget { camera: SearchCameraFilter(), date: SearchDateFilter(), 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 902110f6a8..dbd32ac94b 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -43,6 +43,7 @@ class SearchPage extends HookConsumerWidget { date: prefilter?.date ?? SearchDateFilter(), display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), mediaType: prefilter?.mediaType ?? AssetType.other, + rating: prefilter?.rating ?? SearchRatingFilter(), language: "${context.locale.languageCode}-${context.locale.countryCode}", ), ); diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 58ca892f5f..16655e98f6 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s 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'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/feature_check.dart'; @@ -30,6 +31,7 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar 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'; +import 'package:immich_mobile/widgets/search/search_filter/star_rating_picker.dart'; @RoutePage() class DriftSearchPage extends HookConsumerWidget { @@ -48,6 +50,7 @@ class DriftSearchPage extends HookConsumerWidget { camera: preFilter?.camera ?? SearchCameraFilter(), date: preFilter?.date ?? SearchDateFilter(), display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: preFilter?.rating ?? SearchRatingFilter(), mediaType: preFilter?.mediaType ?? AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", assetId: preFilter?.assetId, @@ -62,10 +65,15 @@ class DriftSearchPage extends HookConsumerWidget { final cameraCurrentFilterWidget = useState(null); final locationCurrentFilterWidget = useState(null); final mediaTypeCurrentFilterWidget = useState(null); + final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); final isSearching = useState(false); + final isRatingEnabled = ref + .watch(userMetadataPreferencesProvider) + .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + SnackBar searchInfoSnackBar(String message) { return SnackBar( content: Text(message, style: context.textTheme.labelLarge), @@ -369,6 +377,35 @@ class DriftSearchPage extends HookConsumerWidget { ); } + // STAR RATING PICKER + showStarRatingPicker() { + handleOnSelected(SearchRatingFilter rating) { + filter.value = filter.value.copyWith(rating: rating); + + ratingCurrentFilterWidget.value = Text( + 'rating_count'.t(args: {'count': rating.rating!}), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith(rating: SearchRatingFilter(rating: null)); + ratingCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FilterBottomSheetScaffold( + title: 'rating'.t(context: context), + onSearch: search, + onClear: handleClear, + child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating), + ), + ); + } + // DISPLAY OPTION showDisplayOptionPicker() { handleOnSelect(Map value) { @@ -629,6 +666,14 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_media_type'.t(context: context), currentFilter: mediaTypeCurrentFilterWidget.value, ), + if (isRatingEnabled) ...[ + SearchFilterChip( + icon: Icons.star_outline_rounded, + onTap: showStarRatingPicker, + label: 'search_filter_star_rating'.t(context: context), + currentFilter: ratingCurrentFilterWidget.value, + ), + ], SearchFilterChip( icon: Icons.display_settings_outlined, onTap: showDisplayOptionPicker, 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 65ba744ec3..294ddfd1f5 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 @@ -34,6 +34,7 @@ class SimilarPhotosActionButton extends ConsumerWidget { camera: SearchCameraFilter(), date: SearchDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: SearchRatingFilter(), mediaType: AssetType.image, ), ); diff --git a/mobile/lib/widgets/search/explore_grid.dart b/mobile/lib/widgets/search/explore_grid.dart index a6e1cf5aac..6af20df029 100644 --- a/mobile/lib/widgets/search/explore_grid.dart +++ b/mobile/lib/widgets/search/explore_grid.dart @@ -55,6 +55,7 @@ class ExploreGrid extends StatelessWidget { camera: SearchCameraFilter(), date: SearchDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: SearchRatingFilter(), mediaType: AssetType.other, ), ), diff --git a/mobile/lib/widgets/search/search_filter/star_rating_picker.dart b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart new file mode 100644 index 0000000000..5591b0e264 --- /dev/null +++ b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; + +class StarRatingPicker extends HookWidget { + const StarRatingPicker({super.key, required this.onSelect, this.filter}); + final Function(SearchRatingFilter) onSelect; + final SearchRatingFilter? filter; + + @override + Widget build(BuildContext context) { + final selectedRating = useState(filter); + + return RadioGroup( + groupValue: selectedRating.value?.rating, + onChanged: (int? newValue) { + if (newValue == null) return; + final newFilter = SearchRatingFilter(rating: newValue); + selectedRating.value = newFilter; + onSelect(newFilter); + }, + child: Column( + children: List.generate( + 6, + (index) => RadioListTile( + key: Key("star_$index"), + title: Text('rating_count'.t(args: {'count': (index)})), + value: index, + ), + ), + ), + ); + } +}