added a swipeable media gallery for chat and folder images and videos with server previews, on-demand originals and zoom, and marked document previews in chat so they no longer pass for photos

This commit is contained in:
MineTec committed 2026-10-28 22:15:57 +01:00
1 parent 952f916231
commit aa176c90e3
22 files changed
+1658 -103

No files matched your search

@@ -0,0 +1,403 @@
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,
),
],
),
);
}