mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
Merge 7fa887bc8c into 6c95058722
This commit is contained in:
commit
8b5d990c80
21 changed files with 694 additions and 201 deletions
37
mobile/lib/mixins/stream_notifier.mixin.dart
Normal file
37
mobile/lib/mixins/stream_notifier.mixin.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
/// Converts a [Stream] into an [AsyncNotifier]
|
||||
///
|
||||
/// The [build] method MUST call [buildFromStream] in order to properly subscribe
|
||||
mixin StreamNotifierMixin<T> {
|
||||
set state(AsyncValue<T> value);
|
||||
|
||||
/// Forwards data into the notifier from [stream]. Future will stay open until [stream] completes
|
||||
///
|
||||
/// Must be called in [build]
|
||||
Future<T> buildFromStream(
|
||||
Ref<AsyncValue<T>> ref,
|
||||
Stream<T> stream, {
|
||||
required T Function(Object error, StackTrace stack) onError,
|
||||
}) {
|
||||
final completer = Completer<T>();
|
||||
|
||||
void apply(T value) {
|
||||
if (completer.isCompleted) {
|
||||
state = AsyncData(value);
|
||||
} else {
|
||||
completer.complete(value);
|
||||
}
|
||||
}
|
||||
|
||||
final subscription = stream.listen(
|
||||
apply,
|
||||
onError: (Object error, StackTrace stack) => apply(onError(error, stack)),
|
||||
);
|
||||
ref.onDispose(subscription.cancel);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,8 @@ 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/drift_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/store/activity.dart';
|
||||
import 'package:immich_mobile/widgets/activities/comment_bubble.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ import 'package:collection/collection.dart';
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_data/model/activity.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.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';
|
||||
import 'package:immich_mobile/store/activity.dart';
|
||||
|
||||
class LikeActivityActionButton extends ConsumerWidget {
|
||||
const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||
|
|
@ -35,8 +35,6 @@ class LikeActivityActionButton extends ConsumerWidget {
|
|||
} else {
|
||||
await ref.read(albumActivityProvider((album?.id ?? "", asset?.id)).notifier).addLike();
|
||||
}
|
||||
|
||||
ref.invalidate(albumActivityProvider((album?.id ?? "", asset?.id)));
|
||||
}
|
||||
|
||||
return activities.when(
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ 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';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/store/activity.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
|
|
@ -11,6 +11,8 @@ abstract final class Store {
|
|||
|
||||
static final people = _store((c) => c.people);
|
||||
|
||||
static final activities = _store((c) => c.activities);
|
||||
|
||||
/// Direct database access for the repositories that have not yet moved into `immich_data`
|
||||
// TODO(rewrite): Remove this provider once all repositories have migrated to `immich_data`
|
||||
static final db = _store((c) => c.db);
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/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),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
mobile/lib/store/activity.dart
Normal file
61
mobile/lib/store/activity.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_data/model/activity.dart';
|
||||
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
|
||||
import 'package:immich_mobile/mixins/stream_notifier.mixin.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/data_store.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Activities associated with a `albumId`, `assetId` pair
|
||||
final albumActivityProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<_AlbumActivity, List<Activity>, (String albumId, String? assetId)>(_AlbumActivity.new);
|
||||
|
||||
class _AlbumActivity extends AutoDisposeFamilyAsyncNotifier<List<Activity>, (String albumId, String? assetId)>
|
||||
with ErrorLoggerMixin, StreamNotifierMixin<List<Activity>> {
|
||||
@override
|
||||
final Logger logger = Logger("ActivityService");
|
||||
|
||||
late String albumId;
|
||||
late String? assetId;
|
||||
|
||||
@override
|
||||
Future<List<Activity>> build((String albumId, String? assetId) args) {
|
||||
albumId = args.$1;
|
||||
assetId = args.$2;
|
||||
|
||||
return buildFromStream(
|
||||
ref,
|
||||
// TODO(rewrite): `force: true` matches the previous behavior of a HTTP request on mount
|
||||
ref.watch(Store.activities).getAll(albumId, assetId: assetId, force: true),
|
||||
onError: (error, stack) {
|
||||
logger.severe("Failed to get all activities for album $albumId", error, stack);
|
||||
return const [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeActivity(String id) async {
|
||||
await logError(
|
||||
() async {
|
||||
await ref.read(Store.activities).remove(albumId, id);
|
||||
},
|
||||
defaultValue: null,
|
||||
errorMessage: "Failed to delete activity",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addLike() async {
|
||||
await guardError(
|
||||
() => ref.read(Store.activities).addLike(albumId, assetId: assetId),
|
||||
errorMessage: "Failed to create like for album $albumId",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addComment(String comment) async {
|
||||
await guardError(
|
||||
() => ref.read(Store.activities).addComment(albumId, comment, assetId: assetId),
|
||||
errorMessage: "Failed to create comment for album $albumId",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.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/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
|
||||
void invalidateAllApiRepositoryProviders(WidgetRef ref) {
|
||||
ref.invalidate(userApiRepositoryProvider);
|
||||
ref.invalidate(activityApiRepositoryProvider);
|
||||
ref.invalidate(partnerApiRepositoryProvider);
|
||||
ref.invalidate(assetApiRepositoryProvider);
|
||||
ref.invalidate(searchApiRepositoryProvider);
|
||||
|
|
|
|||
|
|
@ -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_data/model/activity.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/store/activity.dart';
|
||||
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||
|
||||
|
|
@ -33,15 +37,19 @@ class CommentBubble extends ConsumerWidget {
|
|||
);
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// avatar (hidden for own messages)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:immich_data/db/database.dart';
|
||||
import 'package:immich_data/db/logger.dart';
|
||||
import 'package:immich_data/db/person.dart';
|
||||
import 'package:immich_data/server/activity.dart';
|
||||
import 'package:immich_data/server/person.dart';
|
||||
import 'package:immich_data/store/activity.dart';
|
||||
import 'package:immich_data/store/person.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:sqlite3/common.dart';
|
||||
|
|
@ -62,6 +64,10 @@ class DataController {
|
|||
PersonApiRepository(PeopleApi(_apiClient)),
|
||||
);
|
||||
|
||||
// This is optional and not lazy so we only call `dispose` if necessary
|
||||
ActivityService? _activities;
|
||||
ActivityService get activities => _activities ??= ActivityService(ActivityApiRepository(ActivitiesApi(_apiClient)));
|
||||
|
||||
/// Direct database access for the logic that has not yet moved into this package
|
||||
// TODO(rewrite): Remove once all repositories have migrated into this package
|
||||
Drift get db => _db;
|
||||
|
|
@ -71,6 +77,9 @@ class DataController {
|
|||
DriftLogger get logDb => _logDb;
|
||||
|
||||
Future<void> close() async {
|
||||
await _activities?.dispose();
|
||||
_activities = null;
|
||||
|
||||
await _db.close();
|
||||
|
||||
// Close after the primary DB to ensure all logs are captured
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_data/model/user/user.dart';
|
||||
|
||||
enum ActivityType { comment, like }
|
||||
|
||||
|
|
@ -61,9 +61,3 @@ class Activity {
|
|||
return id.hashCode ^ assetId.hashCode ^ comment.hashCode ^ createdAt.hashCode ^ type.hashCode ^ user.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
class ActivityStats {
|
||||
final int comments;
|
||||
|
||||
const ActivityStats({required this.comments});
|
||||
}
|
||||
|
|
@ -1,18 +1,15 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.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:immich_data/model/activity.dart';
|
||||
import 'package:immich_data/server/api_repository.dart';
|
||||
import 'package:immich_data/server/util/convert.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final activityApiRepositoryProvider = Provider(
|
||||
(ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi),
|
||||
);
|
||||
|
||||
/// Immich HTTP API for album activity (comments and likes)
|
||||
class ActivityApiRepository extends ApiRepository {
|
||||
final ActivitiesApi _api;
|
||||
|
||||
ActivityApiRepository(this._api);
|
||||
@internal
|
||||
const ActivityApiRepository(this._api);
|
||||
|
||||
Future<List<Activity>> getAll(String albumId, {String? assetId}) async {
|
||||
final response = await checkNull(_api.getActivities(albumId, assetId: assetId));
|
||||
|
|
@ -31,19 +28,15 @@ class ActivityApiRepository extends ApiRepository {
|
|||
}
|
||||
|
||||
Future<void> delete(String id) {
|
||||
// TODO(agg23): I think this is a bug; `checkNull` will always throw here
|
||||
return checkNull(_api.deleteActivity(id));
|
||||
}
|
||||
|
||||
Future<ActivityStats> getStats(String albumId, {String? assetId}) async {
|
||||
final response = await checkNull(_api.getActivityStatistics(albumId, assetId: assetId));
|
||||
return ActivityStats(comments: response.comments);
|
||||
}
|
||||
|
||||
static Activity _toActivity(ActivityResponseDto dto) => Activity(
|
||||
id: dto.id,
|
||||
createdAt: dto.createdAt,
|
||||
type: dto.type == ReactionType.comment ? ActivityType.comment : ActivityType.like,
|
||||
user: UserConverter.fromSimpleUserDto(dto.user),
|
||||
user: DtoConverter.toUser(dto.user),
|
||||
assetId: dto.assetId,
|
||||
comment: dto.comment.orElse(null),
|
||||
);
|
||||
29
mobile/packages/data/lib/server/util/convert.dart
Normal file
29
mobile/packages/data/lib/server/util/convert.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:immich_data/model/user/user.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
abstract final class DtoConverter {
|
||||
// TODO(rewrite): This is duplicated from lib/infrastructure/utils/user.converter.dart
|
||||
static UserDto toUser(UserResponseDto dto) => UserDto(
|
||||
id: dto.id,
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
isAdmin: false,
|
||||
updatedAt: DateTime.now(),
|
||||
hasProfileImage: dto.profileImagePath.isNotEmpty,
|
||||
profileChangedAt: dto.profileChangedAt,
|
||||
avatarColor: _toAvatarColor(dto.avatarColor),
|
||||
);
|
||||
}
|
||||
|
||||
AvatarColor _toAvatarColor(UserAvatarColor color) => switch (color) {
|
||||
UserAvatarColor.red => AvatarColor.red,
|
||||
UserAvatarColor.green => AvatarColor.green,
|
||||
UserAvatarColor.blue => AvatarColor.blue,
|
||||
UserAvatarColor.purple => AvatarColor.purple,
|
||||
UserAvatarColor.orange => AvatarColor.orange,
|
||||
UserAvatarColor.pink => AvatarColor.pink,
|
||||
UserAvatarColor.amber => AvatarColor.amber,
|
||||
UserAvatarColor.yellow => AvatarColor.yellow,
|
||||
UserAvatarColor.gray => AvatarColor.gray,
|
||||
UserAvatarColor.primary || _ => AvatarColor.primary,
|
||||
};
|
||||
87
mobile/packages/data/lib/store/activity.dart
Normal file
87
mobile/packages/data/lib/store/activity.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:immich_data/model/activity.dart';
|
||||
import 'package:immich_data/server/activity.dart';
|
||||
import 'package:immich_data/server/errors.dart';
|
||||
import 'package:immich_data/store/util/stream_cache.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// Activities (comments and likes) on shared albums and their assets
|
||||
///
|
||||
/// State is fetched over HTTP and mutated and cached in memory
|
||||
// TODO(agg23): This should not be called simply "Activity"
|
||||
class ActivityService {
|
||||
final ActivityApiRepository _api;
|
||||
|
||||
late final StreamCache<(String albumId, String? assetId), List<Activity>> _cache = StreamCache(
|
||||
fetch: (scope) => _api.getAll(scope.$1, assetId: scope.$2),
|
||||
);
|
||||
|
||||
@internal
|
||||
ActivityService(this._api);
|
||||
|
||||
/// All activities for an album specified by [albumId], or all activities for a specific asset within that album.
|
||||
/// Providing [force] will make a new HTTP request on stream open (legacy behavior)
|
||||
///
|
||||
/// **NOTE:** This is currently reactive only to in memory mutation, not live HTTP changes
|
||||
Stream<List<Activity>> getAll(String albumId, {String? assetId, bool force = false}) {
|
||||
return _cache.get((albumId, assetId), force: force);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final activity = await _api.create(albumId, ActivityType.comment, assetId: assetId, comment: comment);
|
||||
_upsert(albumId, activity);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final activity = await _api.create(albumId, ActivityType.like, assetId: assetId);
|
||||
_upsert(albumId, activity);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// Remove an activity by its [activityId]
|
||||
Future<void> remove(String albumId, String activityId) async {
|
||||
try {
|
||||
await _api.delete(activityId);
|
||||
} on NoResponseDtoError {
|
||||
// TODO(agg23): This error should not be thrown at all
|
||||
}
|
||||
|
||||
// Only drop on "success" (including the broken NoResponseDtoError above)
|
||||
_drop(albumId, activityId);
|
||||
}
|
||||
|
||||
/// Terminate all streams and dispose of the cache
|
||||
Future<void> dispose() {
|
||||
return _cache.dispose();
|
||||
}
|
||||
|
||||
/// Apply an activity upsert to the in memory cache
|
||||
void _upsert(String albumId, Activity activity) {
|
||||
_cache.update(
|
||||
// If there is a list for our album, we update it no matter what
|
||||
// If there is a list for our specific asset, we also update that
|
||||
(scope) => scope.$1 == albumId && (scope.$2 == null || scope.$2 == activity.assetId),
|
||||
(activities) {
|
||||
final index = activities.indexWhere((a) => a.id == activity.id);
|
||||
|
||||
if (index == -1) {
|
||||
// Insert new item
|
||||
return [...activities, activity];
|
||||
} else if (activities[index] == activity) {
|
||||
// No change
|
||||
return activities;
|
||||
} else {
|
||||
// Update existing
|
||||
return [...activities]..[index] = activity;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Drop an activity from any cache entry that may contain it
|
||||
void _drop(String albumId, String activityId) {
|
||||
_cache.update((scope) => scope.$1 == albumId, (activities) => activities.where((a) => a.id != activityId).toList());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@ import 'package:immich_data/model/person.dart';
|
|||
import 'package:immich_data/server/person.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
// TODO(rewrite): This needs to be made reactive
|
||||
class PersonService {
|
||||
final PersonDatabaseRepository _db;
|
||||
final PersonApiRepository _api;
|
||||
|
||||
/// Constructed by [ImmichData]; the app obtains instances from there.
|
||||
@internal
|
||||
const PersonService(this._db, this._api);
|
||||
|
||||
|
|
|
|||
144
mobile/packages/data/lib/store/util/stream_cache.dart
Normal file
144
mobile/packages/data/lib/store/util/stream_cache.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import 'dart:async';
|
||||
|
||||
/// A in memory cache for streams that are backed by emphemeral data (typically HTTP servers).
|
||||
/// Values are fetched on demand and disposed of when the last subscriber disconnects
|
||||
class StreamCache<K, V extends Object> {
|
||||
final Future<V> Function(K key) _fetch;
|
||||
|
||||
final Map<K, _CacheEntry<V>> _entries = {};
|
||||
final Map<K, Future<void>> _inflightFetches = {};
|
||||
|
||||
StreamCache({required this._fetch});
|
||||
|
||||
/// A live view of the value corresponding to [key]. [force] skips the cached emission and always fetches
|
||||
// TODO(rewrite): Remove force; it exists only for old Flutter views that expect 0 data on first subscribe
|
||||
Stream<V> get(K key, {bool force = false}) {
|
||||
// Reprents this call's subscription
|
||||
late final StreamController<V> localStreamController;
|
||||
// Reprents the source of all updates for this key, shared between individual `get` callers
|
||||
StreamSubscription<V>? cacheStreamController;
|
||||
|
||||
localStreamController = StreamController(
|
||||
onListen: () {
|
||||
final entry = _entries.putIfAbsent(key, _CacheEntry.new).ref();
|
||||
|
||||
// Push stream updates from the upstream to the local controller
|
||||
cacheStreamController = entry.updateStreamController.stream.listen(
|
||||
localStreamController.add,
|
||||
onDone: () => unawaited(localStreamController.close()),
|
||||
);
|
||||
|
||||
final currentData = entry.value;
|
||||
if (currentData != null && !force) {
|
||||
// If we have a cached value at call time, and we're not using legacy `force` behavior, immediately emit that value
|
||||
localStreamController.add(currentData);
|
||||
}
|
||||
|
||||
if (currentData == null || force) {
|
||||
// No data/force request, request new data from upstream
|
||||
unawaited(
|
||||
_fetchDeduped(
|
||||
key,
|
||||
).catchError((Object error, StackTrace stack) => localStreamController.addError(error, stack)),
|
||||
);
|
||||
}
|
||||
},
|
||||
onCancel: () async {
|
||||
await cacheStreamController?.cancel();
|
||||
|
||||
final entry = _entries[key];
|
||||
if (entry == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.deref().refCount <= 0) {
|
||||
_entries.remove(key);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return localStreamController.stream;
|
||||
}
|
||||
|
||||
/// Apply [transform] to all values corresponding to keys matching [predicate]
|
||||
///
|
||||
/// Returning the same value (identity) from [transform] will not emit a new value
|
||||
void update(bool Function(K key) predicate, V Function(V value) transform) {
|
||||
for (final MapEntry(:key, value: entry) in _entries.entries) {
|
||||
if (!predicate(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final currentValue = entry.value;
|
||||
if (currentValue == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final newValue = transform(currentValue);
|
||||
if (!identical(newValue, currentValue)) {
|
||||
entry.value = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close all streams and delete caches
|
||||
Future<void> dispose() async {
|
||||
_inflightFetches.clear();
|
||||
|
||||
final oldEntries = [..._entries.values];
|
||||
_entries.clear();
|
||||
|
||||
await Future.wait(oldEntries.map((entry) => entry.updateStreamController.close()));
|
||||
}
|
||||
|
||||
/// Fetch a single value matching the provided [key]. If a matching fetch is already in progress, await that fetch
|
||||
Future<void> _fetchDeduped(K key) {
|
||||
return _inflightFetches.putIfAbsent(key, () async {
|
||||
try {
|
||||
final data = await _fetch(key);
|
||||
final entry = _entries[key];
|
||||
|
||||
if (entry != null) {
|
||||
entry.value = data;
|
||||
}
|
||||
} finally {
|
||||
unawaited(_inflightFetches.remove(key));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A streaming in memory cache entry
|
||||
class _CacheEntry<V> {
|
||||
V? _value;
|
||||
int refCount = 0;
|
||||
|
||||
final StreamController<V> updateStreamController = StreamController.broadcast();
|
||||
|
||||
/// The current value in the cache
|
||||
V? get value => _value;
|
||||
|
||||
/// Set the current cache value, sending it to all subscribers
|
||||
set value(V newValue) {
|
||||
_value = newValue;
|
||||
updateStreamController.add(newValue);
|
||||
}
|
||||
|
||||
/// Increments the reference count of the entry
|
||||
_CacheEntry<V> ref() {
|
||||
refCount += 1;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Decrements the reference count of the entry. If there are no active references, closes the update stream
|
||||
_CacheEntry<V> deref() {
|
||||
if (refCount == 0) {
|
||||
unawaited(updateStreamController.close());
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
refCount -= 1;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ dev_dependencies:
|
|||
flutter_lints: ^5.0.0
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mocktail: ^1.0.5
|
||||
|
||||
# The generated openapi client depends back on immich_mobile, so resolving
|
||||
# this package pulls in the app's dependency graph. Pub only honors the root
|
||||
|
|
|
|||
148
mobile/packages/data/test/store/util/stream_cache_test.dart
Normal file
148
mobile/packages/data/test/store/util/stream_cache_test.dart
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_data/store/util/stream_cache.dart';
|
||||
|
||||
void main() {
|
||||
late int fetches;
|
||||
late StreamCache<String, List<String>> cache;
|
||||
|
||||
setUp(() {
|
||||
fetches = 0;
|
||||
cache = StreamCache(fetch: (key) async => ['$key-v${++fetches}']);
|
||||
addTearDown(cache.dispose);
|
||||
});
|
||||
|
||||
/// Subscribes and collects every emission
|
||||
List<List<String>> subscribe(String key, {bool force = false}) {
|
||||
final emissions = <List<String>>[];
|
||||
final subscription = cache.get(key, force: force).listen(emissions.add);
|
||||
addTearDown(subscription.cancel);
|
||||
return emissions;
|
||||
}
|
||||
|
||||
test('fetches and emits on first subscribe', () async {
|
||||
await expectLater(cache.get('a'), emits(['a-v1']));
|
||||
});
|
||||
|
||||
test('a second subscriber is served from the cache without a fetch', () async {
|
||||
subscribe('a');
|
||||
await pumpEventQueue();
|
||||
|
||||
await expectLater(cache.get('a'), emits(['a-v1']));
|
||||
|
||||
expect(fetches, 1);
|
||||
});
|
||||
|
||||
test('force skips the cached emission and always fetches', () async {
|
||||
subscribe('a');
|
||||
await pumpEventQueue();
|
||||
|
||||
await expectLater(cache.get('a', force: true), emits(['a-v2']));
|
||||
|
||||
expect(fetches, 2);
|
||||
});
|
||||
|
||||
test('keys are cached independently', () async {
|
||||
await expectLater(cache.get('a'), emits(['a-v1']));
|
||||
await expectLater(cache.get('b'), emits(['b-v2']));
|
||||
});
|
||||
|
||||
test('concurrent first subscribers share a single fetch', () async {
|
||||
final completer = Completer<List<String>>();
|
||||
cache = StreamCache(
|
||||
fetch: (key) {
|
||||
fetches++;
|
||||
return completer.future;
|
||||
},
|
||||
);
|
||||
addTearDown(cache.dispose);
|
||||
final first = subscribe('a');
|
||||
final second = subscribe('a');
|
||||
|
||||
completer.complete(['shared']);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(first, [
|
||||
['shared'],
|
||||
]);
|
||||
expect(first, second);
|
||||
expect(fetches, 1);
|
||||
});
|
||||
|
||||
test('a failed fetch is a stream error and the stream survives', () async {
|
||||
var fail = true;
|
||||
cache = StreamCache(fetch: (key) => fail ? Future.error(StateError('down')) : Future.value(['recovered']));
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
final events = <Object>[];
|
||||
final subscription = cache.get('a').listen(events.add, onError: (Object error) => events.add('error'));
|
||||
addTearDown(subscription.cancel);
|
||||
await pumpEventQueue();
|
||||
expect(events, ['error']);
|
||||
|
||||
fail = false;
|
||||
subscribe('a', force: true);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(events, [
|
||||
'error',
|
||||
['recovered'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the cache is dropped when the last subscriber cancels', () async {
|
||||
final subscription = cache.get('a').listen((_) {});
|
||||
await pumpEventQueue();
|
||||
await subscription.cancel();
|
||||
|
||||
await expectLater(cache.get('a'), emits(['a-v2']));
|
||||
|
||||
expect(fetches, 2);
|
||||
});
|
||||
|
||||
group('update', () {
|
||||
test('transforms and publishes every matching cached value', () async {
|
||||
final a = subscribe('a');
|
||||
final b = subscribe('b');
|
||||
final c = subscribe('c');
|
||||
await pumpEventQueue();
|
||||
|
||||
cache.update((key) => key != 'c', (value) => [...value, 'patched']);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(a.last, ['a-v1', 'patched']);
|
||||
expect(b.last, ['b-v2', 'patched']);
|
||||
expect(c.last, ['c-v3']);
|
||||
});
|
||||
|
||||
test('publishes nothing when the transform returns the identical value', () async {
|
||||
final a = subscribe('a');
|
||||
await pumpEventQueue();
|
||||
|
||||
cache.update((_) => true, (value) => value);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(a, hasLength(1));
|
||||
});
|
||||
|
||||
test('does not touch keys that have no cached value yet', () {
|
||||
cache.update((_) => true, (value) => [...value, 'patched']);
|
||||
// No cached values exist; nothing to transform and nothing thrown.
|
||||
expect(fetches, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('dispose ends every subscriber stream', () async {
|
||||
var done = false;
|
||||
final subscription = cache.get('a').listen((_) {});
|
||||
subscription.onDone(() => done = true);
|
||||
addTearDown(subscription.cancel);
|
||||
await pumpEventQueue();
|
||||
|
||||
await cache.dispose();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(done, isTrue);
|
||||
});
|
||||
}
|
||||
144
mobile/test/providers/activity_provider_test.dart
Normal file
144
mobile/test/providers/activity_provider_test.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_data/data_controller.dart';
|
||||
import 'package:immich_data/model/activity.dart';
|
||||
import 'package:immich_data/model/user/user.dart';
|
||||
import 'package:immich_data/store/activity.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/data_store.dart';
|
||||
import 'package:immich_mobile/store/activity.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockDataController extends Mock implements DataController {}
|
||||
|
||||
class MockActivityService extends Mock implements ActivityService {}
|
||||
|
||||
void main() {
|
||||
late ProviderContainer container;
|
||||
late MockActivityService service;
|
||||
late StreamController<List<Activity>> albumScopedStream;
|
||||
late StreamController<List<Activity>> assetScopedStream;
|
||||
|
||||
const albumId = 'album-1';
|
||||
const assetId = 'asset-1';
|
||||
const albumScoped = (albumId, null);
|
||||
const assetScoped = (albumId, assetId);
|
||||
|
||||
final user = UserDto(id: 'user-1', email: 'user@test.com', name: 'User', profileChangedAt: DateTime.utc(2025));
|
||||
|
||||
Activity activity(String id, {String? assetId, ActivityType type = ActivityType.comment, String? comment}) =>
|
||||
Activity(id: id, assetId: assetId, comment: comment, createdAt: DateTime.utc(2025), type: type, user: user);
|
||||
|
||||
Future<void> pumpBothScopes() async {
|
||||
container.listen(albumActivityProvider(assetScoped), (_, _) {});
|
||||
container.listen(albumActivityProvider(albumScoped), (_, _) {});
|
||||
albumScopedStream.add([]);
|
||||
assetScopedStream.add([]);
|
||||
await container.read(albumActivityProvider(assetScoped).future);
|
||||
await container.read(albumActivityProvider(albumScoped).future);
|
||||
}
|
||||
|
||||
List<String> idsIn((String, String?) scope) =>
|
||||
container.read(albumActivityProvider(scope)).requireValue.map((a) => a.id).toList();
|
||||
|
||||
setUp(() {
|
||||
service = MockActivityService();
|
||||
albumScopedStream = StreamController<List<Activity>>.broadcast();
|
||||
assetScopedStream = StreamController<List<Activity>>.broadcast();
|
||||
addTearDown(albumScopedStream.close);
|
||||
addTearDown(assetScopedStream.close);
|
||||
when(() => service.getAll(albumId, assetId: null, force: true)).thenAnswer((_) => albumScopedStream.stream);
|
||||
when(() => service.getAll(albumId, assetId: assetId, force: true)).thenAnswer((_) => assetScopedStream.stream);
|
||||
|
||||
final controller = MockDataController();
|
||||
when(() => controller.activities).thenReturn(service);
|
||||
|
||||
container = ProviderContainer(overrides: [Store.overrideWithValue(controller)]);
|
||||
addTearDown(container.dispose);
|
||||
});
|
||||
|
||||
group('build', () {
|
||||
test('album/asset scopes get their own views', () async {
|
||||
container.listen(albumActivityProvider(assetScoped), (_, _) {});
|
||||
container.listen(albumActivityProvider(albumScoped), (_, _) {});
|
||||
albumScopedStream.add([activity('c1'), activity('c2', assetId: assetId)]);
|
||||
assetScopedStream.add([activity('c2', assetId: assetId)]);
|
||||
|
||||
expect(await container.read(albumActivityProvider(albumScoped).future), hasLength(2));
|
||||
expect(await container.read(albumActivityProvider(assetScoped).future), hasLength(1));
|
||||
});
|
||||
|
||||
test('errors become an empty list', () async {
|
||||
container.listen(albumActivityProvider(albumScoped), (_, _) {});
|
||||
albumScopedStream.addError(Exception('network down'));
|
||||
|
||||
expect(await container.read(albumActivityProvider(albumScoped).future), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('new events get pushed to Riverpod', () async {
|
||||
await pumpBothScopes();
|
||||
|
||||
final like = activity('l1', assetId: assetId, type: ActivityType.like);
|
||||
albumScopedStream.add([like]);
|
||||
assetScopedStream.add([like]);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(idsIn(albumScoped), ['l1']);
|
||||
expect(idsIn(assetScoped), ['l1']);
|
||||
});
|
||||
|
||||
group('mutations', () {
|
||||
test('addLike calls API', () async {
|
||||
final like = activity('l1', assetId: assetId, type: ActivityType.like);
|
||||
when(() => service.addLike(albumId, assetId: assetId)).thenAnswer((_) async => like);
|
||||
await pumpBothScopes();
|
||||
|
||||
await container.read(albumActivityProvider(assetScoped).notifier).addLike();
|
||||
|
||||
verify(() => service.addLike(albumId, assetId: assetId)).called(1);
|
||||
});
|
||||
|
||||
test('addComment calls API', () async {
|
||||
final comment = activity('c1', assetId: assetId, comment: 'nice');
|
||||
when(() => service.addComment(albumId, 'nice', assetId: assetId)).thenAnswer((_) async => comment);
|
||||
await pumpBothScopes();
|
||||
|
||||
await container.read(albumActivityProvider(assetScoped).notifier).addComment('nice');
|
||||
|
||||
verify(() => service.addComment(albumId, 'nice', assetId: assetId)).called(1);
|
||||
});
|
||||
|
||||
test('removeActivity calls API', () async {
|
||||
when(() => service.remove(albumId, 'c1')).thenAnswer((_) async {});
|
||||
await pumpBothScopes();
|
||||
|
||||
await container.read(albumActivityProvider(assetScoped).notifier).removeActivity('c1');
|
||||
|
||||
verify(() => service.remove(albumId, 'c1')).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('failed mutations', () {
|
||||
test('addLike logs error', () async {
|
||||
when(() => service.addLike(albumId, assetId: assetId)).thenAnswer((_) => Future.error(Exception('rejected')));
|
||||
await pumpBothScopes();
|
||||
|
||||
await container.read(albumActivityProvider(assetScoped).notifier).addLike();
|
||||
|
||||
expect(idsIn(assetScoped), isEmpty);
|
||||
expect(idsIn(albumScoped), isEmpty);
|
||||
});
|
||||
|
||||
test('removeActivity logs error', () async {
|
||||
when(() => service.remove(albumId, 'c1')).thenAnswer((_) => Future.error(Exception('rejected')));
|
||||
await pumpBothScopes();
|
||||
|
||||
await container.read(albumActivityProvider(assetScoped).notifier).removeActivity('c1');
|
||||
|
||||
expect(idsIn(assetScoped), isEmpty);
|
||||
expect(idsIn(albumScoped), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue