diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 09718b562..74927dad2 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -51,6 +51,7 @@ class _AssetPageState extends ConsumerState { bool _showingDetails = false; bool _isZoomed = false; + bool _restoreControlsAfterZoom = false; // Frozen during dismiss drag + settle to prevent widget tree swap mid-animation. bool _wasMotionPlayingAtDismiss = false; bool _isDismissAnimating = false; @@ -268,6 +269,8 @@ class _AssetPageState extends ConsumerState { return; } + _restoreControlsAfterZoom = false; + final tapToNavigate = ref.read(appConfigProvider).viewer.tapToNavigate; if (!tapToNavigate) { _viewer.toggleControls(); @@ -299,14 +302,17 @@ class _AssetPageState extends ConsumerState { if (scaleState != PhotoViewScaleState.initial) { if (_dragStart == null) { + _restoreControlsAfterZoom |= ref.read(assetViewerProvider).showingControls; _viewer.setControls(false); } return; } - if (!_showingDetails) { + if (!_showingDetails && _restoreControlsAfterZoom) { _viewer.setControls(true); } + + _restoreControlsAfterZoom = false; } void _listenForScaleBoundaries(PhotoViewControllerBase? controller) { diff --git a/mobile/test/presentation/widgets/asset_viewer/asset_page_controls_test.dart b/mobile/test/presentation/widgets/asset_viewer/asset_page_controls_test.dart new file mode 100644 index 000000000..93e96d3e9 --- /dev/null +++ b/mobile/test/presentation/widgets/asset_viewer/asset_page_controls_test.dart @@ -0,0 +1,231 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/generated/codegen_loader.g.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +import '../../../fixtures/asset.stub.dart'; +import '../../../unit/presentation/presentation_context.dart'; + +class _SingleAssetTimelineService extends TimelineService { + final BaseAsset asset; + + _SingleAssetTimelineService(this.asset) + : super(( + assetSource: (_, _) async => [], + bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]), + origin: TimelineOrigin.main, + )); + + @override + int get totalAssets => 1; + + @override + BaseAsset? getAssetSafe(int index) => index == 0 ? asset : null; +} + +void main() { + late PresentationContext context; + + setUp(() async { + context = await PresentationContext.create(); + }); + + tearDown(() async { + await context.dispose(); + }); + + testWidgets('keeps controls hidden after zooming back out', (tester) async { + final timeline = _SingleAssetTimelineService(LocalAssetStub.image1); + final container = ProviderContainer( + overrides: [...context.overrides, timelineServiceProvider.overrideWithValue(timeline)], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: EasyLocalization( + supportedLocales: locales.values.toList(), + path: translationsPath, + startLocale: locales.values.first, + fallbackLocale: locales.values.first, + saveLocale: false, + useFallbackTranslations: true, + assetLoader: const CodegenLoader(), + child: Builder( + builder: (context) => MaterialApp( + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: const Material(child: AssetPage(index: 0, heroOffset: 0)), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 600)); + + final controls = container.read(assetViewerProvider.notifier); + controls.setControls(false); + + final photoView = tester.widget(find.byType(PhotoView)); + photoView.scaleStateChangedCallback!(PhotoViewScaleState.zoomedIn); + await tester.pump(); + expect(container.read(assetViewerProvider).isZoomed, isTrue); + + photoView.scaleStateChangedCallback!(PhotoViewScaleState.initial); + await tester.pump(); + + expect(container.read(assetViewerProvider).isZoomed, isFalse); + expect(container.read(assetViewerProvider).showingControls, isFalse); + }); + + testWidgets('keeps controls hidden after pinching out past the fitted size', (tester) async { + final timeline = _SingleAssetTimelineService(LocalAssetStub.image1); + final container = ProviderContainer( + overrides: [...context.overrides, timelineServiceProvider.overrideWithValue(timeline)], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: EasyLocalization( + supportedLocales: locales.values.toList(), + path: translationsPath, + startLocale: locales.values.first, + fallbackLocale: locales.values.first, + saveLocale: false, + useFallbackTranslations: true, + assetLoader: const CodegenLoader(), + child: Builder( + builder: (context) => MaterialApp( + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: const Material(child: AssetPage(index: 0, heroOffset: 0)), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 600)); + + final controls = container.read(assetViewerProvider.notifier); + controls.setControls(false); + + final photoView = tester.widget(find.byType(PhotoView)); + + // Pinching smaller than the fitted size reports zoomedOut, not isZoomed, + // but it still hides controls and must not restore a stale "showing" value. + photoView.scaleStateChangedCallback!(PhotoViewScaleState.zoomedOut); + await tester.pump(); + expect(container.read(assetViewerProvider).isZoomed, isFalse); + + photoView.scaleStateChangedCallback!(PhotoViewScaleState.initial); + await tester.pump(); + + expect(container.read(assetViewerProvider).showingControls, isFalse); + }); + + testWidgets('keeps controls hidden after double-tapping back to original size', (tester) async { + final timeline = _SingleAssetTimelineService(LocalAssetStub.image1); + final container = ProviderContainer( + overrides: [...context.overrides, timelineServiceProvider.overrideWithValue(timeline)], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: EasyLocalization( + supportedLocales: locales.values.toList(), + path: translationsPath, + startLocale: locales.values.first, + fallbackLocale: locales.values.first, + saveLocale: false, + useFallbackTranslations: true, + assetLoader: const CodegenLoader(), + child: Builder( + builder: (context) => MaterialApp( + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: const Material(child: AssetPage(index: 0, heroOffset: 0)), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 600)); + + final controls = container.read(assetViewerProvider.notifier); + controls.setControls(false); + + final photoView = tester.widget(find.byType(PhotoView)); + + // Double-tap cycles through originalSize before returning to initial. + photoView.scaleStateChangedCallback!(PhotoViewScaleState.originalSize); + await tester.pump(); + + photoView.scaleStateChangedCallback!(PhotoViewScaleState.initial); + await tester.pump(); + + expect(container.read(assetViewerProvider).showingControls, isFalse); + }); + + testWidgets('restores controls after zoom if they were showing beforehand', (tester) async { + final timeline = _SingleAssetTimelineService(LocalAssetStub.image1); + final container = ProviderContainer( + overrides: [...context.overrides, timelineServiceProvider.overrideWithValue(timeline)], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: EasyLocalization( + supportedLocales: locales.values.toList(), + path: translationsPath, + startLocale: locales.values.first, + fallbackLocale: locales.values.first, + saveLocale: false, + useFallbackTranslations: true, + assetLoader: const CodegenLoader(), + child: Builder( + builder: (context) => MaterialApp( + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: const Material(child: AssetPage(index: 0, heroOffset: 0)), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 600)); + + // Controls start out showing (the default), so zooming and returning to + // the fitted size should bring them back. + expect(container.read(assetViewerProvider).showingControls, isTrue); + + final photoView = tester.widget(find.byType(PhotoView)); + photoView.scaleStateChangedCallback!(PhotoViewScaleState.zoomedIn); + await tester.pump(); + expect(container.read(assetViewerProvider).showingControls, isFalse); + + photoView.scaleStateChangedCallback!(PhotoViewScaleState.initial); + await tester.pump(); + + expect(container.read(assetViewerProvider).showingControls, isTrue); + }); +}