fix: current viewer asset reactivity (#29282)

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong 2026-06-24 00:10:24 +05:30 committed by GitHub
parent f29f86542c
commit 9d6c219276
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 168 additions and 21 deletions

View file

@ -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);
});

View file

@ -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 {}

View file

@ -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);
});
});
}

View file

@ -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();

View 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,
);
}
}