Files
Client/lib/widget/media_gallery/gallery_image_page.dart
T

404 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
],
),
);
}