Files
immich/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart
T
shenlong 7855974a29 feat(mobile): sqlite asset viewer (#19552)
* add full image provider and refactor thumb providers

* photo_view updates

* wip: asset-viewer

* fix controller dispose on page change

* wip: bottom sheet

* fix interactions

* more bottomsheet changes

* generate schema

* PR feedback

* refactor asset viewer

* never rotate and fix background on page change

* use photoview as the loading builder

* precache after delay

* claude: optimizing rebuild of image provider

* claude: optimizing image decoding and caching

* use proper cache for new full size image providers

* chore: load local HEIC fullsize for iOS

* make controller callbacks nullable

* remove imageprovider cache

* do not handle drag gestures when zoomed

* use loadOriginal setting for HEIC / larger images

* preload assets outside timer

* never use same controllers in photo-view gallery

* fix: cannot scroll down once swipe with bottom sheet

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-07-02 18:24:37 +00:00

346 lines
10 KiB
Dart

import 'package:flutter/widgets.dart';
import '../photo_view.dart';
import 'core/photo_view_core.dart';
import 'photo_view_default_widgets.dart';
import 'utils/photo_view_utils.dart';
class ImageWrapper extends StatefulWidget {
const ImageWrapper({
super.key,
required this.imageProvider,
required this.loadingBuilder,
required this.backgroundDecoration,
required this.semanticLabel,
required this.gaplessPlayback,
required this.heroAttributes,
required this.scaleStateChangedCallback,
required this.enableRotation,
required this.controller,
required this.scaleStateController,
required this.maxScale,
required this.minScale,
required this.initialScale,
required this.basePosition,
required this.scaleStateCycle,
required this.onTapUp,
required this.onTapDown,
required this.onDragStart,
required this.onDragEnd,
required this.onDragUpdate,
required this.onScaleEnd,
required this.onLongPressStart,
required this.outerSize,
required this.gestureDetectorBehavior,
required this.tightMode,
required this.filterQuality,
required this.disableGestures,
this.disableScaleGestures,
required this.errorBuilder,
required this.enablePanAlways,
required this.index,
});
final ImageProvider imageProvider;
final LoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
final BoxDecoration backgroundDecoration;
final String? semanticLabel;
final bool gaplessPlayback;
final PhotoViewHeroAttributes? heroAttributes;
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
final bool enableRotation;
final dynamic maxScale;
final dynamic minScale;
final dynamic initialScale;
final PhotoViewControllerBase controller;
final PhotoViewScaleStateController scaleStateController;
final Alignment? basePosition;
final ScaleStateCycle? scaleStateCycle;
final PhotoViewImageTapUpCallback? onTapUp;
final PhotoViewImageTapDownCallback? onTapDown;
final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate;
final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageLongPressStartCallback? onLongPressStart;
final Size outerSize;
final HitTestBehavior? gestureDetectorBehavior;
final bool? tightMode;
final FilterQuality? filterQuality;
final bool? disableGestures;
final bool? disableScaleGestures;
final bool? enablePanAlways;
final int index;
@override
createState() => _ImageWrapperState();
}
class _ImageWrapperState extends State<ImageWrapper> {
ImageStreamListener? _imageStreamListener;
ImageStream? _imageStream;
ImageChunkEvent? _loadingProgress;
ImageInfo? _imageInfo;
bool _loading = true;
Size? _imageSize;
Object? _lastException;
StackTrace? _lastStack;
@override
void dispose() {
super.dispose();
_stopImageStream();
}
@override
void didChangeDependencies() {
_resolveImage();
super.didChangeDependencies();
}
@override
void didUpdateWidget(ImageWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.imageProvider != oldWidget.imageProvider) {
_resolveImage();
}
}
// retrieve image from the provider
void _resolveImage() {
final ImageStream newStream = widget.imageProvider.resolve(
const ImageConfiguration(),
);
_updateSourceStream(newStream);
}
ImageStreamListener _getOrCreateListener() {
void handleImageChunk(ImageChunkEvent event) {
setState(() {
_loadingProgress = event;
_lastException = null;
});
}
void handleImageFrame(ImageInfo info, bool synchronousCall) {
setupCB() {
_imageSize = Size(
info.image.width.toDouble(),
info.image.height.toDouble(),
);
_loading = false;
_imageInfo = _imageInfo;
_loadingProgress = null;
_lastException = null;
_lastStack = null;
}
synchronousCall ? setupCB() : setState(setupCB);
}
void handleError(dynamic error, StackTrace? stackTrace) {
setState(() {
_loading = false;
_lastException = error;
_lastStack = stackTrace;
});
assert(() {
if (widget.errorBuilder == null) {
throw error;
}
return true;
}());
}
_imageStreamListener = ImageStreamListener(
handleImageFrame,
onChunk: handleImageChunk,
onError: handleError,
);
return _imageStreamListener!;
}
void _updateSourceStream(ImageStream newStream) {
if (_imageStream?.key == newStream.key) {
return;
}
_imageStream?.removeListener(_imageStreamListener!);
_imageStream = newStream;
_imageStream!.addListener(_getOrCreateListener());
}
void _stopImageStream() {
_imageStream?.removeListener(_imageStreamListener!);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return _buildLoading(context);
}
if (_lastException != null) {
return _buildError(context);
}
final scaleBoundaries = ScaleBoundaries(
widget.minScale ?? 0.0,
widget.maxScale ?? double.infinity,
widget.initialScale ?? PhotoViewComputedScale.contained,
widget.outerSize,
_imageSize!,
);
return PhotoViewCore(
imageProvider: widget.imageProvider,
backgroundDecoration: widget.backgroundDecoration,
semanticLabel: widget.semanticLabel,
gaplessPlayback: widget.gaplessPlayback,
enableRotation: widget.enableRotation,
heroAttributes: widget.heroAttributes,
basePosition: widget.basePosition ?? Alignment.center,
controller: widget.controller,
scaleStateController: widget.scaleStateController,
scaleStateCycle: widget.scaleStateCycle ?? defaultScaleStateCycle,
scaleBoundaries: scaleBoundaries,
onTapUp: widget.onTapUp,
onTapDown: widget.onTapDown,
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart,
gestureDetectorBehavior: widget.gestureDetectorBehavior,
tightMode: widget.tightMode ?? false,
filterQuality: widget.filterQuality ?? FilterQuality.none,
disableGestures: widget.disableGestures ?? false,
disableScaleGestures: widget.disableScaleGestures ?? false,
enablePanAlways: widget.enablePanAlways ?? false,
);
}
Widget _buildLoading(BuildContext context) {
if (widget.loadingBuilder != null) {
return widget.loadingBuilder!(context, _loadingProgress, widget.index);
}
return PhotoViewDefaultLoading(
event: _loadingProgress,
);
}
Widget _buildError(
BuildContext context,
) {
if (widget.errorBuilder != null) {
return widget.errorBuilder!(context, _lastException!, _lastStack);
}
return PhotoViewDefaultError(
decoration: widget.backgroundDecoration,
);
}
}
class CustomChildWrapper extends StatelessWidget {
const CustomChildWrapper({
super.key,
this.child,
required this.childSize,
required this.backgroundDecoration,
this.heroAttributes,
this.scaleStateChangedCallback,
required this.enableRotation,
required this.controller,
required this.scaleStateController,
required this.maxScale,
required this.minScale,
required this.initialScale,
required this.basePosition,
required this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.onLongPressStart,
required this.outerSize,
this.gestureDetectorBehavior,
required this.tightMode,
required this.filterQuality,
required this.disableGestures,
this.disableScaleGestures,
required this.enablePanAlways,
});
final Widget? child;
final Size? childSize;
final Decoration backgroundDecoration;
final PhotoViewHeroAttributes? heroAttributes;
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
final bool enableRotation;
final PhotoViewControllerBase controller;
final PhotoViewScaleStateController scaleStateController;
final dynamic maxScale;
final dynamic minScale;
final dynamic initialScale;
final Alignment? basePosition;
final ScaleStateCycle? scaleStateCycle;
final PhotoViewImageTapUpCallback? onTapUp;
final PhotoViewImageTapDownCallback? onTapDown;
final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate;
final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageLongPressStartCallback? onLongPressStart;
final Size outerSize;
final HitTestBehavior? gestureDetectorBehavior;
final bool? tightMode;
final FilterQuality? filterQuality;
final bool? disableScaleGestures;
final bool? disableGestures;
final bool? enablePanAlways;
@override
Widget build(BuildContext context) {
final scaleBoundaries = ScaleBoundaries(
minScale ?? 0.0,
maxScale ?? double.infinity,
initialScale ?? PhotoViewComputedScale.contained,
outerSize,
childSize ?? outerSize,
);
return PhotoViewCore.customChild(
customChild: child,
backgroundDecoration: backgroundDecoration,
enableRotation: enableRotation,
heroAttributes: heroAttributes,
controller: controller,
scaleStateController: scaleStateController,
scaleStateCycle: scaleStateCycle ?? defaultScaleStateCycle,
basePosition: basePosition ?? Alignment.center,
scaleBoundaries: scaleBoundaries,
onTapUp: onTapUp,
onTapDown: onTapDown,
onDragStart: onDragStart,
onDragEnd: onDragEnd,
onDragUpdate: onDragUpdate,
onScaleEnd: onScaleEnd,
onLongPressStart: onLongPressStart,
gestureDetectorBehavior: gestureDetectorBehavior,
tightMode: tightMode ?? false,
filterQuality: filterQuality ?? FilterQuality.none,
disableGestures: disableGestures ?? false,
disableScaleGestures: disableScaleGestures ?? false,
enablePanAlways: enablePanAlways ?? false,
);
}
}