mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
fix: current viewer asset reactivity (#29282)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
f29f86542c
commit
9d6c219276
5 changed files with 168 additions and 21 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
|
|
@ -77,18 +79,17 @@ class AssetViewerState {
|
|||
}
|
||||
|
||||
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
StreamSubscription<BaseAsset?>? _assetSubscription;
|
||||
|
||||
@override
|
||||
AssetViewerState build() {
|
||||
ref.listen(_watchedCurrentAssetProvider, (_, next) {
|
||||
final updated = next.valueOrNull;
|
||||
if (updated != null) {
|
||||
state = state.copyWith(currentAsset: updated);
|
||||
}
|
||||
});
|
||||
ref.onDispose(() => _assetSubscription?.cancel());
|
||||
return const AssetViewerState();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_assetSubscription?.cancel();
|
||||
_assetSubscription = null;
|
||||
state = const AssetViewerState();
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +98,16 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||
return;
|
||||
}
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0, showingOcr: false);
|
||||
_watchCurrentAsset(asset);
|
||||
}
|
||||
|
||||
void _watchCurrentAsset(BaseAsset asset) {
|
||||
_assetSubscription?.cancel();
|
||||
_assetSubscription = ref.read(assetServiceProvider).watchAsset(asset).listen((updated) {
|
||||
if (updated != null) {
|
||||
state = state.copyWith(currentAsset: updated);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setOpacity(double opacity) {
|
||||
|
|
@ -150,12 +161,3 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||
}
|
||||
|
||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||
|
||||
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
|
||||
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (asset == null) {
|
||||
return const Stream.empty();
|
||||
}
|
||||
return ref.read(assetServiceProvider).watchAsset(asset);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/partner.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
|
|
@ -16,4 +17,6 @@ class MockAppSettingsService extends Mock implements AppSettingsService {}
|
|||
|
||||
class MockPartnerService extends Mock implements PartnerService {}
|
||||
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../domain/service.mock.dart';
|
||||
import '../../unit/factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
late ProviderContainer container;
|
||||
late MockAssetService assetService;
|
||||
|
||||
setUpAll(() => registerFallbackValue(RemoteAssetFactory.create()));
|
||||
|
||||
setUp(() {
|
||||
assetService = MockAssetService();
|
||||
when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty());
|
||||
|
||||
container = ProviderContainer(overrides: [assetServiceProvider.overrideWithValue(assetService)]);
|
||||
addTearDown(container.dispose);
|
||||
});
|
||||
|
||||
group('AssetViewerStateNotifier asset watching', () {
|
||||
test('propagates stream updates for the current asset into state', () async {
|
||||
final controller = StreamController<BaseAsset?>();
|
||||
addTearDown(controller.close);
|
||||
final asset = RemoteAssetFactory.create();
|
||||
when(() => assetService.watchAsset(asset)).thenAnswer((_) => controller.stream);
|
||||
|
||||
final notifier = container.read(assetViewerProvider.notifier);
|
||||
notifier.setAsset(asset);
|
||||
|
||||
final updated = asset.copyWith(isFavorite: true);
|
||||
controller.add(updated);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(container.read(assetViewerProvider).currentAsset, updated);
|
||||
});
|
||||
|
||||
test('ignores null stream emissions', () async {
|
||||
final controller = StreamController<BaseAsset?>();
|
||||
addTearDown(controller.close);
|
||||
final asset = RemoteAssetFactory.create();
|
||||
when(() => assetService.watchAsset(asset)).thenAnswer((_) => controller.stream);
|
||||
|
||||
container.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
|
||||
controller.add(null);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(container.read(assetViewerProvider).currentAsset, asset);
|
||||
});
|
||||
|
||||
test('reset cancels the subscription so later emissions are dropped', () async {
|
||||
final controller = StreamController<BaseAsset?>();
|
||||
addTearDown(controller.close);
|
||||
final asset = RemoteAssetFactory.create();
|
||||
when(() => assetService.watchAsset(asset)).thenAnswer((_) => controller.stream);
|
||||
|
||||
final notifier = container.read(assetViewerProvider.notifier);
|
||||
notifier.setAsset(asset);
|
||||
notifier.reset();
|
||||
|
||||
controller.add(RemoteAssetFactory.create(isFavorite: true));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(container.read(assetViewerProvider).currentAsset, isNull);
|
||||
});
|
||||
|
||||
test('setAsset switches the current asset, cancels the previous watch and listens to the new one', () async {
|
||||
final first = StreamController<BaseAsset?>();
|
||||
final second = StreamController<BaseAsset?>();
|
||||
addTearDown(first.close);
|
||||
addTearDown(second.close);
|
||||
|
||||
final assetOne = RemoteAssetFactory.create();
|
||||
final assetTwo = RemoteAssetFactory.create();
|
||||
when(() => assetService.watchAsset(assetOne)).thenAnswer((_) => first.stream);
|
||||
when(() => assetService.watchAsset(assetTwo)).thenAnswer((_) => second.stream);
|
||||
|
||||
final notifier = container.read(assetViewerProvider.notifier);
|
||||
notifier.setAsset(assetOne);
|
||||
expect(container.read(assetViewerProvider).currentAsset, assetOne);
|
||||
|
||||
// Updates to the first asset propagate into state.
|
||||
final updatedOne = assetOne.copyWith(visibility: .archive);
|
||||
first.add(updatedOne);
|
||||
await pumpEventQueue();
|
||||
expect(container.read(assetViewerProvider).currentAsset, updatedOne);
|
||||
|
||||
// Switch to new asset
|
||||
notifier.setAsset(assetTwo);
|
||||
expect(container.read(assetViewerProvider).currentAsset, assetTwo);
|
||||
|
||||
// The previous watch is cancelled: stale emissions from the first stream are dropped.
|
||||
first.add(assetOne.copyWith(isFavorite: true));
|
||||
await pumpEventQueue();
|
||||
expect(container.read(assetViewerProvider).currentAsset, assetTwo);
|
||||
|
||||
// The new asset is watched instead: its emissions propagate into state.
|
||||
final updatedTwo = assetTwo.copyWith(isFavorite: true);
|
||||
second.add(updatedTwo);
|
||||
await pumpEventQueue();
|
||||
expect(container.read(assetViewerProvider).currentAsset, updatedTwo);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -5,19 +5,21 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
|
|
@ -41,6 +43,11 @@ class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
|
|||
|
||||
class FakeTimelineService extends Fake implements TimelineService {}
|
||||
|
||||
class FakeAssetService extends Fake implements AssetService {
|
||||
@override
|
||||
Stream<BaseAsset?> watchAsset(BaseAsset asset) => const Stream.empty();
|
||||
}
|
||||
|
||||
class TestViewIntentService extends ViewIntentService {
|
||||
ViewIntentPayload? consumedAttachment;
|
||||
int cleanupStaleTempFilesCalls = 0;
|
||||
|
|
@ -129,6 +136,7 @@ void main() {
|
|||
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
|
||||
return authNotifier;
|
||||
}),
|
||||
assetServiceProvider.overrideWithValue(FakeAssetService()),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -195,9 +203,9 @@ void main() {
|
|||
|
||||
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
|
||||
viewIntentService.consumedAttachment = payload;
|
||||
when(() => resolver.resolve(payload)).thenAnswer(
|
||||
(_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService),
|
||||
);
|
||||
when(
|
||||
() => resolver.resolve(payload),
|
||||
).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService));
|
||||
|
||||
unawaited(handler.onAppResumed());
|
||||
await tester.pump();
|
||||
|
|
|
|||
23
mobile/test/unit/factories/remote_asset_factory.dart
Normal file
23
mobile/test/unit/factories/remote_asset_factory.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
import '../../utils.dart';
|
||||
|
||||
class RemoteAssetFactory {
|
||||
const RemoteAssetFactory();
|
||||
|
||||
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
return RemoteAsset(
|
||||
id: id,
|
||||
name: name ?? 'remote_$id.jpg',
|
||||
ownerId: TestUtils.uuid(ownerId),
|
||||
checksum: 'checksum-$id',
|
||||
type: .image,
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
isFavorite: isFavorite,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue