chore(mobile): static Store to unify and streamline reactivity (#31099)

* chore(mobile): static Store to unify and streamline reactivity

* Add tests of caching and new stores

* Don't instantiate PeopleDbRepository

* Slight DB rename and fix throwing store methods
This commit is contained in:
Adam Gastineau
2026-09-11 10:56:00 -07:00
committed by GitHub
parent c0eed4902a
commit 6874c07dbe
30 changed files with 734 additions and 286 deletions
+2 -2
View File
@@ -6,8 +6,8 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
@DriftAccessor()
class PeopleRepository extends DatabaseAccessor<Drift> with $PeopleRepositoryMixin {
PeopleRepository(super.attachedDatabase);
class PeopleDatabaseRepository extends DatabaseAccessor<Drift> with $PeopleDatabaseRepositoryMixin {
PeopleDatabaseRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
+1 -1
View File
@@ -96,7 +96,7 @@ import 'package:sqlite_async/sqlite_async.dart';
MemoryRepository,
OcrRepository,
PartnerRepository,
PeopleRepository,
PeopleDatabaseRepository,
RemoteAlbumRepository,
RemoteAssetRepository,
RemoteExifRepository,
@@ -1,8 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/api_repository.dart';
import 'package:immich_mobile/infrastructure/utils/user.converter.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:openapi/api.dart';
final activityApiRepositoryProvider = Provider(
@@ -12,11 +12,11 @@ final activityApiRepositoryProvider = Provider(
class ActivityApiRepository extends ApiRepository {
final ActivitiesApi _api;
ActivityApiRepository(this._api);
const ActivityApiRepository(this._api);
Future<List<Activity>> getAll(String albumId, {String? assetId}) async {
final response = await checkNull(_api.getActivities(albumId, assetId: assetId));
return response.map(_toActivity).toList();
return response.map((dto) => _toActivity(dto, albumId)).toList();
}
Future<Activity> create(String albumId, ActivityType type, {String? assetId, String? comment}) async {
@@ -27,15 +27,17 @@ class ActivityApiRepository extends ApiRepository {
comment: comment == null ? const Optional.absent() : Optional.present(comment),
);
final response = await checkNull(_api.createActivity(dto));
return _toActivity(response);
return _toActivity(response, albumId);
}
Future<void> delete(String id) {
// TODO(agg23): I think this is a bug; `checkNull` will always throw here as the response is void (null)
return checkNull(_api.deleteActivity(id));
}
static Activity _toActivity(ActivityResponseDto dto) => Activity(
static Activity _toActivity(ActivityResponseDto dto, String albumId) => Activity(
id: dto.id,
albumId: albumId,
createdAt: dto.createdAt,
type: dto.type == ReactionType.comment ? ActivityType.comment : ActivityType.like,
user: UserConverter.fromSimpleUserDto(dto.user),
+9
View File
@@ -0,0 +1,9 @@
import 'package:immich_mobile/data/store/activity.dart';
import 'package:immich_mobile/data/store/person.dart';
/// Global data layer, providing access to Drift and HTTP APIs, scoped by entity
abstract final class Store {
static final people = PersonStore.instance;
static final activity = ActivityStore.instance;
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/activity.dart';
import 'package:immich_mobile/data/server/errors.dart';
import 'package:immich_mobile/data/store/util/cache.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:logging/logging.dart';
final _log = Logger("ActivityStore");
/// The `albumId` and optional `assetId` identifying an activity list
typedef ActivityScope = (String albumId, String? assetId);
/// Activities (comments and likes) on shared albums and their assets
///
/// State is fetched over HTTP and mutated and cached in memory
extension type const ActivityStore._(Provider<ActivityMutations> _provider) implements Provider<ActivityMutations> {
/// Internal: access through `Store.activity`
static final ActivityStore instance = ActivityStore._(Provider((ref) => ActivityMutations._(ref)));
/// All activities for an album specified by [albumId], or all activities for a specific asset within that album
///
/// **NOTE:** This is currently reactive only to in memory mutation, not live HTTP changes
CacheListQuery<Activity, ActivityScope> list(String albumId, {String? assetId}) => _cache.family((albumId, assetId));
}
final _cache = _ActivityCache();
class _ActivityCache extends StoreCache<Activity, ActivityScope> {
@override
Future<List<Activity>> fetch(Ref ref, ActivityScope scope) async {
try {
return await ref.watch(activityApiRepositoryProvider).getAll(scope.$1, assetId: scope.$2);
} catch (error, stack) {
_log.severe("Failed to get all activities for album ${scope.$1}", error, stack);
return const [];
}
}
@override
Object identity(Activity item) => item.id;
@override
bool shouldContain(ActivityScope scope, Activity item) =>
scope.$1 == item.albumId && (scope.$2 == null || scope.$2 == item.assetId);
}
class ActivityMutations extends CachedStoreMutations<Activity, ActivityScope> {
ActivityMutations._(Ref ref) : super(ref, _cache);
/// Add a comment to an album or asset. Providing [assetId] will add to the corresponding asset, otherwise the comment will be added to the album
Future<Activity> addComment(String albumId, String comment, {String? assetId}) async {
try {
final activity = await read(
activityApiRepositoryProvider,
).create(albumId, ActivityType.comment, assetId: assetId, comment: comment);
cacheUpsert(activity);
return activity;
} catch (error, stack) {
_log.severe("Failed to create comment for album $albumId", error, stack);
rethrow;
}
}
/// Add a like to an album or asset. Providing [assetId] will add to the corresponding asset, otherwise the like will be added to the album
Future<Activity> addLike(String albumId, {String? assetId}) async {
try {
final activity = await read(activityApiRepositoryProvider).create(albumId, ActivityType.like, assetId: assetId);
cacheUpsert(activity);
return activity;
} catch (error, stack) {
_log.severe("Failed to create like for album $albumId", error, stack);
rethrow;
}
}
/// Remove [activity] from its album
Future<void> remove(Activity activity) async {
try {
await read(activityApiRepositoryProvider).delete(activity.id);
} on NoResponseDtoError {
// TODO(agg23): This error should not be thrown at all
} catch (error, stack) {
_log.severe("Failed to delete activity", error, stack);
rethrow;
}
// Only apply on success (including the broken NoResponseDtoError)
cacheRemove(activity.id);
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/person.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
///
/// State is stored in the local DB, while mutations are pushed to both HTTP and DB
extension type const PersonStore._(Provider<PersonMutations> _provider) implements Provider<PersonMutations> {
/// Internal: access through `Store.people`
static final PersonStore instance = PersonStore._(Provider((ref) => PersonMutations._(ref)));
/// Get the person specified by [personId]
///
/// **NOTE:** This is not reactive to changes, and only hits the local DB
AutoDisposeFutureProvider<Person?> byId(String personId) => _byIdProvider(personId);
/// Get the people present in the asset [assetId]
///
/// **NOTE:** This is not reactive to changes, and only hits the local DB
AutoDisposeFutureProvider<List<Person>> forAsset(String assetId) => _forAssetProvider(assetId);
/// Get all known people, honoring the user's minimum detected face count preference
///
/// **NOTE:** This only hits the local DB
AutoDisposeStreamProvider<List<Person>> all() => _allProvider;
}
final _peopleDb = driftProvider.select((db) => db.peopleDatabaseRepository);
// We have to map from non-reactive existing Drift queries to Riverpod reactivity, so we wrap each call in a provider family
// Note that the only reactivity here is in going from no data (fetch start) to data (fetch completed), and the DB swapping (basically never)
final _byIdProvider = FutureProvider.autoDispose.family<Person?, String>(
(ref, personId) => ref.watch(_peopleDb).get(personId),
);
final _forAssetProvider = FutureProvider.autoDispose.family<List<Person>, String>(
(ref, assetId) => ref.watch(_peopleDb).getAssetPeople(assetId),
);
final _allProvider = StreamProvider.autoDispose<List<Person>>((ref) async* {
final prefs = await ref.watch(userMetadataPreferencesProvider.future);
yield* ref.watch(_peopleDb).watch(minFaces: prefs?.minimumFaces ?? 3);
});
class PersonMutations extends StoreMutations {
const PersonMutations._(super.ref);
/// Update a person's name
Future<int> updateName(String personId, String name) async {
await read(personApiRepositoryProvider).update(personId, name: name);
return read(_peopleDb).updateName(personId, name);
}
/// Update a person's birthday
Future<int> updateBirthday(String personId, DateTime birthday) async {
await read(personApiRepositoryProvider).update(personId, birthday: birthday);
return read(_peopleDb).updateBirthday(personId, birthday);
}
}
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// A reactive query of list data from a particular cache. This is scoped to the provided arguments
typedef CacheListQuery<TData, TScope> =
AutoDisposeFamilyAsyncNotifierProvider<CacheNotifier<TData, TScope>, List<TData>, TScope>;
/// A future backed in-memory cache of lists of [TData], scoped by [TScope]
///
/// For every unique scope, the future is fetched on first listen and disposed when there are no more listeners.
/// Mutations are applied using [CachedStoreMutations.cacheUpsert] and [CachedStoreMutations.cacheRemove]
///
/// **NOTE:** Not intended for use with Drift tables; they provide their own persistence
abstract class StoreCache<TData, TScope> {
/// Fetch the contents of [scope]
@protected
Future<List<TData>> fetch(Ref ref, TScope scope);
/// The identity of [item]
@protected
Object identity(TData item);
/// Whether the corresponding list for [scope] should contain [item]
///
/// This is checked across all currently active scopes to keep each cache up to date with the latest changes. Each
/// cache may change independently, thus we must verify that a given item is relevant to it
@protected
bool shouldContain(TScope scope, TData item);
/// The provider family backing this cache
late final family = AsyncNotifierProvider.autoDispose.family<CacheNotifier<TData, TScope>, List<TData>, TScope>(
() => CacheNotifier._(this),
);
/// All active scopes, by count of live provider elements (one per container watching the scope)
final Map<TScope, int> _liveScopes = {};
/// Register a live provider element for [scope]
void _addScope(TScope scope) {
_liveScopes.update(scope, (count) => count + 1, ifAbsent: () => 1);
}
/// Unregister a live provider element for [scope]
void _removeScope(TScope scope) {
final count = _liveScopes[scope] ?? 0;
if (count <= 1) {
_liveScopes.remove(scope);
} else {
_liveScopes[scope] = count - 1;
}
}
/// Patch and upsert in all scopes that will allow [item] given the result from [shouldContain]
void _upsert(Ref ref, TData item) {
final id = identity(item);
_patchLive(
ref,
(scope, list) => shouldContain(scope, item) ? list._upsertWhere((i) => identity(i) == id, item) : list,
);
}
/// Patch and remove [id] from all scopes that contain it
void _remove(Ref ref, Object id) {
_patchLive(ref, (scope, list) => list._without((i) => identity(i) == id));
}
/// Apply [transform] to all scopes
void _patchLive(Ref ref, List<TData> Function(TScope scope, List<TData> current) transform) {
for (final scope in [..._liveScopes.keys]) {
// Guard against scopes registered by a different ProviderContainer (tests)
if (ref.exists(family(scope))) {
ref.read(family(scope).notifier)._patch((current) => transform(scope, current));
}
}
}
}
/// A Riverpod [Notifier] built for handling an in-memory cache
class CacheNotifier<T, Arg> extends AutoDisposeFamilyAsyncNotifier<List<T>, Arg> {
final StoreCache<T, Arg> _cache;
CacheNotifier._(this._cache);
@override
Future<List<T>> build(Arg arg) {
// Track each consumer of this notifier
// Theoretically this could be called from different scopes (tests), so actually reference counting instead of relying on
// Riverpod's deduping is necessary
_cache._addScope(arg);
ref.onDispose(() => _cache._removeScope(arg));
return _cache.fetch(ref, arg);
}
/// Applies [transform] to the currently stored list
///
/// If [transform] returns the same list, no state mutation occurs
void _patch(List<T> Function(List<T> current) transform) {
final current = state.valueOrNull;
if (current == null) {
return;
}
final result = transform(current);
if (!identical(result, current)) {
state = AsyncData(result);
}
}
}
/// Base for a store entry's mutations, to be exposed by the entry's provider via `ref.read(Store.x).doMutation()`
abstract class StoreMutations {
final Ref _ref;
const StoreMutations(this._ref);
/// Read the state associated with a [provider]
@protected
T read<T>(ProviderListenable<T> provider) => _ref.read(provider);
}
/// Mutations for a store entry whose reads are backed by a [StoreCache]
///
/// Apply each mutation to the cache with [cacheUpsert] or [cacheRemove]
abstract class CachedStoreMutations<T, Arg> extends StoreMutations {
final StoreCache<T, Arg> _cache;
const CachedStoreMutations(super.ref, this._cache);
/// Insert or update [item] in every live scope that [StoreCache.shouldContain] it
@protected
void cacheUpsert(T item) => _cache._upsert(_ref, item);
/// Remove the item with identity [id] from every live scope
@protected
void cacheRemove(Object id) => _cache._remove(_ref, id);
}
/// Identity-preserving list edits
extension _ListEdits<T> on List<T> {
/// Upsert [item] in the location specified by [matcher], or to the end if it is not found. Preserves list identity if there are no changes
List<T> _upsertWhere(bool Function(T item) matcher, T item) {
final index = indexWhere(matcher);
if (index == -1) {
// Append
return [...this, item];
} else if (this[index] != item) {
// Update
final list = [...this];
list[index] = item;
return list;
} else {
// Do nothing
return this;
}
}
/// Filter out items matching [matcher]. Preserves list identity if there are no changes
List<T> _without(bool Function(T item) matcher) {
final result = where((item) => !matcher(item)).toList();
return result.length == length ? this : result;
}
}
@@ -1,35 +0,0 @@
import 'dart:async';
import 'package:immich_mobile/data/db/main/dao/person.dart';
import 'package:immich_mobile/data/server/person.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
/// Accesses People; entities mapped to assets for presence and face detection
class PeopleService {
final PeopleRepository _repository;
final PersonApiRepository _personApiRepository;
const PeopleService(this._repository, this._personApiRepository);
Future<Person?> get(String personId) {
return _repository.get(personId);
}
Future<List<Person>> getAssetPeople(String assetId) {
return _repository.getAssetPeople(assetId);
}
Stream<List<Person>> watch({int minFaces = 3}) {
return _repository.watch(minFaces: minFaces);
}
Future<int> updateName(String personId, String name) async {
await _personApiRepository.update(personId, name: name);
return _repository.updateName(personId, name);
}
Future<int> updateBirthday(String personId, DateTime birthday) async {
await _personApiRepository.update(personId, birthday: birthday);
return _repository.updateBirthday(personId, birthday);
}
}
@@ -10,6 +10,7 @@ enum ActivityType { comment, like }
abstract class Activity with _$Activity {
const factory Activity({
required String id,
required String albumId,
String? assetId,
String? comment,
required DateTime createdAt,
@@ -4,12 +4,12 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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/album/album.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/activity_text_field.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/widgets/activities/comment_bubble.dart';
@@ -23,8 +23,7 @@ class ActivitiesPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final activityNotifier = ref.watch(albumActivityProvider((album.id, assetId)).notifier);
final activities = ref.watch(albumActivityProvider((album.id, assetId)));
final activities = ref.watch(Store.activity.list(album.id, assetId: assetId));
final listViewScrollController = useScrollController();
Future<void> scrollToBottom() {
@@ -36,7 +35,12 @@ class ActivitiesPage extends HookConsumerWidget {
}
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
try {
await ref.read(Store.activity).addComment(album.id, comment, assetId: assetId);
} catch (e) {
// TODO(rewrite): Actually handle this
}
unawaited(scrollToBottom());
}
@@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.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/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -11,7 +12,6 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -152,7 +152,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final people = ref.watch(Store.people.all());
return LayoutBuilder(
builder: (context, constraints) {
@@ -3,12 +3,12 @@ import 'dart:async';
import 'package:auto_route/auto_route.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/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
@@ -34,7 +34,7 @@ class _PeopleCollectionPageState extends ConsumerState<PeopleCollectionPage> {
@override
Widget build(BuildContext context) {
final people = ref.watch(getAllPeopleProvider);
final people = ref.watch(Store.people.all());
return LayoutBuilder(
builder: (context, constraints) {
@@ -1,11 +1,11 @@
import 'package:collection/collection.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/generated/translations.g.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -22,20 +22,22 @@ class LikeActivityActionButton extends ConsumerWidget {
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activities = ref.watch(albumActivityProvider((album?.id ?? "", asset?.id)));
final activities = ref.watch(Store.activity.list(album?.id ?? "", assetId: asset?.id));
Future<void> onTap(Activity? liked) async {
if (user == null) {
return;
}
if (liked != null) {
await ref.read(albumActivityProvider((album?.id ?? "", asset?.id)).notifier).removeActivity(liked.id);
} else {
await ref.read(albumActivityProvider((album?.id ?? "", asset?.id)).notifier).addLike();
try {
if (liked != null) {
await ref.read(Store.activity).remove(liked);
} else {
await ref.read(Store.activity).addLike(album?.id ?? "", assetId: asset?.id);
}
} catch (e) {
// TODO(rewrite): Actually handle this
}
ref.invalidate(albumActivityProvider((album?.id ?? "", asset?.id)));
}
return activities.when(
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:auto_route/auto_route.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/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -10,7 +11,6 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -28,7 +28,7 @@ class PeopleDetails extends ConsumerWidget {
return const SizedBox.shrink();
}
final peopleFuture = ref.watch(peopleAssetProvider(asset.id));
final peopleFuture = ref.watch(Store.people.forAsset(asset.id));
Future<void> showNameEditModal(Person person) async {
await showDialog(
@@ -39,7 +39,8 @@ class PeopleDetails extends ConsumerWidget {
},
);
ref.invalidate(peopleAssetProvider(asset.id));
// TODO(agg23): Remove once state is properly reactive
ref.invalidate(Store.people.forAsset(asset.id));
}
return peopleFuture.when(
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:auto_route/auto_route.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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
@@ -10,7 +11,6 @@ import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
@@ -38,7 +38,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
ref.watch(albumActivityProvider((album.id, asset.id)));
ref.watch(Store.activity.list(album.id, assetId: asset.id));
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -2,10 +2,10 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:scroll_date_picker/scroll_date_picker.dart';
@@ -30,7 +30,7 @@ class _PersonBirthdayEditFormState extends ConsumerState<PersonBirthdayEditForm>
Future<void> saveBirthday() async {
try {
final result = await ref.read(peopleServiceProvider).updateBirthday(widget.person.id, _selectedDate);
final result = await ref.read(Store.people).updateBirthday(widget.person.id, _selectedDate);
if (result != 0 && mounted) {
context.pop<DateTime>(_selectedDate);
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -28,7 +28,7 @@ class _PersonNameEditFormState extends ConsumerState<PersonNameEditForm> {
Future<void> onEdit(String personId, String newName) async {
try {
final result = await ref.read(peopleServiceProvider).updateName(personId, newName);
final result = await ref.read(Store.people).updateName(personId, newName);
if (result != 0 && mounted) {
context.pop<String>(newName);
}
@@ -1,81 +0,0 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
// ignore: unintended_html_in_doc_comment
/// Maintains the current list of all activities for <share-album-id, asset>
final albumActivityProvider = AsyncNotifierProvider.autoDispose
.family<AlbumActivity, List<Activity>, (String albumId, String? assetId)>(AlbumActivity.new);
class AlbumActivity extends AutoDisposeFamilyAsyncNotifier<List<Activity>, (String albumId, String? assetId)> {
late String albumId;
late String? assetId;
@override
Future<List<Activity>> build((String albumId, String? assetId) args) async {
albumId = args.$1;
assetId = args.$2;
return ref.watch(activityServiceProvider).getAllActivities(albumId, assetId: assetId);
}
Future<void> removeActivity(String id) async {
if (await ref.watch(activityServiceProvider).removeActivity(id)) {
final removedActivity = _removeFromState(id);
if (removedActivity == null) {
return;
}
if (assetId != null) {
ref.read(albumActivityProvider((albumId, assetId)).notifier)._removeFromState(id);
}
}
}
Future<void> addLike() async {
final activity = await ref.watch(activityServiceProvider).addActivity(albumId, ActivityType.like, assetId: assetId);
if (activity.hasValue) {
_addToState(activity.requireValue);
if (assetId != null) {
ref.read(albumActivityProvider((albumId, assetId)).notifier)._addToState(activity.requireValue);
}
}
}
Future<void> addComment(String comment) async {
final activity = await ref
.watch(activityServiceProvider)
.addActivity(albumId, ActivityType.comment, assetId: assetId, comment: comment);
if (activity.hasValue) {
_addToState(activity.requireValue);
if (assetId != null) {
ref.read(albumActivityProvider((albumId, assetId)).notifier)._addToState(activity.requireValue);
}
}
}
void _addToState(Activity activity) {
final activities = state.valueOrNull ?? [];
if (activities.any((a) => a.id == activity.id)) {
return;
}
state = AsyncData([...activities, activity]);
}
Activity? _removeFromState(String id) {
final activities = state.valueOrNull;
if (activities == null) {
return null;
}
final activity = activities.firstWhereOrNull((a) => a.id == id);
if (activity == null) {
return null;
}
final updated = [...activities]..remove(activity);
state = AsyncData(updated);
return activity;
}
}
@@ -1,13 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/repositories/activity_api.repository.dart';
import 'package:immich_mobile/services/activity.service.dart';
final activityServiceProvider = Provider.autoDispose<ActivityService>((ref) {
return ActivityService(
ref.watch(activityApiRepositoryProvider),
ref.watch(timelineFactoryProvider),
ref.watch(assetServiceProvider),
);
});
@@ -1,21 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/person.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/services/people.service.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
final peopleServiceProvider = Provider<PeopleService>(
(ref) => PeopleService(ref.watch(driftProvider).peopleRepository, ref.watch(personApiRepositoryProvider)),
);
final peopleAssetProvider = FutureProvider.family<List<Person>, String>((ref, assetId) async {
final service = ref.watch(peopleServiceProvider);
return service.getAssetPeople(assetId);
});
final getAllPeopleProvider = StreamProvider<List<Person>>((ref) async* {
final service = ref.watch(peopleServiceProvider);
final prefs = await ref.watch(userMetadataPreferencesProvider.future);
yield* service.watch(minFaces: prefs?.minimumFaces ?? 3);
});
-66
View File
@@ -1,66 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/errors.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/repositories/activity_api.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
class ActivityService with ErrorLoggerMixin {
final ActivityApiRepository _activityApiRepository;
final TimelineFactory _timelineFactory;
final AssetService _assetService;
@override
final Logger logger = Logger("ActivityService");
ActivityService(this._activityApiRepository, this._timelineFactory, this._assetService);
Future<List<Activity>> getAllActivities(String albumId, {String? assetId}) async {
return logError(
() => _activityApiRepository.getAll(albumId, assetId: assetId),
defaultValue: [],
errorMessage: "Failed to get all activities for album $albumId",
);
}
Future<bool> removeActivity(String id) async {
return logError(
() async {
try {
await _activityApiRepository.delete(id);
} on NoResponseDtoError {
return true;
}
return true;
},
defaultValue: false,
errorMessage: "Failed to delete activity",
);
}
AsyncFuture<Activity> addActivity(String albumId, ActivityType type, {String? assetId, String? comment}) async {
return guardError(
() => _activityApiRepository.create(albumId, type, assetId: assetId, comment: comment),
errorMessage: "Failed to create $type for album $albumId",
);
}
Future<AssetViewerRoute?> buildAssetViewerRoute(String assetId, WidgetRef ref) async {
final asset = await _assetService.getRemoteAsset(assetId);
if (asset == null) {
return null;
}
AssetViewer.setAsset(ref, asset);
return AssetViewerRoute(
initialIndex: 0,
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.albumActivities),
currentAlbum: ref.read(currentRemoteAlbumProvider),
);
}
}
+5 -9
View File
@@ -1,17 +1,16 @@
import 'package:auto_route/auto_route.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart' as beta_asset_service;
import 'package:immich_mobile/domain/services/memory.service.dart';
import 'package:immich_mobile/domain/services/people.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -22,7 +21,6 @@ final deepLinkServiceProvider = Provider(
ref.watch(beta_asset_provider.assetServiceProvider),
ref.watch(remoteAlbumServiceProvider),
MemoryService(ref.watch(driftProvider).memoryRepository),
ref.watch(peopleServiceProvider),
ref.watch(currentUserProvider),
),
);
@@ -32,7 +30,6 @@ class DeepLinkService {
final beta_asset_service.AssetService _betaAssetService;
final RemoteAlbumService _betaRemoteAlbumService;
final MemoryService _betaMemoryService;
final PeopleService _betaPeopleService;
final UserDto? _currentUser;
@@ -41,7 +38,6 @@ class DeepLinkService {
this._betaAssetService,
this._betaRemoteAlbumService,
this._betaMemoryService,
this._betaPeopleService,
this._currentUser,
);
@@ -54,7 +50,7 @@ class DeepLinkService {
"memory" => await _buildMemoryDeepLink(queryParams['id']),
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
"people" => await _buildPeopleDeepLink(queryParams['id'] ?? ''),
"people" => await _buildPeopleDeepLink(queryParams['id'] ?? '', ref),
"activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''),
_ => null,
};
@@ -81,7 +77,7 @@ class DeepLinkService {
}
if (peopleRegex.hasMatch(path)) {
final peopleId = peopleRegex.firstMatch(path)?.group(1) ?? '';
return _buildPeopleDeepLink(peopleId);
return _buildPeopleDeepLink(peopleId, ref);
}
return null;
@@ -146,8 +142,8 @@ class DeepLinkService {
return ActivitiesRoute(album: album);
}
Future<PageRouteInfo?> _buildPeopleDeepLink(String personId) async {
final person = await _betaPeopleService.get(personId);
Future<PageRouteInfo?> _buildPeopleDeepLink(String personId, WidgetRef ref) async {
final person = await ref.read(Store.people.byId(personId).future);
if (person == null) {
return null;
+1 -1
View File
@@ -1,8 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/activity.dart';
import 'package:immich_mobile/data/server/person.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/repositories/activity_api.repository.dart';
import 'package:immich_mobile/repositories/album_api_repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
@@ -1,14 +1,18 @@
import 'package:auto_route/auto_route.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/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@@ -28,19 +32,27 @@ class CommentBubble extends ConsumerWidget {
final isLike = activity.type == ActivityType.like;
final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer;
final activityNotifier = ref.watch(
albumActivityProvider((album.id, isAssetActivity ? activity.assetId : null)).notifier,
);
Future<void> openAssetViewer() async {
final activityService = ref.read(activityServiceProvider);
final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref);
if (!context.mounted) {
final asset = await ref.read(assetServiceProvider).getRemoteAsset(activity.assetId!);
if (asset == null || !context.mounted) {
return;
}
if (route != null) {
await context.pushRoute(route);
AssetViewer.setAsset(ref, asset);
await context.pushRoute(
AssetViewerRoute(
initialIndex: 0,
timelineService: ref.read(timelineFactoryProvider).fromAssets([asset], TimelineOrigin.albumActivities),
currentAlbum: ref.read(currentRemoteAlbumProvider),
),
);
}
Future<void> delete() async {
try {
await ref.read(Store.activity).remove(activity);
} catch (e) {
// TODO(rewrite): Actually handle this
}
}
@@ -112,7 +124,7 @@ class CommentBubble extends ConsumerWidget {
final List<Widget> contentChildren = [thumbnail, likes, commentBubble].whereType<Widget>().toList();
return DismissibleActivity(
onDismiss: canDelete ? (id) async => await activityNotifier.removeActivity(id) : null,
onDismiss: canDelete ? (id) => delete() : null,
activity.id,
Align(
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
@@ -1,6 +1,7 @@
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/person.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -8,7 +9,6 @@ import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@@ -23,7 +23,7 @@ class PeoplePicker extends HookConsumerWidget {
final formFocus = useFocusNode();
const imageSize = 60.0;
final searchQuery = useState('');
final people = ref.watch(getAllPeopleProvider);
final people = ref.watch(Store.people.all());
final selectedPeople = useState<Set<Person>>({...?initialSelection});
return Column(
@@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/activity.dart';
import 'package:immich_mobile/data/server/errors.dart';
import 'package:immich_mobile/data/store.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:mocktail/mocktail.dart';
class _MockActivityApi extends Mock implements ActivityApiRepository {}
final _user = UserDto(id: 'u1', email: 'u1@test.com', name: 'User', profileChangedAt: DateTime.utc(2025));
Activity _activity(String id, {String? assetId, ActivityType type = ActivityType.comment}) =>
Activity(id: id, albumId: 'album', assetId: assetId, createdAt: DateTime.utc(2026), type: type, user: _user);
void main() {
late _MockActivityApi api;
late ProviderContainer container;
setUp(() {
api = _MockActivityApi();
container = ProviderContainer(overrides: [activityApiRepositoryProvider.overrideWithValue(api)]);
addTearDown(container.dispose);
// Fetches for any ID default to empty
when(() => api.getAll(any(), assetId: any(named: 'assetId'))).thenAnswer((_) async => []);
});
Future<void> load(String albumId, {String? assetId}) async {
// Create a lock on the provider scope so it doesn't get disposed
container.listen(Store.activity.list(albumId, assetId: assetId), (_, _) {});
await container.read(Store.activity.list(albumId, assetId: assetId).future);
}
List<Activity>? current(String albumId, {String? assetId}) =>
container.read(Store.activity.list(albumId, assetId: assetId)).value;
test('list serves fetched activities', () async {
when(() => api.getAll('album', assetId: null)).thenAnswer((_) async => [_activity('1')]);
when(() => api.getAll('broken', assetId: null)).thenThrow(Exception('offline'));
await load('album');
await load('broken');
expect(current('album'), [_activity('1')]);
expect(current('broken'), isEmpty);
});
test('addComment appends the created activity to the live album and asset lists', () async {
await load('album');
await load('album', assetId: 'asset');
final created = _activity('c1', assetId: 'asset');
when(
() => api.create('album', ActivityType.comment, assetId: 'asset', comment: 'hi'),
).thenAnswer((_) async => created);
await container.read(Store.activity).addComment('album', 'hi', assetId: 'asset');
expect(current('album'), [created]);
expect(current('album', assetId: 'asset'), [created]);
});
test('addLike appends the created like to the live album list', () async {
await load('album');
final created = _activity('l1', type: ActivityType.like);
when(() => api.create('album', ActivityType.like, assetId: null)).thenAnswer((_) async => created);
await container.read(Store.activity).addLike('album');
expect(current('album'), [created]);
});
test('remove deletes the activity', () async {
when(() => api.getAll('album', assetId: null)).thenAnswer((_) async => [_activity('1')]);
await load('album');
// The delete endpoint sends no response body, producing an error on our side
when(() => api.delete('1')).thenThrow(const NoResponseDtoError());
await container.read(Store.activity).remove(_activity('1'));
expect(current('album'), isEmpty);
});
}
@@ -0,0 +1,97 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
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';
class _MockPersonApi extends Mock implements PersonApiRepository {}
void main() {
late MediumRepositoryContext ctx;
late _MockPersonApi api;
late ProviderContainer container;
setUp(() {
ctx = MediumRepositoryContext();
api = _MockPersonApi();
container = ProviderContainer(
overrides: [
driftProvider.overrideWithValue(ctx.db),
personApiRepositoryProvider.overrideWithValue(api),
// No stored preferences: the default minimum face count applies
userMetadataPreferencesProvider.overrideWith((ref) => Future.value(null)),
],
);
addTearDown(container.dispose);
addTearDown(ctx.dispose);
});
Future<Person?> byId(String personId) => container.read(Store.people.byId(personId).future);
test('forAsset serves the people on the asset', () async {
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id);
final person = await ctx.newPerson(ownerId: user.id);
await ctx.newFace(assetId: asset.id, personId: person.id);
final people = await container.read(Store.people.forAsset(asset.id).future);
expect(people.map((p) => p.id), [person.id]);
});
test('all serves named people, hiding unnamed ones lacking the face count', () async {
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id);
final named = await ctx.newPerson(ownerId: user.id, name: 'Alice');
final unnamed = await ctx.newPerson(ownerId: user.id, name: '');
await ctx.newFace(assetId: asset.id, personId: named.id);
await ctx.newFace(assetId: asset.id, personId: unnamed.id);
final people = await container.read(Store.people.all().future);
expect(people.map((p) => p.id), [named.id]);
});
test('updateName pushes to the server, then saves locally', () async {
final user = await ctx.newUser();
final person = await ctx.newPerson(ownerId: user.id, name: 'Old');
when(() => api.update(person.id, name: 'New')).thenAnswer((_) async => Person(id: person.id, name: 'New'));
await container.read(Store.people).updateName(person.id, 'New');
verify(() => api.update(person.id, name: 'New')).called(1);
expect((await byId(person.id))?.name, 'New');
});
test('updateName leaves the DB untouched when the server rejects', () async {
final user = await ctx.newUser();
final person = await ctx.newPerson(ownerId: user.id, name: 'Old');
when(() => api.update(person.id, name: 'New')).thenThrow(Exception('rejected'));
await expectLater(container.read(Store.people).updateName(person.id, 'New'), throwsException);
expect((await byId(person.id))?.name, 'Old');
});
test('updateBirthday pushes to the server, then saves locally', () async {
final user = await ctx.newUser();
final person = await ctx.newPerson(ownerId: user.id);
final birthday = DateTime.utc(1990, 4, 2);
when(
() => api.update(person.id, birthday: birthday),
).thenAnswer((_) async => Person(id: person.id, name: person.name, birthDate: birthday));
await container.read(Store.people).updateBirthday(person.id, birthday);
verify(() => api.update(person.id, birthday: birthday)).called(1);
expect((await byId(person.id))?.birthDate, birthday);
});
}
+146
View File
@@ -0,0 +1,146 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/store/util/cache.dart';
typedef _Item = ({String id, String scope, int version});
_Item _item(String id, String scope, [int version = 1]) => (id: id, scope: scope, version: version);
class _TestCache extends StoreCache<_Item, String> {
final fetchLog = <String>[];
final seed = <String, List<_Item>>{};
@override
Future<List<_Item>> fetch(Ref ref, String scope) async {
fetchLog.add(scope);
return seed[scope] ?? const [];
}
@override
Object identity(_Item item) => item.id;
@override
bool shouldContain(String scope, _Item item) => item.scope == scope;
}
class _TestMutations extends CachedStoreMutations<_Item, String> {
const _TestMutations(super.ref, super.cache);
void upsert(_Item item) => cacheUpsert(item);
void removeById(Object id) => cacheRemove(id);
}
void main() {
late _TestCache cache;
late ProviderContainer container;
late _TestMutations mutations;
setUp(() {
cache = _TestCache();
container = ProviderContainer();
mutations = container.read(Provider((ref) => _TestMutations(ref, cache)));
addTearDown(container.dispose);
});
/// Keeps [scope] alive in [target] for the test's duration, collecting state changes
List<AsyncValue<List<_Item>>> listen(String scope, [ProviderContainer? target]) {
final states = <AsyncValue<List<_Item>>>[];
(target ?? container).listen(cache.family(scope), (_, next) => states.add(next));
return states;
}
Future<List<_Item>> load(String scope, [ProviderContainer? target]) =>
(target ?? container).read(cache.family(scope).future);
List<_Item>? current(String scope) => container.read(cache.family(scope)).value;
test('fetches once per live scope, and refetches on new subscription after its last listener disconnected', () async {
final first = container.listen(cache.family('a'), (_, _) {});
final second = container.listen(cache.family('a'), (_, _) {});
await load('a');
expect(cache.fetchLog, ['a']);
first.close();
await container.pump();
expect(cache.fetchLog, ['a']);
second.close();
await container.pump();
listen('a');
await load('a');
expect(cache.fetchLog, ['a', 'a']);
});
test('upsert inserts or updates by identity, limited to shouldContain', () async {
cache.seed['a'] = [_item('1', 'a', 1)];
listen('a');
listen('b');
await load('a');
await load('b');
// Update existing
mutations.upsert(_item('1', 'a', 2));
// Insert new
mutations.upsert(_item('2', 'a'));
// No scope
mutations.upsert(_item('3', 'c'));
expect(current('a'), [_item('1', 'a', 2), _item('2', 'a')]);
expect(current('b'), isEmpty);
expect(cache.fetchLog, ['a', 'b']);
});
test('remove drops the item from all live scopes', () async {
cache.seed['a'] = [_item('x', 'a')];
cache.seed['b'] = [_item('x', 'b'), _item('y', 'b')];
listen('a');
listen('b');
await load('a');
await load('b');
mutations.removeById('x');
expect(current('a'), isEmpty);
expect(current('b'), [_item('y', 'b')]);
});
test('patches that change nothing do not emit', () async {
cache.seed['a'] = [_item('1', 'a')];
final states = listen('a');
await load('a');
final oldStateCount = states.length;
mutations.upsert(_item('1', 'a'));
mutations.removeById('nope');
expect(states.length, oldStateCount);
});
test('patches scopes that are live in other Riverpod containers', () async {
final other = ProviderContainer();
listen('a', other);
await load('a', other);
listen('a');
await load('a');
// We kill the external container, but ours (the second reference) should still work
other.dispose();
await container.pump();
mutations.upsert(_item('1', 'a'));
expect(current('a'), [_item('1', 'a')]);
});
}
@@ -5,11 +5,11 @@ import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late PeopleRepository sut;
late PeopleDatabaseRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = PeopleRepository(ctx.db);
sut = PeopleDatabaseRepository(ctx.db);
});
tearDown(() async {
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/memory.service.dart';
import 'package:immich_mobile/domain/services/people.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -24,7 +23,6 @@ class MockRemoteAlbumService extends Mock implements RemoteAlbumService {}
class MockMemoryService extends Mock implements MemoryService {}
class MockPeopleService extends Mock implements PeopleService {}
class MockPlatformDeepLink extends Mock implements PlatformDeepLink {}
@@ -111,14 +109,7 @@ void main() {
when(() => ref.read(assetViewerProvider.notifier)).thenReturn(MockAssetViewerStateNotifier());
sut = DeepLinkService(
timelineFactory,
assetService,
remoteAlbumService,
memoryService,
MockPeopleService(),
_user,
);
sut = DeepLinkService(timelineFactory, assetService, remoteAlbumService, memoryService, _user);
addTearDown(() async {
for (final timelineService in createdTimelineServices) {