From ae8dad68fc2184e03fc623da3bbd64125bf8bef8 Mon Sep 17 00:00:00 2001 From: Benjamin Nguyen Date: Wed, 18 Feb 2026 13:16:26 -0800 Subject: [PATCH] feat(mobile): filter by tags (#26196) filter by tags --- i18n/en.json | 2 + mobile/lib/domain/models/tag.model.dart | 29 ++++++ .../repositories/search_api.repository.dart | 2 + .../repositories/tags_api.repository.dart | 17 ++++ .../models/search/search_filter.model.dart | 9 +- .../pages/search/drift_search.page.dart | 53 ++++++++++- .../infrastructure/tag.provider.dart | 17 ++++ mobile/lib/services/api.service.dart | 2 + mobile/lib/widgets/common/tag_picker.dart | 89 +++++++++++++++++++ 9 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 mobile/lib/domain/models/tag.model.dart create mode 100644 mobile/lib/infrastructure/repositories/tags_api.repository.dart create mode 100644 mobile/lib/providers/infrastructure/tag.provider.dart create mode 100644 mobile/lib/widgets/common/tag_picker.dart diff --git a/i18n/en.json b/i18n/en.json index 6e35085be8..95e9584032 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1218,6 +1218,7 @@ "filter_description": "Conditions to filter the target assets", "filter_people": "Filter people", "filter_places": "Filter places", + "filter_tags": "Filter tags", "filters": "Filters", "find_them_fast": "Find them fast by name with search", "first": "First", @@ -1945,6 +1946,7 @@ "search_filter_ocr": "Search by OCR", "search_filter_people_title": "Select people", "search_filter_star_rating": "Star Rating", + "search_filter_tags_title": "Select tags", "search_for": "Search for", "search_for_existing_person": "Search for existing person", "search_no_more_result": "No more results", diff --git a/mobile/lib/domain/models/tag.model.dart b/mobile/lib/domain/models/tag.model.dart new file mode 100644 index 0000000000..357367b13e --- /dev/null +++ b/mobile/lib/domain/models/tag.model.dart @@ -0,0 +1,29 @@ +import 'package:openapi/api.dart'; + +class Tag { + final String id; + final String value; + + const Tag({required this.id, required this.value}); + + @override + String toString() { + return 'Tag(id: $id, value: $value)'; + } + + @override + bool operator ==(covariant Tag other) { + if (identical(this, other)) return true; + + return other.id == id && other.value == value; + } + + @override + int get hashCode { + return id.hashCode ^ value.hashCode; + } + + static Tag fromDto(TagResponseDto dto) { + return Tag(id: dto.id, value: dto.value); + } +} diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 043a42b1a4..bcfddfce6e 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -35,6 +35,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), + tagIds: filter.tagIds, type: type, page: page, size: 100, @@ -59,6 +60,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), + tagIds: filter.tagIds, type: type, page: page, size: 1000, diff --git a/mobile/lib/infrastructure/repositories/tags_api.repository.dart b/mobile/lib/infrastructure/repositories/tags_api.repository.dart new file mode 100644 index 0000000000..e81b79c459 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/tags_api.repository.dart @@ -0,0 +1,17 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/api.repository.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:openapi/api.dart'; + +final tagsApiRepositoryProvider = Provider( + (ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi), +); + +class TagsApiRepository extends ApiRepository { + final TagsApi _api; + const TagsApiRepository(this._api); + + Future?> getAllTags() async { + return await _api.getAllTags(); + } +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 2d45913fcb..1b730e0c68 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -214,6 +214,7 @@ class SearchFilter { String? ocr; String? language; String? assetId; + List? tagIds; Set people; SearchLocationFilter location; SearchCameraFilter camera; @@ -231,6 +232,7 @@ class SearchFilter { this.ocr, this.language, this.assetId, + this.tagIds, required this.people, required this.location, required this.camera, @@ -246,6 +248,7 @@ class SearchFilter { (description == null || (description!.isEmpty)) && (assetId == null || (assetId!.isEmpty)) && (ocr == null || (ocr!.isEmpty)) && + (tagIds ?? []).isEmpty && people.isEmpty && location.country == null && location.state == null && @@ -269,6 +272,7 @@ class SearchFilter { String? ocr, String? assetId, Set? people, + List? tagIds, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, @@ -290,12 +294,13 @@ class SearchFilter { display: display ?? this.display, rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, + tagIds: tagIds ?? this.tagIds, ); } @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, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; + 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)'; } @override @@ -309,6 +314,7 @@ class SearchFilter { other.ocr == ocr && other.assetId == assetId && other.people == people && + other.tagIds == tagIds && other.location == location && other.camera == camera && other.date == date && @@ -326,6 +332,7 @@ class SearchFilter { ocr.hashCode ^ assetId.hashCode ^ people.hashCode ^ + tagIds.hashCode ^ location.hashCode ^ camera.hashCode ^ date.hashCode ^ diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 62ec11f7ed..45a14643c9 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -24,6 +25,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/feature_check.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; +import 'package:immich_mobile/widgets/common/tag_picker.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'; @@ -62,6 +64,7 @@ class DriftSearchPage extends HookConsumerWidget { mediaType: preFilter?.mediaType ?? AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", assetId: preFilter?.assetId, + tagIds: preFilter?.tagIds ?? [], ), ); @@ -72,15 +75,14 @@ class DriftSearchPage extends HookConsumerWidget { 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 isSearching = useState(false); - final isRatingEnabled = ref - .watch(userMetadataPreferencesProvider) - .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + final userPreferences = ref.watch(userMetadataPreferencesProvider); SnackBar searchInfoSnackBar(String message) { return SnackBar( @@ -177,6 +179,42 @@ class DriftSearchPage extends HookConsumerWidget { ); } + showTagPicker() { + handleOnSelect(Iterable tags) { + filter.value = filter.value.copyWith(tagIds: tags.map((t) => t.id).toList()); + final label = tags.map((t) => t.value).join(', '); + if (label.isEmpty) { + tagCurrentFilterWidget.value = null; + } else { + tagCurrentFilterWidget.value = Text( + label.isEmpty ? 'tags'.t(context: context) : label, + style: context.textTheme.labelLarge, + ); + } + } + + handleClear() { + filter.value = filter.value.copyWith(tagIds: []); + tagCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FractionallySizedBox( + heightFactor: 0.8, + child: FilterBottomSheetScaffold( + title: 'search_filter_tags_title'.t(context: context), + expanded: true, + onSearch: search, + onClear: handleClear, + child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), + ), + ), + ); + } + showLocationPicker() { handleOnSelect(Map value) { filter.value = filter.value.copyWith( @@ -658,6 +696,13 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_location'.t(context: context), currentFilter: locationCurrentFilterWidget.value, ), + if (userPreferences.value?.tagsEnabled ?? false) + SearchFilterChip( + icon: Icons.sell_outlined, + onTap: showTagPicker, + label: 'tags'.t(context: context), + currentFilter: tagCurrentFilterWidget.value, + ), SearchFilterChip( icon: Icons.camera_alt_outlined, onTap: showCameraPicker, @@ -677,7 +722,7 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_media_type'.t(context: context), currentFilter: mediaTypeCurrentFilterWidget.value, ), - if (isRatingEnabled) ...[ + if (userPreferences.value?.ratingsEnabled ?? false) ...[ SearchFilterChip( icon: Icons.star_outline_rounded, onTap: showStarRatingPicker, diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart new file mode 100644 index 0000000000..23d4d86861 --- /dev/null +++ b/mobile/lib/providers/infrastructure/tag.provider.dart @@ -0,0 +1,17 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart'; + +class TagNotifier extends AsyncNotifier> { + @override + Future> build() async { + final repo = ref.read(tagsApiRepositoryProvider); + final allTags = await repo.getAllTags(); + if (allTags == null) { + return {}; + } + return allTags.map((t) => Tag.fromDto(t)).toSet(); + } +} + +final tagProvider = AsyncNotifierProvider>(TagNotifier.new); diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 1a714b6f40..bafe780647 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -35,6 +35,7 @@ class ApiService implements Authentication { late ViewsApi viewApi; late MemoriesApi memoriesApi; late SessionsApi sessionsApi; + late TagsApi tagsApi; ApiService() { // The below line ensures that the api clients are initialized when the service is instantiated @@ -74,6 +75,7 @@ class ApiService implements Authentication { viewApi = ViewsApi(_apiClient); memoriesApi = MemoriesApi(_apiClient); sessionsApi = SessionsApi(_apiClient); + tagsApi = TagsApi(_apiClient); } Future _setUserAgentHeader() async { diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart new file mode 100644 index 0000000000..0ab25d14cb --- /dev/null +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; + +class TagPicker extends HookConsumerWidget { + const TagPicker({super.key, required this.onSelect, required this.filter}); + + final Function(Iterable) onSelect; + final Set filter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formFocus = useFocusNode(); + final searchQuery = useState(''); + final tags = ref.watch(tagProvider); + final selectedTagIds = useState>(filter); + final borderRadius = const BorderRadius.all(Radius.circular(10)); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: SearchField( + focusNode: formFocus, + onChanged: (value) => searchQuery.value = value, + onTapOutside: (_) => formFocus.unfocus(), + filled: true, + hintText: 'filter_tags'.tr(), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0), + child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1), + ), + Expanded( + child: tags.widgetWhen( + onData: (tags) { + final queryResult = tags + .where((t) => t.value.toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList(); + return ListView.builder( + itemCount: queryResult.length, + padding: const EdgeInsets.all(8), + itemBuilder: (context, index) { + final tag = queryResult[index]; + final isSelected = selectedTagIds.value.any((id) => id == tag.id); + + return Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Container( + decoration: BoxDecoration( + color: isSelected ? context.primaryColor : context.primaryColor.withAlpha(25), + borderRadius: borderRadius, + ), + child: ListTile( + title: Text( + tag.value, + style: context.textTheme.bodyLarge?.copyWith( + color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + ), + onTap: () { + final newSelected = {...selectedTagIds.value}; + if (isSelected) { + newSelected.removeWhere((id) => id == tag.id); + } else { + newSelected.add(tag.id); + } + selectedTagIds.value = newSelected; + onSelect(tags.where((t) => newSelected.contains(t.id))); + }, + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } +}