import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import "package:flutter_image_compress/flutter_image_compress.dart"; import 'package:logging/logging.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photos/core/cache/thumbnail_in_memory_cache.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/db/files_db.dart'; import "package:photos/events/files_updated_event.dart"; import 'package:photos/events/local_photos_updated_event.dart'; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; import "package:photos/ui/actions/file/file_actions.dart"; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/utils/file_util.dart'; import 'package:photos/utils/image_util.dart'; import 'package:photos/utils/thumbnail_util.dart'; class ZoomableImage extends StatefulWidget { final EnteFile photo; final Function(bool)? shouldDisableScroll; final String? tagPrefix; final Decoration? backgroundDecoration; final bool shouldCover; final bool isGuestView; const ZoomableImage( this.photo, { super.key, this.shouldDisableScroll, required this.tagPrefix, this.backgroundDecoration, this.shouldCover = false, this.isGuestView = false, }); @override State createState() => _ZoomableImageState(); } class _ZoomableImageState extends State { late Logger _logger; late EnteFile _photo; ImageProvider? _imageProvider; bool _loadedSmallThumbnail = false; bool _loadingLargeThumbnail = false; bool _loadedLargeThumbnail = false; bool _loadingFinalImage = false; bool _loadedFinalImage = false; bool _convertToSupportedFormat = false; ValueChanged? _scaleStateChangedCallback; bool _isZooming = false; PhotoViewController _photoViewController = PhotoViewController(); final _scaleStateController = PhotoViewScaleStateController(); @override void initState() { super.initState(); _photo = widget.photo; _logger = Logger("ZoomableImage"); _logger.info('initState for ${_photo.generatedID} with tag ${_photo.tag}'); _scaleStateChangedCallback = (value) { if (widget.shouldDisableScroll != null) { widget.shouldDisableScroll!(value != PhotoViewScaleState.initial); } _isZooming = value != PhotoViewScaleState.initial; debugPrint("isZooming = $_isZooming, currentState $value"); // _logger.info('is reakky zooming $_isZooming with state $value'); }; } @override void dispose() { _photoViewController.dispose(); _scaleStateController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (_photo.isRemoteFile) { _loadNetworkImage(); } else { _loadLocalImage(context); } Widget content; if (_imageProvider != null) { content = PhotoViewGestureDetectorScope( axis: Axis.vertical, child: PhotoView( key: ValueKey(_loadedFinalImage), imageProvider: _imageProvider, controller: _photoViewController, scaleStateController: _scaleStateController, scaleStateChangedCallback: _scaleStateChangedCallback, minScale: widget.shouldCover ? PhotoViewComputedScale.covered : PhotoViewComputedScale.contained, gaplessPlayback: true, heroAttributes: PhotoViewHeroAttributes( tag: widget.tagPrefix! + _photo.tag, ), backgroundDecoration: widget.backgroundDecoration as BoxDecoration?, loadingBuilder: (context, event) { // This is to make sure the hero anitmation animates and fits in the //dimensions of the image on screen. final screenDimensions = MediaQuery.sizeOf(context); late final double screenRelativeImageWidth; late final double screenRelativeImageHeight; final screenWidth = screenDimensions.width; final screenHeight = screenDimensions.height; final aspectRatioOfScreen = screenWidth / screenHeight; final aspectRatioOfImage = _photo.width / _photo.height; if (aspectRatioOfImage > aspectRatioOfScreen) { screenRelativeImageWidth = screenWidth; screenRelativeImageHeight = screenWidth / aspectRatioOfImage; } else if (aspectRatioOfImage < aspectRatioOfScreen) { screenRelativeImageHeight = screenHeight; screenRelativeImageWidth = screenHeight * aspectRatioOfImage; } else { screenRelativeImageWidth = screenWidth; screenRelativeImageHeight = screenHeight; } return Center( child: SizedBox( width: screenRelativeImageWidth, height: screenRelativeImageHeight, child: Hero( tag: widget.tagPrefix! + _photo.tag, child: const EnteLoadingWidget( color: Colors.white, ), ), ), ); }, ), ); } else { content = const EnteLoadingWidget( color: Colors.white, ); } final GestureDragUpdateCallback? verticalDragCallback = _isZooming || widget.isGuestView ? null : (d) => { if (!_isZooming) { if (d.delta.dy > dragSensitivity) { {Navigator.of(context).pop()}, } else if (d.delta.dy < (dragSensitivity * -1)) { showDetailsSheet(context, widget.photo), }, }, }; return GestureDetector( onVerticalDragUpdate: verticalDragCallback, child: content, ); } void _loadNetworkImage() { if (!_loadedSmallThumbnail && !_loadedFinalImage) { final cachedThumbnail = ThumbnailInMemoryLruCache.get(_photo); if (cachedThumbnail != null) { _imageProvider = Image.memory(cachedThumbnail).image; _loadedSmallThumbnail = true; } else { getThumbnailFromServer(_photo).then((file) { final imageProvider = Image.memory(file).image; if (mounted) { precacheImage(imageProvider, context).then((value) { if (mounted) { setState(() { _imageProvider = imageProvider; _loadedSmallThumbnail = true; }); } }).catchError((e) { _logger.severe("Could not load image " + _photo.toString()); _loadedSmallThumbnail = true; }); } }); } } if (!_loadedFinalImage && !_loadingFinalImage) { _loadingFinalImage = true; getFileFromServer(_photo).then((file) { if (file != null) { _onFileLoaded( file, ); } else { _loadingFinalImage = false; } }); } } void _loadLocalImage(BuildContext context) { if (!_loadedSmallThumbnail && !_loadedLargeThumbnail && !_loadedFinalImage) { final cachedThumbnail = ThumbnailInMemoryLruCache.get(_photo, thumbnailSmallSize); if (cachedThumbnail != null) { _imageProvider = Image.memory(cachedThumbnail).image; _loadedSmallThumbnail = true; } } if (!_loadingLargeThumbnail && !_loadedLargeThumbnail && !_loadedFinalImage) { _loadingLargeThumbnail = true; getThumbnailFromLocal(_photo, size: thumbnailLargeSize, quality: 100) .then((cachedThumbnail) { if (cachedThumbnail != null) { _onLargeThumbnailLoaded(Image.memory(cachedThumbnail).image, context); } }); } if (!_loadingFinalImage && !_loadedFinalImage) { _loadingFinalImage = true; getFile( _photo, isOrigin: Platform.isIOS && _isGIF(), // since on iOS GIFs playback only when origin-files are loaded ).then((file) { if (file != null && file.existsSync()) { _onFileLoaded( file, ); } else { _logger.info("File was deleted " + _photo.toString()); if (_photo.uploadedFileID != null) { _photo.localID = null; FilesDB.instance.update(_photo); _loadNetworkImage(); } else { FilesDB.instance.deleteLocalFile(_photo); Bus.instance.fire( LocalPhotosUpdatedEvent( [_photo], type: EventType.deletedFromDevice, source: "zoomPreview", ), ); } } }); } } void _onLargeThumbnailLoaded( ImageProvider imageProvider, BuildContext context, ) { if (mounted && !_loadedFinalImage) { precacheImage(imageProvider, context).then((value) { if (mounted && !_loadedFinalImage) { setState(() { _imageProvider = imageProvider; _loadedLargeThumbnail = true; }); } }); } } void _onFileLoaded(File file) { final imageProvider = Image.file( file, gaplessPlayback: true, ).image; if (mounted) { precacheImage( imageProvider, context, onError: (exception, _) async { _logger .info(exception.toString() + ". Filename: ${_photo.displayName}"); if (exception.toString().contains( "Codec failed to produce an image, possibly due to invalid image data", )) { unawaited(_loadInSupportedFormat(file)); } }, ).then((value) { if (mounted && !_loadedFinalImage && !_convertToSupportedFormat) { _updateViewWithFinalImage(imageProvider); } }); } } Future _updateViewWithFinalImage(ImageProvider imageProvider) async { await _updatePhotoViewController( previewImageProvider: _imageProvider, finalImageProvider: imageProvider, ); setState(() { _imageProvider = imageProvider; _loadedFinalImage = true; _logger.info("Final image loaded"); }); } Future _updatePhotoViewController({ required ImageProvider? previewImageProvider, required ImageProvider finalImageProvider, }) async { final bool shouldFixPosition = previewImageProvider != null && _isZooming && _photoViewController.scale != null; ImageInfo? finalImageInfo; if (shouldFixPosition) { final prevImageInfo = await getImageInfo(previewImageProvider); finalImageInfo = await getImageInfo(finalImageProvider); final scale = _photoViewController.scale! / (finalImageInfo.image.width / prevImageInfo.image.width); final currentPosition = _photoViewController.value.position; _photoViewController = PhotoViewController( initialPosition: currentPosition, initialScale: scale, ); // Fix for auto-zooming when final image is loaded after double tapping //twice. _scaleStateController.scaleState = PhotoViewScaleState.zoomedIn; } final bool canUpdateMetadata = _photo.canEditMetaInfo; // forcefully get finalImageInfo is dimensions are not available in metadata if (finalImageInfo == null && canUpdateMetadata && !_photo.hasDimensions) { finalImageInfo = await getImageInfo(finalImageProvider); } } bool _isGIF() => _photo.displayName.toLowerCase().endsWith(".gif"); Future _loadInSupportedFormat(File file) async { _logger.info("Compressing ${_photo.displayName} to viewable format"); _convertToSupportedFormat = true; final compressedFile = await FlutterImageCompress.compressWithFile(file.path); if (compressedFile != null) { final imageProvider = MemoryImage(compressedFile); unawaited( precacheImage(imageProvider, context).then((value) { if (mounted) { _updateViewWithFinalImage(imageProvider); } }), ); } else { _logger.severe( "Failed to compress image ${_photo.displayName} to viewable format", ); } } }