import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/cache_manager_util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/flutter_util.dart' as flutter_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/mobile/android/content_uri_image_provider.dart'; import 'package:nc_photos/np_api_util.dart'; import 'package:nc_photos/widget/cached_network_image_mod.dart' as mod; import 'package:nc_photos/widget/network_thumbnail.dart'; import 'package:np_codegen/np_codegen.dart'; part 'image_viewer.g.dart'; class LocalImageViewer extends StatefulWidget { const LocalImageViewer({ Key? key, required this.file, required this.canZoom, this.onLoaded, this.onHeightChanged, this.onZoomStarted, this.onZoomEnded, }) : super(key: key); @override createState() => _LocalImageViewerState(); final LocalFile file; final bool canZoom; final VoidCallback? onLoaded; final ValueChanged? onHeightChanged; final VoidCallback? onZoomStarted; final VoidCallback? onZoomEnded; } @npLog class _LocalImageViewerState extends State { @override build(BuildContext context) { final ImageProvider provider; if (widget.file is LocalUriFile) { provider = ContentUriImage((widget.file as LocalUriFile).uri); } else { throw ArgumentError("Invalid file"); } return _ImageViewer( canZoom: widget.canZoom, onHeightChanged: widget.onHeightChanged, onZoomStarted: widget.onZoomStarted, onZoomEnded: widget.onZoomEnded, child: Image( image: provider, fit: BoxFit.contain, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { WidgetsBinding.instance.addPostFrameCallback((_) { _onItemLoaded(); }); return child; }, ), ); } void _onItemLoaded() { if (!_isLoaded) { _log.info("[_onItemLoaded] ${widget.file.logTag}"); _isLoaded = true; widget.onLoaded?.call(); } } var _isLoaded = false; } class RemoteImageViewer extends StatefulWidget { const RemoteImageViewer({ Key? key, required this.account, required this.file, required this.canZoom, this.onLoaded, this.onHeightChanged, this.onZoomStarted, this.onZoomEnded, }) : super(key: key); @override createState() => _RemoteImageViewerState(); static void preloadImage(Account account, FileDescriptor file) { LargeImageCacheManager.inst.getFileStream( _getImageUrl(account, file), headers: { "Authorization": AuthUtil.fromAccount(account).toHeaderValue(), }, ); } final Account account; final FileDescriptor file; final bool canZoom; final VoidCallback? onLoaded; final ValueChanged? onHeightChanged; final VoidCallback? onZoomStarted; final VoidCallback? onZoomEnded; } @npLog class _RemoteImageViewerState extends State { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { // needed to get rid of the large image blinking during Hero animation setState(() {}); } }); } @override Widget build(BuildContext context) { return _ImageViewer( canZoom: widget.canZoom, onHeightChanged: widget.onHeightChanged, onZoomStarted: widget.onZoomStarted, onZoomEnded: widget.onZoomEnded, child: Stack( fit: StackFit.passthrough, children: [ Hero( tag: flutter_util.getImageHeroTag(widget.file), flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) { _isHeroDone = false; animation.addStatusListener(_animationListener); return flutter_util.defaultHeroFlightShuttleBuilder( flightContext, animation, flightDirection, fromHeroContext, toHeroContext, ); }, child: CachedNetworkImage( fit: BoxFit.contain, cacheManager: ThumbnailCacheManager.inst, imageUrl: NetworkRectThumbnail.imageUrlForFile( widget.account, widget.file), httpHeaders: { "Authorization": AuthUtil.fromAccount(widget.account).toHeaderValue(), }, fadeInDuration: const Duration(), filterQuality: FilterQuality.high, imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, ), ), if (_isHeroDone) mod.CachedNetworkImage( cacheManager: LargeImageCacheManager.inst, imageUrl: _getImageUrl(widget.account, widget.file), httpHeaders: { "Authorization": AuthUtil.fromAccount(widget.account).toHeaderValue(), }, fit: BoxFit.contain, fadeInDuration: const Duration(), filterQuality: FilterQuality.high, imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, imageBuilder: (context, child, imageProvider) { WidgetsBinding.instance.addPostFrameCallback((_) { _onItemLoaded(); }); const SizeChangedLayoutNotification().dispatch(context); return child; }, ), ], ), ); } void _onItemLoaded() { if (!_isLoaded) { _log.info("[_onItemLoaded]"); _isLoaded = true; widget.onLoaded?.call(); } } void _animationListener(AnimationStatus status) { if (status == AnimationStatus.completed) { _isHeroDone = true; if (mounted) { setState(() {}); } } } var _isLoaded = false; // initially set to true such that the large image will show when hero didn't // run (i.e., when swiping in viewer) var _isHeroDone = true; } class _ImageViewer extends StatefulWidget { const _ImageViewer({ Key? key, required this.child, required this.canZoom, this.onHeightChanged, this.onZoomStarted, this.onZoomEnded, }) : super(key: key); @override createState() => _ImageViewerState(); final Widget child; final bool canZoom; final ValueChanged? onHeightChanged; final VoidCallback? onZoomStarted; final VoidCallback? onZoomEnded; } @npLog class _ImageViewerState extends State<_ImageViewer> with TickerProviderStateMixin { @override build(BuildContext context) { final content = InteractiveViewer( minScale: 1.0, maxScale: 3.5, transformationController: _transformationController, panEnabled: widget.canZoom && _isZoomed, scaleEnabled: widget.canZoom, // allow the image to be zoomed to fill the whole screen child: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, alignment: Alignment.center, child: NotificationListener( onNotification: (_) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_key.currentContext != null) { widget.onHeightChanged?.call(_key.currentContext!.size!.height); } }); return false; }, child: SizeChangedLayoutNotifier( key: _key, child: IntrinsicHeight(child: widget.child), ), ), ), ); if (widget.canZoom) { return Listener( onPointerDown: (_) { ++_finger; if (_finger >= 2) { _setIsZooming(true); } }, onPointerUp: (event) { --_finger; if (_finger < 2) { _setIsZooming(false); } _prevFingerPosition = event.position; }, onPointerCancel: (event) { --_finger; if (_finger < 2) { _setIsZooming(false); } }, onPointerSignal: (event) { if (event is PointerScrollEvent && event.kind == PointerDeviceKind.mouse) { if (event.scrollDelta.dy < 0) { // scroll up _setIsZooming(true); } else if (event.scrollDelta.dy > 0) { // scroll down _setIsZooming(false); } } }, child: GestureDetector( onDoubleTap: () { if (_isZoomed) { // restore transformation _autoZoomOut(); } else { _autoZoomIn(); } }, child: content, ), ); } else { return content; } } @override dispose() { super.dispose(); _transformationController.dispose(); } void _setIsZooming(bool flag) { _isZooming = flag; final next = _isZoomed; if (next != _wasZoomed) { _wasZoomed = next; _log.info("[_setIsZooming] Is zoomed: $next"); if (next) { widget.onZoomStarted?.call(); } else { widget.onZoomEnded?.call(); } } } bool get _isZoomed => _isZooming || _transformationController.value.getMaxScaleOnAxis() != 1.0; /// Called when double tapping the image to zoom in to the default level void _autoZoomIn() { final animController = AnimationController(duration: k.animationDurationShort, vsync: this); final originX = -_prevFingerPosition.dx / 2; final originY = -_prevFingerPosition.dy / 2; final anim = Matrix4Tween( begin: Matrix4.identity(), end: Matrix4.identity() ..scale(2.0) ..translate(originX, originY)) .animate(animController); animController ..addListener(() { _transformationController.value = anim.value; }) ..addStatusListener((status) { if (status == AnimationStatus.completed) { _setIsZooming(false); } }) ..forward(); _setIsZooming(true); } /// Called when double tapping the zoomed image to zoom out void _autoZoomOut() { final animController = AnimationController(duration: k.animationDurationShort, vsync: this); final anim = Matrix4Tween( begin: _transformationController.value, end: Matrix4.identity()) .animate(animController); animController ..addListener(() { _transformationController.value = anim.value; }) ..addStatusListener((status) { if (status == AnimationStatus.completed) { _setIsZooming(false); } }) ..forward(); _setIsZooming(true); } final _key = GlobalKey(); final _transformationController = TransformationController(); var _isZooming = false; var _wasZoomed = false; int _finger = 0; var _prevFingerPosition = const Offset(0, 0); } String _getImageUrl(Account account, FileDescriptor file) { if (file.fdMime == "image/gif") { return api_util.getFileUrl(account, file); } else { return api_util.getFilePreviewUrl( account, file, width: k.photoLargeSize, height: k.photoLargeSize, isKeepAspectRatio: true, ); } }