diff --git a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart deleted file mode 100644 index 5006209591dd4..0000000000000 --- a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart +++ /dev/null @@ -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 { - 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 isZoomedListener; - - @override - State createState() { - return _RemotePhotoViewState(); - } -} diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 393d0fcba9d61..2ee580450a8e2 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -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(false); final indexOfAsset = useState(assetList.indexOf(asset)); final isPlayingMotionVideo = useState(false); - ValueNotifier isZoomedListener = ValueNotifier(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, + ); +} diff --git a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart deleted file mode 100644 index c2368dfc10109..0000000000000 --- a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart +++ /dev/null @@ -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 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(), - ), - ], - ); - } -} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 313a53e5fb8b9..873873524fbf3 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -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]), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 897b532225f6a..b307fc6cb5237 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -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(); - return MaterialPageX( - 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(); return MaterialPageX( @@ -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 { - ImageViewerRoute( - {Key? key, - required String heroTag, - required Asset asset, - required String authToken, - required void Function() isZoomedFunction, - required ValueNotifier 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 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 { diff --git a/mobile/lib/shared/ui/photo_view/photo_view.dart b/mobile/lib/shared/ui/photo_view/photo_view.dart new file mode 100644 index 0000000000000..9a5a87aac1689 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/photo_view.dart @@ -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 { +/// +/// 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: [ +/// 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 { +/// +/// 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: [ +/// 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? 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 createState() { + return _PhotoViewState(); + } +} + +class _PhotoViewState extends State + 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, +); diff --git a/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart b/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart new file mode 100644 index 0000000000000..6cbdb259c9d1b --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart @@ -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( +/// 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? 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? 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 createState() { + return _PhotoViewGalleryState(); + } +} + +class _PhotoViewGalleryState extends State { + 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; +} diff --git a/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller.dart b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller.dart new file mode 100644 index 0000000000000..40e707351aadf --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller.dart @@ -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 { + /// The output for state/value updates. Usually a broadcast [Stream] + Stream 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 { + 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.broadcast(); + _outputCtrl.sink.add(initial); + } + + final IgnorableValueNotifier _valueNotifier; + + late PhotoViewControllerValue initial; + + late StreamController _outputCtrl; + + @override + Stream 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; + } +} diff --git a/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart new file mode 100644 index 0000000000000..6be06a4a3994e --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart @@ -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 { + 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(); + } +} diff --git a/mobile/lib/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart new file mode 100644 index 0000000000000..dfd43a24927d0 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart @@ -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 _scaleStateNotifier = + IgnorableValueNotifier(PhotoViewScaleState.initial) + ..addListener(_scaleStateChangeListener); + final StreamController _outputScaleStateCtrl = + StreamController.broadcast() + ..sink.add(PhotoViewScaleState.initial); + + /// The output for state/value updates + Stream 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); + } +} diff --git a/mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart b/mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart new file mode 100644 index 0000000000000..5728301482dd1 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart @@ -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 createState() { + return PhotoViewCoreState(); + } + + bool get hasCustomChild => customChild != null; +} + +class PhotoViewCoreState extends State + with + TickerProviderStateMixin, + PhotoViewControllerDelegate, + HitCornersDetector { + Offset? _normalizedPosition; + double? _scaleBefore; + double? _rotationBefore; + + late final AnimationController _scaleAnimationController; + Animation? _scaleAnimation; + + late final AnimationController _positionAnimationController; + Animation? _positionAnimation; + + late final AnimationController _rotationAnimationController = + AnimationController(vsync: this)..addListener(handleRotationAnimation); + Animation? _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( + begin: from, + end: to, + ).animate(_scaleAnimationController); + _scaleAnimationController + ..value = 0.0 + ..fling(velocity: 0.4); + } + + void animatePosition(Offset from, Offset to) { + _positionAnimation = Tween(begin: from, end: to) + .animate(_positionAnimationController); + _positionAnimationController + ..value = 0.0 + ..fling(velocity: 0.4); + } + + void animateRotation(double from, double to) { + _rotationAnimation = Tween(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 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; +} diff --git a/mobile/lib/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart new file mode 100644 index 0000000000000..201ca20f41d65 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart @@ -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 gestures = + {}; + + if (onTapDown != null || onTapUp != null) { + gestures[TapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance + ..onTapDown = onTapDown + ..onTapUp = onTapUp; + }, + ); + } + + if (onDragStart != null || onDragEnd != null || onDragUpdate != null) { + gestures[VerticalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => VerticalDragGestureRecognizer(debugOwner: this), + (VerticalDragGestureRecognizer instance) { + instance + ..onStart = onDragStart + ..onUpdate = onDragUpdate + ..onEnd = onDragEnd; + }, + ); + } + + gestures[DoubleTapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => DoubleTapGestureRecognizer(debugOwner: this), + (DoubleTapGestureRecognizer instance) { + instance.onDoubleTap = onDoubleTap; + }, + ); + + gestures[PhotoViewGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => 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 _pointerLocations = {}; + + Offset? _initialFocalPoint; + Offset? _currentFocalPoint; + double? _initialSpan; + double? _currentSpan; + + bool ready = true; + + @override + void addAllowedPointer(PointerDownEvent event) { + if (ready) { + ready = false; + _pointerLocations = {}; + } + 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(); + 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; +} diff --git a/mobile/lib/shared/ui/photo_view/src/core/photo_view_hit_corners.dart b/mobile/lib/shared/ui/photo_view/src/core/photo_view_hit_corners.dart new file mode 100644 index 0000000000000..3210aed8e29a2 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/core/photo_view_hit_corners.dart @@ -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; +} diff --git a/mobile/lib/shared/ui/photo_view/src/photo_view_computed_scale.dart b/mobile/lib/shared/ui/photo_view/src/photo_view_computed_scale.dart new file mode 100644 index 0000000000000..a01db562c7e1f --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/photo_view_computed_scale.dart @@ -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; +} diff --git a/mobile/lib/shared/ui/photo_view/src/photo_view_default_widgets.dart b/mobile/lib/shared/ui/photo_view/src/photo_view_default_widgets.dart new file mode 100644 index 0000000000000..339463b3f85d3 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/photo_view_default_widgets.dart @@ -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), + ), + ); + } +} diff --git a/mobile/lib/shared/ui/photo_view/src/photo_view_scale_state.dart b/mobile/lib/shared/ui/photo_view/src/photo_view_scale_state.dart new file mode 100644 index 0000000000000..fc6d4db3f9156 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/photo_view_scale_state.dart @@ -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; +} diff --git a/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart b/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart new file mode 100644 index 0000000000000..da80f18962f44 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart @@ -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? 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 { + 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? 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, + ); + } +} diff --git a/mobile/lib/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart b/mobile/lib/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart new file mode 100644 index 0000000000000..95f6552be3e89 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart @@ -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? _ignorableListeners = + ObserverList(); + + 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 localListeners = + List.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 extends IgnorableChangeNotifier + implements ValueListenable { + 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)'; +} diff --git a/mobile/lib/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart b/mobile/lib/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart new file mode 100644 index 0000000000000..1fbbb73c339af --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart @@ -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; +} diff --git a/mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart b/mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart new file mode 100644 index 0000000000000..d8329c7f8e204 --- /dev/null +++ b/mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart @@ -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; +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 3de8b9a02db99..e8004e54e6733 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b504762b69ff2..79f7144664b45 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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