chore(mobile): migrate OCR, Tag, and UserMetadata to Store (#31704)

* chore(mobile): migrate OCR, Tag, and UserMetadata to Store

* Add comments to Tag HTTP repository

* Add mounted check
This commit is contained in:
Adam Gastineau
2026-09-22 18:29:33 +00:00
committed by GitHub
parent f367ab2121
commit 91247f1492
28 changed files with 214 additions and 180 deletions
@@ -1,8 +1,8 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/data/db/main/dao/ocr.drift.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/data/db/main/table/asset/ocr.drift.dart';
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.drift.dart';
@DriftAccessor()
class OcrRepository extends DatabaseAccessor<Drift> with $OcrRepositoryMixin {
@@ -9,12 +9,12 @@ class UserMetadataRepository extends DatabaseAccessor<Drift> {
Drift get _db => attachedDatabase;
Future<List<UserMetadata>> getUserMetadata(String userId) {
Stream<List<UserMetadata>> watchUserMetadata(String userId) {
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(userId));
return query.map((userMetadata) {
return userMetadata.toDto();
}).get();
}).watch();
}
}
+2 -2
View File
@@ -6,7 +6,9 @@ import 'package:drift/drift.dart';
import 'package:drift/src/runtime/executor/stream_queries.dart' show StreamQueryStore;
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/data/db/main/dao/ocr.dart';
import 'package:immich_mobile/data/db/main/dao/person.dart';
import 'package:immich_mobile/data/db/main/dao/user_metadata.dart';
import 'package:immich_mobile/data/db/main/database.drift.dart';
import 'package:immich_mobile/data/db/main/database.steps.dart';
import 'package:immich_mobile/data/db/main/table/app/settings.dart';
@@ -40,7 +42,6 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
@@ -51,7 +52,6 @@ import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
+33
View File
@@ -0,0 +1,33 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/api_repository.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:openapi/api.dart';
final tagApiRepositoryProvider = Provider((ref) => TagApiRepository(ref.watch(apiServiceProvider).tagsApi));
class TagApiRepository extends ApiRepository {
final TagsApi _api;
const TagApiRepository(this._api);
/// Apply every tag in [tagIds] to every asset in [assetIds], returning the number of assets successfully tagged
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds));
return response?.count ?? 0;
}
/// Retrieves all known tags
Future<List<Tag>> getAll() async {
final response = await checkNull(_api.getAllTags());
return response.map(_toTag).toList();
}
/// Create the tags named [values], returning the list of successfully created (or pre-existing) tags
Future<List<Tag>> upsert(List<String> tags) async {
final response = await checkNull(_api.upsertTags(TagUpsertDto(tags: tags)));
return response.map(_toTag).toList();
}
static Tag _toTag(TagResponseDto dto) => .new(id: dto.id, value: dto.value);
}
+9
View File
@@ -2,7 +2,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/data_controller.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/data/store/activity.dart';
import 'package:immich_mobile/data/store/ocr.dart';
import 'package:immich_mobile/data/store/person.dart';
import 'package:immich_mobile/data/store/tag.dart';
import 'package:immich_mobile/data/store/user_metadata.dart';
import 'package:immich_mobile/services/api.service.dart';
/// The [DataController] backing this container's store
@@ -36,4 +39,10 @@ abstract final class Store {
static final people = PersonStore.instance;
static final activity = ActivityStore.instance;
static final ocr = OcrStore.instance;
static final tags = TagStore.instance;
static final userMetadata = UserMetadataStore.instance;
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store/util/cache.dart';
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
/// Text recognized within an asset
///
/// State is stored in the local DB, and is only written by sync
extension type const OcrStore._(Provider<OcrMutations> _provider) implements Provider<OcrMutations> {
/// Internal: access through `Store.ocr`
static final OcrStore instance = OcrStore._(Provider((ref) => OcrMutations._(ref)));
/// The visible text recognized within the asset [assetId]
///
/// **NOTE:** This is not reactive to changes, and only hits the local DB
AutoDisposeFutureProvider<List<Ocr>> forAsset(String assetId) => _forAssetProvider(assetId);
}
final _ocrDb = driftProvider.select((db) => db.ocrRepository);
final _forAssetProvider = FutureProvider.autoDispose.family<List<Ocr>, String>(
(ref, assetId) => ref.watch(_ocrDb).get(assetId),
);
class OcrMutations extends StoreMutations {
const OcrMutations._(super.ref);
}
+2 -3
View File
@@ -1,11 +1,10 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/person.dart';
import 'package:immich_mobile/data/store/user_metadata.dart';
import 'package:immich_mobile/data/store/util/cache.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
// TODO(rewrite): Remove once user metadata is a store entry of its own
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
/// People representing collections of faces and assets
///
@@ -41,7 +40,7 @@ final _forAssetProvider = StreamProvider.autoDispose.family<List<Person>, String
);
final _allProvider = StreamProvider.autoDispose<List<Person>>((ref) async* {
final prefs = await ref.watch(userMetadataPreferencesProvider.future);
final prefs = await ref.watch(UserMetadataStore.instance.preferences().future);
yield* ref
.watch(_peopleDb)
+52
View File
@@ -0,0 +1,52 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/tag.dart';
import 'package:immich_mobile/data/store/util/cache.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:logging/logging.dart';
final _log = Logger("TagStore");
/// User defined tags that can be applied to assets
///
/// All operations directly access the HTTP API only
extension type const TagStore._(Provider<TagMutations> _provider) implements Provider<TagMutations> {
static final TagStore instance = TagStore._(Provider((ref) => TagMutations._(ref)));
/// Every tag known to the server
///
/// **NOTE:** This is not reactive to changes, and only hits the HTTP API
AutoDisposeFutureProvider<List<Tag>> all() => _allProvider;
}
final _allProvider = FutureProvider.autoDispose<List<Tag>>((ref) async {
try {
return await ref.watch(tagApiRepositoryProvider).getAll();
} catch (error, stack) {
_log.severe("Failed to get all tags", error, stack);
return const [];
}
});
class TagMutations extends StoreMutations {
const TagMutations._(super.ref);
/// Create the tags named [values], returning the list of successfully created (or pre-existing) tags
Future<List<Tag>> upsert(List<String> values) async {
try {
return await read(tagApiRepositoryProvider).upsert(values);
} catch (error, stack) {
_log.severe("Failed to upsert tags", error, stack);
rethrow;
}
}
/// Apply every tag in [tagIds] to every asset in [assetIds], returning the number of assets successfully tagged
Future<int> applyToAssets(List<String> assetIds, List<String> tagIds) async {
try {
return await read(tagApiRepositoryProvider).bulkTagAssets(assetIds, tagIds);
} catch (error, stack) {
_log.severe("Failed to tag assets", error, stack);
rethrow;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store/util/cache.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
// TODO(rewrite): Remove once the current user is a store entry of its own
import 'package:immich_mobile/providers/user.provider.dart';
/// Server held metadata about the current user; their preferences, onboarding state and license
///
/// State is stored in the local DB, and is only written by server sync
extension type const UserMetadataStore._(Provider<UserMetadataMutations> _provider)
implements Provider<UserMetadataMutations> {
static final UserMetadataStore instance = UserMetadataStore._(Provider((ref) => UserMetadataMutations._(ref)));
/// The current user's server side preferences, or `null` if they have not synced yet
///
/// **NOTE:** This is only reactive to the local DB
StreamProvider<Preferences?> preferences() => _preferencesProvider;
}
final _preferencesProvider = StreamProvider<Preferences?>((ref) {
final userId = ref.watch(currentUserProvider.select((user) => user?.id));
if (userId == null) {
return Stream.value(null);
}
final repository = driftProvider.select((db) => db.userMetadataRepository);
return ref
.watch(repository)
.watchUserMetadata(userId)
.map((metadataList) => metadataList.firstWhereOrNull((metadata) => metadata.preferences != null)?.preferences);
});
class UserMetadataMutations extends StoreMutations {
const UserMetadataMutations._(super.ref);
}
-5
View File
@@ -1,13 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
part 'tag.model.freezed.dart';
@freezed
abstract class Tag with _$Tag {
const factory Tag({required String id, required String value}) = _Tag;
static Tag fromDto(TagResponseDto dto) {
return Tag(id: dto.id, value: dto.value);
}
}
@@ -1,12 +0,0 @@
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
class OcrService {
final OcrRepository _repository;
const OcrService(this._repository);
Future<List<Ocr>?> get(String assetId) {
return _repository.get(assetId);
}
}
@@ -1,31 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
final tagServiceProvider = Provider<TagService>((ref) => TagService(ref.watch(tagsApiRepositoryProvider)));
class TagService {
final TagsApiRepository _repository;
const TagService(this._repository);
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
return _repository.bulkTagAssets(assetIds, tagIds);
}
Future<Set<Tag>> getAllTags() async {
final dtos = await _repository.getAllTags();
if (dtos == null) {
return {};
}
return dtos.map((dto) => Tag.fromDto(dto)).toSet();
}
Future<List<Tag>> upsertTags(List<String> tags) async {
final dtos = await _repository.upsertTags(tags);
if (dtos == null) {
return [];
}
return dtos.map((dto) => Tag.fromDto(dto)).toList();
}
}
@@ -1,26 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/api_repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:openapi/api.dart';
final tagsApiRepositoryProvider = Provider<TagsApiRepository>(
(ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi),
);
class TagsApiRepository extends ApiRepository {
final TagsApi _api;
const TagsApiRepository(this._api);
Future<List<TagResponseDto>?> getAllTags() async {
return await _api.getAllTags();
}
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds));
return response?.count ?? 0;
}
Future<List<TagResponseDto>?> upsertTags(List<String> tags) async {
return _api.upsertTags(TagUpsertDto(tags: tags));
}
}
@@ -1,11 +1,11 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/data/db/main/dao/user_metadata.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/data/db/main/table/user/auth_user.drift.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/mapper.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
@DriftAccessor()
class UserRepository extends DatabaseAccessor<Drift> with $UserRepositoryMixin {
@@ -1,18 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
final _stateProvider = Provider.family.autoDispose<List<String>?, ActionSource>((ref, source) {
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
Store.userMetadata.preferences().select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
if (!tagsEnabled) {
return null;
@@ -66,20 +64,19 @@ Future<void> tagAssets(
required Set<String> selected,
required Set<String> created,
}) async {
final tagService = ref.read(tagServiceProvider);
final toastService = ref.read(toastServiceProvider);
final tagIds = {...selected};
if (created.isNotEmpty) {
final tags = await tagService.upsertTags(created.toList());
final tags = await ref.read(Store.tags).upsert(created.toList());
tagIds.addAll(tags.map((tag) => tag.id));
}
if (tagIds.isEmpty) {
if (tagIds.isEmpty || !context.mounted) {
return;
}
final count = await tagService.bulkTagAssets(assetIds, tagIds.toList());
ref.invalidate(tagProvider);
final count = await ref.read(Store.tags).applyToAssets(assetIds, tagIds.toList());
if (context.mounted) {
toastService.success(context.t.tagged_assets(count: count));
}
@@ -1,11 +1,11 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
@@ -19,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -75,7 +74,7 @@ class SearchPage extends HookConsumerWidget {
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final userPreferences = ref.watch(userMetadataPreferencesProvider);
final userPreferences = ref.watch(Store.userMetadata.preferences()).valueOrNull;
void search(SearchFilter f) {
if (f == filter.value) {
@@ -669,7 +668,7 @@ class SearchPage extends HookConsumerWidget {
label: context.t.search_filter_location,
currentFilter: locationCurrentFilterWidget.value,
),
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
if (userPreferences?.tagsEnabled ?? false)
SearchFilterChip(
icon: Icons.sell_outlined,
onTap: showTagPicker,
@@ -695,7 +694,7 @@ class SearchPage extends HookConsumerWidget {
label: context.t.search_filter_media_type,
currentFilter: mediaTypeCurrentFilterWidget.value,
),
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
if (userPreferences?.ratingsEnabled ?? false)
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
class RatingDetails extends ConsumerWidget {
final ExifInfo? exifInfo;
@@ -17,7 +17,7 @@ class RatingDetails extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.watch(Store.userMetadata.preferences())
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
if (!isRatingEnabled) {
@@ -1,13 +1,12 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
class OcrOverlay extends ConsumerStatefulWidget {
@@ -92,11 +91,11 @@ class _OcrOverlayState extends ConsumerState<OcrOverlay> {
return const SizedBox.shrink();
}
final ocrData = ref.watch(ocrAssetProvider((widget.asset as RemoteAsset).id));
final ocrData = ref.watch(Store.ocr.forAsset((widget.asset as RemoteAsset).id));
return ocrData.when(
data: (data) {
if (data == null || data.isEmpty) {
if (data.isEmpty) {
return const SizedBox.shrink();
}
return _OcrBoxes(
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
class OcrToggleButton extends ConsumerWidget {
final BaseAsset asset;
@@ -12,7 +12,7 @@ class OcrToggleButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
final hasOcr = asset is RemoteAsset && ref.watch(Store.ocr.forAsset(asset.id)).valueOrNull?.isNotEmpty == true;
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
return AnimatedSwitcher(
@@ -1,9 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/domain/services/ocr.service.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final ocrAssetProvider = FutureProvider.autoDispose.family<List<Ocr>?, String>((ref, assetId) async {
final service = OcrService(ref.watch(driftProvider).ocrRepository);
return service.get(assetId);
});
@@ -1,14 +0,0 @@
// ignore_for_file: use-ref-and-state-synchronously
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
class TagNotifier extends AsyncNotifier<Set<Tag>> {
@override
Future<Set<Tag>> build() async {
return ref.watch(tagServiceProvider).getAllTags();
}
}
final tagProvider = AsyncNotifierProvider<TagNotifier, Set<Tag>>(TagNotifier.new);
@@ -1,19 +0,0 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final userMetadataProvider = FutureProvider<List<UserMetadata>>((ref) async {
final repository = ref.watch(driftProvider).userMetadataRepository;
final user = ref.watch(currentUserProvider);
if (user == null) {
return [];
}
return repository.getUserMetadata(user.id);
});
final userMetadataPreferencesProvider = FutureProvider<Preferences?>((ref) async {
final metadataList = await ref.watch(userMetadataProvider.future);
return metadataList.firstWhereOrNull((meta) => meta.preferences != null)?.preferences;
});
+3 -3
View File
@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
String _trimSlashes(String s) => s.replaceAll(RegExp(r'^/+|/+$'), '');
@@ -84,7 +84,7 @@ class TagPicker extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final formFocus = useFocusNode();
final searchQuery = useState('');
final tags = ref.watch(tagProvider);
final tags = ref.watch(Store.tags.all());
final selectedTagIds = useState<Set<String>>(initialSelection);
const borderRadius = BorderRadius.all(Radius.circular(10));
final selectedNewTagValues = useState<Set<String>>({});
@@ -6,7 +6,6 @@ import 'package:immich_mobile/data/server/person.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../medium/repository_context.dart';
@@ -26,7 +25,7 @@ void main() {
driftProvider.overrideWithValue(ctx.db),
personApiRepositoryProvider.overrideWithValue(api),
// No stored preferences: the default minimum face count applies
userMetadataPreferencesProvider.overrideWith((ref) => Future.value(null)),
Store.userMetadata.preferences().overrideWith((ref) => Stream.value(null)),
],
);
addTearDown(container.dispose);
+2 -2
View File
@@ -1,4 +1,4 @@
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/data/server/tag.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -18,7 +18,7 @@ class MockAuthApiRepository extends Mock implements AuthApiRepository {}
class MockAuthRepository extends Mock implements AuthRepository {}
class MockTagService extends Mock implements TagService {}
class MockTagApiRepository extends Mock implements TagApiRepository {}
class MockDownloadRepository extends Mock implements DownloadRepository {}
+11 -11
View File
@@ -125,7 +125,7 @@ class ServiceMocks {
final asset = AssetServiceStub(MockAssetService());
final album = RemoteAlbumServiceStub(MockRemoteAlbumService());
final cleanup = CleanupServiceStub(MockCleanupService());
final tag = TagServiceStub(MockTagService());
final tag = TagApiRepositoryStub(MockTagApiRepository());
final backgroundSync = MockBackgroundSyncManager();
final upload = MockForegroundUploadService();
final cast = MockGCastService();
@@ -154,7 +154,7 @@ class ServiceMocks {
_stubAssetService();
_stubRemoteAlbumService();
_stubCleanupService();
_stubTagService();
_stubTagApi();
_stubBackgroundSync();
_stubForegroundUpload();
}
@@ -198,10 +198,10 @@ class ServiceMocks {
when(cleanup.deleteLocalAssets).thenAnswer((_) async => 0);
}
void _stubTagService() {
void _stubTagApi() {
when(tag.bulkTagAssets).thenAnswer((_) async => 0);
when(tag.upsertTags).thenAnswer((_) async => const []);
when(tag.getAllTags).thenAnswer((_) async => const {});
when(tag.upsert).thenAnswer((_) async => const []);
when(tag.getAll).thenAnswer((_) async => const []);
}
void _stubBackgroundSync() {
@@ -442,13 +442,13 @@ extension type const PermissionRepositoryStub(MockPermissionRepository repo) imp
() => repo.getAndroidSdkVersion();
}
extension type const TagServiceStub(MockTagService service) implements Stub<MockTagService> {
extension type const TagApiRepositoryStub(MockTagApiRepository repo) implements Stub<MockTagApiRepository> {
Future<int> Function() get bulkTagAssets =>
() => service.bulkTagAssets(any(), any());
() => repo.bulkTagAssets(any(), any());
Future<List<Tag>> Function() get upsertTags =>
() => service.upsertTags(any());
Future<List<Tag>> Function() get upsert =>
() => repo.upsert(any());
Future<Set<Tag>> Function() get getAllTags =>
() => service.getAllTags();
Future<List<Tag>> Function() get getAll =>
() => repo.getAll();
}
@@ -1,20 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/tag.action.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../../repository.mocks.dart';
import '../../factories/local_asset_factory.dart';
import '../../factories/remote_asset_factory.dart';
@@ -22,11 +20,11 @@ import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockTagService tagService;
late MockTagApiRepository tagApi;
setUp(() async {
context = await PresentationContext.create();
tagService = context.service.tag.service;
tagApi = context.service.tag.repo;
});
tearDown(() async {
@@ -97,8 +95,7 @@ void main() {
overrides: [
...context.selected(selection),
toastServiceProvider.overrideWithValue(context.service.toast),
tagServiceProvider.overrideWithValue(tagService),
userMetadataPreferencesProvider.overrideWith((ref) async => const .new(tagsEnabled: true)),
Store.userMetadata.preferences().overrideWith((ref) => Stream.value(const .new(tagsEnabled: true))),
],
);
@@ -119,13 +116,13 @@ void main() {
testWidgets('applies the picked tags and reports the count', (tester) async {
final asset = owned();
when(() => tagService.bulkTagAssets(any(), any())).thenAnswer((_) async => 1);
when(() => tagApi.bulkTagAssets(any(), any())).thenAnswer((_) async => 1);
await pumpTag(tester, {asset});
await applyTags([asset.id], selected: {'tag-1'});
await tester.pumpAndSettle();
verify(() => tagService.bulkTagAssets([asset.id], ['tag-1'])).called(1);
verify(() => tagApi.bulkTagAssets([asset.id], ['tag-1'])).called(1);
final message = verify(() => context.service.toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.tagged_assets(count: 1));
@@ -133,15 +130,15 @@ void main() {
testWidgets('creates new tags first and applies them alongside the picked ones', (tester) async {
final asset = owned();
when(() => tagService.upsertTags(any())).thenAnswer((_) async => [const Tag(id: 'made-1', value: 'brand new')]);
when(() => tagService.bulkTagAssets(any(), any())).thenAnswer((_) async => 1);
when(() => tagApi.upsert(any())).thenAnswer((_) async => [const Tag(id: 'made-1', value: 'brand new')]);
when(() => tagApi.bulkTagAssets(any(), any())).thenAnswer((_) async => 1);
await pumpTag(tester, {asset});
await applyTags([asset.id], selected: {'tag-1'}, created: {'brand new'});
await tester.pumpAndSettle();
verify(() => tagService.upsertTags(['brand new'])).called(1);
final tagIds = verify(() => tagService.bulkTagAssets([asset.id], captureAny())).captured.single as List<String>;
verify(() => tagApi.upsert(['brand new'])).called(1);
final tagIds = verify(() => tagApi.bulkTagAssets([asset.id], captureAny())).captured.single as List<String>;
expect(tagIds, containsAll(['tag-1', 'made-1']));
});
@@ -152,7 +149,7 @@ void main() {
await applyTags([asset.id]);
await tester.pumpAndSettle();
verifyNever(() => tagService.bulkTagAssets(any(), any()));
verifyNever(() => tagApi.bulkTagAssets(any(), any()));
verifyNever(() => context.service.toast.success(any()));
});
});
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/data/server/tag.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
@@ -63,6 +64,7 @@ class PresentationContext {
serverInfoServiceProvider.overrideWithValue(service.serverInfo),
inLockedViewProvider.overrideWithValue(false),
assetMediaRepositoryProvider.overrideWithValue(repository.assetMedia.api),
tagApiRepositoryProvider.overrideWithValue(service.tag.repo),
];
Drift _mockDrift() {