mirror of
https://github.com/immich-app/immich
synced 2026-08-22 13:13:05 +00:00
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:
parent
51c2f21497
commit
8dde2d55b3
12 changed files with 411 additions and 111 deletions
|
|
@ -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": "تاريخ الإنشاء",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'''
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue