fix(mobile): Keep asset viewer controls hidden if they were hidden before zooming. (#31832)

This commit is contained in:
Dag Stuan
2026-10-16 20:51:28 +03:00
committed by Codex
parent 9e89e65d23
commit 92ab6e9c41
2 changed files with 238 additions and 1 deletions
@@ -51,6 +51,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
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<AssetPage> {
return;
}
_restoreControlsAfterZoom = false;
final tapToNavigate = ref.read(appConfigProvider).viewer.tapToNavigate;
if (!tapToNavigate) {
_viewer.toggleControls();
@@ -299,14 +302,17 @@ class _AssetPageState extends ConsumerState<AssetPage> {
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) {
@@ -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<PhotoView>(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<PhotoView>(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<PhotoView>(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<PhotoView>(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);
});
}