mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-25 15:52:33 -04:00 
			
		
		
		
	(fix)mobile: Improve the gallery to improve scale, double tap, and swipe gesture detection (#1502)
* photoviewgallery * stiffer scrolling to react more like google photos * adds a dx threshhold for the swipe/up down from the original dropped point * stopped wrapping imageview in gallery viewer to avoid the double photoview issue. breaks imageview page pinch-to-zoom, so i need to fix that for other callers * refactors gallery view to use remoteimage directly and breaks imageviewpage * removed image_viewer_page * adds minscale * adds photo_view to repository * double tap to zoom out with hacked commit * double tapping! * got up and down swipe gestures working * fixed wrong cache and headers in image providers * fixed image quality and added videos back in * local loading asset image fix * precaches images * fixes lint errors * deleted remote_photo_view and more linters * fixes scale * load preview and load original * precache does original / preview as well * refactored image providers to nice functions and added JPEG thumbnail format to remote image thumbnail lookup * moved photo_view to shared/ui/ * three stage loading with webp and fixes some thumbnail fits * fixed local thumbnail * fixed paging in iOS
This commit is contained in:
		
							parent
							
								
									391bf052e4
								
							
						
					
					
						commit
						02f5a86ee9
					
				| @ -1,205 +0,0 @@ | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart' | ||||
|     show AssetEntityImageProvider, ThumbnailSize; | ||||
| import 'package:photo_view/photo_view.dart'; | ||||
| 
 | ||||
| enum _RemoteImageStatus { empty, thumbnail, preview, full } | ||||
| 
 | ||||
| class _RemotePhotoViewState extends State<RemotePhotoView> { | ||||
|   late ImageProvider _imageProvider; | ||||
|   _RemoteImageStatus _status = _RemoteImageStatus.empty; | ||||
|   bool _zoomedIn = false; | ||||
| 
 | ||||
|   late ImageProvider _fullProvider; | ||||
|   late ImageProvider _previewProvider; | ||||
|   late ImageProvider _thumbnailProvider; | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final bool forbidZoom = _status == _RemoteImageStatus.thumbnail; | ||||
| 
 | ||||
|     return IgnorePointer( | ||||
|       ignoring: forbidZoom, | ||||
|       child: Listener( | ||||
|         onPointerMove: handleSwipUpDown, | ||||
|         child: PhotoView( | ||||
|           imageProvider: _imageProvider, | ||||
|           minScale: PhotoViewComputedScale.contained, | ||||
|           enablePanAlways: false, | ||||
|           scaleStateChangedCallback: _scaleStateChanged, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   void handleSwipUpDown(PointerMoveEvent details) { | ||||
|     int sensitivity = 15; | ||||
| 
 | ||||
|     if (_zoomedIn) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (details.delta.dy > sensitivity) { | ||||
|       widget.onSwipeDown(); | ||||
|     } else if (details.delta.dy < -sensitivity) { | ||||
|       widget.onSwipeUp(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   void _scaleStateChanged(PhotoViewScaleState state) { | ||||
|     _zoomedIn = state != PhotoViewScaleState.initial; | ||||
|     if (_zoomedIn) { | ||||
|       widget.isZoomedListener.value = true; | ||||
|     } else { | ||||
|       widget.isZoomedListener.value = false; | ||||
|     } | ||||
|     widget.isZoomedFunction(); | ||||
|   } | ||||
| 
 | ||||
|   CachedNetworkImageProvider _authorizedImageProvider( | ||||
|     String url, | ||||
|     String cacheKey, | ||||
|   ) { | ||||
|     return CachedNetworkImageProvider( | ||||
|       url, | ||||
|       headers: {"Authorization": widget.authToken}, | ||||
|       cacheKey: cacheKey, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   void _performStateTransition( | ||||
|     _RemoteImageStatus newStatus, | ||||
|     ImageProvider provider, | ||||
|   ) { | ||||
|     if (_status == newStatus) return; | ||||
| 
 | ||||
|     if (_status == _RemoteImageStatus.full && | ||||
|         newStatus == _RemoteImageStatus.thumbnail) return; | ||||
| 
 | ||||
|     if (_status == _RemoteImageStatus.preview && | ||||
|         newStatus == _RemoteImageStatus.thumbnail) return; | ||||
| 
 | ||||
|     if (_status == _RemoteImageStatus.full && | ||||
|         newStatus == _RemoteImageStatus.preview) return; | ||||
| 
 | ||||
|     if (!mounted) return; | ||||
| 
 | ||||
|     setState(() { | ||||
|       _status = newStatus; | ||||
|       _imageProvider = provider; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   void _loadImages() { | ||||
|     if (widget.asset.isLocal) { | ||||
|       _imageProvider = AssetEntityImageProvider( | ||||
|         widget.asset.local!, | ||||
|         isOriginal: false, | ||||
|         thumbnailSize: const ThumbnailSize.square(250), | ||||
|       ); | ||||
|       _fullProvider = AssetEntityImageProvider(widget.asset.local!); | ||||
|       _fullProvider.resolve(const ImageConfiguration()).addListener( | ||||
|         ImageStreamListener((ImageInfo image, _) { | ||||
|           _performStateTransition( | ||||
|             _RemoteImageStatus.full, | ||||
|             _fullProvider, | ||||
|           ); | ||||
|         }), | ||||
|       ); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     _thumbnailProvider = _authorizedImageProvider( | ||||
|       getThumbnailUrl(widget.asset.remote!), | ||||
|       getThumbnailCacheKey(widget.asset.remote!), | ||||
|     ); | ||||
|     _imageProvider = _thumbnailProvider; | ||||
| 
 | ||||
|     _thumbnailProvider.resolve(const ImageConfiguration()).addListener( | ||||
|       ImageStreamListener((ImageInfo imageInfo, _) { | ||||
|         _performStateTransition( | ||||
|           _RemoteImageStatus.thumbnail, | ||||
|           _thumbnailProvider, | ||||
|         ); | ||||
|       }), | ||||
|     ); | ||||
| 
 | ||||
|     if (widget.loadPreview) { | ||||
|       _previewProvider = _authorizedImageProvider( | ||||
|         getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG), | ||||
|         getThumbnailCacheKey(widget.asset.remote!, type: ThumbnailFormat.JPEG), | ||||
|       ); | ||||
|       _previewProvider.resolve(const ImageConfiguration()).addListener( | ||||
|         ImageStreamListener((ImageInfo imageInfo, _) { | ||||
|           _performStateTransition(_RemoteImageStatus.preview, _previewProvider); | ||||
|         }), | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (widget.loadOriginal) { | ||||
|       _fullProvider = _authorizedImageProvider( | ||||
|         getImageUrl(widget.asset.remote!), | ||||
|         getImageCacheKey(widget.asset.remote!), | ||||
|       ); | ||||
|       _fullProvider.resolve(const ImageConfiguration()).addListener( | ||||
|         ImageStreamListener((ImageInfo imageInfo, _) { | ||||
|           _performStateTransition(_RemoteImageStatus.full, _fullProvider); | ||||
|         }), | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _loadImages(); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void dispose() async { | ||||
|     super.dispose(); | ||||
| 
 | ||||
|     if (_status == _RemoteImageStatus.full) { | ||||
|       await _fullProvider.evict(); | ||||
|     } else if (_status == _RemoteImageStatus.preview) { | ||||
|       await _previewProvider.evict(); | ||||
|     } else if (_status == _RemoteImageStatus.thumbnail) { | ||||
|       await _thumbnailProvider.evict(); | ||||
|     } | ||||
| 
 | ||||
|     await _imageProvider.evict(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class RemotePhotoView extends StatefulWidget { | ||||
|   const RemotePhotoView({ | ||||
|     Key? key, | ||||
|     required this.asset, | ||||
|     required this.authToken, | ||||
|     required this.loadPreview, | ||||
|     required this.loadOriginal, | ||||
|     required this.isZoomedFunction, | ||||
|     required this.isZoomedListener, | ||||
|     required this.onSwipeDown, | ||||
|     required this.onSwipeUp, | ||||
|   }) : super(key: key); | ||||
| 
 | ||||
|   final Asset asset; | ||||
|   final String authToken; | ||||
|   final bool loadPreview; | ||||
|   final bool loadOriginal; | ||||
|   final void Function() onSwipeDown; | ||||
|   final void Function() onSwipeUp; | ||||
|   final void Function() isZoomedFunction; | ||||
| 
 | ||||
|   final ValueNotifier<bool> isZoomedListener; | ||||
| 
 | ||||
|   @override | ||||
|   State<StatefulWidget> createState() { | ||||
|     return _RemotePhotoViewState(); | ||||
|   } | ||||
| } | ||||
| @ -1,4 +1,7 @@ | ||||
| import 'dart:io'; | ||||
| 
 | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| @ -9,14 +12,21 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; | ||||
| import 'package:immich_mobile/modules/home/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart'; | ||||
| import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; | ||||
| import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
| import 'package:openapi/api.dart' as api; | ||||
| 
 | ||||
| // ignore: must_be_immutable | ||||
| class GalleryViewerPage extends HookConsumerWidget { | ||||
| @ -40,7 +50,8 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|     final isZoomed = useState<bool>(false); | ||||
|     final indexOfAsset = useState(assetList.indexOf(asset)); | ||||
|     final isPlayingMotionVideo = useState(false); | ||||
|     ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false); | ||||
|     late Offset localPosition; | ||||
|     final authToken = 'Bearer ${box.get(accessTokenKey)}'; | ||||
| 
 | ||||
|     PageController controller = | ||||
|         PageController(initialPage: assetList.indexOf(asset)); | ||||
| @ -57,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|       [], | ||||
|     ); | ||||
| 
 | ||||
|     getAssetExif() async { | ||||
|     void getAssetExif() async { | ||||
|       if (assetList[indexOfAsset.value].isRemote) { | ||||
|         assetDetail = await ref | ||||
|             .watch(assetServiceProvider) | ||||
| @ -68,27 +79,96 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     void showInfo() { | ||||
|       showModalBottomSheet( | ||||
|         shape: RoundedRectangleBorder( | ||||
|           borderRadius: BorderRadius.circular(15.0), | ||||
|     /// Thumbnail image of a remote asset. Required asset.remote != null | ||||
|     ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) { | ||||
|       return CachedNetworkImageProvider( | ||||
|         getThumbnailUrl( | ||||
|           asset.remote!, | ||||
|           type: type, | ||||
|         ), | ||||
|         barrierColor: Colors.transparent, | ||||
|         backgroundColor: Colors.transparent, | ||||
|         isScrollControlled: true, | ||||
|         context: context, | ||||
|         builder: (context) { | ||||
|           return ExifBottomSheet(assetDetail: assetDetail!); | ||||
|         }, | ||||
|         cacheKey: getThumbnailCacheKey( | ||||
|           asset.remote!, | ||||
|           type: type, | ||||
|         ), | ||||
|         headers: {"Authorization": authToken}, | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     //make isZoomed listener call instead | ||||
|     void isZoomedMethod() { | ||||
|       if (isZoomedListener.value) { | ||||
|         isZoomed.value = true; | ||||
|       } else { | ||||
|         isZoomed.value = false; | ||||
|     /// Original (large) image of a remote asset. Required asset.remote != null | ||||
|     ImageProvider originalImageProvider(Asset asset) { | ||||
|       return CachedNetworkImageProvider( | ||||
|         getImageUrl(asset.remote!), | ||||
|         cacheKey: getImageCacheKey(asset.remote!), | ||||
|         headers: {"Authorization": authToken}, | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     /// Thumbnail image of a local asset. Required asset.local != null | ||||
|     ImageProvider localThumbnailImageProvider(Asset asset) { | ||||
|       return AssetEntityImageProvider( | ||||
|         asset.local!, | ||||
|         isOriginal: false, | ||||
|         thumbnailSize: const ThumbnailSize.square(250), | ||||
|       ); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     /// Original (large) image of a local asset. Required asset.local != null | ||||
|     ImageProvider localImageProvider(Asset asset) { | ||||
|       return AssetEntityImageProvider(asset.local!); | ||||
|     } | ||||
| 
 | ||||
|     void precacheNextImage(int index) { | ||||
|       if (index < assetList.length && index > 0) { | ||||
|         final asset = assetList[index]; | ||||
|         if (asset.isLocal) { | ||||
|           // Preload the local asset | ||||
|           precacheImage(localImageProvider(asset), context); | ||||
|         } else { | ||||
|           // Probably load WEBP either way | ||||
|           precacheImage( | ||||
|             remoteThumbnailImageProvider( | ||||
|               asset,  | ||||
|               api.ThumbnailFormat.WEBP, | ||||
|             ), | ||||
|             context, | ||||
|           ); | ||||
|           if (isLoadPreview.value) { | ||||
|             // Precache the JPEG thumbnail | ||||
|             precacheImage( | ||||
|               remoteThumbnailImageProvider( | ||||
|                 asset, | ||||
|                 api.ThumbnailFormat.JPEG, | ||||
|               ), | ||||
|               context, | ||||
|             ); | ||||
|           } | ||||
|           if (isLoadOriginal.value) { | ||||
|             // Preload the original asset | ||||
|             precacheImage( | ||||
|               originalImageProvider(asset), | ||||
|               context, | ||||
|             ); | ||||
|           } | ||||
| 
 | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     void showInfo() { | ||||
|       if (assetList[indexOfAsset.value].isRemote) { | ||||
|         showModalBottomSheet( | ||||
|           shape: RoundedRectangleBorder( | ||||
|             borderRadius: BorderRadius.circular(15.0), | ||||
|           ), | ||||
|           barrierColor: Colors.transparent, | ||||
|           backgroundColor: Colors.transparent, | ||||
|           isScrollControlled: true, | ||||
|           context: context, | ||||
|           builder: (context) { | ||||
|             return ExifBottomSheet(assetDetail: assetDetail!); | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
| @ -122,6 +202,28 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     void handleSwipeUpDown(DragUpdateDetails details) { | ||||
|       int sensitivity = 15; | ||||
|       int dxThreshhold = 50; | ||||
| 
 | ||||
|       if (isZoomed.value) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // Check for delta from initial down point | ||||
|       final d = details.localPosition - localPosition; | ||||
|       // If the magnitude of the dx swipe is large, we probably didn't mean to go down | ||||
|       if (d.dx.abs() > dxThreshhold) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       if (details.delta.dy > sensitivity) { | ||||
|         AutoRouter.of(context).pop(); | ||||
|       } else if (details.delta.dy < -sensitivity) { | ||||
|         showInfo(); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return Scaffold( | ||||
|       backgroundColor: Colors.black, | ||||
|       appBar: TopControlAppBar( | ||||
| @ -150,61 +252,93 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|         onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]), | ||||
|       ), | ||||
|       body: SafeArea( | ||||
|         child: PageView.builder( | ||||
|           controller: controller, | ||||
|           pageSnapping: true, | ||||
|           physics: isZoomed.value | ||||
|               ? const NeverScrollableScrollPhysics() | ||||
|               : const BouncingScrollPhysics(), | ||||
|         child: PhotoViewGallery.builder( | ||||
|           scaleStateChangedCallback: (state) => isZoomed.value = state != PhotoViewScaleState.initial, | ||||
|           pageController: controller, | ||||
|           scrollPhysics: isZoomed.value | ||||
|               ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in | ||||
|               : (Platform.isIOS  | ||||
|                 ? const BouncingScrollPhysics()  // Use bouncing physics for iOS | ||||
|                 : const ImmichPageViewScrollPhysics() // Use heavy physics for Android | ||||
|               ), | ||||
|           itemCount: assetList.length, | ||||
|           scrollDirection: Axis.horizontal, | ||||
|           onPageChanged: (value) { | ||||
|             // Precache image | ||||
|             if (indexOfAsset.value < value) { | ||||
|               // Moving forwards, so precache the next asset | ||||
|               precacheNextImage(value + 1); | ||||
|             } else { | ||||
|               // Moving backwards, so precache previous asset | ||||
|               precacheNextImage(value - 1); | ||||
|             } | ||||
|             indexOfAsset.value = value; | ||||
|             HapticFeedback.selectionClick(); | ||||
|           }, | ||||
|           itemBuilder: (context, index) { | ||||
|             getAssetExif(); | ||||
|           loadingBuilder: isLoadPreview.value ? (context, event) { | ||||
|             final asset = assetList[indexOfAsset.value]; | ||||
|             if (!asset.isLocal) { | ||||
|               // Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive | ||||
|               // Three-Stage Loading (WEBP -> JPEG -> Original) | ||||
|               final webPThumbnail = CachedNetworkImage( | ||||
|                 imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP), | ||||
|                 cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP), | ||||
|                 httpHeaders: { 'Authorization': authToken }, | ||||
|                 progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),), | ||||
|                 fit: BoxFit.contain, | ||||
|               ); | ||||
| 
 | ||||
|             if (assetList[index].isImage) { | ||||
|               if (isPlayingMotionVideo.value) { | ||||
|                 return VideoViewerPage( | ||||
|                   asset: assetList[index], | ||||
|                   isMotionVideo: true, | ||||
|                   onVideoEnded: () { | ||||
|                     isPlayingMotionVideo.value = false; | ||||
|                   }, | ||||
|                 ); | ||||
|               } else { | ||||
|                 return ImageViewerPage( | ||||
|                   authToken: 'Bearer ${box.get(accessTokenKey)}', | ||||
|                   isZoomedFunction: isZoomedMethod, | ||||
|                   isZoomedListener: isZoomedListener, | ||||
|                   asset: assetList[index], | ||||
|                   heroTag: assetList[index].id, | ||||
|                   loadPreview: isLoadPreview.value, | ||||
|                   loadOriginal: isLoadOriginal.value, | ||||
|                   showExifSheet: showInfo, | ||||
|                 ); | ||||
|               } | ||||
|               return CachedNetworkImage( | ||||
|                 imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG), | ||||
|                 cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG), | ||||
|                 httpHeaders: { 'Authorization': authToken }, | ||||
|                 fit: BoxFit.contain, | ||||
|                 placeholder: (_, __) => webPThumbnail, | ||||
|               ); | ||||
|             } else { | ||||
|               return GestureDetector( | ||||
|                 onVerticalDragUpdate: (details) { | ||||
|                   const int sensitivity = 15; | ||||
|                   if (details.delta.dy > sensitivity) { | ||||
|                     // swipe down | ||||
|                     AutoRouter.of(context).pop(); | ||||
|                   } else if (details.delta.dy < -sensitivity) { | ||||
|                     // swipe up | ||||
|                     showInfo(); | ||||
|                   } | ||||
|                 }, | ||||
|                 child: Hero( | ||||
|                   tag: assetList[index].id, | ||||
|                   child: VideoViewerPage( | ||||
|                     asset: assetList[index], | ||||
|                     isMotionVideo: false, | ||||
|                     onVideoEnded: () {}, | ||||
|                   ), | ||||
|               return Image( | ||||
|                 image: localThumbnailImageProvider(asset), | ||||
|                 fit: BoxFit.contain, | ||||
|               ); | ||||
|             } | ||||
|           } : null, | ||||
|           builder: (context, index) { | ||||
|             getAssetExif(); | ||||
|             if (assetList[index].isImage && !isPlayingMotionVideo.value) { | ||||
|               // Show photo | ||||
|               final ImageProvider provider; | ||||
|               if (assetList[index].isLocal) { | ||||
|                 provider = localImageProvider(assetList[index]); | ||||
|               } else { | ||||
|                 if (isLoadOriginal.value) { | ||||
|                   provider = originalImageProvider(assetList[index]); | ||||
|                 } else { | ||||
|                   provider = remoteThumbnailImageProvider( | ||||
|                     assetList[index],  | ||||
|                     api.ThumbnailFormat.JPEG, | ||||
|                   ); | ||||
|                 } | ||||
|               } | ||||
|               return PhotoViewGalleryPageOptions( | ||||
|                 onDragStart: (_, details, __) => localPosition = details.localPosition, | ||||
|                 onDragUpdate: (_, details, __) => handleSwipeUpDown(details), | ||||
|                 imageProvider: provider, | ||||
|                 heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id), | ||||
|                 minScale: PhotoViewComputedScale.contained, | ||||
|               ); | ||||
|             } else { | ||||
|               return PhotoViewGalleryPageOptions.customChild( | ||||
|                 onDragStart: (_, details, __) => localPosition = details.localPosition, | ||||
|                 onDragUpdate: (_, details, __) => handleSwipeUpDown(details), | ||||
|                 heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id), | ||||
|                 child: VideoViewerPage( | ||||
|                   asset: assetList[index], | ||||
|                   isMotionVideo: isPlayingMotionVideo.value, | ||||
|                   onVideoEnded: () { | ||||
|                     if (isPlayingMotionVideo.value) { | ||||
|                       isPlayingMotionVideo.value = false; | ||||
|                     } | ||||
|                   }, | ||||
|                 ), | ||||
|               ); | ||||
|             } | ||||
| @ -214,3 +348,19 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class ImmichPageViewScrollPhysics extends ScrollPhysics { | ||||
|   const ImmichPageViewScrollPhysics({super.parent}); | ||||
| 
 | ||||
|   @override | ||||
|   ImmichPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) { | ||||
|     return ImmichPageViewScrollPhysics(parent: buildParent(ancestor)!); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   SpringDescription get spring => const SpringDescription( | ||||
|     mass: 100, | ||||
|     stiffness: 100, | ||||
|     damping: .90, | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -1,84 +0,0 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart'; | ||||
| import 'package:immich_mobile/modules/home/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| 
 | ||||
| // ignore: must_be_immutable | ||||
| class ImageViewerPage extends HookConsumerWidget { | ||||
|   final String heroTag; | ||||
|   final Asset asset; | ||||
|   final String authToken; | ||||
|   final ValueNotifier<bool> isZoomedListener; | ||||
|   final void Function() isZoomedFunction; | ||||
|   final void Function()? showExifSheet; | ||||
|   final bool loadPreview; | ||||
|   final bool loadOriginal; | ||||
| 
 | ||||
|   ImageViewerPage({ | ||||
|     Key? key, | ||||
|     required this.heroTag, | ||||
|     required this.asset, | ||||
|     required this.authToken, | ||||
|     required this.isZoomedFunction, | ||||
|     required this.isZoomedListener, | ||||
|     required this.loadPreview, | ||||
|     required this.loadOriginal, | ||||
|     this.showExifSheet, | ||||
|   }) : super(key: key); | ||||
| 
 | ||||
|   Asset? assetDetail; | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final downloadAssetStatus = | ||||
|         ref.watch(imageViewerStateProvider).downloadAssetStatus; | ||||
| 
 | ||||
|     getAssetExif() async { | ||||
|       if (asset.isRemote) { | ||||
|         assetDetail = | ||||
|             await ref.watch(assetServiceProvider).getAssetById(asset.id); | ||||
|       } else { | ||||
|         // TODO local exif parsing? | ||||
|         assetDetail = asset; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     useEffect( | ||||
|       () { | ||||
|         getAssetExif(); | ||||
|         return null; | ||||
|       }, | ||||
|       [], | ||||
|     ); | ||||
| 
 | ||||
|     return Stack( | ||||
|       children: [ | ||||
|         Center( | ||||
|           child: Hero( | ||||
|             tag: heroTag, | ||||
|             child: RemotePhotoView( | ||||
|               asset: asset, | ||||
|               authToken: authToken, | ||||
|               loadPreview: loadPreview, | ||||
|               loadOriginal: loadOriginal, | ||||
|               isZoomedFunction: isZoomedFunction, | ||||
|               isZoomedListener: isZoomedListener, | ||||
|               onSwipeDown: () => AutoRouter.of(context).pop(), | ||||
|               onSwipeUp: (asset.isRemote && showExifSheet  != null) ? showExifSheet! : () {}, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         if (downloadAssetStatus == DownloadAssetStatus.loading) | ||||
|           const Center( | ||||
|             child: ImmichLoadingIndicator(), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -10,7 +10,6 @@ import 'package:immich_mobile/modules/album/views/select_additional_user_for_sha | ||||
| import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart'; | ||||
| import 'package:immich_mobile/modules/album/views/sharing_page.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; | ||||
| import 'package:immich_mobile/modules/backup/views/album_preview_page.dart'; | ||||
| import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart'; | ||||
| @ -52,7 +51,6 @@ part 'router.gr.dart'; | ||||
|       transitionsBuilder: TransitionsBuilders.fadeIn, | ||||
|     ), | ||||
|     AutoRoute(page: GalleryViewerPage, guards: [AuthGuard]), | ||||
|     AutoRoute(page: ImageViewerPage, guards: [AuthGuard]), | ||||
|     AutoRoute(page: VideoViewerPage, guards: [AuthGuard]), | ||||
|     AutoRoute(page: BackupControllerPage, guards: [AuthGuard]), | ||||
|     AutoRoute(page: SearchResultPage, guards: [AuthGuard]), | ||||
|  | ||||
| @ -48,21 +48,6 @@ class _$AppRouter extends RootStackRouter { | ||||
|           child: GalleryViewerPage( | ||||
|               key: args.key, assetList: args.assetList, asset: args.asset)); | ||||
|     }, | ||||
|     ImageViewerRoute.name: (routeData) { | ||||
|       final args = routeData.argsAs<ImageViewerRouteArgs>(); | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, | ||||
|           child: ImageViewerPage( | ||||
|               key: args.key, | ||||
|               heroTag: args.heroTag, | ||||
|               asset: args.asset, | ||||
|               authToken: args.authToken, | ||||
|               isZoomedFunction: args.isZoomedFunction, | ||||
|               isZoomedListener: args.isZoomedListener, | ||||
|               loadPreview: args.loadPreview, | ||||
|               loadOriginal: args.loadOriginal, | ||||
|               showExifSheet: args.showExifSheet)); | ||||
|     }, | ||||
|     VideoViewerRoute.name: (routeData) { | ||||
|       final args = routeData.argsAs<VideoViewerRouteArgs>(); | ||||
|       return MaterialPageX<dynamic>( | ||||
| @ -204,8 +189,6 @@ class _$AppRouter extends RootStackRouter { | ||||
|             ]), | ||||
|         RouteConfig(GalleryViewerRoute.name, | ||||
|             path: '/gallery-viewer-page', guards: [authGuard]), | ||||
|         RouteConfig(ImageViewerRoute.name, | ||||
|             path: '/image-viewer-page', guards: [authGuard]), | ||||
|         RouteConfig(VideoViewerRoute.name, | ||||
|             path: '/video-viewer-page', guards: [authGuard]), | ||||
|         RouteConfig(BackupControllerRoute.name, | ||||
| @ -299,71 +282,6 @@ class GalleryViewerRouteArgs { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// generated route for | ||||
| /// [ImageViewerPage] | ||||
| class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> { | ||||
|   ImageViewerRoute( | ||||
|       {Key? key, | ||||
|       required String heroTag, | ||||
|       required Asset asset, | ||||
|       required String authToken, | ||||
|       required void Function() isZoomedFunction, | ||||
|       required ValueNotifier<bool> isZoomedListener, | ||||
|       required bool loadPreview, | ||||
|       required bool loadOriginal, | ||||
|       void Function()? showExifSheet}) | ||||
|       : super(ImageViewerRoute.name, | ||||
|             path: '/image-viewer-page', | ||||
|             args: ImageViewerRouteArgs( | ||||
|                 key: key, | ||||
|                 heroTag: heroTag, | ||||
|                 asset: asset, | ||||
|                 authToken: authToken, | ||||
|                 isZoomedFunction: isZoomedFunction, | ||||
|                 isZoomedListener: isZoomedListener, | ||||
|                 loadPreview: loadPreview, | ||||
|                 loadOriginal: loadOriginal, | ||||
|                 showExifSheet: showExifSheet)); | ||||
| 
 | ||||
|   static const String name = 'ImageViewerRoute'; | ||||
| } | ||||
| 
 | ||||
| class ImageViewerRouteArgs { | ||||
|   const ImageViewerRouteArgs( | ||||
|       {this.key, | ||||
|       required this.heroTag, | ||||
|       required this.asset, | ||||
|       required this.authToken, | ||||
|       required this.isZoomedFunction, | ||||
|       required this.isZoomedListener, | ||||
|       required this.loadPreview, | ||||
|       required this.loadOriginal, | ||||
|       this.showExifSheet}); | ||||
| 
 | ||||
|   final Key? key; | ||||
| 
 | ||||
|   final String heroTag; | ||||
| 
 | ||||
|   final Asset asset; | ||||
| 
 | ||||
|   final String authToken; | ||||
| 
 | ||||
|   final void Function() isZoomedFunction; | ||||
| 
 | ||||
|   final ValueNotifier<bool> isZoomedListener; | ||||
| 
 | ||||
|   final bool loadPreview; | ||||
| 
 | ||||
|   final bool loadOriginal; | ||||
| 
 | ||||
|   final void Function()? showExifSheet; | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}'; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// generated route for | ||||
| /// [VideoViewerPage] | ||||
| class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> { | ||||
|  | ||||
							
								
								
									
										653
									
								
								mobile/lib/shared/ui/photo_view/photo_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										653
									
								
								mobile/lib/shared/ui/photo_view/photo_view.dart
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,653 @@ | ||||
| library photo_view; | ||||
| 
 | ||||
| import 'package:flutter/material.dart'; | ||||
| 
 | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_wrappers.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart'; | ||||
| 
 | ||||
| export 'src/controller/photo_view_controller.dart'; | ||||
| export 'src/controller/photo_view_scalestate_controller.dart'; | ||||
| export 'src/core/photo_view_gesture_detector.dart' | ||||
|     show PhotoViewGestureDetectorScope, PhotoViewPageViewScrollPhysics; | ||||
| export 'src/photo_view_computed_scale.dart'; | ||||
| export 'src/photo_view_scale_state.dart'; | ||||
| export 'src/utils/photo_view_hero_attributes.dart'; | ||||
| 
 | ||||
| /// A [StatefulWidget] that contains all the photo view rendering elements. | ||||
| /// | ||||
| /// Sample code to use within an image: | ||||
| /// | ||||
| /// ``` | ||||
| /// PhotoView( | ||||
| ///  imageProvider: imageProvider, | ||||
| ///  loadingBuilder: (context, progress) => Center( | ||||
| ///            child: Container( | ||||
| ///              width: 20.0, | ||||
| ///              height: 20.0, | ||||
| ///              child: CircularProgressIndicator( | ||||
| ///                value: _progress == null | ||||
| ///                    ? null | ||||
| ///                    : _progress.cumulativeBytesLoaded / | ||||
| ///                        _progress.expectedTotalBytes, | ||||
| ///              ), | ||||
| ///            ), | ||||
| ///          ), | ||||
| ///  backgroundDecoration: BoxDecoration(color: Colors.black), | ||||
| ///  gaplessPlayback: false, | ||||
| ///  customSize: MediaQuery.of(context).size, | ||||
| ///  heroAttributes: const HeroAttributes( | ||||
| ///   tag: "someTag", | ||||
| ///   transitionOnUserGestures: true, | ||||
| ///  ), | ||||
| ///  scaleStateChangedCallback: this.onScaleStateChanged, | ||||
| ///  enableRotation: true, | ||||
| ///  controller:  controller, | ||||
| ///  minScale: PhotoViewComputedScale.contained * 0.8, | ||||
| ///  maxScale: PhotoViewComputedScale.covered * 1.8, | ||||
| ///  initialScale: PhotoViewComputedScale.contained, | ||||
| ///  basePosition: Alignment.center, | ||||
| ///  scaleStateCycle: scaleStateCycle | ||||
| /// ); | ||||
| /// ``` | ||||
| /// | ||||
| /// You can customize to show an custom child instead of an image: | ||||
| /// | ||||
| /// ``` | ||||
| /// PhotoView.customChild( | ||||
| ///  child: Container( | ||||
| ///    width: 220.0, | ||||
| ///    height: 250.0, | ||||
| ///    child: const Text( | ||||
| ///      "Hello there, this is a text", | ||||
| ///    ) | ||||
| ///  ), | ||||
| ///  childSize: const Size(220.0, 250.0), | ||||
| ///  backgroundDecoration: BoxDecoration(color: Colors.black), | ||||
| ///  gaplessPlayback: false, | ||||
| ///  customSize: MediaQuery.of(context).size, | ||||
| ///  heroAttributes: const HeroAttributes( | ||||
| ///   tag: "someTag", | ||||
| ///   transitionOnUserGestures: true, | ||||
| ///  ), | ||||
| ///  scaleStateChangedCallback: this.onScaleStateChanged, | ||||
| ///  enableRotation: true, | ||||
| ///  controller:  controller, | ||||
| ///  minScale: PhotoViewComputedScale.contained * 0.8, | ||||
| ///  maxScale: PhotoViewComputedScale.covered * 1.8, | ||||
| ///  initialScale: PhotoViewComputedScale.contained, | ||||
| ///  basePosition: Alignment.center, | ||||
| ///  scaleStateCycle: scaleStateCycle | ||||
| /// ); | ||||
| /// ``` | ||||
| /// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant | ||||
| /// | ||||
| /// Sample using [maxScale], [minScale] and [initialScale] | ||||
| /// | ||||
| /// ``` | ||||
| /// PhotoView( | ||||
| ///  imageProvider: imageProvider, | ||||
| ///  minScale: PhotoViewComputedScale.contained * 0.8, | ||||
| ///  maxScale: PhotoViewComputedScale.covered * 1.8, | ||||
| ///  initialScale: PhotoViewComputedScale.contained * 1.1, | ||||
| /// ); | ||||
| /// ``` | ||||
| /// | ||||
| /// [customSize] is used to define the viewPort size in which the image will be | ||||
| /// scaled to. This argument is rarely used. By default is the size that this widget assumes. | ||||
| /// | ||||
| /// The argument [gaplessPlayback] is used to continue showing the old image | ||||
| /// (`true`), or briefly show nothing (`false`), when the [imageProvider] | ||||
| /// changes.By default it's set to `false`. | ||||
| /// | ||||
| /// To use within an hero animation, specify [heroAttributes]. When | ||||
| /// [heroAttributes] is specified, the image provider retrieval process should | ||||
| /// be sync. | ||||
| /// | ||||
| /// Sample using hero animation: | ||||
| /// ``` | ||||
| /// // screen1 | ||||
| ///   ... | ||||
| ///   Hero( | ||||
| ///     tag: "someTag", | ||||
| ///     child: Image.asset( | ||||
| ///       "assets/large-image.jpg", | ||||
| ///       width: 150.0 | ||||
| ///     ), | ||||
| ///   ) | ||||
| /// // screen2 | ||||
| /// ... | ||||
| /// child: PhotoView( | ||||
| ///   imageProvider: AssetImage("assets/large-image.jpg"), | ||||
| ///   heroAttributes: const HeroAttributes(tag: "someTag"), | ||||
| /// ) | ||||
| /// ``` | ||||
| /// | ||||
| /// **Note: If you don't want to the zoomed image do not overlaps the size of the container, use [ClipRect](https://docs.flutter.io/flutter/widgets/ClipRect-class.html)** | ||||
| /// | ||||
| /// ## Controllers | ||||
| /// | ||||
| /// Controllers, when specified to PhotoView widget, enables the author(you) to listen for state updates through a `Stream` and change those values externally. | ||||
| /// | ||||
| /// While [PhotoViewScaleStateController] is only responsible for the `scaleState`, [PhotoViewController] is responsible for all fields os [PhotoViewControllerValue]. | ||||
| /// | ||||
| /// To use them, pass a instance of those items on [controller] or [scaleStateController]; | ||||
| /// | ||||
| /// Since those follows the standard controller pattern found in widgets like [PageView] and [ScrollView], whoever instantiates it, should [dispose] it afterwards. | ||||
| /// | ||||
| /// Example of [controller] usage, only listening for state changes: | ||||
| /// | ||||
| /// ``` | ||||
| /// class _ExampleWidgetState extends State<ExampleWidget> { | ||||
| /// | ||||
| ///   PhotoViewController controller; | ||||
| ///   double scaleCopy; | ||||
| /// | ||||
| ///   @override | ||||
| ///   void initState() { | ||||
| ///     super.initState(); | ||||
| ///     controller = PhotoViewController() | ||||
| ///       ..outputStateStream.listen(listener); | ||||
| ///   } | ||||
| /// | ||||
| ///   @override | ||||
| ///   void dispose() { | ||||
| ///     controller.dispose(); | ||||
| ///     super.dispose(); | ||||
| ///   } | ||||
| /// | ||||
| ///   void listener(PhotoViewControllerValue value){ | ||||
| ///     setState((){ | ||||
| ///       scaleCopy = value.scale; | ||||
| ///     }) | ||||
| ///   } | ||||
| /// | ||||
| ///   @override | ||||
| ///   Widget build(BuildContext context) { | ||||
| ///     return Stack( | ||||
| ///       children: <Widget>[ | ||||
| ///         Positioned.fill( | ||||
| ///             child: PhotoView( | ||||
| ///               imageProvider: AssetImage("assets/pudim.png"), | ||||
| ///               controller: controller, | ||||
| ///             ); | ||||
| ///         ), | ||||
| ///         Text("Scale applied: $scaleCopy") | ||||
| ///       ], | ||||
| ///     ); | ||||
| ///   } | ||||
| /// } | ||||
| /// ``` | ||||
| /// | ||||
| /// An example of [scaleStateController] with state changes: | ||||
| /// ``` | ||||
| /// class _ExampleWidgetState extends State<ExampleWidget> { | ||||
| /// | ||||
| ///   PhotoViewScaleStateController scaleStateController; | ||||
| /// | ||||
| ///   @override | ||||
| ///   void initState() { | ||||
| ///     super.initState(); | ||||
| ///     scaleStateController = PhotoViewScaleStateController(); | ||||
| ///   } | ||||
| /// | ||||
| ///   @override | ||||
| ///   void dispose() { | ||||
| ///     scaleStateController.dispose(); | ||||
| ///     super.dispose(); | ||||
| ///   } | ||||
| /// | ||||
| ///   void goBack(){ | ||||
| ///     scaleStateController.scaleState = PhotoViewScaleState.originalSize; | ||||
| ///   } | ||||
| /// | ||||
| ///   @override | ||||
| ///   Widget build(BuildContext context) { | ||||
| ///     return Stack( | ||||
| ///       children: <Widget>[ | ||||
| ///         Positioned.fill( | ||||
| ///             child: PhotoView( | ||||
| ///               imageProvider: AssetImage("assets/pudim.png"), | ||||
| ///               scaleStateController: scaleStateController, | ||||
| ///             ); | ||||
| ///         ), | ||||
| ///         FlatButton( | ||||
| ///           child: Text("Go to original size"), | ||||
| ///           onPressed: goBack, | ||||
| ///         ); | ||||
| ///       ], | ||||
| ///     ); | ||||
| ///   } | ||||
| /// } | ||||
| /// ``` | ||||
| /// | ||||
| class PhotoView extends StatefulWidget { | ||||
|   /// Creates a widget that displays a zoomable image. | ||||
|   /// | ||||
|   /// To show an image from the network or from an asset bundle, use their respective | ||||
|   /// image providers, ie: [AssetImage] or [NetworkImage] | ||||
|   /// | ||||
|   /// Internally, the image is rendered within an [Image] widget. | ||||
|   const PhotoView({ | ||||
|     Key? key, | ||||
|     required this.imageProvider, | ||||
|     this.loadingBuilder, | ||||
|     this.backgroundDecoration, | ||||
|     this.wantKeepAlive = false, | ||||
|     this.gaplessPlayback = false, | ||||
|     this.heroAttributes, | ||||
|     this.scaleStateChangedCallback, | ||||
|     this.enableRotation = false, | ||||
|     this.controller, | ||||
|     this.scaleStateController, | ||||
|     this.maxScale, | ||||
|     this.minScale, | ||||
|     this.initialScale, | ||||
|     this.basePosition, | ||||
|     this.scaleStateCycle, | ||||
|     this.onTapUp, | ||||
|     this.onTapDown, | ||||
|     this.onDragStart, | ||||
|     this.onDragEnd, | ||||
|     this.onDragUpdate, | ||||
|     this.onScaleEnd, | ||||
|     this.customSize, | ||||
|     this.gestureDetectorBehavior, | ||||
|     this.tightMode, | ||||
|     this.filterQuality, | ||||
|     this.disableGestures, | ||||
|     this.errorBuilder, | ||||
|     this.enablePanAlways, | ||||
|   })  : child = null, | ||||
|         childSize = null, | ||||
|         super(key: key); | ||||
| 
 | ||||
|   /// Creates a widget that displays a zoomable child. | ||||
|   /// | ||||
|   /// It has been created to resemble [PhotoView] behavior within widgets that aren't an image, such as [Container], [Text] or a svg. | ||||
|   /// | ||||
|   /// Instead of a [imageProvider], this constructor will receive a [child] and a [childSize]. | ||||
|   /// | ||||
|   const PhotoView.customChild({ | ||||
|     Key? key, | ||||
|     required this.child, | ||||
|     this.childSize, | ||||
|     this.backgroundDecoration, | ||||
|     this.wantKeepAlive = false, | ||||
|     this.heroAttributes, | ||||
|     this.scaleStateChangedCallback, | ||||
|     this.enableRotation = false, | ||||
|     this.controller, | ||||
|     this.scaleStateController, | ||||
|     this.maxScale, | ||||
|     this.minScale, | ||||
|     this.initialScale, | ||||
|     this.basePosition, | ||||
|     this.scaleStateCycle, | ||||
|     this.onTapUp, | ||||
|     this.onTapDown, | ||||
|     this.onDragStart, | ||||
|     this.onDragEnd, | ||||
|     this.onDragUpdate, | ||||
|     this.onScaleEnd, | ||||
|     this.customSize, | ||||
|     this.gestureDetectorBehavior, | ||||
|     this.tightMode, | ||||
|     this.filterQuality, | ||||
|     this.disableGestures, | ||||
|     this.enablePanAlways, | ||||
|   })  : errorBuilder = null, | ||||
|         imageProvider = null, | ||||
|         gaplessPlayback = false, | ||||
|         loadingBuilder = null, | ||||
|         super(key: key); | ||||
| 
 | ||||
|   /// Given a [imageProvider] it resolves into an zoomable image widget using. It | ||||
|   /// is required | ||||
|   final ImageProvider? imageProvider; | ||||
| 
 | ||||
|   /// While [imageProvider] is not resolved, [loadingBuilder] is called by [PhotoView] | ||||
|   /// into the screen, by default it is a centered [CircularProgressIndicator] | ||||
|   final LoadingBuilder? loadingBuilder; | ||||
| 
 | ||||
|   /// Show loadFailedChild when the image failed to load | ||||
|   final ImageErrorWidgetBuilder? errorBuilder; | ||||
| 
 | ||||
|   /// Changes the background behind image, defaults to `Colors.black`. | ||||
|   final BoxDecoration? backgroundDecoration; | ||||
| 
 | ||||
|   /// This is used to keep the state of an image in the gallery (e.g. scale state). | ||||
|   /// `false` -> resets the state (default) | ||||
|   /// `true`  -> keeps the state | ||||
|   final bool wantKeepAlive; | ||||
| 
 | ||||
|   /// This is used to continue showing the old image (`true`), or briefly show | ||||
|   /// nothing (`false`), when the `imageProvider` changes. By default it's set | ||||
|   /// to `false`. | ||||
|   final bool gaplessPlayback; | ||||
| 
 | ||||
|   /// Attributes that are going to be passed to [PhotoViewCore]'s | ||||
|   /// [Hero]. Leave this property undefined if you don't want a hero animation. | ||||
|   final PhotoViewHeroAttributes? heroAttributes; | ||||
| 
 | ||||
|   /// Defines the size of the scaling base of the image inside [PhotoView], | ||||
|   /// by default it is `MediaQuery.of(context).size`. | ||||
|   final Size? customSize; | ||||
| 
 | ||||
|   /// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in. | ||||
|   final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback; | ||||
| 
 | ||||
|   /// A flag that enables the rotation gesture support | ||||
|   final bool enableRotation; | ||||
| 
 | ||||
|   /// The specified custom child to be shown instead of a image | ||||
|   final Widget? child; | ||||
| 
 | ||||
|   /// The size of the custom [child]. [PhotoView] uses this value to compute the relation between the child and the container's size to calculate the scale value. | ||||
|   final Size? childSize; | ||||
| 
 | ||||
|   /// Defines the maximum size in which the image will be allowed to assume, it | ||||
|   /// is proportional to the original image size. Can be either a double (absolute value) or a | ||||
|   /// [PhotoViewComputedScale], that can be multiplied by a double | ||||
|   final dynamic maxScale; | ||||
| 
 | ||||
|   /// Defines the minimum size in which the image will be allowed to assume, it | ||||
|   /// is proportional to the original image size. Can be either a double (absolute value) or a | ||||
|   /// [PhotoViewComputedScale], that can be multiplied by a double | ||||
|   final dynamic minScale; | ||||
| 
 | ||||
|   /// Defines the initial size in which the image will be assume in the mounting of the component, it | ||||
|   /// is proportional to the original image size. Can be either a double (absolute value) or a | ||||
|   /// [PhotoViewComputedScale], that can be multiplied by a double | ||||
|   final dynamic initialScale; | ||||
| 
 | ||||
|   /// A way to control PhotoView transformation factors externally and listen to its updates | ||||
|   final PhotoViewControllerBase? controller; | ||||
| 
 | ||||
|   /// A way to control PhotoViewScaleState value externally and listen to its updates | ||||
|   final PhotoViewScaleStateController? scaleStateController; | ||||
| 
 | ||||
|   /// The alignment of the scale origin in relation to the widget size. Default is [Alignment.center] | ||||
|   final Alignment? basePosition; | ||||
| 
 | ||||
|   /// Defines de next [PhotoViewScaleState] given the actual one. Default is [defaultScaleStateCycle] | ||||
|   final ScaleStateCycle? scaleStateCycle; | ||||
| 
 | ||||
|   /// A pointer that will trigger a tap has stopped contacting the screen at a | ||||
|   /// particular location. | ||||
|   final PhotoViewImageTapUpCallback? onTapUp; | ||||
| 
 | ||||
|   /// A pointer that might cause a tap has contacted the screen at a particular | ||||
|   /// location. | ||||
|   final PhotoViewImageTapDownCallback? onTapDown; | ||||
| 
 | ||||
|   /// A pointer that might cause a tap has contacted the screen at a particular | ||||
|   /// location. | ||||
|   final PhotoViewImageDragStartCallback? onDragStart; | ||||
| 
 | ||||
|   /// A pointer that might cause a tap has contacted the screen at a particular | ||||
|   /// location. | ||||
|   final PhotoViewImageDragEndCallback? onDragEnd; | ||||
| 
 | ||||
|   /// A pointer that might cause a tap has contacted the screen at a particular | ||||
|   /// location. | ||||
|   final PhotoViewImageDragUpdateCallback? onDragUpdate; | ||||
| 
 | ||||
|   /// A pointer that will trigger a scale has stopped contacting the screen at a | ||||
|   /// particular location. | ||||
|   final PhotoViewImageScaleEndCallback? onScaleEnd; | ||||
| 
 | ||||
|   /// [HitTestBehavior] to be passed to the internal gesture detector. | ||||
|   final HitTestBehavior? gestureDetectorBehavior; | ||||
| 
 | ||||
|   /// Enables tight mode, making background container assume the size of the image/child. | ||||
|   /// Useful when inside a [Dialog] | ||||
|   final bool? tightMode; | ||||
| 
 | ||||
|   /// Quality levels for image filters. | ||||
|   final FilterQuality? filterQuality; | ||||
| 
 | ||||
|   // Removes gesture detector if `true`. | ||||
|   // Useful when custom gesture detector is used in child widget. | ||||
|   final bool? disableGestures; | ||||
| 
 | ||||
|   /// Enable pan the widget even if it's smaller than the hole parent widget. | ||||
|   /// Useful when you want to drag a widget without restrictions. | ||||
|   final bool? enablePanAlways; | ||||
| 
 | ||||
|   bool get _isCustomChild { | ||||
|     return child != null; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   State<StatefulWidget> createState() { | ||||
|     return _PhotoViewState(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _PhotoViewState extends State<PhotoView> | ||||
|     with AutomaticKeepAliveClientMixin { | ||||
|   // image retrieval | ||||
| 
 | ||||
|   // controller | ||||
|   late bool _controlledController; | ||||
|   late PhotoViewControllerBase _controller; | ||||
|   late bool _controlledScaleStateController; | ||||
|   late PhotoViewScaleStateController _scaleStateController; | ||||
| 
 | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
| 
 | ||||
|     if (widget.controller == null) { | ||||
|       _controlledController = true; | ||||
|       _controller = PhotoViewController(); | ||||
|     } else { | ||||
|       _controlledController = false; | ||||
|       _controller = widget.controller!; | ||||
|     } | ||||
| 
 | ||||
|     if (widget.scaleStateController == null) { | ||||
|       _controlledScaleStateController = true; | ||||
|       _scaleStateController = PhotoViewScaleStateController(); | ||||
|     } else { | ||||
|       _controlledScaleStateController = false; | ||||
|       _scaleStateController = widget.scaleStateController!; | ||||
|     } | ||||
| 
 | ||||
|     _scaleStateController.outputScaleStateStream.listen(scaleStateListener); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void didUpdateWidget(PhotoView oldWidget) { | ||||
|     if (widget.controller == null) { | ||||
|       if (!_controlledController) { | ||||
|         _controlledController = true; | ||||
|         _controller = PhotoViewController(); | ||||
|       } | ||||
|     } else { | ||||
|       _controlledController = false; | ||||
|       _controller = widget.controller!; | ||||
|     } | ||||
| 
 | ||||
|     if (widget.scaleStateController == null) { | ||||
|       if (!_controlledScaleStateController) { | ||||
|         _controlledScaleStateController = true; | ||||
|         _scaleStateController = PhotoViewScaleStateController(); | ||||
|       } | ||||
|     } else { | ||||
|       _controlledScaleStateController = false; | ||||
|       _scaleStateController = widget.scaleStateController!; | ||||
|     } | ||||
|     super.didUpdateWidget(oldWidget); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void dispose() { | ||||
|     if (_controlledController) { | ||||
|       _controller.dispose(); | ||||
|     } | ||||
|     if (_controlledScaleStateController) { | ||||
|       _scaleStateController.dispose(); | ||||
|     } | ||||
|     super.dispose(); | ||||
|   } | ||||
| 
 | ||||
|   void scaleStateListener(PhotoViewScaleState scaleState) { | ||||
|     if (widget.scaleStateChangedCallback != null) { | ||||
|       widget.scaleStateChangedCallback!(_scaleStateController.scaleState); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     super.build(context); | ||||
|     return LayoutBuilder( | ||||
|       builder: ( | ||||
|         BuildContext context, | ||||
|         BoxConstraints constraints, | ||||
|       ) { | ||||
|         final computedOuterSize = widget.customSize ?? constraints.biggest; | ||||
|         final backgroundDecoration = widget.backgroundDecoration ?? | ||||
|             const BoxDecoration(color: Colors.black); | ||||
| 
 | ||||
|         return widget._isCustomChild | ||||
|             ? CustomChildWrapper( | ||||
|                 childSize: widget.childSize, | ||||
|                 backgroundDecoration: backgroundDecoration, | ||||
|                 heroAttributes: widget.heroAttributes, | ||||
|                 scaleStateChangedCallback: widget.scaleStateChangedCallback, | ||||
|                 enableRotation: widget.enableRotation, | ||||
|                 controller: _controller, | ||||
|                 scaleStateController: _scaleStateController, | ||||
|                 maxScale: widget.maxScale, | ||||
|                 minScale: widget.minScale, | ||||
|                 initialScale: widget.initialScale, | ||||
|                 basePosition: widget.basePosition, | ||||
|                 scaleStateCycle: widget.scaleStateCycle, | ||||
|                 onTapUp: widget.onTapUp, | ||||
|                 onTapDown: widget.onTapDown, | ||||
|                 onDragStart: widget.onDragStart, | ||||
|                 onDragEnd: widget.onDragEnd, | ||||
|                 onDragUpdate: widget.onDragUpdate, | ||||
|                 onScaleEnd: widget.onScaleEnd, | ||||
|                 outerSize: computedOuterSize, | ||||
|                 gestureDetectorBehavior: widget.gestureDetectorBehavior, | ||||
|                 tightMode: widget.tightMode, | ||||
|                 filterQuality: widget.filterQuality, | ||||
|                 disableGestures: widget.disableGestures, | ||||
|                 enablePanAlways: widget.enablePanAlways, | ||||
|                 child: widget.child, | ||||
|               ) | ||||
|             : ImageWrapper( | ||||
|                 imageProvider: widget.imageProvider!, | ||||
|                 loadingBuilder: widget.loadingBuilder, | ||||
|                 backgroundDecoration: backgroundDecoration, | ||||
|                 gaplessPlayback: widget.gaplessPlayback, | ||||
|                 heroAttributes: widget.heroAttributes, | ||||
|                 scaleStateChangedCallback: widget.scaleStateChangedCallback, | ||||
|                 enableRotation: widget.enableRotation, | ||||
|                 controller: _controller, | ||||
|                 scaleStateController: _scaleStateController, | ||||
|                 maxScale: widget.maxScale, | ||||
|                 minScale: widget.minScale, | ||||
|                 initialScale: widget.initialScale, | ||||
|                 basePosition: widget.basePosition, | ||||
|                 scaleStateCycle: widget.scaleStateCycle, | ||||
|                 onTapUp: widget.onTapUp, | ||||
|                 onTapDown: widget.onTapDown, | ||||
|                 onDragStart: widget.onDragStart, | ||||
|                 onDragEnd: widget.onDragEnd, | ||||
|                 onDragUpdate: widget.onDragUpdate, | ||||
|                 onScaleEnd: widget.onScaleEnd, | ||||
|                 outerSize: computedOuterSize, | ||||
|                 gestureDetectorBehavior: widget.gestureDetectorBehavior, | ||||
|                 tightMode: widget.tightMode, | ||||
|                 filterQuality: widget.filterQuality, | ||||
|                 disableGestures: widget.disableGestures, | ||||
|                 errorBuilder: widget.errorBuilder, | ||||
|                 enablePanAlways: widget.enablePanAlways, | ||||
|               ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool get wantKeepAlive => widget.wantKeepAlive; | ||||
| } | ||||
| 
 | ||||
| /// The default [ScaleStateCycle] | ||||
| PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) { | ||||
|   switch (actual) { | ||||
|     case PhotoViewScaleState.initial: | ||||
|       return PhotoViewScaleState.covering; | ||||
|     case PhotoViewScaleState.covering: | ||||
|       return PhotoViewScaleState.originalSize; | ||||
|     case PhotoViewScaleState.originalSize: | ||||
|       return PhotoViewScaleState.initial; | ||||
|     case PhotoViewScaleState.zoomedIn: | ||||
|     case PhotoViewScaleState.zoomedOut: | ||||
|       return PhotoViewScaleState.initial; | ||||
|     default: | ||||
|       return PhotoViewScaleState.initial; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one | ||||
| /// It is used internally to walk in the "doubletap gesture cycle". | ||||
| /// It is passed to [PhotoView.scaleStateCycle] | ||||
| typedef ScaleStateCycle = PhotoViewScaleState Function( | ||||
|   PhotoViewScaleState actual, | ||||
| ); | ||||
| 
 | ||||
| /// A type definition for a callback when the user taps up the photoview region | ||||
| typedef PhotoViewImageTapUpCallback = Function( | ||||
|   BuildContext context, | ||||
|   TapUpDetails details, | ||||
|   PhotoViewControllerValue controllerValue, | ||||
| ); | ||||
| 
 | ||||
| /// A type definition for a callback when the user taps down the photoview region | ||||
| typedef PhotoViewImageTapDownCallback = Function( | ||||
|   BuildContext context, | ||||
|   TapDownDetails details, | ||||
|   PhotoViewControllerValue controllerValue, | ||||
| ); | ||||
| 
 | ||||
| /// A type definition for a callback when the user drags up | ||||
| typedef PhotoViewImageDragStartCallback = Function( | ||||
|   BuildContext context, | ||||
|   DragStartDetails details, | ||||
|   PhotoViewControllerValue controllerValue, | ||||
| ); | ||||
| 
 | ||||
| /// A type definition for a callback when the user drags  | ||||
| typedef PhotoViewImageDragUpdateCallback = Function( | ||||
|   BuildContext context, | ||||
|   DragUpdateDetails details, | ||||
|   PhotoViewControllerValue controllerValue, | ||||
| ); | ||||
| 
 | ||||
| /// A type definition for a callback when the user taps down the photoview region | ||||
| typedef PhotoViewImageDragEndCallback = Function( | ||||
|   BuildContext context, | ||||
|   DragEndDetails details, | ||||
|   PhotoViewControllerValue controllerValue, | ||||
| ); | ||||
| 
 | ||||
| /// A type definition for a callback when a user finished scale | ||||
| typedef PhotoViewImageScaleEndCallback = Function( | ||||
|   BuildContext context, | ||||
|   ScaleEndDetails details, | ||||
|   PhotoViewControllerValue controllerValue, | ||||
| ); | ||||
| 
 | ||||
| /// A type definition for a callback to show a widget while the image is loading, a [ImageChunkEvent] is passed to inform progress | ||||
| typedef LoadingBuilder = Widget Function( | ||||
|   BuildContext context, | ||||
|   ImageChunkEvent? event, | ||||
| ); | ||||
							
								
								
									
										446
									
								
								mobile/lib/shared/ui/photo_view/photo_view_gallery.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										446
									
								
								mobile/lib/shared/ui/photo_view/photo_view_gallery.dart
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,446 @@ | ||||
| library photo_view_gallery; | ||||
| 
 | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart' | ||||
|     show | ||||
|         LoadingBuilder, | ||||
|         PhotoView, | ||||
|         PhotoViewImageTapDownCallback, | ||||
|         PhotoViewImageTapUpCallback, | ||||
|         PhotoViewImageDragStartCallback, | ||||
|         PhotoViewImageDragEndCallback, | ||||
|         PhotoViewImageDragUpdateCallback, | ||||
|         PhotoViewImageScaleEndCallback, | ||||
|         ScaleStateCycle; | ||||
| 
 | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart'; | ||||
| 
 | ||||
| /// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery] | ||||
| typedef PhotoViewGalleryPageChangedCallback = void Function(int index); | ||||
| 
 | ||||
| /// A type definition for a [Function] that defines a page in [PhotoViewGallery.build] | ||||
| typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function( | ||||
|   BuildContext context,  | ||||
|   int index, | ||||
| ); | ||||
| 
 | ||||
| /// A [StatefulWidget] that shows multiple [PhotoView] widgets in a [PageView] | ||||
| /// | ||||
| /// Some of [PhotoView] constructor options are passed direct to [PhotoViewGallery] constructor. Those options will affect the gallery in a whole. | ||||
| /// | ||||
| /// Some of the options may be defined to each image individually, such as `initialScale` or `PhotoViewHeroAttributes`. Those must be passed via each [PhotoViewGalleryPageOptions]. | ||||
| /// | ||||
| /// Example of usage as a list of options: | ||||
| /// ``` | ||||
| /// PhotoViewGallery( | ||||
| ///   pageOptions: <PhotoViewGalleryPageOptions>[ | ||||
| ///     PhotoViewGalleryPageOptions( | ||||
| ///       imageProvider: AssetImage("assets/gallery1.jpg"), | ||||
| ///       heroAttributes: const PhotoViewHeroAttributes(tag: "tag1"), | ||||
| ///     ), | ||||
| ///     PhotoViewGalleryPageOptions( | ||||
| ///       imageProvider: AssetImage("assets/gallery2.jpg"), | ||||
| ///       heroAttributes: const PhotoViewHeroAttributes(tag: "tag2"), | ||||
| ///       maxScale: PhotoViewComputedScale.contained * 0.3 | ||||
| ///     ), | ||||
| ///     PhotoViewGalleryPageOptions( | ||||
| ///       imageProvider: AssetImage("assets/gallery3.jpg"), | ||||
| ///       minScale: PhotoViewComputedScale.contained * 0.8, | ||||
| ///       maxScale: PhotoViewComputedScale.covered * 1.1, | ||||
| ///       heroAttributes: const HeroAttributes(tag: "tag3"), | ||||
| ///     ), | ||||
| ///   ], | ||||
| ///   loadingBuilder: (context, progress) => Center( | ||||
| ///            child: Container( | ||||
| ///              width: 20.0, | ||||
| ///              height: 20.0, | ||||
| ///              child: CircularProgressIndicator( | ||||
| ///                value: _progress == null | ||||
| ///                    ? null | ||||
| ///                    : _progress.cumulativeBytesLoaded / | ||||
| ///                        _progress.expectedTotalBytes, | ||||
| ///              ), | ||||
| ///            ), | ||||
| ///          ), | ||||
| ///   backgroundDecoration: widget.backgroundDecoration, | ||||
| ///   pageController: widget.pageController, | ||||
| ///   onPageChanged: onPageChanged, | ||||
| /// ) | ||||
| /// ``` | ||||
| /// | ||||
| /// Example of usage with builder pattern: | ||||
| /// ``` | ||||
| /// PhotoViewGallery.builder( | ||||
| ///   scrollPhysics: const BouncingScrollPhysics(), | ||||
| ///   builder: (BuildContext context, int index) { | ||||
| ///     return PhotoViewGalleryPageOptions( | ||||
| ///       imageProvider: AssetImage(widget.galleryItems[index].image), | ||||
| ///       initialScale: PhotoViewComputedScale.contained * 0.8, | ||||
| ///       minScale: PhotoViewComputedScale.contained * 0.8, | ||||
| ///       maxScale: PhotoViewComputedScale.covered * 1.1, | ||||
| ///       heroAttributes: HeroAttributes(tag: galleryItems[index].id), | ||||
| ///     ); | ||||
| ///   }, | ||||
| ///   itemCount: galleryItems.length, | ||||
| ///   loadingBuilder: (context, progress) => Center( | ||||
| ///            child: Container( | ||||
| ///              width: 20.0, | ||||
| ///              height: 20.0, | ||||
| ///              child: CircularProgressIndicator( | ||||
| ///                value: _progress == null | ||||
| ///                    ? null | ||||
| ///                    : _progress.cumulativeBytesLoaded / | ||||
| ///                        _progress.expectedTotalBytes, | ||||
| ///              ), | ||||
| ///            ), | ||||
| ///          ), | ||||
| ///   backgroundDecoration: widget.backgroundDecoration, | ||||
| ///   pageController: widget.pageController, | ||||
| ///   onPageChanged: onPageChanged, | ||||
| /// ) | ||||
| /// ``` | ||||
| class PhotoViewGallery extends StatefulWidget { | ||||
|   /// Construct a gallery with static items through a list of [PhotoViewGalleryPageOptions]. | ||||
|   const PhotoViewGallery({ | ||||
|     Key? key, | ||||
|     required this.pageOptions, | ||||
|     this.loadingBuilder, | ||||
|     this.backgroundDecoration, | ||||
|     this.wantKeepAlive = false, | ||||
|     this.gaplessPlayback = false, | ||||
|     this.reverse = false, | ||||
|     this.pageController, | ||||
|     this.onPageChanged, | ||||
|     this.scaleStateChangedCallback, | ||||
|     this.enableRotation = false, | ||||
|     this.scrollPhysics, | ||||
|     this.scrollDirection = Axis.horizontal, | ||||
|     this.customSize, | ||||
|     this.allowImplicitScrolling = false, | ||||
|   })  : itemCount = null, | ||||
|         builder = null, | ||||
|         super(key: key); | ||||
| 
 | ||||
|   /// Construct a gallery with dynamic items. | ||||
|   /// | ||||
|   /// The builder must return a [PhotoViewGalleryPageOptions]. | ||||
|   const PhotoViewGallery.builder({ | ||||
|     Key? key, | ||||
|     required this.itemCount, | ||||
|     required this.builder, | ||||
|     this.loadingBuilder, | ||||
|     this.backgroundDecoration, | ||||
|     this.wantKeepAlive = false, | ||||
|     this.gaplessPlayback = false, | ||||
|     this.reverse = false, | ||||
|     this.pageController, | ||||
|     this.onPageChanged, | ||||
|     this.scaleStateChangedCallback, | ||||
|     this.enableRotation = false, | ||||
|     this.scrollPhysics, | ||||
|     this.scrollDirection = Axis.horizontal, | ||||
|     this.customSize, | ||||
|     this.allowImplicitScrolling = false, | ||||
|   })  : pageOptions = null, | ||||
|         assert(itemCount != null), | ||||
|         assert(builder != null), | ||||
|         super(key: key); | ||||
| 
 | ||||
|   /// A list of options to describe the items in the gallery | ||||
|   final List<PhotoViewGalleryPageOptions>? pageOptions; | ||||
| 
 | ||||
|   /// The count of items in the gallery, only used when constructed via [PhotoViewGallery.builder] | ||||
|   final int? itemCount; | ||||
| 
 | ||||
|   /// Called to build items for the gallery when using [PhotoViewGallery.builder] | ||||
|   final PhotoViewGalleryBuilder? builder; | ||||
| 
 | ||||
|   /// [ScrollPhysics] for the internal [PageView] | ||||
|   final ScrollPhysics? scrollPhysics; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.loadingBuilder] | ||||
|   final LoadingBuilder? loadingBuilder; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.backgroundDecoration] | ||||
|   final BoxDecoration? backgroundDecoration; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.wantKeepAlive] | ||||
|   final bool wantKeepAlive; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.gaplessPlayback] | ||||
|   final bool gaplessPlayback; | ||||
| 
 | ||||
|   /// Mirror to [PageView.reverse] | ||||
|   final bool reverse; | ||||
| 
 | ||||
|   /// An object that controls the [PageView] inside [PhotoViewGallery] | ||||
|   final PageController? pageController; | ||||
| 
 | ||||
|   /// An callback to be called on a page change | ||||
|   final PhotoViewGalleryPageChangedCallback? onPageChanged; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.scaleStateChangedCallback] | ||||
|   final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.enableRotation] | ||||
|   final bool enableRotation; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.customSize] | ||||
|   final Size? customSize; | ||||
| 
 | ||||
|   /// The axis along which the [PageView] scrolls. Mirror to [PageView.scrollDirection] | ||||
|   final Axis scrollDirection; | ||||
| 
 | ||||
|   /// When user attempts to move it to the next element, focus will traverse to the next page in the page view. | ||||
|   final bool allowImplicitScrolling; | ||||
| 
 | ||||
|   bool get _isBuilder => builder != null; | ||||
| 
 | ||||
|   @override | ||||
|   State<StatefulWidget> createState() { | ||||
|     return _PhotoViewGalleryState(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _PhotoViewGalleryState extends State<PhotoViewGallery> { | ||||
|   late final PageController _controller = | ||||
|       widget.pageController ?? PageController(); | ||||
| 
 | ||||
|   void scaleStateChangedCallback(PhotoViewScaleState scaleState) { | ||||
|     if (widget.scaleStateChangedCallback != null) { | ||||
|       widget.scaleStateChangedCallback!(scaleState); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   int get actualPage { | ||||
|     return _controller.hasClients ? _controller.page!.floor() : 0; | ||||
|   } | ||||
| 
 | ||||
|   int get itemCount { | ||||
|     if (widget._isBuilder) { | ||||
|       return widget.itemCount!; | ||||
|     } | ||||
|     return widget.pageOptions!.length; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     // Enable corner hit test | ||||
|     return PhotoViewGestureDetectorScope( | ||||
|       axis: widget.scrollDirection, | ||||
|       child: PageView.builder( | ||||
|         reverse: widget.reverse, | ||||
|         controller: _controller, | ||||
|         onPageChanged: widget.onPageChanged, | ||||
|         itemCount: itemCount, | ||||
|         itemBuilder: _buildItem, | ||||
|         scrollDirection: widget.scrollDirection, | ||||
|         physics: widget.scrollPhysics, | ||||
|         allowImplicitScrolling: widget.allowImplicitScrolling, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   Widget _buildItem(BuildContext context, int index) { | ||||
|     final pageOption = _buildPageOption(context, index); | ||||
|     final isCustomChild = pageOption.child != null; | ||||
| 
 | ||||
|     final PhotoView photoView = isCustomChild | ||||
|         ? PhotoView.customChild( | ||||
|             key: ObjectKey(index), | ||||
|             childSize: pageOption.childSize, | ||||
|             backgroundDecoration: widget.backgroundDecoration, | ||||
|             wantKeepAlive: widget.wantKeepAlive, | ||||
|             controller: pageOption.controller, | ||||
|             scaleStateController: pageOption.scaleStateController, | ||||
|             customSize: widget.customSize, | ||||
|             heroAttributes: pageOption.heroAttributes, | ||||
|             scaleStateChangedCallback: scaleStateChangedCallback, | ||||
|             enableRotation: widget.enableRotation, | ||||
|             initialScale: pageOption.initialScale, | ||||
|             minScale: pageOption.minScale, | ||||
|             maxScale: pageOption.maxScale, | ||||
|             scaleStateCycle: pageOption.scaleStateCycle, | ||||
|             onTapUp: pageOption.onTapUp, | ||||
|             onTapDown: pageOption.onTapDown, | ||||
|             onDragStart: pageOption.onDragStart, | ||||
|             onDragEnd: pageOption.onDragEnd, | ||||
|             onDragUpdate: pageOption.onDragUpdate, | ||||
|             onScaleEnd: pageOption.onScaleEnd, | ||||
|             gestureDetectorBehavior: pageOption.gestureDetectorBehavior, | ||||
|             tightMode: pageOption.tightMode, | ||||
|             filterQuality: pageOption.filterQuality, | ||||
|             basePosition: pageOption.basePosition, | ||||
|             disableGestures: pageOption.disableGestures, | ||||
|             child: pageOption.child, | ||||
|           ) | ||||
|         : PhotoView( | ||||
|             key: ObjectKey(index), | ||||
|             imageProvider: pageOption.imageProvider, | ||||
|             loadingBuilder: widget.loadingBuilder, | ||||
|             backgroundDecoration: widget.backgroundDecoration, | ||||
|             wantKeepAlive: widget.wantKeepAlive, | ||||
|             controller: pageOption.controller, | ||||
|             scaleStateController: pageOption.scaleStateController, | ||||
|             customSize: widget.customSize, | ||||
|             gaplessPlayback: widget.gaplessPlayback, | ||||
|             heroAttributes: pageOption.heroAttributes, | ||||
|             scaleStateChangedCallback: scaleStateChangedCallback, | ||||
|             enableRotation: widget.enableRotation, | ||||
|             initialScale: pageOption.initialScale, | ||||
|             minScale: pageOption.minScale, | ||||
|             maxScale: pageOption.maxScale, | ||||
|             scaleStateCycle: pageOption.scaleStateCycle, | ||||
|             onTapUp: pageOption.onTapUp, | ||||
|             onTapDown: pageOption.onTapDown, | ||||
|             onDragStart: pageOption.onDragStart, | ||||
|             onDragEnd: pageOption.onDragEnd, | ||||
|             onDragUpdate: pageOption.onDragUpdate, | ||||
|             onScaleEnd: pageOption.onScaleEnd, | ||||
|             gestureDetectorBehavior: pageOption.gestureDetectorBehavior, | ||||
|             tightMode: pageOption.tightMode, | ||||
|             filterQuality: pageOption.filterQuality, | ||||
|             basePosition: pageOption.basePosition, | ||||
|             disableGestures: pageOption.disableGestures, | ||||
|             errorBuilder: pageOption.errorBuilder, | ||||
|           ); | ||||
| 
 | ||||
|     return ClipRect( | ||||
|       child: photoView, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   PhotoViewGalleryPageOptions _buildPageOption(BuildContext context, int index) { | ||||
|     if (widget._isBuilder) { | ||||
|       return widget.builder!(context, index); | ||||
|     } | ||||
|     return widget.pageOptions![index]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// A helper class that wraps individual options of a page in [PhotoViewGallery] | ||||
| /// | ||||
| /// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant | ||||
| /// | ||||
| class PhotoViewGalleryPageOptions { | ||||
|   PhotoViewGalleryPageOptions({ | ||||
|     Key? key, | ||||
|     required this.imageProvider, | ||||
|     this.heroAttributes, | ||||
|     this.minScale, | ||||
|     this.maxScale, | ||||
|     this.initialScale, | ||||
|     this.controller, | ||||
|     this.scaleStateController, | ||||
|     this.basePosition, | ||||
|     this.scaleStateCycle, | ||||
|     this.onTapUp, | ||||
|     this.onTapDown, | ||||
|     this.onDragStart, | ||||
|     this.onDragEnd, | ||||
|     this.onDragUpdate, | ||||
|     this.onScaleEnd, | ||||
|     this.gestureDetectorBehavior, | ||||
|     this.tightMode, | ||||
|     this.filterQuality, | ||||
|     this.disableGestures, | ||||
|     this.errorBuilder, | ||||
|   })  : child = null, | ||||
|         childSize = null, | ||||
|         assert(imageProvider != null); | ||||
| 
 | ||||
|   PhotoViewGalleryPageOptions.customChild({ | ||||
|     required this.child, | ||||
|     this.childSize, | ||||
|     this.heroAttributes, | ||||
|     this.minScale, | ||||
|     this.maxScale, | ||||
|     this.initialScale, | ||||
|     this.controller, | ||||
|     this.scaleStateController, | ||||
|     this.basePosition, | ||||
|     this.scaleStateCycle, | ||||
|     this.onTapUp, | ||||
|     this.onTapDown, | ||||
|     this.onDragStart, | ||||
|     this.onDragEnd, | ||||
|     this.onDragUpdate, | ||||
|     this.onScaleEnd, | ||||
|     this.gestureDetectorBehavior, | ||||
|     this.tightMode, | ||||
|     this.filterQuality, | ||||
|     this.disableGestures, | ||||
|   })  : errorBuilder = null, | ||||
|         imageProvider = null; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.imageProvider] | ||||
|   final ImageProvider? imageProvider; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.heroAttributes] | ||||
|   final PhotoViewHeroAttributes? heroAttributes; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.minScale] | ||||
|   final dynamic minScale; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.maxScale] | ||||
|   final dynamic maxScale; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.initialScale] | ||||
|   final dynamic initialScale; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.controller] | ||||
|   final PhotoViewController? controller; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.scaleStateController] | ||||
|   final PhotoViewScaleStateController? scaleStateController; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.basePosition] | ||||
|   final Alignment? basePosition; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.child] | ||||
|   final Widget? child; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.childSize] | ||||
|   final Size? childSize; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.scaleStateCycle] | ||||
|   final ScaleStateCycle? scaleStateCycle; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.onTapUp] | ||||
|   final PhotoViewImageTapUpCallback? onTapUp; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.onDragUp] | ||||
|   final PhotoViewImageDragStartCallback? onDragStart; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.onDragDown] | ||||
|   final PhotoViewImageDragEndCallback? onDragEnd; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.onDraUpdate] | ||||
|   final PhotoViewImageDragUpdateCallback? onDragUpdate; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.onTapDown] | ||||
|   final PhotoViewImageTapDownCallback? onTapDown; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.onScaleEnd] | ||||
|   final PhotoViewImageScaleEndCallback? onScaleEnd; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.gestureDetectorBehavior] | ||||
|   final HitTestBehavior? gestureDetectorBehavior; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.tightMode] | ||||
|   final bool? tightMode; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.disableGestures] | ||||
|   final bool? disableGestures; | ||||
| 
 | ||||
|   /// Quality levels for image filters. | ||||
|   final FilterQuality? filterQuality; | ||||
| 
 | ||||
|   /// Mirror to [PhotoView.errorBuilder] | ||||
|   final ImageErrorWidgetBuilder? errorBuilder; | ||||
| } | ||||
| @ -0,0 +1,291 @@ | ||||
| import 'dart:async'; | ||||
| 
 | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart'; | ||||
| 
 | ||||
| /// The interface in which controllers will be implemented. | ||||
| /// | ||||
| /// It concerns storing the state ([PhotoViewControllerValue]) and streaming its updates. | ||||
| /// [PhotoViewImageWrapper] will respond to user gestures setting thew fields in the instance of a controller. | ||||
| /// | ||||
| /// Any instance of a controller must be disposed after unmount. So if you instantiate a [PhotoViewController] or your custom implementation, do not forget to dispose it when not using it anymore. | ||||
| /// | ||||
| /// The controller exposes value fields like [scale] or [rotationFocus]. Usually those fields will be only getters and setters serving as hooks to the internal [PhotoViewControllerValue]. | ||||
| /// | ||||
| /// The default implementation used by [PhotoView] is [PhotoViewController]. | ||||
| /// | ||||
| /// This was created to allow customization (you can create your own controller class) | ||||
| /// | ||||
| /// Previously it controlled `scaleState` as well, but duw to some [concerns](https://github.com/renancaraujo/photo_view/issues/127) | ||||
| /// [ScaleStateListener is responsible for tat value now | ||||
| /// | ||||
| /// As it is a controller, whoever instantiates it, should [dispose] it afterwards. | ||||
| /// | ||||
| abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> { | ||||
|   /// The output for state/value updates. Usually a broadcast [Stream] | ||||
|   Stream<T> get outputStateStream; | ||||
| 
 | ||||
|   /// The state value before the last change or the initial state if the state has not been changed. | ||||
|   late T prevValue; | ||||
| 
 | ||||
|   /// The actual state value | ||||
|   late T value; | ||||
| 
 | ||||
|   /// Resets the state to the initial value; | ||||
|   void reset(); | ||||
| 
 | ||||
|   /// Closes streams and removes eventual listeners. | ||||
|   void dispose(); | ||||
| 
 | ||||
|   /// Add a listener that will ignore updates made internally | ||||
|   /// | ||||
|   /// Since it is made for internal use, it is not performatic to use more than one | ||||
|   /// listener. Prefer [outputStateStream] | ||||
|   void addIgnorableListener(VoidCallback callback); | ||||
| 
 | ||||
|   /// Remove a listener that will ignore updates made internally | ||||
|   /// | ||||
|   /// Since it is made for internal use, it is not performatic to use more than one | ||||
|   /// listener. Prefer [outputStateStream] | ||||
|   void removeIgnorableListener(VoidCallback callback); | ||||
| 
 | ||||
|   /// The position of the image in the screen given its offset after pan gestures. | ||||
|   late Offset position; | ||||
| 
 | ||||
|   /// The scale factor to transform the child (image or a customChild). | ||||
|   late double? scale; | ||||
| 
 | ||||
|   /// Nevermind this method :D, look away | ||||
|   void setScaleInvisibly(double? scale); | ||||
| 
 | ||||
|   /// The rotation factor to transform the child (image or a customChild). | ||||
|   late double rotation; | ||||
| 
 | ||||
|   /// The center of the rotation transformation. It is a coordinate referring to the absolute dimensions of the image. | ||||
|   Offset? rotationFocusPoint; | ||||
| 
 | ||||
|   /// Update multiple fields of the state with only one update streamed. | ||||
|   void updateMultiple({ | ||||
|     Offset? position, | ||||
|     double? scale, | ||||
|     double? rotation, | ||||
|     Offset? rotationFocusPoint, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| /// The state value stored and streamed by [PhotoViewController]. | ||||
| @immutable | ||||
| class PhotoViewControllerValue { | ||||
|   const PhotoViewControllerValue({ | ||||
|     required this.position, | ||||
|     required this.scale, | ||||
|     required this.rotation, | ||||
|     required this.rotationFocusPoint, | ||||
|   }); | ||||
| 
 | ||||
|   final Offset position; | ||||
|   final double? scale; | ||||
|   final double rotation; | ||||
|   final Offset? rotationFocusPoint; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is PhotoViewControllerValue && | ||||
|           runtimeType == other.runtimeType && | ||||
|           position == other.position && | ||||
|           scale == other.scale && | ||||
|           rotation == other.rotation && | ||||
|           rotationFocusPoint == other.rotationFocusPoint; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|       position.hashCode ^ | ||||
|       scale.hashCode ^ | ||||
|       rotation.hashCode ^ | ||||
|       rotationFocusPoint.hashCode; | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'PhotoViewControllerValue{position: $position, scale: $scale, rotation: $rotation, rotationFocusPoint: $rotationFocusPoint}'; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// The default implementation of [PhotoViewControllerBase]. | ||||
| /// | ||||
| /// Containing a [ValueNotifier] it stores the state in the [value] field and streams | ||||
| /// updates via [outputStateStream]. | ||||
| /// | ||||
| /// For details of fields and methods, check [PhotoViewControllerBase]. | ||||
| /// | ||||
| class PhotoViewController | ||||
|     implements PhotoViewControllerBase<PhotoViewControllerValue> { | ||||
|   PhotoViewController({ | ||||
|     Offset initialPosition = Offset.zero, | ||||
|     double initialRotation = 0.0, | ||||
|     double? initialScale, | ||||
|   })  : _valueNotifier = IgnorableValueNotifier( | ||||
|           PhotoViewControllerValue( | ||||
|             position: initialPosition, | ||||
|             rotation: initialRotation, | ||||
|             scale: initialScale, | ||||
|             rotationFocusPoint: null, | ||||
|           ), | ||||
|         ), | ||||
|         super() { | ||||
|     initial = value; | ||||
|     prevValue = initial; | ||||
| 
 | ||||
|     _valueNotifier.addListener(_changeListener); | ||||
|     _outputCtrl = StreamController<PhotoViewControllerValue>.broadcast(); | ||||
|     _outputCtrl.sink.add(initial); | ||||
|   } | ||||
| 
 | ||||
|   final IgnorableValueNotifier<PhotoViewControllerValue> _valueNotifier; | ||||
| 
 | ||||
|   late PhotoViewControllerValue initial; | ||||
| 
 | ||||
|   late StreamController<PhotoViewControllerValue> _outputCtrl; | ||||
| 
 | ||||
|   @override | ||||
|   Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream; | ||||
| 
 | ||||
|   @override | ||||
|   late PhotoViewControllerValue prevValue; | ||||
| 
 | ||||
|   @override | ||||
|   void reset() { | ||||
|     value = initial; | ||||
|   } | ||||
| 
 | ||||
|   void _changeListener() { | ||||
|     _outputCtrl.sink.add(value); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void addIgnorableListener(VoidCallback callback) { | ||||
|     _valueNotifier.addIgnorableListener(callback); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void removeIgnorableListener(VoidCallback callback) { | ||||
|     _valueNotifier.removeIgnorableListener(callback); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _outputCtrl.close(); | ||||
|     _valueNotifier.dispose(); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   set position(Offset position) { | ||||
|     if (value.position == position) { | ||||
|       return; | ||||
|     } | ||||
|     prevValue = value; | ||||
|     value = PhotoViewControllerValue( | ||||
|       position: position, | ||||
|       scale: scale, | ||||
|       rotation: rotation, | ||||
|       rotationFocusPoint: rotationFocusPoint, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Offset get position => value.position; | ||||
| 
 | ||||
|   @override | ||||
|   set scale(double? scale) { | ||||
|     if (value.scale == scale) { | ||||
|       return; | ||||
|     } | ||||
|     prevValue = value; | ||||
|     value = PhotoViewControllerValue( | ||||
|       position: position, | ||||
|       scale: scale, | ||||
|       rotation: rotation, | ||||
|       rotationFocusPoint: rotationFocusPoint, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   double? get scale => value.scale; | ||||
| 
 | ||||
|   @override | ||||
|   void setScaleInvisibly(double? scale) { | ||||
|     if (value.scale == scale) { | ||||
|       return; | ||||
|     } | ||||
|     prevValue = value; | ||||
|     _valueNotifier.updateIgnoring( | ||||
|       PhotoViewControllerValue( | ||||
|         position: position, | ||||
|         scale: scale, | ||||
|         rotation: rotation, | ||||
|         rotationFocusPoint: rotationFocusPoint, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   set rotation(double rotation) { | ||||
|     if (value.rotation == rotation) { | ||||
|       return; | ||||
|     } | ||||
|     prevValue = value; | ||||
|     value = PhotoViewControllerValue( | ||||
|       position: position, | ||||
|       scale: scale, | ||||
|       rotation: rotation, | ||||
|       rotationFocusPoint: rotationFocusPoint, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   double get rotation => value.rotation; | ||||
| 
 | ||||
|   @override | ||||
|   set rotationFocusPoint(Offset? rotationFocusPoint) { | ||||
|     if (value.rotationFocusPoint == rotationFocusPoint) { | ||||
|       return; | ||||
|     } | ||||
|     prevValue = value; | ||||
|     value = PhotoViewControllerValue( | ||||
|       position: position, | ||||
|       scale: scale, | ||||
|       rotation: rotation, | ||||
|       rotationFocusPoint: rotationFocusPoint, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Offset? get rotationFocusPoint => value.rotationFocusPoint; | ||||
| 
 | ||||
|   @override | ||||
|   void updateMultiple({ | ||||
|     Offset? position, | ||||
|     double? scale, | ||||
|     double? rotation, | ||||
|     Offset? rotationFocusPoint, | ||||
|   }) { | ||||
|     prevValue = value; | ||||
|     value = PhotoViewControllerValue( | ||||
|       position: position ?? value.position, | ||||
|       scale: scale ?? value.scale, | ||||
|       rotation: rotation ?? value.rotation, | ||||
|       rotationFocusPoint: rotationFocusPoint ?? value.rotationFocusPoint, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   PhotoViewControllerValue get value => _valueNotifier.value; | ||||
| 
 | ||||
|   @override | ||||
|   set value(PhotoViewControllerValue newValue) { | ||||
|     if (_valueNotifier.value == newValue) { | ||||
|       return; | ||||
|     } | ||||
|     _valueNotifier.value = newValue; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,214 @@ | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart' | ||||
|     show | ||||
|         PhotoViewControllerBase, | ||||
|         PhotoViewScaleState, | ||||
|         PhotoViewScaleStateController, | ||||
|         ScaleStateCycle; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart'; | ||||
| 
 | ||||
| /// A  class to hold internal layout logic to sync both controller states | ||||
| /// | ||||
| /// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers. | ||||
| mixin PhotoViewControllerDelegate on State<PhotoViewCore> { | ||||
|   PhotoViewControllerBase get controller => widget.controller; | ||||
| 
 | ||||
|   PhotoViewScaleStateController get scaleStateController => | ||||
|       widget.scaleStateController; | ||||
| 
 | ||||
|   ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries; | ||||
| 
 | ||||
|   ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle; | ||||
| 
 | ||||
|   Alignment get basePosition => widget.basePosition; | ||||
|   Function(double prevScale, double nextScale)? _animateScale; | ||||
| 
 | ||||
|   /// Mark if scale need recalculation, useful for scale boundaries changes. | ||||
|   bool markNeedsScaleRecalc = true; | ||||
| 
 | ||||
|   void initDelegate() { | ||||
|     controller.addIgnorableListener(_blindScaleListener); | ||||
|     scaleStateController.addIgnorableListener(_blindScaleStateListener); | ||||
|   } | ||||
| 
 | ||||
|   void _blindScaleStateListener() { | ||||
|     if (!scaleStateController.hasChanged) { | ||||
|       return; | ||||
|     } | ||||
|     if (_animateScale == null || scaleStateController.isZooming) { | ||||
|       controller.setScaleInvisibly(scale); | ||||
|       return; | ||||
|     } | ||||
|     final double prevScale = controller.scale ?? | ||||
|         getScaleForScaleState( | ||||
|           scaleStateController.prevScaleState, | ||||
|           scaleBoundaries, | ||||
|         ); | ||||
| 
 | ||||
|     final double nextScale = getScaleForScaleState( | ||||
|       scaleStateController.scaleState, | ||||
|       scaleBoundaries, | ||||
|     ); | ||||
| 
 | ||||
|     _animateScale!(prevScale, nextScale); | ||||
|   } | ||||
| 
 | ||||
|   void addAnimateOnScaleStateUpdate( | ||||
|     void Function(double prevScale, double nextScale) animateScale, | ||||
|   ) { | ||||
|     _animateScale = animateScale; | ||||
|   } | ||||
| 
 | ||||
|   void _blindScaleListener() { | ||||
|     if (!widget.enablePanAlways) { | ||||
|       controller.position = clampPosition(); | ||||
|     } | ||||
|     if (controller.scale == controller.prevValue.scale) { | ||||
|       return; | ||||
|     } | ||||
|     final PhotoViewScaleState newScaleState = | ||||
|         (scale > scaleBoundaries.initialScale) | ||||
|             ? PhotoViewScaleState.zoomedIn | ||||
|             : PhotoViewScaleState.zoomedOut; | ||||
| 
 | ||||
|     scaleStateController.setInvisibly(newScaleState); | ||||
|   } | ||||
| 
 | ||||
|   Offset get position => controller.position; | ||||
| 
 | ||||
|   double get scale { | ||||
|     // for figuring out initial scale | ||||
|     final needsRecalc = markNeedsScaleRecalc && | ||||
|         !scaleStateController.scaleState.isScaleStateZooming; | ||||
| 
 | ||||
|     final scaleExistsOnController = controller.scale != null; | ||||
|     if (needsRecalc || !scaleExistsOnController) { | ||||
|       final newScale = getScaleForScaleState( | ||||
|         scaleStateController.scaleState, | ||||
|         scaleBoundaries, | ||||
|       ); | ||||
|       markNeedsScaleRecalc = false; | ||||
|       scale = newScale; | ||||
|       return newScale; | ||||
|     } | ||||
|     return controller.scale!; | ||||
|   } | ||||
| 
 | ||||
|   set scale(double scale) => controller.setScaleInvisibly(scale); | ||||
| 
 | ||||
|   void updateMultiple({ | ||||
|     Offset? position, | ||||
|     double? scale, | ||||
|     double? rotation, | ||||
|     Offset? rotationFocusPoint, | ||||
|   }) { | ||||
|     controller.updateMultiple( | ||||
|       position: position, | ||||
|       scale: scale, | ||||
|       rotation: rotation, | ||||
|       rotationFocusPoint: rotationFocusPoint, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   void updateScaleStateFromNewScale(double newScale) { | ||||
|     PhotoViewScaleState newScaleState = PhotoViewScaleState.initial; | ||||
|     if (scale != scaleBoundaries.initialScale) { | ||||
|       newScaleState = (newScale > scaleBoundaries.initialScale) | ||||
|           ? PhotoViewScaleState.zoomedIn | ||||
|           : PhotoViewScaleState.zoomedOut; | ||||
|     } | ||||
|     scaleStateController.setInvisibly(newScaleState); | ||||
|   } | ||||
| 
 | ||||
|   void nextScaleState() { | ||||
|     final PhotoViewScaleState scaleState = scaleStateController.scaleState; | ||||
|     if (scaleState == PhotoViewScaleState.zoomedIn || | ||||
|         scaleState == PhotoViewScaleState.zoomedOut) { | ||||
|       scaleStateController.scaleState = scaleStateCycle(scaleState); | ||||
|       return; | ||||
|     } | ||||
|     final double originalScale = getScaleForScaleState( | ||||
|       scaleState, | ||||
|       scaleBoundaries, | ||||
|     ); | ||||
| 
 | ||||
|     double prevScale = originalScale; | ||||
|     PhotoViewScaleState prevScaleState = scaleState; | ||||
|     double nextScale = originalScale; | ||||
|     PhotoViewScaleState nextScaleState = scaleState; | ||||
| 
 | ||||
|     do { | ||||
|       prevScale = nextScale; | ||||
|       prevScaleState = nextScaleState; | ||||
|       nextScaleState = scaleStateCycle(prevScaleState); | ||||
|       nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries); | ||||
|     } while (prevScale == nextScale && scaleState != nextScaleState); | ||||
| 
 | ||||
|     if (originalScale == nextScale) { | ||||
|       return; | ||||
|     } | ||||
|     scaleStateController.scaleState = nextScaleState; | ||||
|   } | ||||
| 
 | ||||
|   CornersRange cornersX({double? scale}) { | ||||
|     final double s = scale ?? this.scale; | ||||
| 
 | ||||
|     final double computedWidth = scaleBoundaries.childSize.width * s; | ||||
|     final double screenWidth = scaleBoundaries.outerSize.width; | ||||
| 
 | ||||
|     final double positionX = basePosition.x; | ||||
|     final double widthDiff = computedWidth - screenWidth; | ||||
| 
 | ||||
|     final double minX = ((positionX - 1).abs() / 2) * widthDiff * -1; | ||||
|     final double maxX = ((positionX + 1).abs() / 2) * widthDiff; | ||||
|     return CornersRange(minX, maxX); | ||||
|   } | ||||
| 
 | ||||
|   CornersRange cornersY({double? scale}) { | ||||
|     final double s = scale ?? this.scale; | ||||
| 
 | ||||
|     final double computedHeight = scaleBoundaries.childSize.height * s; | ||||
|     final double screenHeight = scaleBoundaries.outerSize.height; | ||||
| 
 | ||||
|     final double positionY = basePosition.y; | ||||
|     final double heightDiff = computedHeight - screenHeight; | ||||
| 
 | ||||
|     final double minY = ((positionY - 1).abs() / 2) * heightDiff * -1; | ||||
|     final double maxY = ((positionY + 1).abs() / 2) * heightDiff; | ||||
|     return CornersRange(minY, maxY); | ||||
|   } | ||||
| 
 | ||||
|   Offset clampPosition({Offset? position, double? scale}) { | ||||
|     final double s = scale ?? this.scale; | ||||
|     final Offset p = position ?? this.position; | ||||
| 
 | ||||
|     final double computedWidth = scaleBoundaries.childSize.width * s; | ||||
|     final double computedHeight = scaleBoundaries.childSize.height * s; | ||||
| 
 | ||||
|     final double screenWidth = scaleBoundaries.outerSize.width; | ||||
|     final double screenHeight = scaleBoundaries.outerSize.height; | ||||
| 
 | ||||
|     double finalX = 0.0; | ||||
|     if (screenWidth < computedWidth) { | ||||
|       final cornersX = this.cornersX(scale: s); | ||||
|       finalX = p.dx.clamp(cornersX.min, cornersX.max); | ||||
|     } | ||||
| 
 | ||||
|     double finalY = 0.0; | ||||
|     if (screenHeight < computedHeight) { | ||||
|       final cornersY = this.cornersY(scale: s); | ||||
|       finalY = p.dy.clamp(cornersY.min, cornersY.max); | ||||
|     } | ||||
| 
 | ||||
|     return Offset(finalX, finalY); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _animateScale = null; | ||||
|     controller.removeIgnorableListener(_blindScaleListener); | ||||
|     scaleStateController.removeIgnorableListener(_blindScaleStateListener); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,98 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:ui'; | ||||
| 
 | ||||
| import 'package:flutter/widgets.dart' show VoidCallback; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart'; | ||||
| 
 | ||||
| typedef ScaleStateListener = void Function(double prevScale, double nextScale); | ||||
| 
 | ||||
| /// A controller responsible only by [scaleState]. | ||||
| /// | ||||
| /// Scale state is a common value with represents the step in which the [PhotoView.scaleStateCycle] is. | ||||
| /// This cycle is triggered by the "doubleTap" gesture. | ||||
| /// | ||||
| /// Any change in its [scaleState] should animate the scale of image/content. | ||||
| /// | ||||
| /// As it is a controller, whoever instantiates it, should [dispose] it afterwards. | ||||
| /// | ||||
| /// The updates should be done via [scaleState] setter and the updated listened via [outputScaleStateStream] | ||||
| /// | ||||
| class PhotoViewScaleStateController { | ||||
|   late final IgnorableValueNotifier<PhotoViewScaleState> _scaleStateNotifier = | ||||
|       IgnorableValueNotifier(PhotoViewScaleState.initial) | ||||
|         ..addListener(_scaleStateChangeListener); | ||||
|   final StreamController<PhotoViewScaleState> _outputScaleStateCtrl = | ||||
|       StreamController<PhotoViewScaleState>.broadcast() | ||||
|         ..sink.add(PhotoViewScaleState.initial); | ||||
| 
 | ||||
|   /// The output for state/value updates | ||||
|   Stream<PhotoViewScaleState> get outputScaleStateStream => | ||||
|       _outputScaleStateCtrl.stream; | ||||
| 
 | ||||
|   /// The state value before the last change or the initial state if the state has not been changed. | ||||
|   PhotoViewScaleState prevScaleState = PhotoViewScaleState.initial; | ||||
| 
 | ||||
|   /// The actual state value | ||||
|   PhotoViewScaleState get scaleState => _scaleStateNotifier.value; | ||||
| 
 | ||||
|   /// Updates scaleState and notify all listeners (and the stream) | ||||
|   set scaleState(PhotoViewScaleState newValue) { | ||||
|     if (_scaleStateNotifier.value == newValue) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     prevScaleState = _scaleStateNotifier.value; | ||||
|     _scaleStateNotifier.value = newValue; | ||||
|   } | ||||
| 
 | ||||
|   /// Checks if its actual value is different than previousValue | ||||
|   bool get hasChanged => prevScaleState != scaleState; | ||||
| 
 | ||||
|   /// Check if is `zoomedIn` & `zoomedOut` | ||||
|   bool get isZooming => | ||||
|       scaleState == PhotoViewScaleState.zoomedIn || | ||||
|       scaleState == PhotoViewScaleState.zoomedOut; | ||||
| 
 | ||||
|   /// Resets the state to the initial value; | ||||
|   void reset() { | ||||
|     prevScaleState = scaleState; | ||||
|     scaleState = PhotoViewScaleState.initial; | ||||
|   } | ||||
| 
 | ||||
|   /// Closes streams and removes eventual listeners | ||||
|   void dispose() { | ||||
|     _outputScaleStateCtrl.close(); | ||||
|     _scaleStateNotifier.dispose(); | ||||
|   } | ||||
| 
 | ||||
|   /// Nevermind this method :D, look away | ||||
|   /// Seriously: It is used to change scale state without trigging updates on the [] | ||||
|   void setInvisibly(PhotoViewScaleState newValue) { | ||||
|     if (_scaleStateNotifier.value == newValue) { | ||||
|       return; | ||||
|     } | ||||
|     prevScaleState = _scaleStateNotifier.value; | ||||
|     _scaleStateNotifier.updateIgnoring(newValue); | ||||
|   } | ||||
| 
 | ||||
|   void _scaleStateChangeListener() { | ||||
|     _outputScaleStateCtrl.sink.add(scaleState); | ||||
|   } | ||||
| 
 | ||||
|   /// Add a listener that will ignore updates made internally | ||||
|   /// | ||||
|   /// Since it is made for internal use, it is not performatic to use more than one | ||||
|   /// listener. Prefer [outputScaleStateStream] | ||||
|   void addIgnorableListener(VoidCallback callback) { | ||||
|     _scaleStateNotifier.addIgnorableListener(callback); | ||||
|   } | ||||
| 
 | ||||
|   /// Remove a listener that will ignore updates made internally | ||||
|   /// | ||||
|   /// Since it is made for internal use, it is not performatic to use more than one | ||||
|   /// listener. Prefer [outputScaleStateStream] | ||||
|   void removeIgnorableListener(VoidCallback callback) { | ||||
|     _scaleStateNotifier.removeIgnorableListener(callback); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										461
									
								
								mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										461
									
								
								mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,461 @@ | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart' | ||||
|     show | ||||
|         PhotoViewScaleState, | ||||
|         PhotoViewHeroAttributes, | ||||
|         PhotoViewImageTapDownCallback, | ||||
|         PhotoViewImageTapUpCallback, | ||||
|         PhotoViewImageScaleEndCallback, | ||||
|         PhotoViewImageDragEndCallback, | ||||
|         PhotoViewImageDragStartCallback, | ||||
|         PhotoViewImageDragUpdateCallback, | ||||
|         ScaleStateCycle; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_hit_corners.dart'; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart'; | ||||
| 
 | ||||
| const _defaultDecoration = BoxDecoration( | ||||
|   color: Color.fromRGBO(0, 0, 0, 1.0), | ||||
| ); | ||||
| 
 | ||||
| /// Internal widget in which controls all animations lifecycle, core responses | ||||
| /// to user gestures, updates to  the controller state and mounts the entire PhotoView Layout | ||||
| class PhotoViewCore extends StatefulWidget { | ||||
|   const PhotoViewCore({ | ||||
|     Key? key, | ||||
|     required this.imageProvider, | ||||
|     required this.backgroundDecoration, | ||||
|     required this.gaplessPlayback, | ||||
|     required this.heroAttributes, | ||||
|     required this.enableRotation, | ||||
|     required this.onTapUp, | ||||
|     required this.onTapDown, | ||||
|     required this.onDragStart, | ||||
|     required this.onDragEnd, | ||||
|     required this.onDragUpdate, | ||||
|     required this.onScaleEnd, | ||||
|     required this.gestureDetectorBehavior, | ||||
|     required this.controller, | ||||
|     required this.scaleBoundaries, | ||||
|     required this.scaleStateCycle, | ||||
|     required this.scaleStateController, | ||||
|     required this.basePosition, | ||||
|     required this.tightMode, | ||||
|     required this.filterQuality, | ||||
|     required this.disableGestures, | ||||
|     required this.enablePanAlways, | ||||
|   })  : customChild = null, | ||||
|         super(key: key); | ||||
| 
 | ||||
|   const PhotoViewCore.customChild({ | ||||
|     Key? key, | ||||
|     required this.customChild, | ||||
|     required this.backgroundDecoration, | ||||
|     this.heroAttributes, | ||||
|     required this.enableRotation, | ||||
|     this.onTapUp, | ||||
|     this.onTapDown, | ||||
|     this.onDragStart, | ||||
|     this.onDragEnd, | ||||
|     this.onDragUpdate, | ||||
|     this.onScaleEnd, | ||||
|     this.gestureDetectorBehavior, | ||||
|     required this.controller, | ||||
|     required this.scaleBoundaries, | ||||
|     required this.scaleStateCycle, | ||||
|     required this.scaleStateController, | ||||
|     required this.basePosition, | ||||
|     required this.tightMode, | ||||
|     required this.filterQuality, | ||||
|     required this.disableGestures, | ||||
|     required this.enablePanAlways, | ||||
|   })  : imageProvider = null, | ||||
|         gaplessPlayback = false, | ||||
|         super(key: key); | ||||
| 
 | ||||
|   final Decoration? backgroundDecoration; | ||||
|   final ImageProvider? imageProvider; | ||||
|   final bool? gaplessPlayback; | ||||
|   final PhotoViewHeroAttributes? heroAttributes; | ||||
|   final bool enableRotation; | ||||
|   final Widget? customChild; | ||||
| 
 | ||||
|   final PhotoViewControllerBase controller; | ||||
|   final PhotoViewScaleStateController scaleStateController; | ||||
|   final ScaleBoundaries scaleBoundaries; | ||||
|   final ScaleStateCycle scaleStateCycle; | ||||
|   final Alignment basePosition; | ||||
| 
 | ||||
|   final PhotoViewImageTapUpCallback? onTapUp; | ||||
|   final PhotoViewImageTapDownCallback? onTapDown; | ||||
|   final PhotoViewImageScaleEndCallback? onScaleEnd; | ||||
| 
 | ||||
|   final PhotoViewImageDragStartCallback? onDragStart; | ||||
|   final PhotoViewImageDragEndCallback? onDragEnd; | ||||
|   final PhotoViewImageDragUpdateCallback? onDragUpdate; | ||||
| 
 | ||||
|   final HitTestBehavior? gestureDetectorBehavior; | ||||
|   final bool tightMode; | ||||
|   final bool disableGestures; | ||||
|   final bool enablePanAlways; | ||||
| 
 | ||||
|   final FilterQuality filterQuality; | ||||
| 
 | ||||
|   @override | ||||
|   State<StatefulWidget> createState() { | ||||
|     return PhotoViewCoreState(); | ||||
|   } | ||||
| 
 | ||||
|   bool get hasCustomChild => customChild != null; | ||||
| } | ||||
| 
 | ||||
| class PhotoViewCoreState extends State<PhotoViewCore> | ||||
|     with | ||||
|         TickerProviderStateMixin, | ||||
|         PhotoViewControllerDelegate, | ||||
|         HitCornersDetector { | ||||
|   Offset? _normalizedPosition; | ||||
|   double? _scaleBefore; | ||||
|   double? _rotationBefore; | ||||
| 
 | ||||
|   late final AnimationController _scaleAnimationController; | ||||
|   Animation<double>? _scaleAnimation; | ||||
| 
 | ||||
|   late final AnimationController _positionAnimationController; | ||||
|   Animation<Offset>? _positionAnimation; | ||||
| 
 | ||||
|   late final AnimationController _rotationAnimationController = | ||||
|       AnimationController(vsync: this)..addListener(handleRotationAnimation); | ||||
|   Animation<double>? _rotationAnimation; | ||||
| 
 | ||||
|   PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes; | ||||
| 
 | ||||
|   late ScaleBoundaries cachedScaleBoundaries = widget.scaleBoundaries; | ||||
| 
 | ||||
|   void handleScaleAnimation() { | ||||
|     scale = _scaleAnimation!.value; | ||||
|   } | ||||
| 
 | ||||
|   void handlePositionAnimate() { | ||||
|     controller.position = _positionAnimation!.value; | ||||
|   } | ||||
| 
 | ||||
|   void handleRotationAnimation() { | ||||
|     controller.rotation = _rotationAnimation!.value; | ||||
|   } | ||||
| 
 | ||||
|   void onScaleStart(ScaleStartDetails details) { | ||||
|     _rotationBefore = controller.rotation; | ||||
|     _scaleBefore = scale; | ||||
|     _normalizedPosition = details.focalPoint - controller.position; | ||||
|     _scaleAnimationController.stop(); | ||||
|     _positionAnimationController.stop(); | ||||
|     _rotationAnimationController.stop(); | ||||
|   } | ||||
| 
 | ||||
|   void onScaleUpdate(ScaleUpdateDetails details) { | ||||
|     final double newScale = _scaleBefore! * details.scale; | ||||
|     final Offset delta = details.focalPoint - _normalizedPosition!; | ||||
| 
 | ||||
|     updateScaleStateFromNewScale(newScale); | ||||
| 
 | ||||
|     updateMultiple( | ||||
|       scale: newScale, | ||||
|       position: widget.enablePanAlways | ||||
|           ? delta | ||||
|           : clampPosition(position: delta * details.scale), | ||||
|       rotation: | ||||
|           widget.enableRotation ? _rotationBefore! + details.rotation : null, | ||||
|       rotationFocusPoint: widget.enableRotation ? details.focalPoint : null, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   void onScaleEnd(ScaleEndDetails details) { | ||||
|     final double s = scale; | ||||
|     final Offset p = controller.position; | ||||
|     final double maxScale = scaleBoundaries.maxScale; | ||||
|     final double minScale = scaleBoundaries.minScale; | ||||
| 
 | ||||
|     widget.onScaleEnd?.call(context, details, controller.value); | ||||
| 
 | ||||
|     //animate back to maxScale if gesture exceeded the maxScale specified | ||||
|     if (s > maxScale) { | ||||
|       final double scaleComebackRatio = maxScale / s; | ||||
|       animateScale(s, maxScale); | ||||
|       final Offset clampedPosition = clampPosition( | ||||
|         position: p * scaleComebackRatio, | ||||
|         scale: maxScale, | ||||
|       ); | ||||
|       animatePosition(p, clampedPosition); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     //animate back to minScale if gesture fell smaller than the minScale specified | ||||
|     if (s < minScale) { | ||||
|       final double scaleComebackRatio = minScale / s; | ||||
|       animateScale(s, minScale); | ||||
|       animatePosition( | ||||
|         p, | ||||
|         clampPosition( | ||||
|           position: p * scaleComebackRatio, | ||||
|           scale: minScale, | ||||
|         ), | ||||
|       ); | ||||
|       return; | ||||
|     } | ||||
|     // get magnitude from gesture velocity | ||||
|     final double magnitude = details.velocity.pixelsPerSecond.distance; | ||||
| 
 | ||||
|     // animate velocity only if there is no scale change and a significant magnitude | ||||
|     if (_scaleBefore! / s == 1.0 && magnitude >= 400.0) { | ||||
|       final Offset direction = details.velocity.pixelsPerSecond / magnitude; | ||||
|       animatePosition( | ||||
|         p, | ||||
|         clampPosition(position: p + direction * 100.0), | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   void onDoubleTap() { | ||||
|     nextScaleState(); | ||||
|   } | ||||
| 
 | ||||
|   void animateScale(double from, double to) { | ||||
|     _scaleAnimation = Tween<double>( | ||||
|       begin: from, | ||||
|       end: to, | ||||
|     ).animate(_scaleAnimationController); | ||||
|     _scaleAnimationController | ||||
|       ..value = 0.0 | ||||
|       ..fling(velocity: 0.4); | ||||
|   } | ||||
| 
 | ||||
|   void animatePosition(Offset from, Offset to) { | ||||
|     _positionAnimation = Tween<Offset>(begin: from, end: to) | ||||
|         .animate(_positionAnimationController); | ||||
|     _positionAnimationController | ||||
|       ..value = 0.0 | ||||
|       ..fling(velocity: 0.4); | ||||
|   } | ||||
| 
 | ||||
|   void animateRotation(double from, double to) { | ||||
|     _rotationAnimation = Tween<double>(begin: from, end: to) | ||||
|         .animate(_rotationAnimationController); | ||||
|     _rotationAnimationController | ||||
|       ..value = 0.0 | ||||
|       ..fling(velocity: 0.4); | ||||
|   } | ||||
| 
 | ||||
|   void onAnimationStatus(AnimationStatus status) { | ||||
|     if (status == AnimationStatus.completed) { | ||||
|       onAnimationStatusCompleted(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /// Check if scale is equal to initial after scale animation update | ||||
|   void onAnimationStatusCompleted() { | ||||
|     if (scaleStateController.scaleState != PhotoViewScaleState.initial && | ||||
|         scale == scaleBoundaries.initialScale) { | ||||
|       scaleStateController.setInvisibly(PhotoViewScaleState.initial); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     initDelegate(); | ||||
|     addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate); | ||||
| 
 | ||||
|     cachedScaleBoundaries = widget.scaleBoundaries; | ||||
| 
 | ||||
|     _scaleAnimationController = AnimationController(vsync: this) | ||||
|       ..addListener(handleScaleAnimation) | ||||
|       ..addStatusListener(onAnimationStatus); | ||||
|     _positionAnimationController = AnimationController(vsync: this) | ||||
|       ..addListener(handlePositionAnimate); | ||||
|   } | ||||
| 
 | ||||
|   void animateOnScaleStateUpdate(double prevScale, double nextScale) { | ||||
|     animateScale(prevScale, nextScale); | ||||
|     animatePosition(controller.position, Offset.zero); | ||||
|     animateRotation(controller.rotation, 0.0); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _scaleAnimationController.removeStatusListener(onAnimationStatus); | ||||
|     _scaleAnimationController.dispose(); | ||||
|     _positionAnimationController.dispose(); | ||||
|     _rotationAnimationController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| 
 | ||||
|   void onTapUp(TapUpDetails details) { | ||||
|     widget.onTapUp?.call(context, details, controller.value); | ||||
|   } | ||||
| 
 | ||||
|   void onTapDown(TapDownDetails details) { | ||||
|     widget.onTapDown?.call(context, details, controller.value); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     // Check if we need a recalc on the scale | ||||
|     if (widget.scaleBoundaries != cachedScaleBoundaries) { | ||||
|       markNeedsScaleRecalc = true; | ||||
|       cachedScaleBoundaries = widget.scaleBoundaries; | ||||
|     } | ||||
| 
 | ||||
|     return StreamBuilder( | ||||
|       stream: controller.outputStateStream, | ||||
|       initialData: controller.prevValue, | ||||
|       builder: ( | ||||
|         BuildContext context, | ||||
|         AsyncSnapshot<PhotoViewControllerValue> snapshot, | ||||
|       ) { | ||||
|         if (snapshot.hasData) { | ||||
|           final PhotoViewControllerValue value = snapshot.data!; | ||||
|           final useImageScale = widget.filterQuality != FilterQuality.none; | ||||
| 
 | ||||
|           final computedScale = useImageScale ? 1.0 : scale; | ||||
| 
 | ||||
|           final matrix = Matrix4.identity() | ||||
|             ..translate(value.position.dx, value.position.dy) | ||||
|             ..scale(computedScale) | ||||
|             ..rotateZ(value.rotation); | ||||
| 
 | ||||
|           final Widget customChildLayout = CustomSingleChildLayout( | ||||
|             delegate: _CenterWithOriginalSizeDelegate( | ||||
|               scaleBoundaries.childSize, | ||||
|               basePosition, | ||||
|               useImageScale, | ||||
|             ), | ||||
|             child: _buildHero(), | ||||
|           ); | ||||
| 
 | ||||
|           final child = Container( | ||||
|             constraints: widget.tightMode | ||||
|                 ? BoxConstraints.tight(scaleBoundaries.childSize * scale) | ||||
|                 : null, | ||||
|             decoration: widget.backgroundDecoration ?? _defaultDecoration, | ||||
|             child: Center( | ||||
|               child: Transform( | ||||
|                 transform: matrix, | ||||
|                 alignment: basePosition, | ||||
|                 child: customChildLayout, | ||||
|               ), | ||||
|             ), | ||||
|           ); | ||||
| 
 | ||||
|           if (widget.disableGestures) { | ||||
|             return child; | ||||
|           } | ||||
| 
 | ||||
|           return PhotoViewGestureDetector( | ||||
|             onDoubleTap: nextScaleState, | ||||
|             onScaleStart: onScaleStart, | ||||
|             onScaleUpdate: onScaleUpdate, | ||||
|             onScaleEnd: onScaleEnd, | ||||
|             onDragStart:  widget.onDragStart != null  | ||||
|                ? (details) => widget.onDragStart!(context, details, value) | ||||
|                : null, | ||||
|             onDragEnd:  widget.onDragEnd != null  | ||||
|                ? (details) => widget.onDragEnd!(context, details, value) | ||||
|                : null, | ||||
|             onDragUpdate: widget.onDragUpdate != null  | ||||
|                ? (details) => widget.onDragUpdate!(context, details, value) | ||||
|                : null, | ||||
|             hitDetector: this, | ||||
|             onTapUp: widget.onTapUp != null | ||||
|                 ? (details) => widget.onTapUp!(context, details, value) | ||||
|                 : null, | ||||
|             onTapDown: widget.onTapDown != null | ||||
|                 ? (details) => widget.onTapDown!(context, details, value) | ||||
|                 : null, | ||||
|             child: child, | ||||
|           ); | ||||
|         } else { | ||||
|           return Container(); | ||||
|         } | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   Widget _buildHero() { | ||||
|     return heroAttributes != null | ||||
|         ? Hero( | ||||
|             tag: heroAttributes!.tag, | ||||
|             createRectTween: heroAttributes!.createRectTween, | ||||
|             flightShuttleBuilder: heroAttributes!.flightShuttleBuilder, | ||||
|             placeholderBuilder: heroAttributes!.placeholderBuilder, | ||||
|             transitionOnUserGestures: heroAttributes!.transitionOnUserGestures, | ||||
|             child: _buildChild(), | ||||
|           ) | ||||
|         : _buildChild(); | ||||
|   } | ||||
| 
 | ||||
|   Widget _buildChild() { | ||||
|     return widget.hasCustomChild | ||||
|         ? widget.customChild! | ||||
|         : Image( | ||||
|             image: widget.imageProvider!, | ||||
|             gaplessPlayback: widget.gaplessPlayback ?? false, | ||||
|             filterQuality: widget.filterQuality, | ||||
|             width: scaleBoundaries.childSize.width * scale, | ||||
|             fit: BoxFit.contain, | ||||
|           ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate { | ||||
|   const _CenterWithOriginalSizeDelegate( | ||||
|     this.subjectSize, | ||||
|     this.basePosition, | ||||
|     this.useImageScale, | ||||
|   ); | ||||
| 
 | ||||
|   final Size subjectSize; | ||||
|   final Alignment basePosition; | ||||
|   final bool useImageScale; | ||||
| 
 | ||||
|   @override | ||||
|   Offset getPositionForChild(Size size, Size childSize) { | ||||
|     final childWidth = useImageScale ? childSize.width : subjectSize.width; | ||||
|     final childHeight = useImageScale ? childSize.height : subjectSize.height; | ||||
| 
 | ||||
|     final halfWidth = (size.width - childWidth) / 2; | ||||
|     final halfHeight = (size.height - childHeight) / 2; | ||||
| 
 | ||||
|     final double offsetX = halfWidth * (basePosition.x + 1); | ||||
|     final double offsetY = halfHeight * (basePosition.y + 1); | ||||
|     return Offset(offsetX, offsetY); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   BoxConstraints getConstraintsForChild(BoxConstraints constraints) { | ||||
|     return useImageScale | ||||
|         ? const BoxConstraints() | ||||
|         : BoxConstraints.tight(subjectSize); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) { | ||||
|     return oldDelegate != this; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is _CenterWithOriginalSizeDelegate && | ||||
|           runtimeType == other.runtimeType && | ||||
|           subjectSize == other.subjectSize && | ||||
|           basePosition == other.basePosition && | ||||
|           useImageScale == other.useImageScale; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|       subjectSize.hashCode ^ basePosition.hashCode ^ useImageScale.hashCode; | ||||
| } | ||||
| @ -0,0 +1,293 @@ | ||||
| import 'package:flutter/gestures.dart'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
| 
 | ||||
| import 'photo_view_hit_corners.dart'; | ||||
| 
 | ||||
| /// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c) | ||||
| /// for the gist | ||||
| class PhotoViewGestureDetector extends StatelessWidget { | ||||
|   const PhotoViewGestureDetector({ | ||||
|     Key? key, | ||||
|     this.hitDetector, | ||||
|     this.onScaleStart, | ||||
|     this.onScaleUpdate, | ||||
|     this.onScaleEnd, | ||||
|     this.onDoubleTap, | ||||
|     this.onDragStart, | ||||
|     this.onDragEnd, | ||||
|     this.onDragUpdate, | ||||
|     this.child, | ||||
|     this.onTapUp, | ||||
|     this.onTapDown, | ||||
|     this.behavior, | ||||
|   }) : super(key: key); | ||||
| 
 | ||||
|   final GestureDoubleTapCallback? onDoubleTap; | ||||
|   final HitCornersDetector? hitDetector; | ||||
| 
 | ||||
|   final GestureScaleStartCallback? onScaleStart; | ||||
|   final GestureScaleUpdateCallback? onScaleUpdate; | ||||
|   final GestureScaleEndCallback? onScaleEnd; | ||||
| 
 | ||||
|   final GestureDragEndCallback? onDragEnd; | ||||
|   final GestureDragStartCallback? onDragStart; | ||||
|   final GestureDragUpdateCallback? onDragUpdate; | ||||
| 
 | ||||
|   final GestureTapUpCallback? onTapUp; | ||||
|   final GestureTapDownCallback? onTapDown; | ||||
| 
 | ||||
|   final Widget? child; | ||||
| 
 | ||||
|   final HitTestBehavior? behavior; | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final scope = PhotoViewGestureDetectorScope.of(context); | ||||
| 
 | ||||
|     final Axis? axis = scope?.axis; | ||||
|     final touchSlopFactor = scope?.touchSlopFactor ?? 2; | ||||
| 
 | ||||
|     final Map<Type, GestureRecognizerFactory> gestures = | ||||
|         <Type, GestureRecognizerFactory>{}; | ||||
| 
 | ||||
|     if (onTapDown != null || onTapUp != null) { | ||||
|       gestures[TapGestureRecognizer] = | ||||
|           GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( | ||||
|         () => TapGestureRecognizer(debugOwner: this), | ||||
|         (TapGestureRecognizer instance) { | ||||
|           instance | ||||
|             ..onTapDown = onTapDown | ||||
|             ..onTapUp = onTapUp; | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (onDragStart != null || onDragEnd != null || onDragUpdate != null) { | ||||
|       gestures[VerticalDragGestureRecognizer] =  | ||||
|           GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>( | ||||
|         () => VerticalDragGestureRecognizer(debugOwner: this), | ||||
|         (VerticalDragGestureRecognizer instance) { | ||||
|           instance | ||||
|               ..onStart = onDragStart | ||||
|               ..onUpdate = onDragUpdate | ||||
|               ..onEnd = onDragEnd; | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     gestures[DoubleTapGestureRecognizer] = | ||||
|         GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>( | ||||
|       () => DoubleTapGestureRecognizer(debugOwner: this), | ||||
|       (DoubleTapGestureRecognizer instance) { | ||||
|         instance.onDoubleTap = onDoubleTap; | ||||
|       }, | ||||
|     ); | ||||
| 
 | ||||
|     gestures[PhotoViewGestureRecognizer] = | ||||
|         GestureRecognizerFactoryWithHandlers<PhotoViewGestureRecognizer>( | ||||
|       () => PhotoViewGestureRecognizer( | ||||
|           hitDetector: hitDetector, | ||||
|           debugOwner: this, | ||||
|           validateAxis: axis, | ||||
|           touchSlopFactor: touchSlopFactor, | ||||
|         ), | ||||
|       (PhotoViewGestureRecognizer instance) { | ||||
|         instance | ||||
|           ..onStart = onScaleStart | ||||
|           ..onUpdate = onScaleUpdate | ||||
|           ..onEnd = onScaleEnd; | ||||
|       }, | ||||
|     ); | ||||
| 
 | ||||
|     return RawGestureDetector( | ||||
|       behavior: behavior, | ||||
|       gestures: gestures, | ||||
|       child: child, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class PhotoViewGestureRecognizer extends ScaleGestureRecognizer { | ||||
|   PhotoViewGestureRecognizer({ | ||||
|     this.hitDetector, | ||||
|     Object? debugOwner, | ||||
|     this.validateAxis, | ||||
|     this.touchSlopFactor = 1, | ||||
|     PointerDeviceKind? kind, | ||||
|   }) : super(debugOwner: debugOwner, supportedDevices: null); | ||||
|   final HitCornersDetector? hitDetector; | ||||
|   final Axis? validateAxis; | ||||
|   final double touchSlopFactor; | ||||
| 
 | ||||
|   Map<int, Offset> _pointerLocations = <int, Offset>{}; | ||||
| 
 | ||||
|   Offset? _initialFocalPoint; | ||||
|   Offset? _currentFocalPoint; | ||||
|   double? _initialSpan; | ||||
|   double? _currentSpan; | ||||
| 
 | ||||
|   bool ready = true; | ||||
| 
 | ||||
|   @override | ||||
|   void addAllowedPointer(PointerDownEvent event) { | ||||
|     if (ready) { | ||||
|       ready = false; | ||||
|       _pointerLocations = <int, Offset>{}; | ||||
|     } | ||||
|     super.addAllowedPointer(event); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void didStopTrackingLastPointer(int pointer) { | ||||
|     ready = true; | ||||
|     super.didStopTrackingLastPointer(pointer); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void handleEvent(PointerEvent event) { | ||||
|     if (validateAxis != null) { | ||||
|       bool didChangeConfiguration = false; | ||||
|       if (event is PointerMoveEvent) { | ||||
|         if (!event.synthesized) { | ||||
|           _pointerLocations[event.pointer] = event.position; | ||||
|         } | ||||
|       } else if (event is PointerDownEvent) { | ||||
|         _pointerLocations[event.pointer] = event.position; | ||||
|         didChangeConfiguration = true; | ||||
|       } else if (event is PointerUpEvent || event is PointerCancelEvent) { | ||||
|         _pointerLocations.remove(event.pointer); | ||||
|         didChangeConfiguration = true; | ||||
|       } | ||||
| 
 | ||||
|       _updateDistances(); | ||||
| 
 | ||||
|       if (didChangeConfiguration) { | ||||
|         // cf super._reconfigure | ||||
|         _initialFocalPoint = _currentFocalPoint; | ||||
|         _initialSpan = _currentSpan; | ||||
|       } | ||||
| 
 | ||||
|       _decideIfWeAcceptEvent(event); | ||||
|     } | ||||
|     super.handleEvent(event); | ||||
|   } | ||||
| 
 | ||||
|   void _updateDistances() { | ||||
|     // cf super._update | ||||
|     final int count = _pointerLocations.keys.length; | ||||
| 
 | ||||
|     // Compute the focal point | ||||
|     Offset focalPoint = Offset.zero; | ||||
|     for (final int pointer in _pointerLocations.keys) { | ||||
|       focalPoint += _pointerLocations[pointer]!; | ||||
|     } | ||||
|     _currentFocalPoint = | ||||
|         count > 0 ? focalPoint / count.toDouble() : Offset.zero; | ||||
| 
 | ||||
|     // Span is the average deviation from focal point. Horizontal and vertical | ||||
|     // spans are the average deviations from the focal point's horizontal and | ||||
|     // vertical coordinates, respectively. | ||||
|     double totalDeviation = 0.0; | ||||
|     for (final int pointer in _pointerLocations.keys) { | ||||
|       totalDeviation += | ||||
|           (_currentFocalPoint! - _pointerLocations[pointer]!).distance; | ||||
|     } | ||||
|     _currentSpan = count > 0 ? totalDeviation / count : 0.0; | ||||
|   } | ||||
| 
 | ||||
|   void _decideIfWeAcceptEvent(PointerEvent event) { | ||||
|     final move = _initialFocalPoint! - _currentFocalPoint!; | ||||
|     final bool shouldMove = validateAxis == Axis.vertical | ||||
|         ? hitDetector!.shouldMove(move, Axis.vertical) | ||||
|         : hitDetector!.shouldMove(move, Axis.horizontal); | ||||
|     if (shouldMove || _pointerLocations.keys.length > 1) { | ||||
|       final double spanDelta = (_currentSpan! - _initialSpan!).abs(); | ||||
|       final double focalPointDelta = | ||||
|           (_currentFocalPoint! - _initialFocalPoint!).distance; | ||||
|       // warning: do not compare `focalPointDelta` to `kPanSlop` | ||||
|       // `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop` | ||||
|       // and PhotoView recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView` | ||||
|       // setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0` | ||||
|       // setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView` | ||||
|       if (spanDelta > kScaleSlop || | ||||
|           focalPointDelta > kTouchSlop * touchSlopFactor) { | ||||
|         acceptGesture(event.pointer); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// An [InheritedWidget] responsible to give a axis aware scope to [PhotoViewGestureRecognizer]. | ||||
| /// | ||||
| /// When using this, PhotoView will test if the content zoomed has hit edge every time user pinches, | ||||
| /// if so, it will let parent gesture detectors win the gesture arena | ||||
| /// | ||||
| /// Useful when placing PhotoView inside a gesture sensitive context, | ||||
| /// such as [PageView], [Dismissible], [BottomSheet]. | ||||
| /// | ||||
| /// Usage example: | ||||
| /// ``` | ||||
| /// PhotoViewGestureDetectorScope( | ||||
| ///   axis: Axis.vertical, | ||||
| ///   child: PhotoView( | ||||
| ///     imageProvider: AssetImage("assets/pudim.jpg"), | ||||
| ///   ), | ||||
| /// ); | ||||
| /// ``` | ||||
| class PhotoViewGestureDetectorScope extends InheritedWidget { | ||||
|   const PhotoViewGestureDetectorScope({ | ||||
|     super.key,  | ||||
|     this.axis, | ||||
|     this.touchSlopFactor = .2, | ||||
|     required Widget child, | ||||
|   }) : super(child: child); | ||||
| 
 | ||||
|   static PhotoViewGestureDetectorScope? of(BuildContext context) { | ||||
|     final PhotoViewGestureDetectorScope? scope = context | ||||
|         .dependOnInheritedWidgetOfExactType<PhotoViewGestureDetectorScope>(); | ||||
|     return scope; | ||||
|   } | ||||
| 
 | ||||
|   final Axis? axis; | ||||
| 
 | ||||
|   // in [0, 1[ | ||||
|   // 0: most reactive but will not let tap recognizers accept gestures | ||||
|   // <1: less reactive but gives the most leeway to other recognizers | ||||
|   // 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree | ||||
|   final double touchSlopFactor;   | ||||
| 
 | ||||
|   @override | ||||
|   bool updateShouldNotify(PhotoViewGestureDetectorScope oldWidget) { | ||||
|     return axis != oldWidget.axis && touchSlopFactor != oldWidget.touchSlopFactor; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // `PageView` contains a `Scrollable` which sets up a `HorizontalDragGestureRecognizer` | ||||
| // this recognizer will win in the gesture arena when the drag distance reaches `kTouchSlop` | ||||
| // we cannot change that, but we can prevent the scrollable from panning until this threshold is reached | ||||
| // and let other recognizers accept the gesture instead | ||||
| class PhotoViewPageViewScrollPhysics extends ScrollPhysics { | ||||
|   const PhotoViewPageViewScrollPhysics({ | ||||
|     this.touchSlopFactor = 0.1, | ||||
|     ScrollPhysics? parent, | ||||
|   }) : super(parent: parent); | ||||
| 
 | ||||
| 
 | ||||
|   // in [0, 1] | ||||
|   // 0: most reactive but will not let PhotoView recognizers accept gestures | ||||
|   // 1: less reactive but gives the most leeway to PhotoView recognizers | ||||
|   final double touchSlopFactor; | ||||
| 
 | ||||
| 
 | ||||
|   @override | ||||
|   PhotoViewPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) { | ||||
|     return PhotoViewPageViewScrollPhysics( | ||||
|       touchSlopFactor: touchSlopFactor, | ||||
|       parent: buildParent(ancestor), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   @override | ||||
|   double get dragStartDistanceMotionThreshold => kTouchSlop * touchSlopFactor; | ||||
| } | ||||
| @ -0,0 +1,77 @@ | ||||
| import 'package:flutter/widgets.dart'; | ||||
| 
 | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart' | ||||
|     show PhotoViewControllerDelegate; | ||||
| 
 | ||||
| mixin HitCornersDetector on PhotoViewControllerDelegate { | ||||
|   HitCorners _hitCornersX() { | ||||
|     final double childWidth = scaleBoundaries.childSize.width * scale; | ||||
|     final double screenWidth = scaleBoundaries.outerSize.width; | ||||
|     if (screenWidth >= childWidth) { | ||||
|       return const HitCorners(true, true); | ||||
|     } | ||||
|     final x = -position.dx; | ||||
|     final cornersX = this.cornersX(); | ||||
|     return HitCorners(x <= cornersX.min, x >= cornersX.max); | ||||
|   } | ||||
| 
 | ||||
|   HitCorners _hitCornersY() { | ||||
|     final double childHeight = scaleBoundaries.childSize.height * scale; | ||||
|     final double screenHeight = scaleBoundaries.outerSize.height; | ||||
|     if (screenHeight >= childHeight) { | ||||
|       return const HitCorners(true, true); | ||||
|     } | ||||
|     final y = -position.dy; | ||||
|     final cornersY = this.cornersY(); | ||||
|     return HitCorners(y <= cornersY.min, y >= cornersY.max); | ||||
|   } | ||||
| 
 | ||||
|   bool _shouldMoveAxis(HitCorners hitCorners, double mainAxisMove, double crossAxisMove) { | ||||
|     if (mainAxisMove == 0) { | ||||
|       return false; | ||||
|     } | ||||
|     if (!hitCorners.hasHitAny) { | ||||
|       return true; | ||||
|     } | ||||
|     final axisBlocked = hitCorners.hasHitBoth || | ||||
|         (hitCorners.hasHitMax ? mainAxisMove > 0 : mainAxisMove < 0); | ||||
|     if (axisBlocked) { | ||||
|       return false; | ||||
|     } | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   bool _shouldMoveX(Offset move) { | ||||
|     final hitCornersX = _hitCornersX(); | ||||
|     final mainAxisMove = move.dx; | ||||
|     final crossAxisMove = move.dy; | ||||
| 
 | ||||
|     return _shouldMoveAxis(hitCornersX, mainAxisMove, crossAxisMove); | ||||
|   } | ||||
| 
 | ||||
|   bool _shouldMoveY(Offset move) { | ||||
|     final hitCornersY = _hitCornersY(); | ||||
|     final mainAxisMove = move.dy; | ||||
|     final crossAxisMove = move.dx; | ||||
| 
 | ||||
|     return _shouldMoveAxis(hitCornersY, mainAxisMove, crossAxisMove); | ||||
|   } | ||||
| 
 | ||||
|   bool shouldMove(Offset move, Axis mainAxis) { | ||||
|     if (mainAxis == Axis.vertical) { | ||||
|       return _shouldMoveY(move); | ||||
|     } | ||||
|     return _shouldMoveX(move); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class HitCorners { | ||||
|   const HitCorners(this.hasHitMin, this.hasHitMax); | ||||
| 
 | ||||
|   final bool hasHitMin; | ||||
|   final bool hasHitMax; | ||||
| 
 | ||||
|   bool get hasHitAny => hasHitMin || hasHitMax; | ||||
| 
 | ||||
|   bool get hasHitBoth => hasHitMin && hasHitMax; | ||||
| } | ||||
| @ -0,0 +1,36 @@ | ||||
| /// A class that work as a enum. It overloads the operator `*` saving the double as a multiplier. | ||||
| /// | ||||
| /// ``` | ||||
| /// PhotoViewComputedScale.contained * 2 | ||||
| /// ``` | ||||
| /// | ||||
| class PhotoViewComputedScale { | ||||
|   const PhotoViewComputedScale._internal(this._value, [this.multiplier = 1.0]); | ||||
| 
 | ||||
|   final String _value; | ||||
|   final double multiplier; | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'Enum.$_value'; | ||||
| 
 | ||||
|   static const contained = PhotoViewComputedScale._internal('contained'); | ||||
|   static const covered = PhotoViewComputedScale._internal('covered'); | ||||
| 
 | ||||
|   PhotoViewComputedScale operator *(double multiplier) { | ||||
|     return PhotoViewComputedScale._internal(_value, multiplier); | ||||
|   } | ||||
| 
 | ||||
|   PhotoViewComputedScale operator /(double divider) { | ||||
|     return PhotoViewComputedScale._internal(_value, 1 / divider); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is PhotoViewComputedScale && | ||||
|           runtimeType == other.runtimeType && | ||||
|           _value == other._value; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => _value.hashCode; | ||||
| } | ||||
| @ -0,0 +1,45 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| 
 | ||||
| class PhotoViewDefaultError extends StatelessWidget { | ||||
|   const PhotoViewDefaultError({Key? key, required this.decoration}) | ||||
|       : super(key: key); | ||||
| 
 | ||||
|   final BoxDecoration decoration; | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return DecoratedBox( | ||||
|       decoration: decoration, | ||||
|       child: Center( | ||||
|         child: Icon( | ||||
|           Icons.broken_image, | ||||
|           color: Colors.grey[400], | ||||
|           size: 40.0, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class PhotoViewDefaultLoading extends StatelessWidget { | ||||
|   const PhotoViewDefaultLoading({Key? key, this.event}) : super(key: key); | ||||
| 
 | ||||
|   final ImageChunkEvent? event; | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final expectedBytes = event?.expectedTotalBytes; | ||||
|     final loadedBytes = event?.cumulativeBytesLoaded; | ||||
|     final value = loadedBytes != null && expectedBytes != null | ||||
|         ? loadedBytes / expectedBytes | ||||
|         : null; | ||||
| 
 | ||||
|     return Center( | ||||
|       child: SizedBox( | ||||
|         width: 20.0, | ||||
|         height: 20.0, | ||||
|         child: CircularProgressIndicator(value: value), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,12 @@ | ||||
| /// A way to represent the step of the "doubletap gesture cycle" in which PhotoView is. | ||||
| enum PhotoViewScaleState { | ||||
|   initial, | ||||
|   covering, | ||||
|   originalSize, | ||||
|   zoomedIn, | ||||
|   zoomedOut; | ||||
| 
 | ||||
|   bool get isScaleStateZooming => | ||||
|       this == PhotoViewScaleState.zoomedIn || | ||||
|       this == PhotoViewScaleState.zoomedOut; | ||||
| } | ||||
							
								
								
									
										327
									
								
								mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										327
									
								
								mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,327 @@ | ||||
| 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({ | ||||
|     Key? key, | ||||
|     required this.imageProvider, | ||||
|     required this.loadingBuilder, | ||||
|     required this.backgroundDecoration, | ||||
|     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.outerSize, | ||||
|     required this.gestureDetectorBehavior, | ||||
|     required this.tightMode, | ||||
|     required this.filterQuality, | ||||
|     required this.disableGestures, | ||||
|     required this.errorBuilder, | ||||
|     required this.enablePanAlways, | ||||
|   }) : super(key: key); | ||||
| 
 | ||||
|   final ImageProvider imageProvider; | ||||
|   final LoadingBuilder? loadingBuilder; | ||||
|   final ImageErrorWidgetBuilder? errorBuilder; | ||||
|   final BoxDecoration backgroundDecoration; | ||||
|   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 Size outerSize; | ||||
|   final HitTestBehavior? gestureDetectorBehavior; | ||||
|   final bool? tightMode; | ||||
|   final FilterQuality? filterQuality; | ||||
|   final bool? disableGestures; | ||||
|   final bool? enablePanAlways; | ||||
| 
 | ||||
|   @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, | ||||
|       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, | ||||
|       gestureDetectorBehavior: widget.gestureDetectorBehavior, | ||||
|       tightMode: widget.tightMode ?? false, | ||||
|       filterQuality: widget.filterQuality ?? FilterQuality.none, | ||||
|       disableGestures: widget.disableGestures ?? false, | ||||
|       enablePanAlways: widget.enablePanAlways ?? false, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   Widget _buildLoading(BuildContext context) { | ||||
|     if (widget.loadingBuilder != null) { | ||||
|       return widget.loadingBuilder!(context, _loadingProgress); | ||||
|     } | ||||
| 
 | ||||
|     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({ | ||||
|     Key? 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, | ||||
|     required this.outerSize, | ||||
|     this.gestureDetectorBehavior, | ||||
|     required this.tightMode, | ||||
|     required this.filterQuality, | ||||
|     required this.disableGestures, | ||||
|     required this.enablePanAlways, | ||||
|   }) : super(key: key); | ||||
| 
 | ||||
|   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 Size outerSize; | ||||
|   final HitTestBehavior? gestureDetectorBehavior; | ||||
|   final bool? tightMode; | ||||
|   final FilterQuality? filterQuality; | ||||
|   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, | ||||
|       gestureDetectorBehavior: gestureDetectorBehavior, | ||||
|       tightMode: tightMode ?? false, | ||||
|       filterQuality: filterQuality ?? FilterQuality.none, | ||||
|       disableGestures: disableGestures ?? false, | ||||
|       enablePanAlways: enablePanAlways ?? false, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,109 @@ | ||||
| import 'package:flutter/foundation.dart'; | ||||
| 
 | ||||
| /// A [ChangeNotifier] that has a second collection of listeners: the ignorable ones | ||||
| /// | ||||
| /// Those listeners will be fired when [notifyListeners] fires and will be ignored | ||||
| /// when [notifySomeListeners] fires. | ||||
| /// | ||||
| /// The common collection of listeners inherited from [ChangeNotifier] will be fired | ||||
| /// every time. | ||||
| class IgnorableChangeNotifier extends ChangeNotifier { | ||||
|   ObserverList<VoidCallback>? _ignorableListeners = | ||||
|       ObserverList<VoidCallback>(); | ||||
| 
 | ||||
|   bool _debugAssertNotDisposed() { | ||||
|     assert(() { | ||||
|       if (_ignorableListeners == null) { | ||||
|         AssertionError([ | ||||
|           'A $runtimeType was used after being disposed.', | ||||
|           'Once you have called dispose() on a $runtimeType, it can no longer be used.' | ||||
|         ]); | ||||
|       } | ||||
|       return true; | ||||
|     }()); | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool get hasListeners { | ||||
|     return super.hasListeners || (_ignorableListeners?.isNotEmpty ?? false); | ||||
|   } | ||||
| 
 | ||||
|   void addIgnorableListener(listener) { | ||||
|     assert(_debugAssertNotDisposed()); | ||||
|     _ignorableListeners!.add(listener); | ||||
|   } | ||||
| 
 | ||||
|   void removeIgnorableListener(listener) { | ||||
|     assert(_debugAssertNotDisposed()); | ||||
|     _ignorableListeners!.remove(listener); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _ignorableListeners = null; | ||||
|     super.dispose(); | ||||
|   } | ||||
| 
 | ||||
|   @protected | ||||
|   @override | ||||
|   @visibleForTesting | ||||
|   void notifyListeners() { | ||||
|     super.notifyListeners(); | ||||
|     if (_ignorableListeners != null) { | ||||
|       final List<VoidCallback> localListeners = | ||||
|           List<VoidCallback>.from(_ignorableListeners!); | ||||
|       for (VoidCallback listener in localListeners) { | ||||
|         try { | ||||
|           if (_ignorableListeners!.contains(listener)) { | ||||
|             listener(); | ||||
|           } | ||||
|         } catch (exception, stack) { | ||||
|           FlutterError.reportError( | ||||
|             FlutterErrorDetails( | ||||
|               exception: exception, | ||||
|               stack: stack, | ||||
|               library: 'Photoview library', | ||||
|             ), | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /// Ignores the ignoreables | ||||
|   @protected | ||||
|   void notifySomeListeners() { | ||||
|     super.notifyListeners(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// Just like [ValueNotifier] except it extends [IgnorableChangeNotifier] which has | ||||
| /// listeners that wont fire when [updateIgnoring] is called. | ||||
| class IgnorableValueNotifier<T> extends IgnorableChangeNotifier | ||||
|     implements ValueListenable<T> { | ||||
|   IgnorableValueNotifier(this._value); | ||||
| 
 | ||||
|   @override | ||||
|   T get value => _value; | ||||
|   T _value; | ||||
| 
 | ||||
|   set value(T newValue) { | ||||
|     if (_value == newValue) { | ||||
|       return; | ||||
|     } | ||||
|     _value = newValue; | ||||
|     notifyListeners(); | ||||
|   } | ||||
| 
 | ||||
|   void updateIgnoring(T newValue) { | ||||
|     if (_value == newValue) { | ||||
|       return; | ||||
|     } | ||||
|     _value = newValue; | ||||
|     notifySomeListeners(); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => '${describeIdentity(this)}($value)'; | ||||
| } | ||||
| @ -0,0 +1,28 @@ | ||||
| import 'package:flutter/widgets.dart'; | ||||
| 
 | ||||
| /// Data class that holds the attributes that are going to be passed to | ||||
| /// [PhotoViewImageWrapper]'s [Hero]. | ||||
| class PhotoViewHeroAttributes { | ||||
|   const PhotoViewHeroAttributes({ | ||||
|     required this.tag, | ||||
|     this.createRectTween, | ||||
|     this.flightShuttleBuilder, | ||||
|     this.placeholderBuilder, | ||||
|     this.transitionOnUserGestures = false, | ||||
|   }); | ||||
| 
 | ||||
|   /// Mirror to [Hero.tag] | ||||
|   final Object tag; | ||||
| 
 | ||||
|   /// Mirror to [Hero.createRectTween] | ||||
|   final CreateRectTween? createRectTween; | ||||
| 
 | ||||
|   /// Mirror to [Hero.flightShuttleBuilder] | ||||
|   final HeroFlightShuttleBuilder? flightShuttleBuilder; | ||||
| 
 | ||||
|   /// Mirror to [Hero.placeholderBuilder] | ||||
|   final HeroPlaceholderBuilder? placeholderBuilder; | ||||
| 
 | ||||
|   /// Mirror to [Hero.transitionOnUserGestures] | ||||
|   final bool transitionOnUserGestures; | ||||
| } | ||||
							
								
								
									
										145
									
								
								mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,145 @@ | ||||
| import 'dart:math' as math; | ||||
| import 'dart:ui' show Size; | ||||
| 
 | ||||
| import "package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart"; | ||||
| import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; | ||||
| 
 | ||||
| /// Given a [PhotoViewScaleState], returns a scale value considering [scaleBoundaries]. | ||||
| double getScaleForScaleState( | ||||
|   PhotoViewScaleState scaleState, | ||||
|   ScaleBoundaries scaleBoundaries, | ||||
| ) { | ||||
|   switch (scaleState) { | ||||
|     case PhotoViewScaleState.initial: | ||||
|     case PhotoViewScaleState.zoomedIn: | ||||
|     case PhotoViewScaleState.zoomedOut: | ||||
|       return _clampSize(scaleBoundaries.initialScale, scaleBoundaries); | ||||
|     case PhotoViewScaleState.covering: | ||||
|       return _clampSize( | ||||
|         _scaleForCovering( | ||||
|           scaleBoundaries.outerSize,  | ||||
|           scaleBoundaries.childSize, | ||||
|         ), | ||||
|         scaleBoundaries, | ||||
|       ); | ||||
|     case PhotoViewScaleState.originalSize: | ||||
|       return _clampSize(1.0, scaleBoundaries); | ||||
|     // Will never be reached | ||||
|     default: | ||||
|       return 0; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// Internal class to wraps custom scale boundaries (min, max and initial) | ||||
| /// Also, stores values regarding the two sizes: the container and teh child. | ||||
| class ScaleBoundaries { | ||||
|   const ScaleBoundaries( | ||||
|     this._minScale, | ||||
|     this._maxScale, | ||||
|     this._initialScale, | ||||
|     this.outerSize, | ||||
|     this.childSize, | ||||
|   ); | ||||
| 
 | ||||
|   final dynamic _minScale; | ||||
|   final dynamic _maxScale; | ||||
|   final dynamic _initialScale; | ||||
|   final Size outerSize; | ||||
|   final Size childSize; | ||||
| 
 | ||||
|   double get minScale { | ||||
|     assert(_minScale is double || _minScale is PhotoViewComputedScale); | ||||
|     if (_minScale == PhotoViewComputedScale.contained) { | ||||
|       return _scaleForContained(outerSize, childSize) * | ||||
|           (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as | ||||
|     } | ||||
|     if (_minScale == PhotoViewComputedScale.covered) { | ||||
|       return _scaleForCovering(outerSize, childSize) * | ||||
|           (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as | ||||
|     } | ||||
|     assert(_minScale >= 0.0); | ||||
|     return _minScale; | ||||
|   } | ||||
| 
 | ||||
|   double get maxScale { | ||||
|     assert(_maxScale is double || _maxScale is PhotoViewComputedScale); | ||||
|     if (_maxScale == PhotoViewComputedScale.contained) { | ||||
|       return (_scaleForContained(outerSize, childSize) * | ||||
|               (_maxScale as PhotoViewComputedScale) // ignore: avoid_as | ||||
|                   .multiplier) | ||||
|           .clamp(minScale, double.infinity); | ||||
|     } | ||||
|     if (_maxScale == PhotoViewComputedScale.covered) { | ||||
|       return (_scaleForCovering(outerSize, childSize) * | ||||
|               (_maxScale as PhotoViewComputedScale) // ignore: avoid_as | ||||
|                   .multiplier) | ||||
|           .clamp(minScale, double.infinity); | ||||
|     } | ||||
|     return _maxScale.clamp(minScale, double.infinity); | ||||
|   } | ||||
| 
 | ||||
|   double get initialScale { | ||||
|     assert(_initialScale is double || _initialScale is PhotoViewComputedScale); | ||||
|     if (_initialScale == PhotoViewComputedScale.contained) { | ||||
|       return _scaleForContained(outerSize, childSize) * | ||||
|           (_initialScale as PhotoViewComputedScale) // ignore: avoid_as | ||||
|               .multiplier; | ||||
|     } | ||||
|     if (_initialScale == PhotoViewComputedScale.covered) { | ||||
|       return _scaleForCovering(outerSize, childSize) * | ||||
|           (_initialScale as PhotoViewComputedScale) // ignore: avoid_as | ||||
|               .multiplier; | ||||
|     } | ||||
|     return _initialScale.clamp(minScale, maxScale); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is ScaleBoundaries && | ||||
|           runtimeType == other.runtimeType && | ||||
|           _minScale == other._minScale && | ||||
|           _maxScale == other._maxScale && | ||||
|           _initialScale == other._initialScale && | ||||
|           outerSize == other.outerSize && | ||||
|           childSize == other.childSize; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|       _minScale.hashCode ^ | ||||
|       _maxScale.hashCode ^ | ||||
|       _initialScale.hashCode ^ | ||||
|       outerSize.hashCode ^ | ||||
|       childSize.hashCode; | ||||
| } | ||||
| 
 | ||||
| double _scaleForContained(Size size, Size childSize) { | ||||
|   final double imageWidth = childSize.width; | ||||
|   final double imageHeight = childSize.height; | ||||
| 
 | ||||
|   final double screenWidth = size.width; | ||||
|   final double screenHeight = size.height; | ||||
| 
 | ||||
|   return math.min(screenWidth / imageWidth, screenHeight / imageHeight); | ||||
| } | ||||
| 
 | ||||
| double _scaleForCovering(Size size, Size childSize) { | ||||
|   final double imageWidth = childSize.width; | ||||
|   final double imageHeight = childSize.height; | ||||
| 
 | ||||
|   final double screenWidth = size.width; | ||||
|   final double screenHeight = size.height; | ||||
| 
 | ||||
|   return math.max(screenWidth / imageWidth, screenHeight / imageHeight); | ||||
| } | ||||
| 
 | ||||
| double _clampSize(double size, ScaleBoundaries scaleBoundaries) { | ||||
|   return size.clamp(scaleBoundaries.minScale, scaleBoundaries.maxScale); | ||||
| } | ||||
| 
 | ||||
| /// Simple class to store a min and a max value | ||||
| class CornersRange { | ||||
|   const CornersRange(this.min, this.max); | ||||
|   final double min; | ||||
|   final double max; | ||||
| } | ||||
| @ -239,6 +239,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.2.3" | ||||
|   easy_image_viewer: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: easy_image_viewer | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.2.0" | ||||
|   easy_localization: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @ -757,13 +764,6 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.5.0" | ||||
|   photo_view: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: photo_view | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.14.0" | ||||
|   platform: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | ||||
| @ -23,7 +23,6 @@ dependencies: | ||||
|   video_player: ^2.2.18 | ||||
|   chewie: ^1.3.5 | ||||
|   badges: ^2.0.2 | ||||
|   photo_view: ^0.14.0 | ||||
|   socket_io_client: ^2.0.0-beta.4-nullsafety.0 | ||||
|   flutter_map: ^0.14.0 | ||||
|   flutter_udid: ^2.0.0 | ||||
| @ -41,6 +40,7 @@ dependencies: | ||||
|   collection: ^1.16.0 | ||||
|   http_parser: ^4.0.1 | ||||
|   flutter_web_auth: ^0.5.0 | ||||
|   easy_image_viewer: ^1.2.0 | ||||
| 
 | ||||
|   openapi: | ||||
|     path: openapi | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user