From 91247f1492320522774a303c653063cd25d46c4a Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Tue, 22 Sep 2026 11:29:33 -0700 Subject: [PATCH] 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 --- .../db/main/dao/ocr.dart} | 2 +- .../db/main/dao/user_metadata.dart} | 4 +- mobile/lib/data/db/main/database.dart | 4 +- mobile/lib/data/server/tag.dart | 33 ++++++++++++ mobile/lib/data/store.dart | 9 ++++ mobile/lib/data/store/ocr.dart | 27 ++++++++++ mobile/lib/data/store/person.dart | 5 +- mobile/lib/data/store/tag.dart | 52 +++++++++++++++++++ mobile/lib/data/store/user_metadata.dart | 37 +++++++++++++ mobile/lib/domain/models/tag.model.dart | 5 -- mobile/lib/domain/services/ocr.service.dart | 12 ----- mobile/lib/domain/services/tag.service.dart | 31 ----------- .../repositories/tags_api.repository.dart | 26 ---------- .../repositories/user.repository.dart | 2 +- .../lib/presentation/actions/tag.action.dart | 15 +++--- .../pages/search/search.page.dart | 11 ++-- .../asset_details/rating_details.widget.dart | 4 +- .../asset_viewer/ocr_overlay.widget.dart | 7 ++- .../ocr_toggle_button.widget.dart | 4 +- .../infrastructure/ocr.provider.dart | 9 ---- .../infrastructure/tag.provider.dart | 14 ----- .../user_metadata.provider.dart | 19 ------- mobile/lib/widgets/common/tag_picker.dart | 6 +-- mobile/test/data/store/person_store_test.dart | 3 +- mobile/test/repository.mocks.dart | 4 +- mobile/test/unit/mocks.dart | 22 ++++---- .../actions/download_tag_action_test.dart | 25 ++++----- .../presentation/presentation_context.dart | 2 + 28 files changed, 214 insertions(+), 180 deletions(-) rename mobile/lib/{infrastructure/repositories/ocr.repository.dart => data/db/main/dao/ocr.dart} (92%) rename mobile/lib/{infrastructure/repositories/user_metadata.repository.dart => data/db/main/dao/user_metadata.dart} (92%) create mode 100644 mobile/lib/data/server/tag.dart create mode 100644 mobile/lib/data/store/ocr.dart create mode 100644 mobile/lib/data/store/tag.dart create mode 100644 mobile/lib/data/store/user_metadata.dart delete mode 100644 mobile/lib/domain/services/ocr.service.dart delete mode 100644 mobile/lib/domain/services/tag.service.dart delete mode 100644 mobile/lib/infrastructure/repositories/tags_api.repository.dart delete mode 100644 mobile/lib/providers/infrastructure/ocr.provider.dart delete mode 100644 mobile/lib/providers/infrastructure/tag.provider.dart delete mode 100644 mobile/lib/providers/infrastructure/user_metadata.provider.dart diff --git a/mobile/lib/infrastructure/repositories/ocr.repository.dart b/mobile/lib/data/db/main/dao/ocr.dart similarity index 92% rename from mobile/lib/infrastructure/repositories/ocr.repository.dart rename to mobile/lib/data/db/main/dao/ocr.dart index 48f8cbac6..658f50791 100644 --- a/mobile/lib/infrastructure/repositories/ocr.repository.dart +++ b/mobile/lib/data/db/main/dao/ocr.dart @@ -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 with $OcrRepositoryMixin { diff --git a/mobile/lib/infrastructure/repositories/user_metadata.repository.dart b/mobile/lib/data/db/main/dao/user_metadata.dart similarity index 92% rename from mobile/lib/infrastructure/repositories/user_metadata.repository.dart rename to mobile/lib/data/db/main/dao/user_metadata.dart index 4128c6bb3..f601edcc5 100644 --- a/mobile/lib/infrastructure/repositories/user_metadata.repository.dart +++ b/mobile/lib/data/db/main/dao/user_metadata.dart @@ -9,12 +9,12 @@ class UserMetadataRepository extends DatabaseAccessor { Drift get _db => attachedDatabase; - Future> getUserMetadata(String userId) { + Stream> watchUserMetadata(String userId) { final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(userId)); return query.map((userMetadata) { return userMetadata.toDto(); - }).get(); + }).watch(); } } diff --git a/mobile/lib/data/db/main/database.dart b/mobile/lib/data/db/main/database.dart index f479eb7bb..6152bbd6a 100644 --- a/mobile/lib/data/db/main/database.dart +++ b/mobile/lib/data/db/main/database.dart @@ -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'; diff --git a/mobile/lib/data/server/tag.dart b/mobile/lib/data/server/tag.dart new file mode 100644 index 000000000..106051ee5 --- /dev/null +++ b/mobile/lib/data/server/tag.dart @@ -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 bulkTagAssets(List assetIds, List tagIds) async { + final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds)); + return response?.count ?? 0; + } + + /// Retrieves all known tags + Future> 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> upsert(List 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); +} diff --git a/mobile/lib/data/store.dart b/mobile/lib/data/store.dart index c625c5584..7654ef18f 100644 --- a/mobile/lib/data/store.dart +++ b/mobile/lib/data/store.dart @@ -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; } diff --git a/mobile/lib/data/store/ocr.dart b/mobile/lib/data/store/ocr.dart new file mode 100644 index 000000000..b8f3fd22b --- /dev/null +++ b/mobile/lib/data/store/ocr.dart @@ -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 _provider) implements Provider { + /// 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> forAsset(String assetId) => _forAssetProvider(assetId); +} + +final _ocrDb = driftProvider.select((db) => db.ocrRepository); + +final _forAssetProvider = FutureProvider.autoDispose.family, String>( + (ref, assetId) => ref.watch(_ocrDb).get(assetId), +); + +class OcrMutations extends StoreMutations { + const OcrMutations._(super.ref); +} diff --git a/mobile/lib/data/store/person.dart b/mobile/lib/data/store/person.dart index 205ccd1bc..593874a56 100644 --- a/mobile/lib/data/store/person.dart +++ b/mobile/lib/data/store/person.dart @@ -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, String ); final _allProvider = StreamProvider.autoDispose>((ref) async* { - final prefs = await ref.watch(userMetadataPreferencesProvider.future); + final prefs = await ref.watch(UserMetadataStore.instance.preferences().future); yield* ref .watch(_peopleDb) diff --git a/mobile/lib/data/store/tag.dart b/mobile/lib/data/store/tag.dart new file mode 100644 index 000000000..ff0739aa5 --- /dev/null +++ b/mobile/lib/data/store/tag.dart @@ -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 _provider) implements Provider { + 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> all() => _allProvider; +} + +final _allProvider = FutureProvider.autoDispose>((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> upsert(List 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 applyToAssets(List assetIds, List tagIds) async { + try { + return await read(tagApiRepositoryProvider).bulkTagAssets(assetIds, tagIds); + } catch (error, stack) { + _log.severe("Failed to tag assets", error, stack); + rethrow; + } + } +} diff --git a/mobile/lib/data/store/user_metadata.dart b/mobile/lib/data/store/user_metadata.dart new file mode 100644 index 000000000..1916fddd5 --- /dev/null +++ b/mobile/lib/data/store/user_metadata.dart @@ -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 _provider) + implements Provider { + 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() => _preferencesProvider; +} + +final _preferencesProvider = StreamProvider((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); +} diff --git a/mobile/lib/domain/models/tag.model.dart b/mobile/lib/domain/models/tag.model.dart index d38931a28..943764e4c 100644 --- a/mobile/lib/domain/models/tag.model.dart +++ b/mobile/lib/domain/models/tag.model.dart @@ -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); - } } diff --git a/mobile/lib/domain/services/ocr.service.dart b/mobile/lib/domain/services/ocr.service.dart deleted file mode 100644 index 2786b1ce2..000000000 --- a/mobile/lib/domain/services/ocr.service.dart +++ /dev/null @@ -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?> get(String assetId) { - return _repository.get(assetId); - } -} diff --git a/mobile/lib/domain/services/tag.service.dart b/mobile/lib/domain/services/tag.service.dart deleted file mode 100644 index 6eeb83d4c..000000000 --- a/mobile/lib/domain/services/tag.service.dart +++ /dev/null @@ -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((ref) => TagService(ref.watch(tagsApiRepositoryProvider))); - -class TagService { - final TagsApiRepository _repository; - - const TagService(this._repository); - - Future bulkTagAssets(List assetIds, List tagIds) async { - return _repository.bulkTagAssets(assetIds, tagIds); - } - - Future> getAllTags() async { - final dtos = await _repository.getAllTags(); - if (dtos == null) { - return {}; - } - return dtos.map((dto) => Tag.fromDto(dto)).toSet(); - } - - Future> upsertTags(List tags) async { - final dtos = await _repository.upsertTags(tags); - if (dtos == null) { - return []; - } - return dtos.map((dto) => Tag.fromDto(dto)).toList(); - } -} diff --git a/mobile/lib/infrastructure/repositories/tags_api.repository.dart b/mobile/lib/infrastructure/repositories/tags_api.repository.dart deleted file mode 100644 index 9c38c2793..000000000 --- a/mobile/lib/infrastructure/repositories/tags_api.repository.dart +++ /dev/null @@ -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( - (ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi), -); - -class TagsApiRepository extends ApiRepository { - final TagsApi _api; - const TagsApiRepository(this._api); - - Future?> getAllTags() async { - return await _api.getAllTags(); - } - - Future bulkTagAssets(List assetIds, List tagIds) async { - final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds)); - return response?.count ?? 0; - } - - Future?> upsertTags(List tags) async { - return _api.upsertTags(TagUpsertDto(tags: tags)); - } -} diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart index 242e0186f..0d59f92b4 100644 --- a/mobile/lib/infrastructure/repositories/user.repository.dart +++ b/mobile/lib/infrastructure/repositories/user.repository.dart @@ -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 with $UserRepositoryMixin { diff --git a/mobile/lib/presentation/actions/tag.action.dart b/mobile/lib/presentation/actions/tag.action.dart index 978648708..11293f438 100644 --- a/mobile/lib/presentation/actions/tag.action.dart +++ b/mobile/lib/presentation/actions/tag.action.dart @@ -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?, 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 tagAssets( required Set selected, required Set 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)); } diff --git a/mobile/lib/presentation/pages/search/search.page.dart b/mobile/lib/presentation/pages/search/search.page.dart index a5c2423af..cb75d2d5b 100644 --- a/mobile/lib/presentation/pages/search/search.page.dart +++ b/mobile/lib/presentation/pages/search/search.page.dart @@ -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(null); final displayOptionCurrentFilterWidget = useState(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, diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart index f07fb5c11..68988a373 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -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) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart index b86d7298d..dda9cc5ff 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart @@ -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 { 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( diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart index cdff72796..17c1a2883 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart @@ -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( diff --git a/mobile/lib/providers/infrastructure/ocr.provider.dart b/mobile/lib/providers/infrastructure/ocr.provider.dart deleted file mode 100644 index 20cfdbf5b..000000000 --- a/mobile/lib/providers/infrastructure/ocr.provider.dart +++ /dev/null @@ -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?, String>((ref, assetId) async { - final service = OcrService(ref.watch(driftProvider).ocrRepository); - return service.get(assetId); -}); diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart deleted file mode 100644 index 83f0617b4..000000000 --- a/mobile/lib/providers/infrastructure/tag.provider.dart +++ /dev/null @@ -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> { - @override - Future> build() async { - return ref.watch(tagServiceProvider).getAllTags(); - } -} - -final tagProvider = AsyncNotifierProvider>(TagNotifier.new); diff --git a/mobile/lib/providers/infrastructure/user_metadata.provider.dart b/mobile/lib/providers/infrastructure/user_metadata.provider.dart deleted file mode 100644 index dd29b41a4..000000000 --- a/mobile/lib/providers/infrastructure/user_metadata.provider.dart +++ /dev/null @@ -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>((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((ref) async { - final metadataList = await ref.watch(userMetadataProvider.future); - return metadataList.firstWhereOrNull((meta) => meta.preferences != null)?.preferences; -}); diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart index 09701ede2..d19ec63cc 100644 --- a/mobile/lib/widgets/common/tag_picker.dart +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -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>(initialSelection); const borderRadius = BorderRadius.all(Radius.circular(10)); final selectedNewTagValues = useState>({}); diff --git a/mobile/test/data/store/person_store_test.dart b/mobile/test/data/store/person_store_test.dart index 24f41b3e8..75ac95792 100644 --- a/mobile/test/data/store/person_store_test.dart +++ b/mobile/test/data/store/person_store_test.dart @@ -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); diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 164e7fe06..37362cc1f 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -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 {} diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 14f196dcf..c8a0808f0 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -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 { +extension type const TagApiRepositoryStub(MockTagApiRepository repo) implements Stub { Future Function() get bulkTagAssets => - () => service.bulkTagAssets(any(), any()); + () => repo.bulkTagAssets(any(), any()); - Future> Function() get upsertTags => - () => service.upsertTags(any()); + Future> Function() get upsert => + () => repo.upsert(any()); - Future> Function() get getAllTags => - () => service.getAllTags(); + Future> Function() get getAll => + () => repo.getAll(); } diff --git a/mobile/test/unit/presentation/actions/download_tag_action_test.dart b/mobile/test/unit/presentation/actions/download_tag_action_test.dart index de003ffed..79e3191ad 100644 --- a/mobile/test/unit/presentation/actions/download_tag_action_test.dart +++ b/mobile/test/unit/presentation/actions/download_tag_action_test.dart @@ -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; + verify(() => tagApi.upsert(['brand new'])).called(1); + final tagIds = verify(() => tagApi.bulkTagAssets([asset.id], captureAny())).captured.single as List; 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())); }); }); diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 8563900ea..0bf18ed57 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -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() {