404 lines
12 KiB
Dart
404 lines
12 KiB
Dart
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<int> rotateSignal;
|
||
|
||
const GalleryImagePage({
|
||
required this.item,
|
||
required this.isActive,
|
||
required this.rotateSignal,
|
||
super.key,
|
||
});
|
||
|
||
@override
|
||
State<GalleryImagePage> createState() => _GalleryImagePageState();
|
||
}
|
||
|
||
class _GalleryImagePageState extends State<GalleryImagePage> {
|
||
// 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<PhotoViewControllerValue>? _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<double?>? _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<void> _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<double?>(
|
||
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,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|