From 8dde2d55b37afa55ec35110d28b2e51570b0443b Mon Sep 17 00:00:00 2001 From: Omar Salama Date: Sat, 8 Aug 2026 02:58:09 +0300 Subject: [PATCH] feat(mobile): add storage status filtering option - Introduced new storage status options: "On Device Only", "On Server Only", and "All" in search filters. - Updated search service to handle filtering based on storage status, allowing for device-only or server-only asset searches. - Enhanced UI components to support new storage status filters, including a dedicated picker for selecting storage options. - Adjusted existing search logic to accommodate new filtering criteria and ensure proper asset retrieval based on selected storage status. --- i18n/ar.json | 4 + i18n/en.json | 4 + .../lib/domain/services/search.service.dart | 50 +++++- .../repositories/local_asset.repository.dart | 55 +++++++ .../models/search/search_filter.model.dart | 27 +++- .../actions/similar_photos.action.dart | 3 + .../pages/search/drift_search.page.dart | 122 +++++++++++++- .../search/paginated_search.provider.dart | 150 +++++++++--------- .../infrastructure/search.provider.dart | 8 +- .../search_filter/display_option_picker.dart | 6 +- .../search_filter/search_filter_chip.dart | 46 +++--- .../search_filter/storage_status_picker.dart | 47 ++++++ 12 files changed, 411 insertions(+), 111 deletions(-) create mode 100644 mobile/lib/widgets/search/search_filter/storage_status_picker.dart diff --git a/i18n/ar.json b/i18n/ar.json index 84a0628066..48f8f63bbd 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -1840,6 +1840,9 @@ "search_filter_media_type_title": "اختر نوع الوسائط", "search_filter_people_title": "اختر الاشخاص", "search_filter_star_rating": "تقييم النجوم", + "search_filter_storage_not_backed_up": "على الجهاز فقط", + "search_filter_storage_server_only": "على الخادم فقط", + "search_filter_storage_title": "مكان التخزين", "search_filter_tags_title": "‪تحديد العلامات", "search_for": "البحث عن", "search_for_existing_person": "البحث عن شخص موجود", @@ -2031,6 +2034,7 @@ "slideshow_settings": "إعدادات عرض الشرائح", "slideshow_title": "عرض الشرائح", "smart_album": "ألبوم ذكي", + "smart_filters_cleared_device_only": "تم مسح الفلاتر الذكية لأنها غير متوفرة للأصول الموجودة على الجهاز فقط.", "some_assets_already_have_a_location_warning": "بعض الملفات المحددة تحتوي بالفعل على موقع جغرافي", "sort_albums_by": "رتب الألبومات حسب...", "sort_created": "تاريخ الإنشاء", diff --git a/i18n/en.json b/i18n/en.json index 857265f004..e14d5d52ab 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1841,6 +1841,9 @@ "search_filter_media_type_title": "Select media type", "search_filter_people_title": "Select people", "search_filter_star_rating": "Star Rating", + "search_filter_storage_not_backed_up": "On Device Only", + "search_filter_storage_server_only": "On Server Only", + "search_filter_storage_title": "Storage Location", "search_filter_tags_title": "Select tags", "search_for": "Search for", "search_for_existing_person": "Search for existing person", @@ -2032,6 +2035,7 @@ "slideshow_settings": "Slideshow settings", "slideshow_title": "Slideshow", "smart_album": "Smart album", + "smart_filters_cleared_device_only": "Smart filters were cleared because they aren't available for device-only assets.", "some_assets_already_have_a_location_warning": "Some of the selected assets already have a location", "sort_albums_by": "Sort albums by...", "sort_created": "Date created", diff --git a/mobile/lib/domain/services/search.service.dart b/mobile/lib/domain/services/search.service.dart index 8b93e9c8cc..bc1075330e 100644 --- a/mobile/lib/domain/services/search.service.dart +++ b/mobile/lib/domain/services/search.service.dart @@ -1,7 +1,9 @@ import 'package:immich_mobile/domain/models/search_result.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart' hide AssetVisibility; @@ -9,8 +11,9 @@ import 'package:openapi/api.dart' hide AssetVisibility; class SearchService { final _log = Logger("SearchService"); final SearchApiRepository _searchApiRepository; + final DriftLocalAssetRepository _localAssetRepository; - SearchService(this._searchApiRepository); + SearchService(this._searchApiRepository, this._localAssetRepository); Future?> getSearchSuggestions( SearchSuggestionType type, { @@ -35,15 +38,52 @@ class SearchService { Future search(SearchFilter filter, int page) async { try { - final response = await _searchApiRepository.search(filter, page); + if (filter.storage == SearchStorageStatus.notBackedUp) { + final localAssets = await _localAssetRepository.searchDeviceOnlyAssets(filter, page); + final nextPage = localAssets.length == DriftLocalAssetRepository.searchPageSize ? page + 1 : null; + return SearchResult(assets: localAssets, nextPage: nextPage); + } - if (response == null || response.assets.items.isEmpty) { + var currentPage = page; + List accumulatedAssets = []; + int? nextPage; + int iterations = 0; + + while (true) { + iterations++; + final response = await _searchApiRepository.search(filter, currentPage); + + if (response == null || response.assets.items.isEmpty) { + nextPage = null; + break; + } + + var assets = response.assets.items.map((e) => e.toDto()).toList(); + nextPage = response.assets.nextPage?.toInt(); + + if (filter.storage == SearchStorageStatus.serverOnly) { + final checksums = assets.map((a) => a.checksum).where((c) => c != null).cast(); + final localChecksums = await _localAssetRepository.getExistingChecksums(checksums); + assets = assets.where((a) => a.checksum == null || !localChecksums.contains(a.checksum)).toList(); + } + + accumulatedAssets.addAll(assets); + + // Break if we are not filtering, or if we have collected enough items, or if there are no more pages + if (filter.storage != SearchStorageStatus.serverOnly || accumulatedAssets.length >= 20 || nextPage == null || iterations >= 3) { + break; + } + + currentPage = nextPage; + } + + if (page == 1 && accumulatedAssets.isEmpty && nextPage == null) { return null; } return SearchResult( - assets: response.assets.items.map((e) => e.toDto()).toList(), - nextPage: response.assets.nextPage?.toInt(), + assets: accumulatedAssets, + nextPage: nextPage, ); } catch (error, stackTrace) { _log.severe("Failed to search for assets", error, stackTrace); diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 8396d6d2a6..e77f20724c 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -9,7 +9,10 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; class RemovalCandidatesResult { final List assets; @@ -21,6 +24,8 @@ class RemovalCandidatesResult { class DriftLocalAssetRepository extends DriftDatabaseRepository { final Drift _db; + static const int searchPageSize = 100; + const DriftLocalAssetRepository(this._db) : super(_db); SingleOrNullSelectable _assetSelectable(String id) { @@ -222,6 +227,56 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } + Future> getExistingChecksums(Iterable checksums) async { + if (checksums.isEmpty) return {}; + final result = {}; + for (final slice in checksums.toSet().slices(kDriftMaxChunk)) { + final query = _db.localAssetEntity.selectOnly() + ..addColumns([_db.localAssetEntity.checksum]) + ..where(_db.localAssetEntity.checksum.isIn(slice)); + final rows = await query.get(); + result.addAll(rows.map((row) => row.read(_db.localAssetEntity.checksum)!)); + } + return result; + } + + Future> searchDeviceOnlyAssets(SearchFilter filter, int page) async { + final query = _db.localAssetEntity.select().join([ + leftOuterJoin( + _db.remoteAssetEntity, + _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum), + ), + ]); + + Expression whereClause = _db.remoteAssetEntity.id.isNull(); + + if (filter.date.takenAfter != null) { + whereClause = whereClause & _db.localAssetEntity.createdAt.isBiggerOrEqualValue(filter.date.takenAfter!); + } + if (filter.date.takenBefore != null) { + whereClause = whereClause & _db.localAssetEntity.createdAt.isSmallerOrEqualValue(filter.date.takenBefore!); + } + if (filter.mediaType == AssetType.image) { + whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image); + } else if (filter.mediaType == AssetType.video) { + whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video); + } + if (filter.display.isFavorite) { + whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(true); + } + if (filter.filename != null && filter.filename!.isNotEmpty) { + whereClause = whereClause & _db.localAssetEntity.name.like('%${filter.filename!}%'); + } + + query.where(whereClause); + query.orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]); + + query.limit(searchPageSize, offset: (page - 1) * searchPageSize); + + final rows = await query.get(); + return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList(); + } + Future reconcileHashesFromCloudId() async { await _db.customUpdate( ''' diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 825acb32c6..b3f9f90222 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -225,6 +225,8 @@ class SearchDisplayFilters { int get hashCode => isNotInAlbum.hashCode ^ isArchive.hashCode ^ isFavorite.hashCode; } +enum SearchStorageStatus { all, notBackedUp, serverOnly } + class SearchFilter { String? context; String? filename; @@ -239,6 +241,7 @@ class SearchFilter { SearchDateFilter date; SearchRatingFilter rating; SearchDisplayFilters display; + SearchStorageStatus storage; // Enum AssetType mediaType; @@ -256,10 +259,27 @@ class SearchFilter { required this.camera, required this.date, required this.display, + required this.storage, required this.rating, required this.mediaType, }); + bool get hasServerOnlyFilters { + return people.isNotEmpty || + location.country != null || + location.state != null || + location.city != null || + camera.make != null || + camera.model != null || + rating.rating.isSome || + display.isArchive || + display.isNotInAlbum || + (tagIds != null && tagIds!.isNotEmpty) || + (context != null && context!.isNotEmpty) || + (description != null && description!.isNotEmpty) || + (ocr != null && ocr!.isNotEmpty); + } + bool get isEmpty { return (context == null || (context != null && context!.isEmpty)) && (filename == null || (filename!.isEmpty)) && @@ -278,6 +298,7 @@ class SearchFilter { display.isNotInAlbum == false && display.isArchive == false && display.isFavorite == false && + storage == SearchStorageStatus.all && rating.rating.isNone && mediaType == AssetType.other; } @@ -295,6 +316,7 @@ class SearchFilter { SearchCameraFilter? camera, SearchDateFilter? date, SearchDisplayFilters? display, + SearchStorageStatus? storage, SearchRatingFilter? rating, AssetType? mediaType, }) { @@ -310,6 +332,7 @@ class SearchFilter { camera: camera ?? this.camera, date: date ?? this.date, display: display ?? this.display, + storage: storage ?? this.storage, rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, tagIds: tagIds ?? this.tagIds, @@ -318,7 +341,7 @@ class SearchFilter { @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, tagIds: $tagIds, camera: $camera, date: $date, display: $display, storage: $storage, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; } @override @@ -339,6 +362,7 @@ class SearchFilter { other.camera == camera && other.date == date && other.display == display && + other.storage == storage && other.rating == rating && other.mediaType == mediaType; } @@ -357,6 +381,7 @@ class SearchFilter { camera.hashCode ^ date.hashCode ^ display.hashCode ^ + storage.hashCode ^ rating.hashCode ^ mediaType.hashCode; } diff --git a/mobile/lib/presentation/actions/similar_photos.action.dart b/mobile/lib/presentation/actions/similar_photos.action.dart index 3c0b7b129b..c3c6e277bb 100644 --- a/mobile/lib/presentation/actions/similar_photos.action.dart +++ b/mobile/lib/presentation/actions/similar_photos.action.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.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/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -32,8 +33,10 @@ class SimilarPhotosAction extends ActionBuilder { camera: .new(), date: .new(), display: .new(isNotInAlbum: false, isArchive: false, isFavorite: false), + storage: SearchStorageStatus.all, rating: .new(), mediaType: .other, + language: '', ), ); diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 12935b4f23..ba610faf08 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -17,6 +17,8 @@ import 'package:immich_mobile/generated/translations.g.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/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.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'; @@ -34,8 +36,8 @@ 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'; import 'package:immich_mobile/widgets/search/search_filter/star_rating_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/storage_status_picker.dart'; @RoutePage() class DriftSearchPage extends HookConsumerWidget { @@ -60,6 +62,7 @@ class DriftSearchPage extends HookConsumerWidget { camera: SearchCameraFilter(), date: SearchDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + storage: SearchStorageStatus.all, rating: SearchRatingFilter(), mediaType: AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", @@ -77,6 +80,7 @@ class DriftSearchPage extends HookConsumerWidget { final mediaTypeCurrentFilterWidget = useState(null); final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); + final storageStatusCurrentFilterWidget = useState(null); final userPreferences = ref.watch(userMetadataPreferencesProvider); @@ -85,12 +89,55 @@ class DriftSearchPage extends HookConsumerWidget { return; } - filter.value = f; + var activeFilter = f; + + if (f.storage == SearchStorageStatus.notBackedUp) { + if (f.hasServerOnlyFilters) { + activeFilter = activeFilter.copyWith( + people: {}, + location: SearchLocationFilter(), + camera: SearchCameraFilter(), + rating: SearchRatingFilter(), + display: f.display.copyWith(isArchive: false, isNotInAlbum: false), + tagIds: [], + context: '', + description: '', + ocr: '', + ); + + peopleCurrentFilterWidget.value = null; + locationCurrentFilterWidget.value = null; + cameraCurrentFilterWidget.value = null; + tagCurrentFilterWidget.value = null; + ratingCurrentFilterWidget.value = null; + + if (textSearchType.value != TextSearchType.filename) { + textSearchController.clear(); + textSearchType.value = TextSearchType.filename; + searchHintText.value = 'file_name_or_extension'.t(context: context); + } + + final displayFilterText = [ + if (activeFilter.display.isFavorite) 'favorite'.t(context: context), + ]; + displayOptionCurrentFilterWidget.value = displayFilterText.isNotEmpty + ? Text(displayFilterText.join(', '), style: context.textTheme.labelLarge) + : null; + + ImmichToast.show( + context: context, + msg: "smart_filters_cleared_device_only".t(context: context), + toastType: ToastType.info, + ); + } + } + + filter.value = activeFilter; ref.read(paginatedSearchProvider.notifier).clear(); - if (!f.isEmpty) { - unawaited(ref.read(paginatedSearchProvider.notifier).search(f)); + if (!activeFilter.isEmpty) { + unawaited(ref.read(paginatedSearchProvider.notifier).search(activeFilter)); } } @@ -119,6 +166,7 @@ class DriftSearchPage extends HookConsumerWidget { mediaTypeCurrentFilterWidget.value = null; ratingCurrentFilterWidget.value = null; displayOptionCurrentFilterWidget.value = null; + storageStatusCurrentFilterWidget.value = null; locationCurrentFilterWidget.value = preFilter.location.city != null ? Text(preFilter.location.city!, style: context.textTheme.labelLarge) : null; @@ -489,7 +537,54 @@ class DriftSearchPage extends HookConsumerWidget { title: 'display_options'.t(context: context), onSearch: handleApply, onClear: handleClear, - child: DisplayOptionPicker(onSelect: handleOnSelect, filter: filter.value.display), + child: DisplayOptionPicker( + onSelect: handleOnSelect, + filter: filter.value.display, + disabledOptions: filter.value.storage == SearchStorageStatus.notBackedUp + ? [DisplayOption.notInAlbum, DisplayOption.archive] + : [], + ), + ), + ), + ); + } + + // STORAGE STATUS + void showStorageStatusPicker() { + var storage = filter.value.storage; + + void handleOnSelect(SearchStorageStatus value) { + storage = value; + } + + void handleClear() { + storageStatusCurrentFilterWidget.value = null; + search( + filter.value.copyWith( + storage: SearchStorageStatus.all, + ), + ); + } + + void handleApply() { + final filterText = [ + if (storage == SearchStorageStatus.notBackedUp) 'search_filter_storage_not_backed_up'.t(context: context), + if (storage == SearchStorageStatus.serverOnly) 'search_filter_storage_server_only'.t(context: context), + ]; + storageStatusCurrentFilterWidget.value = filterText.isNotEmpty + ? Text(filterText.join(', '), style: context.textTheme.labelLarge) + : null; + search(filter.value.copyWith(storage: storage)); + } + + unawaited( + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_storage_title'.t(context: context), + onSearch: handleApply, + onClear: handleClear, + child: StorageStatusPicker(onSelect: handleOnSelect, filter: filter.value.storage), ), ), ); @@ -553,7 +648,7 @@ class DriftSearchPage extends HookConsumerWidget { selectedColor: context.colorScheme.primary, selected: textSearchType.value == TextSearchType.context, ), - onPressed: () { + onPressed: filter.value.storage == SearchStorageStatus.notBackedUp ? null : () { textSearchType.value = TextSearchType.context; searchHintText.value = 'sunrise_on_the_beach'.t(context: context); }, @@ -590,7 +685,7 @@ class DriftSearchPage extends HookConsumerWidget { selectedColor: context.colorScheme.primary, selected: textSearchType.value == TextSearchType.description, ), - onPressed: () { + onPressed: filter.value.storage == SearchStorageStatus.notBackedUp ? null : () { textSearchType.value = TextSearchType.description; searchHintText.value = 'search_by_description_example'.t(context: context); }, @@ -610,7 +705,7 @@ class DriftSearchPage extends HookConsumerWidget { selectedColor: context.colorScheme.primary, selected: textSearchType.value == TextSearchType.ocr, ), - onPressed: () { + onPressed: filter.value.storage == SearchStorageStatus.notBackedUp ? null : () { textSearchType.value = TextSearchType.ocr; searchHintText.value = 'search_by_ocr_example'.t(context: context); }, @@ -665,12 +760,14 @@ class DriftSearchPage extends HookConsumerWidget { onTap: showPeoplePicker, label: 'people'.t(context: context), currentFilter: peopleCurrentFilterWidget.value, + isEnabled: filter.value.storage != SearchStorageStatus.notBackedUp, ), SearchFilterChip( icon: Icons.location_on_outlined, onTap: showLocationPicker, label: 'search_filter_location'.t(context: context), currentFilter: locationCurrentFilterWidget.value, + isEnabled: filter.value.storage != SearchStorageStatus.notBackedUp, ), if (userPreferences.valueOrNull?.tagsEnabled ?? false) SearchFilterChip( @@ -678,12 +775,14 @@ class DriftSearchPage extends HookConsumerWidget { onTap: showTagPicker, label: 'tags'.t(context: context), currentFilter: tagCurrentFilterWidget.value, + isEnabled: filter.value.storage != SearchStorageStatus.notBackedUp, ), SearchFilterChip( icon: Icons.camera_alt_outlined, onTap: showCameraPicker, label: 'camera'.t(context: context), currentFilter: cameraCurrentFilterWidget.value, + isEnabled: filter.value.storage != SearchStorageStatus.notBackedUp, ), SearchFilterChip( icon: Icons.date_range_outlined, @@ -704,6 +803,7 @@ class DriftSearchPage extends HookConsumerWidget { onTap: showStarRatingPicker, label: 'search_filter_star_rating'.t(context: context), currentFilter: ratingCurrentFilterWidget.value, + isEnabled: filter.value.storage != SearchStorageStatus.notBackedUp, ), SearchFilterChip( icon: Icons.display_settings_outlined, @@ -711,6 +811,12 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_display_options'.t(context: context), currentFilter: displayOptionCurrentFilterWidget.value, ), + SearchFilterChip( + icon: Icons.sd_storage_outlined, + onTap: showStorageStatusPicker, + label: 'search_filter_storage_title'.t(context: context), + currentFilter: storageStatusCurrentFilterWidget.value, + ), ], ), ), diff --git a/mobile/lib/presentation/pages/search/paginated_search.provider.dart b/mobile/lib/presentation/pages/search/paginated_search.provider.dart index e9839f31ea..3a5835eb7b 100644 --- a/mobile/lib/presentation/pages/search/paginated_search.provider.dart +++ b/mobile/lib/presentation/pages/search/paginated_search.provider.dart @@ -1,76 +1,74 @@ -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/services/search.service.dart'; -import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; - -final searchPreFilterProvider = NotifierProvider(SearchFilterProvider.new); - -class SearchFilterProvider extends Notifier { - @override - SearchFilter? build() { - return null; - } - - void setFilter(SearchFilter? filter) { - state = filter; - } - - void clear() { - state = null; - } -} - -class SearchState { - final List assets; - final int? nextPage; - final bool isLoading; - - const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false}); -} - -final paginatedSearchProvider = StateNotifierProvider( - (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), -); - -class PaginatedSearchNotifier extends StateNotifier { - final SearchService _searchService; - final _assetCountController = StreamController.broadcast(); - - PaginatedSearchNotifier(this._searchService) : super(const SearchState()); - - Stream get assetCount => _assetCountController.stream; - - Future search(SearchFilter filter) async { - if (state.nextPage == null || state.isLoading) { - return; - } - - state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true); - - final result = await _searchService.search(filter, state.nextPage!); - - if (result == null) { - state = SearchState(assets: state.assets, nextPage: state.nextPage); - return; - } - - final assets = [...state.assets, ...result.assets]; - state = SearchState(assets: assets, nextPage: result.nextPage); - - _assetCountController.add(assets.length); - } - - void clear() { - state = const SearchState(); - _assetCountController.add(0); - } - - @override - void dispose() { - unawaited(_assetCountController.close()); - super.dispose(); - } -} +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/search.service.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; + +final searchPreFilterProvider = NotifierProvider(SearchFilterProvider.new); + +class SearchFilterProvider extends Notifier { + @override + SearchFilter? build() { + return null; + } + + void setFilter(SearchFilter? filter) { + state = filter; + } + + void clear() { + state = null; + } +} + +class SearchState { + final List assets; + final int? nextPage; + final bool isLoading; + + const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false}); +} + +final paginatedSearchProvider = StateNotifierProvider( + (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), +); + +class PaginatedSearchNotifier extends StateNotifier { + final SearchService _searchService; + final _assetCountController = StreamController.broadcast(); + + PaginatedSearchNotifier(this._searchService) : super(const SearchState()); + + Stream get assetCount => _assetCountController.stream; + + Future search(SearchFilter filter) async { + if (state.nextPage == null || state.isLoading) { + return; + } + + state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true); + + final result = await _searchService.search(filter, state.nextPage!); + + if (result == null) { + state = SearchState(assets: state.assets, nextPage: null, isLoading: false); + return; + } + + state = SearchState(assets: [...state.assets, ...result.assets], nextPage: result.nextPage, isLoading: false); + _assetCountController.add(state.assets.length); + } + + void clear() { + state = const SearchState(); + _assetCountController.add(0); + } + + @override + void dispose() { + unawaited(_assetCountController.close()); + super.dispose(); + } +} diff --git a/mobile/lib/providers/infrastructure/search.provider.dart b/mobile/lib/providers/infrastructure/search.provider.dart index 7d992f9d5f..ce8efa2ae8 100644 --- a/mobile/lib/providers/infrastructure/search.provider.dart +++ b/mobile/lib/providers/infrastructure/search.provider.dart @@ -2,7 +2,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/search.service.dart'; import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; final searchApiRepositoryProvider = Provider((ref) => SearchApiRepository(ref.watch(apiServiceProvider).searchApi)); -final searchServiceProvider = Provider((ref) => SearchService(ref.watch(searchApiRepositoryProvider))); +final searchServiceProvider = Provider( + (ref) => SearchService( + ref.watch(searchApiRepositoryProvider), + ref.watch(localAssetRepository), + ), +); diff --git a/mobile/lib/widgets/search/search_filter/display_option_picker.dart b/mobile/lib/widgets/search/search_filter/display_option_picker.dart index a64eab7b71..de939e538e 100644 --- a/mobile/lib/widgets/search/search_filter/display_option_picker.dart +++ b/mobile/lib/widgets/search/search_filter/display_option_picker.dart @@ -6,10 +6,11 @@ import 'package:immich_mobile/models/search/search_filter.model.dart'; enum DisplayOption { notInAlbum, favorite, archive } class DisplayOptionPicker extends HookWidget { - const DisplayOptionPicker({super.key, required this.onSelect, this.filter}); + const DisplayOptionPicker({super.key, required this.onSelect, this.filter, this.disabledOptions = const []}); final Function(Map) onSelect; final SearchDisplayFilters? filter; + final List disabledOptions; @override Widget build(BuildContext context) { @@ -23,6 +24,7 @@ class DisplayOptionPicker extends HookWidget { shrinkWrap: true, children: [ CheckboxListTile( + enabled: !disabledOptions.contains(DisplayOption.notInAlbum), title: const Text('search_filter_display_option_not_in_album').tr(), value: options.value[DisplayOption.notInAlbum], onChanged: (bool? value) { @@ -31,6 +33,7 @@ class DisplayOptionPicker extends HookWidget { }, ), CheckboxListTile( + enabled: !disabledOptions.contains(DisplayOption.favorite), title: const Text('favorite').tr(), value: options.value[DisplayOption.favorite], onChanged: (value) { @@ -39,6 +42,7 @@ class DisplayOptionPicker extends HookWidget { }, ), CheckboxListTile( + enabled: !disabledOptions.contains(DisplayOption.archive), title: const Text('archive').tr(), value: options.value[DisplayOption.archive], onChanged: (value) { diff --git a/mobile/lib/widgets/search/search_filter/search_filter_chip.dart b/mobile/lib/widgets/search/search_filter/search_filter_chip.dart index d539ee38f3..27e8145b75 100644 --- a/mobile/lib/widgets/search/search_filter/search_filter_chip.dart +++ b/mobile/lib/widgets/search/search_filter/search_filter_chip.dart @@ -6,14 +6,16 @@ class SearchFilterChip extends StatelessWidget { final Function() onTap; final Widget? currentFilter; final IconData icon; + final bool isEnabled; - const SearchFilterChip({super.key, required this.label, required this.onTap, required this.icon, this.currentFilter}); + const SearchFilterChip({super.key, required this.label, required this.onTap, required this.icon, this.currentFilter, this.isEnabled = true}); @override Widget build(BuildContext context) { + Widget child; if (currentFilter != null) { - return GestureDetector( - onTap: onTap, + child = GestureDetector( + onTap: isEnabled ? onTap : null, child: Card( elevation: 0, color: context.colorScheme.secondaryContainer, @@ -24,23 +26,29 @@ class SearchFilterChip extends StatelessWidget { ), ), ); - } - return GestureDetector( - onTap: onTap, - child: Card( - elevation: 0, - shape: StadiumBorder(side: BorderSide(color: context.colorScheme.outline.withAlpha(15))), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0), - child: Row( - children: [ - Icon(icon, size: 18), - const SizedBox(width: 4.0), - Text(label, style: TextStyle(color: context.colorScheme.onSecondaryContainer)), - ], + } else { + child = GestureDetector( + onTap: isEnabled ? onTap : null, + child: Card( + elevation: 0, + shape: StadiumBorder(side: BorderSide(color: context.colorScheme.outline.withAlpha(15))), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0), + child: Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: 4.0), + Text(label, style: TextStyle(color: context.colorScheme.onSecondaryContainer)), + ], + ), ), ), - ), - ); + ); + } + + if (!isEnabled) { + return Opacity(opacity: 0.5, child: child); + } + return child; } } diff --git a/mobile/lib/widgets/search/search_filter/storage_status_picker.dart b/mobile/lib/widgets/search/search_filter/storage_status_picker.dart new file mode 100644 index 0000000000..5385245aa8 --- /dev/null +++ b/mobile/lib/widgets/search/search_filter/storage_status_picker.dart @@ -0,0 +1,47 @@ +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'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; + +class StorageStatusPicker extends HookWidget { + const StorageStatusPicker({super.key, required this.onSelect, required this.filter}); + + final Function(SearchStorageStatus) onSelect; + final SearchStorageStatus filter; + + @override + Widget build(BuildContext context) { + final currentOption = useState(filter); + + void handleSelect(SearchStorageStatus? value) { + if (value == null) return; + currentOption.value = value; + onSelect(value); + } + + return ListView( + shrinkWrap: true, + children: [ + RadioListTile( + title: Text('all'.t(context: context)), + value: SearchStorageStatus.all, + groupValue: currentOption.value, + onChanged: handleSelect, + ), + RadioListTile( + title: Text('search_filter_storage_not_backed_up'.t(context: context)), + value: SearchStorageStatus.notBackedUp, + groupValue: currentOption.value, + onChanged: handleSelect, + ), + RadioListTile( + title: Text('search_filter_storage_server_only'.t(context: context)), + value: SearchStorageStatus.serverOnly, + groupValue: currentOption.value, + onChanged: handleSelect, + ), + ], + ); + } +}