import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; import '../../utils/screen_bound_image.dart'; import '../app_progress_indicator.dart'; import 'gallery_cache.dart'; import 'gallery_item.dart'; /// One image in the gallery: the server preview first, the original on top /// once the user rests on the page or zooms in. class GalleryImagePage extends StatefulWidget { final GalleryItem item; final bool isActive; final ValueListenable rotateSignal; const GalleryImagePage({ required this.item, required this.isActive, required this.rotateSignal, super.key, }); @override State createState() => _GalleryImagePageState(); } class _GalleryImagePageState extends State { // Short enough to feel instant, long enough that flicking through a chat // does not pull every original. static const _restDelay = Duration(milliseconds: 400); static const _fadeDuration = Duration(milliseconds: 200); static const _doubleTapZoom = 2.5; /// Max zoom reaches this many times the image's native pixel density /// (1 image pixel = 1 screen pixel), so sharp photos zoom far in. static const _beyondNativeZoom = 3.0; /// Floor for small images, which would otherwise barely zoom at all. static const _minMaxZoom = 4.0; final PhotoViewController _controller = PhotoViewController(); final PhotoViewScaleStateController _scaleState = PhotoViewScaleStateController(); final List<(ImageStream, ImageStreamListener)> _streams = []; StreamSubscription? _zoomSub; Timer? _restTimer; Timer? _hidePreviewTimer; ImageProvider? _preview; ImageProvider? _original; Size? _imageSize; /// Decoded pixel size of the original, once shown. Size? _originalSize; bool _previewFailed = false; bool _originalFailed = false; bool _loadingOriginal = false; bool _originalReady = false; bool _hidePreview = false; ValueListenable? _progress; late int _rotationsSeen; bool get _isZoomed => switch (_scaleState.scaleState) { PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering || PhotoViewScaleState.originalSize => true, PhotoViewScaleState.initial || PhotoViewScaleState.zoomedOut => false, }; @override void initState() { super.initState(); _rotationsSeen = widget.rotateSignal.value; widget.rotateSignal.addListener(_onRotate); GalleryOriginals.instance.addListener(_onFlightsChanged); _progress = GalleryOriginals.instance.progressOf(widget.item); // Pinch zoom only updates the scale state silently, so listen to the // controller instead of scaleStateChangedCallback. _zoomSub = _controller.outputStateStream.listen((_) { if (_isZoomed) _loadOriginal(); }); } @override void didChangeDependencies() { super.didChangeDependencies(); if (_preview != null) return; final preview = galleryPreviewProvider(context, widget.item); _preview = preview; _listen( preview, onImage: _adoptSize, onError: () { setState(() => _previewFailed = true); _loadOriginal(); }, ); unawaited(_useCachedOriginal()); } @override void didUpdateWidget(covariant GalleryImagePage oldWidget) { super.didUpdateWidget(oldWidget); if (widget.isActive == oldWidget.isActive) return; if (widget.isActive) { _scheduleRest(); } else { _restTimer?.cancel(); _scaleState.reset(); } } @override void dispose() { widget.rotateSignal.removeListener(_onRotate); GalleryOriginals.instance.removeListener(_onFlightsChanged); _restTimer?.cancel(); _hidePreviewTimer?.cancel(); unawaited(_zoomSub?.cancel()); for (final (stream, listener) in _streams) { stream.removeListener(listener); } _controller.dispose(); _scaleState.dispose(); super.dispose(); } Future _useCachedOriginal() async { final cached = await GalleryOriginals.instance.cached(widget.item); if (!mounted || _original != null) return; if (cached != null) { _setOriginal(cached); } else if (widget.item.needsOriginal) { _loadOriginal(); } else { _scheduleRest(); } } void _scheduleRest() { if (!widget.isActive || _original != null || _originalFailed) return; _restTimer?.cancel(); _restTimer = Timer(_restDelay, _loadOriginal); } void _loadOriginal() { if (_original != null || _loadingOriginal || _originalFailed) return; _restTimer?.cancel(); _loadingOriginal = true; GalleryOriginals.instance .load(widget.item) .then( (file) { if (mounted) _setOriginal(file); }, onError: (Object _) { if (!mounted) return; // The preview stays; no automatic retry, or every zoom frame would // start a new request. setState(() { _loadingOriginal = false; _originalFailed = true; }); }, ); } void _setOriginal(File file) { final original = screenBoundImage(context, FileImage(file), scale: 2); setState(() { _original = original; _loadingOriginal = false; }); _listen( original, onImage: (size) { _adoptSize(size); setState(() { _originalReady = true; _originalSize = size; }); // Drop the preview after the fade so transparent images don't show // it through. _hidePreviewTimer = Timer(_fadeDuration, () { if (mounted) setState(() => _hidePreview = true); }); }, onError: () => setState(() { _original = null; _originalFailed = true; }), ); } /// The first resolved image fixes the zoom geometry; the other one is drawn /// into the same box, so swapping preview and original keeps the zoom. void _adoptSize(Size size) { if (_imageSize != null) return; setState(() => _imageSize = size); } void _listen( ImageProvider provider, { required void Function(Size size) onImage, required VoidCallback onError, }) { final stream = provider.resolve(createLocalImageConfiguration(context)); late final ImageStreamListener listener; late final (ImageStream, ImageStreamListener) entry; // A completer throws when a listener is removed twice, so a fired // listener also leaves the list dispose() walks. void detach() { stream.removeListener(listener); _streams.remove(entry); } listener = ImageStreamListener( (info, _) { detach(); if (!mounted) return; onImage( Size(info.image.width.toDouble(), info.image.height.toDouble()), ); }, onError: (_, _) { detach(); if (mounted) onError(); }, ); entry = (stream, listener); _streams.add(entry); stream.addListener(listener); } void _onRotate() { final value = widget.rotateSignal.value; final pending = value - _rotationsSeen; _rotationsSeen = value; if (widget.isActive && pending > 0) { _controller.rotation += pending * pi / 2; } } void _onFlightsChanged() { final progress = GalleryOriginals.instance.progressOf(widget.item); if (progress != _progress && mounted) setState(() => _progress = progress); } /// Double tap toggles between fitted and [_doubleTapZoom]× fitted. static PhotoViewScaleState _scaleCycle(PhotoViewScaleState state) => state == PhotoViewScaleState.initial ? PhotoViewScaleState.originalSize : PhotoViewScaleState.initial; @override Widget build(BuildContext context) { final imageSize = _imageSize; final background = Theme.of(context).colorScheme.surface; if (imageSize == null) { if (_previewFailed && _originalFailed) { return const _ImageUnavailable(); } return const Center(child: AppProgressIndicator.large()); } final preview = _preview; final original = _original; final progress = _progress; return Stack( fit: StackFit.expand, children: [ LayoutBuilder( builder: (context, constraints) { // PhotoView's "original size" is the child box itself; sizing it // to a multiple of the fitted image makes that the double-tap // zoom, independent of preview vs. original resolution. final fitted = applyBoxFit( BoxFit.contain, imageSize, constraints.biggest, ).destination; final childSize = fitted * _doubleTapZoom; // Until the original is in, the preview's resolution bounds the // zoom; it widens once the original arrives. final detail = _originalSize ?? imageSize; final nativeZoom = detail.width / MediaQuery.devicePixelRatioOf(context) / fitted.width; final maxZoom = max(_minMaxZoom, nativeZoom * _beyondNativeZoom); return PhotoView.customChild( controller: _controller, scaleStateController: _scaleState, childSize: childSize, initialScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.contained * maxZoom, scaleStateCycle: _scaleCycle, // A non-none filterQuality makes PhotoView resize only its own // Image instead of transforming — a custom child would never // scale. Quality is set on the images below instead. filterQuality: FilterQuality.none, backgroundDecoration: BoxDecoration(color: background), child: SizedBox.fromSize( size: childSize, child: Stack( fit: StackFit.expand, children: [ if (preview != null && !_previewFailed && !_hidePreview) Image( image: preview, fit: BoxFit.contain, filterQuality: FilterQuality.medium, gaplessPlayback: true, ), if (original != null) AnimatedOpacity( opacity: _originalReady ? 1 : 0, duration: _fadeDuration, child: Image( image: original, fit: BoxFit.contain, filterQuality: FilterQuality.medium, gaplessPlayback: true, ), ), ], ), ), ); }, ), Positioned( right: 16, bottom: 16, child: SafeArea( child: AnimatedSwitcher( duration: _fadeDuration, child: progress == null ? const SizedBox.shrink() : ValueListenableBuilder( valueListenable: progress, builder: (context, value, _) => GalleryProgressBadge(value: value), ), ), ), ), ], ); } } /// Small, unobtrusive ring for a running original download. class GalleryProgressBadge extends StatelessWidget { final double? value; const GalleryProgressBadge({required this.value, super.key}); @override Widget build(BuildContext context) => DecoratedBox( decoration: const BoxDecoration( color: Colors.black45, shape: BoxShape.circle, ), child: Padding( padding: const EdgeInsets.all(6), child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator( value: value, strokeWidth: 2, color: Colors.white, semanticsLabel: 'Original wird geladen', ), ), ), ); } class _ImageUnavailable extends StatelessWidget { const _ImageUnavailable(); @override Widget build(BuildContext context) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.broken_image_outlined, size: 48), const SizedBox(height: 12), Text( 'Bild konnte nicht geladen werden', style: Theme.of(context).textTheme.bodyMedium, ), ], ), ); }