diff --git a/mobile/lib/presentation/pages/edit/drift_edit.page.dart b/mobile/lib/presentation/pages/edit/drift_edit.page.dart index 2e2d39e386..98d21db319 100644 --- a/mobile/lib/presentation/pages/edit/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/edit/drift_edit.page.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/constants/aspect_ratios.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/editor.utils.dart'; @@ -117,7 +118,9 @@ class _DriftEditImagePageState extends ConsumerState with Ti bottom: false, child: Column( children: [ - Expanded(child: _EditorPreview(image: widget.image)), + Expanded( + child: ProgressiveImageGuard(child: _EditorPreview(image: widget.image)), + ), AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeInOut, @@ -427,7 +430,9 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi padding: const EdgeInsets.all(10), width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight, height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth, - child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white), + child: ProgressiveImageGuard( + child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white), + ), ), ), ), diff --git a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart index 3fb32b7d93..2ea7ba2f19 100644 --- a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart +++ b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart @@ -10,6 +10,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; @@ -170,7 +171,9 @@ class _ProfilePictureCropPageState extends ConsumerState ], ), child: ClipRRect( - child: CropImage(controller: _cropController, image: image, gridColor: Colors.white), + child: ProgressiveImageGuard( + child: CropImage(controller: _cropController, image: image, gridColor: Colors.white), + ), ), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 87f97d5754..9a63233ad6 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; @@ -301,7 +302,8 @@ class _NativeVideoViewerState extends ConsumerState with Widg return IgnorePointer( child: Stack( children: [ - if (!_isVideoReady || widget.asset.isMotionPhoto || isCasting) Center(child: widget.image), + if (!_isVideoReady || widget.asset.isMotionPhoto || isCasting) + Center(child: ProgressiveImageGuard(child: widget.image)), if (!isCasting) ...[ Visibility.maintain( visible: _isVideoReady, diff --git a/mobile/lib/presentation/widgets/images/full_image.widget.dart b/mobile/lib/presentation/widgets/images/full_image.widget.dart index 77ea996b89..54950187ae 100644 --- a/mobile/lib/presentation/widgets/images/full_image.widget.dart +++ b/mobile/lib/presentation/widgets/images/full_image.widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:octo_image/octo_image.dart'; @@ -21,18 +22,20 @@ class FullImage extends StatelessWidget { @override Widget build(BuildContext context) { final provider = getFullImageProvider(asset, size: size); - return OctoImage( - fadeInDuration: const Duration(milliseconds: 0), - fadeOutDuration: const Duration(milliseconds: 100), - placeholderBuilder: placeholder != null ? (_) => placeholder! : null, - image: provider, - width: size.width, - height: size.height, - fit: fit, - errorBuilder: (context, error, stackTrace) { - provider.evict(); - return const Icon(Icons.image_not_supported_outlined, size: 32); - }, + return ProgressiveImageGuard( + child: OctoImage( + fadeInDuration: const Duration(milliseconds: 0), + fadeOutDuration: const Duration(milliseconds: 100), + placeholderBuilder: placeholder != null ? (_) => placeholder! : null, + image: provider, + width: size.width, + height: size.height, + fit: fit, + errorBuilder: (context, error, stackTrace) { + provider.evict(); + return const Icon(Icons.image_not_supported_outlined, size: 32); + }, + ), ); } } diff --git a/mobile/lib/presentation/widgets/images/progressive_image_guard.dart b/mobile/lib/presentation/widgets/images/progressive_image_guard.dart new file mode 100644 index 0000000000..31fb082d13 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/progressive_image_guard.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; + +/// Keeps progressive image streams delivering frames when the platform +/// requests reduced animations. +/// +/// The full-image providers emit multiple, increasingly higher-quality images +/// (thumbnail -> preview -> original) as successive frames of a single image +/// stream. Since Flutter 3.44, [Image] stops listening to its stream after the +/// first frame when [MediaQueryData.disableAnimations] is set (e.g. Android's +/// "Remove animations" accessibility setting or an animator duration scale of +/// zero), which would freeze these images at their low-res first frame. +/// Photos are not animations, so clear the flag for this subtree. +class ProgressiveImageGuard extends StatelessWidget { + const ProgressiveImageGuard({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final disableAnimations = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + if (!disableAnimations) { + return child; + } + return MediaQuery(data: MediaQuery.of(context).copyWith(disableAnimations: false), child: child); + } +} diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index 44b547a5f1..dcc16baa36 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -404,26 +405,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic if (_currentAsset != null) Opacity( opacity: _crossFadeAnimation.value, - child: SizedBox( - width: double.infinity, - height: double.infinity, - child: Image( - alignment: Alignment.topRight, - image: getFullImageProvider(_currentAsset!), - fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return Container(); - }, - errorBuilder: (context, error, stackTrace) { - return SizedBox( - width: double.infinity, - height: double.infinity, - child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), - ); - }, + child: ProgressiveImageGuard( + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_currentAsset!), + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), + ); + }, + ), ), ), ), @@ -431,26 +434,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic if (_nextAsset != null) Opacity( opacity: 1.0 - _crossFadeAnimation.value, - child: SizedBox( - width: double.infinity, - height: double.infinity, - child: Image( - alignment: Alignment.topRight, - image: getFullImageProvider(_nextAsset!), - fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return const SizedBox.shrink(); - }, - errorBuilder: (context, error, stackTrace) { - return SizedBox( - width: double.infinity, - height: double.infinity, - child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), - ); - }, + child: ProgressiveImageGuard( + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_nextAsset!), + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return const SizedBox.shrink(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), + ); + }, + ), ), ), ), diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index a2a9d1bdbd..1a4caf8359 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -507,26 +508,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic if (_currentAsset != null) Opacity( opacity: _crossFadeAnimation.value, - child: SizedBox( - width: double.infinity, - height: double.infinity, - child: Image( - alignment: Alignment.topRight, - image: getFullImageProvider(_currentAsset!), - fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return Container(); - }, - errorBuilder: (context, error, stackTrace) { - return SizedBox( - width: double.infinity, - height: double.infinity, - child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), - ); - }, + child: ProgressiveImageGuard( + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_currentAsset!), + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), + ); + }, + ), ), ), ), @@ -534,26 +537,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic if (_nextAsset != null) Opacity( opacity: 1.0 - _crossFadeAnimation.value, - child: SizedBox( - width: double.infinity, - height: double.infinity, - child: Image( - alignment: Alignment.topRight, - image: getFullImageProvider(_nextAsset!), - fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return const SizedBox.shrink(); - }, - errorBuilder: (context, error, stackTrace) { - return SizedBox( - width: double.infinity, - height: double.infinity, - child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), - ); - }, + child: ProgressiveImageGuard( + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_nextAsset!), + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return const SizedBox.shrink(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), + ); + }, + ), ), ), ), diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 2fc136302d..7e0ca636a6 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -483,26 +484,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic if (_currentAsset != null) Opacity( opacity: _crossFadeAnimation.value, - child: SizedBox( - width: double.infinity, - height: double.infinity, - child: Image( - alignment: Alignment.topRight, - image: getFullImageProvider(_currentAsset!), - fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return Container(); - }, - errorBuilder: (context, error, stackTrace) { - return SizedBox( - width: double.infinity, - height: double.infinity, - child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), - ); - }, + child: ProgressiveImageGuard( + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_currentAsset!), + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), + ); + }, + ), ), ), ), @@ -510,26 +513,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic if (_nextAsset != null) Opacity( opacity: 1.0 - _crossFadeAnimation.value, - child: SizedBox( - width: double.infinity, - height: double.infinity, - child: Image( - alignment: Alignment.topRight, - image: getFullImageProvider(_nextAsset!), - fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return const SizedBox.shrink(); - }, - errorBuilder: (context, error, stackTrace) { - return SizedBox( - width: double.infinity, - height: double.infinity, - child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), - ); - }, + child: ProgressiveImageGuard( + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_nextAsset!), + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return const SizedBox.shrink(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), + ); + }, + ), ), ), ), diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index 265feb756e..a48b5cbc75 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart' show PhotoViewScaleState, @@ -438,15 +439,17 @@ class PhotoViewCoreState extends State height: scaleBoundaries.childSize.height * scale, child: widget.customChild!, ) - : Image( - key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null, - image: widget.imageProvider!, - semanticLabel: widget.semanticLabel, - gaplessPlayback: widget.gaplessPlayback ?? false, - filterQuality: widget.filterQuality, - width: scaleBoundaries.childSize.width * scale, - fit: BoxFit.contain, - isAntiAlias: widget.filterQuality == FilterQuality.high, + : ProgressiveImageGuard( + child: Image( + key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null, + image: widget.imageProvider!, + semanticLabel: widget.semanticLabel, + gaplessPlayback: widget.gaplessPlayback ?? false, + filterQuality: widget.filterQuality, + width: scaleBoundaries.childSize.width * scale, + fit: BoxFit.contain, + isAntiAlias: widget.filterQuality == FilterQuality.high, + ), ); } } diff --git a/mobile/test/presentation/widgets/images/progressive_image_test.dart b/mobile/test/presentation/widgets/images/progressive_image_test.dart new file mode 100644 index 0000000000..98e6467645 --- /dev/null +++ b/mobile/test/presentation/widgets/images/progressive_image_test.dart @@ -0,0 +1,296 @@ +// End-to-end tests for progressive image loading (thumbnail -> preview) +// through the real provider/completer pipeline with a mocked platform +// image API. +// +// The "animations are disabled" cases are regression tests for +// https://github.com/immich-app/immich/issues/29727: since Flutter 3.44, +// a paused Image widget stops listening to its stream after the first +// frame, freezing progressive images at the low-res thumbnail. + +import 'dart:async'; +import 'dart:ffi' hide Size; +import 'dart:ui' as ui; + +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/platform/remote_image_api.g.dart'; +import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/utils/cache/custom_image_cache.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +import '../../../test_utils.dart'; + +class _CustomCacheBinding extends AutomatedTestWidgetsFlutterBinding { + @override + ImageCache createImageCache() => CustomImageCache(); +} + +const kThumbSize = 16; +const kPreviewSize = 64; +const kOriginalSize = 128; + +late Uint8List thumbPng; +late Uint8List previewPng; +late Uint8List originalPng; + +final requestedUrls = []; + +Future _pngBytes(int size) async { + final image = await createTestImage(width: size, height: size); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + return data!.buffer.asUint8List(); +} + +void _installRemoteImageApiMock() { + const channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage', + RemoteImageApi.pigeonChannelCodec, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, ( + message, + ) async { + final args = message as List; + final url = args[0] as String; + requestedUrls.add(url); + + final Uint8List bytes; + if (url.contains('size=thumbnail')) { + bytes = thumbPng; + } else if (url.contains('size=preview')) { + bytes = previewPng; + } else { + bytes = originalPng; + } + + final pointer = malloc(bytes.length); + pointer.asTypedList(bytes.length).setAll(0, bytes); + return [ + {'pointer': pointer.address, 'length': bytes.length}, + ]; + }); + + const cancelChannel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest', + RemoteImageApi.pigeonChannelCodec, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler( + cancelChannel, + (message) async => [null], + ); +} + +Future _precacheThumbnail(WidgetTester tester, dynamic asset) async { + await tester.runAsync(() async { + final provider = getThumbnailImageProvider(asset)!; + final completer = Completer(); + final stream = provider.resolve(ImageConfiguration.empty); + final listener = ImageStreamListener((info, _) { + info.dispose(); + if (!completer.isCompleted) { + completer.complete(); + } + }, onError: (e, s) => completer.completeError(e, s)); + stream.addListener(listener); + await completer.future; + stream.removeListener(listener); + }); +} + +Future _settle(WidgetTester tester) async { + for (int i = 0; i < 10; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(); + } +} + +int? _renderedImageWidth(WidgetTester tester) { + final rawImages = tester.widgetList(find.byType(RawImage)).toList(); + return rawImages.isEmpty ? null : rawImages.first.image?.width; +} + +void main() { + _CustomCacheBinding(); + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + TestUtils.init(); + final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false); + await StoreService.I.put(StoreKey.serverEndpoint, 'http://localhost:3000'); + await SettingsRepository.ensureInitialized(db); + }); + + setUp(() { + requestedUrls.clear(); + _installRemoteImageApiMock(); + imageCache.clear(); + imageCache.clearLiveImages(); + }); + + int assetCounter = 0; + + Future setUpAsset(WidgetTester tester) async { + await tester.runAsync(() async { + thumbPng = await _pngBytes(kThumbSize); + previewPng = await _pngBytes(kPreviewSize); + originalPng = await _pngBytes(kOriginalSize); + }); + return TestUtils.createRemoteAsset(id: 'asset-${++assetCounter}', width: 3000, height: 4000); + } + + testWidgets('FullImage shows preview (thumbnail pre-cached by timeline)', (tester) async { + final asset = await setUpAsset(tester); + await _precacheThumbnail(tester, asset); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox(width: 400, height: 800, child: FullImage(asset, size: const Size(400, 800))), + ), + ), + ); + + await _settle(tester); + expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls'); + }); + + testWidgets('FullImage shows preview (thumbnail not cached)', (tester) async { + final asset = await setUpAsset(tester); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox(width: 400, height: 800, child: FullImage(asset, size: const Size(400, 800))), + ), + ), + ); + + await _settle(tester); + expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls'); + }); + + testWidgets('PhotoView shows preview (thumbnail pre-cached by timeline)', (tester) async { + final asset = await setUpAsset(tester); + await _precacheThumbnail(tester, asset); + + final provider = getFullImageProvider(asset, size: const Size(400, 800)); + await tester.pumpWidget( + MaterialApp( + home: PhotoView( + imageProvider: provider, + index: 0, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + tightMode: true, + enablePanAlways: true, + ), + ), + ); + + await _settle(tester); + expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls'); + }); + + testWidgets('PhotoView shows preview (thumbnail not cached)', (tester) async { + final asset = await setUpAsset(tester); + + final provider = getFullImageProvider(asset, size: const Size(400, 800)); + await tester.pumpWidget( + MaterialApp( + home: PhotoView( + imageProvider: provider, + index: 0, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + tightMode: true, + enablePanAlways: true, + ), + ), + ); + + await _settle(tester); + expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls'); + }); + + testWidgets('FullImage shows preview when animations are disabled (issue #29727)', (tester) async { + final asset = await setUpAsset(tester); + await _precacheThumbnail(tester, asset); + + // Android "remove animations" / animator duration scale 0 sets + // MediaQuery.disableAnimations, which pauses Image stream listening + // after the first frame on Flutter 3.44+. + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: Center( + child: SizedBox(width: 400, height: 800, child: FullImage(asset, size: const Size(400, 800))), + ), + ), + ), + ); + + await _settle(tester); + expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls'); + }); + + testWidgets('PhotoView shows preview when animations are disabled (issue #29727)', (tester) async { + final asset = await setUpAsset(tester); + await _precacheThumbnail(tester, asset); + + final provider = getFullImageProvider(asset, size: const Size(400, 800)); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: PhotoView( + imageProvider: provider, + index: 0, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + tightMode: true, + enablePanAlways: true, + ), + ), + ), + ); + + await _settle(tester); + expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls'); + }); + + testWidgets('memory page pattern: precacheImage then FullImage', (tester) async { + final asset = await setUpAsset(tester); + await _precacheThumbnail(tester, asset); + + // The memory page precaches the full image provider before showing the card. + await tester.pumpWidget(MaterialApp(home: Builder(builder: (context) => const SizedBox()))); + final context = tester.element(find.byType(SizedBox)); + final precacheFuture = precacheImage(getFullImageProvider(asset, size: const Size(400, 800)), context); + await tester.pump(); + await tester.runAsync(() => precacheFuture); + // Post-frame callback removes the precache listener. + await tester.pump(); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox(width: 400, height: 800, child: FullImage(asset, size: const Size(400, 800))), + ), + ), + ); + + await _settle(tester); + expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls'); + }); +}