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.
This commit is contained in:
Omar Salama 2026-08-08 02:58:09 +03:00
parent 51c2f21497
commit 8dde2d55b3
12 changed files with 411 additions and 111 deletions

View file

@ -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": "تاريخ الإنشاء",

View file

@ -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",

View file

@ -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<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
@ -35,15 +38,52 @@ class SearchService {
Future<SearchResult?> 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<BaseAsset> 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<String>();
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);

View file

@ -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<LocalAsset> 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<LocalAsset?> _assetSelectable(String id) {
@ -222,6 +227,56 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
Future<Set<String>> getExistingChecksums(Iterable<String> checksums) async {
if (checksums.isEmpty) return {};
final result = <String>{};
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<List<LocalAsset>> searchDeviceOnlyAssets(SearchFilter filter, int page) async {
final query = _db.localAssetEntity.select().join([
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
),
]);
Expression<bool> 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<void> reconcileHashesFromCloudId() async {
await _db.customUpdate(
'''

View file

@ -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;
}

View file

@ -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: '',
),
);

View file

@ -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<Widget?>(null);
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final storageStatusCurrentFilterWidget = useState<Widget?>(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,
),
],
),
),

View file

@ -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, SearchFilter?>(SearchFilterProvider.new);
class SearchFilterProvider extends Notifier<SearchFilter?> {
@override
SearchFilter? build() {
return null;
}
void setFilter(SearchFilter? filter) {
state = filter;
}
void clear() {
state = null;
}
}
class SearchState {
final List<BaseAsset> assets;
final int? nextPage;
final bool isLoading;
const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false});
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final SearchService _searchService;
final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
Stream<int> get assetCount => _assetCountController.stream;
Future<void> 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, SearchFilter?>(SearchFilterProvider.new);
class SearchFilterProvider extends Notifier<SearchFilter?> {
@override
SearchFilter? build() {
return null;
}
void setFilter(SearchFilter? filter) {
state = filter;
}
void clear() {
state = null;
}
}
class SearchState {
final List<BaseAsset> assets;
final int? nextPage;
final bool isLoading;
const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false});
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final SearchService _searchService;
final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
Stream<int> get assetCount => _assetCountController.stream;
Future<void> 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();
}
}

View file

@ -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),
),
);

View file

@ -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<DisplayOption, bool>) onSelect;
final SearchDisplayFilters? filter;
final List<DisplayOption> 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) {

View file

@ -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;
}
}

View file

@ -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<SearchStorageStatus>(filter);
void handleSelect(SearchStorageStatus? value) {
if (value == null) return;
currentOption.value = value;
onSelect(value);
}
return ListView(
shrinkWrap: true,
children: [
RadioListTile<SearchStorageStatus>(
title: Text('all'.t(context: context)),
value: SearchStorageStatus.all,
groupValue: currentOption.value,
onChanged: handleSelect,
),
RadioListTile<SearchStorageStatus>(
title: Text('search_filter_storage_not_backed_up'.t(context: context)),
value: SearchStorageStatus.notBackedUp,
groupValue: currentOption.value,
onChanged: handleSelect,
),
RadioListTile<SearchStorageStatus>(
title: Text('search_filter_storage_server_only'.t(context: context)),
value: SearchStorageStatus.serverOnly,
groupValue: currentOption.value,
onChanged: handleSelect,
),
],
);
}
}