1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-16 16:56:46 +01:00

(fix)mobile: Improve the gallery to improve scale, double tap, and swipe gesture detection (#1502)

* photoviewgallery

* stiffer scrolling to react more like google photos

* adds a dx threshhold for the swipe/up down from the original dropped point

* stopped wrapping imageview in gallery viewer to avoid the double photoview issue. breaks imageview page pinch-to-zoom, so i need to fix that for other callers

* refactors gallery view to use remoteimage directly and breaks imageviewpage

* removed image_viewer_page

* adds minscale

* adds photo_view to repository

* double tap to zoom out with hacked commit

* double tapping!

* got up and down swipe gestures working

* fixed wrong cache and headers in image providers

* fixed image quality and added videos back in

* local loading asset image fix

* precaches images

* fixes lint errors

* deleted remote_photo_view and more linters

* fixes scale

* load preview and load original

* precache does original / preview as well

* refactored image providers to nice functions and added JPEG thumbnail format to remote image thumbnail lookup

* moved photo_view to shared/ui/

* three stage loading with webp and fixes some thumbnail fits

* fixed local thumbnail

* fixed paging in iOS
This commit is contained in:
martyfuhry 2023-02-01 11:59:34 -05:00 committed by GitHub
parent 391bf052e4
commit 02f5a86ee9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 3460 additions and 448 deletions

View file

@ -1,205 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart'
show AssetEntityImageProvider, ThumbnailSize;
import 'package:photo_view/photo_view.dart';
enum _RemoteImageStatus { empty, thumbnail, preview, full }
class _RemotePhotoViewState extends State<RemotePhotoView> {
late ImageProvider _imageProvider;
_RemoteImageStatus _status = _RemoteImageStatus.empty;
bool _zoomedIn = false;
late ImageProvider _fullProvider;
late ImageProvider _previewProvider;
late ImageProvider _thumbnailProvider;
@override
Widget build(BuildContext context) {
final bool forbidZoom = _status == _RemoteImageStatus.thumbnail;
return IgnorePointer(
ignoring: forbidZoom,
child: Listener(
onPointerMove: handleSwipUpDown,
child: PhotoView(
imageProvider: _imageProvider,
minScale: PhotoViewComputedScale.contained,
enablePanAlways: false,
scaleStateChangedCallback: _scaleStateChanged,
),
),
);
}
void handleSwipUpDown(PointerMoveEvent details) {
int sensitivity = 15;
if (_zoomedIn) {
return;
}
if (details.delta.dy > sensitivity) {
widget.onSwipeDown();
} else if (details.delta.dy < -sensitivity) {
widget.onSwipeUp();
}
}
void _scaleStateChanged(PhotoViewScaleState state) {
_zoomedIn = state != PhotoViewScaleState.initial;
if (_zoomedIn) {
widget.isZoomedListener.value = true;
} else {
widget.isZoomedListener.value = false;
}
widget.isZoomedFunction();
}
CachedNetworkImageProvider _authorizedImageProvider(
String url,
String cacheKey,
) {
return CachedNetworkImageProvider(
url,
headers: {"Authorization": widget.authToken},
cacheKey: cacheKey,
);
}
void _performStateTransition(
_RemoteImageStatus newStatus,
ImageProvider provider,
) {
if (_status == newStatus) return;
if (_status == _RemoteImageStatus.full &&
newStatus == _RemoteImageStatus.thumbnail) return;
if (_status == _RemoteImageStatus.preview &&
newStatus == _RemoteImageStatus.thumbnail) return;
if (_status == _RemoteImageStatus.full &&
newStatus == _RemoteImageStatus.preview) return;
if (!mounted) return;
setState(() {
_status = newStatus;
_imageProvider = provider;
});
}
void _loadImages() {
if (widget.asset.isLocal) {
_imageProvider = AssetEntityImageProvider(
widget.asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250),
);
_fullProvider = AssetEntityImageProvider(widget.asset.local!);
_fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo image, _) {
_performStateTransition(
_RemoteImageStatus.full,
_fullProvider,
);
}),
);
return;
}
_thumbnailProvider = _authorizedImageProvider(
getThumbnailUrl(widget.asset.remote!),
getThumbnailCacheKey(widget.asset.remote!),
);
_imageProvider = _thumbnailProvider;
_thumbnailProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(
_RemoteImageStatus.thumbnail,
_thumbnailProvider,
);
}),
);
if (widget.loadPreview) {
_previewProvider = _authorizedImageProvider(
getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG),
getThumbnailCacheKey(widget.asset.remote!, type: ThumbnailFormat.JPEG),
);
_previewProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.preview, _previewProvider);
}),
);
}
if (widget.loadOriginal) {
_fullProvider = _authorizedImageProvider(
getImageUrl(widget.asset.remote!),
getImageCacheKey(widget.asset.remote!),
);
_fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.full, _fullProvider);
}),
);
}
}
@override
void initState() {
super.initState();
_loadImages();
}
@override
void dispose() async {
super.dispose();
if (_status == _RemoteImageStatus.full) {
await _fullProvider.evict();
} else if (_status == _RemoteImageStatus.preview) {
await _previewProvider.evict();
} else if (_status == _RemoteImageStatus.thumbnail) {
await _thumbnailProvider.evict();
}
await _imageProvider.evict();
}
}
class RemotePhotoView extends StatefulWidget {
const RemotePhotoView({
Key? key,
required this.asset,
required this.authToken,
required this.loadPreview,
required this.loadOriginal,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.onSwipeDown,
required this.onSwipeUp,
}) : super(key: key);
final Asset asset;
final String authToken;
final bool loadPreview;
final bool loadOriginal;
final void Function() onSwipeDown;
final void Function() onSwipeUp;
final void Function() isZoomedFunction;
final ValueNotifier<bool> isZoomedListener;
@override
State<StatefulWidget> createState() {
return _RemotePhotoViewState();
}
}

View file

@ -1,4 +1,7 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -9,14 +12,21 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:openapi/api.dart' as api;
// ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget {
@ -40,7 +50,8 @@ class GalleryViewerPage extends HookConsumerWidget {
final isZoomed = useState<bool>(false);
final indexOfAsset = useState(assetList.indexOf(asset));
final isPlayingMotionVideo = useState(false);
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
late Offset localPosition;
final authToken = 'Bearer ${box.get(accessTokenKey)}';
PageController controller =
PageController(initialPage: assetList.indexOf(asset));
@ -57,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget {
[],
);
getAssetExif() async {
void getAssetExif() async {
if (assetList[indexOfAsset.value].isRemote) {
assetDetail = await ref
.watch(assetServiceProvider)
@ -68,27 +79,96 @@ class GalleryViewerPage extends HookConsumerWidget {
}
}
void showInfo() {
showModalBottomSheet(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
/// Thumbnail image of a remote asset. Required asset.remote != null
ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) {
return CachedNetworkImageProvider(
getThumbnailUrl(
asset.remote!,
type: type,
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
cacheKey: getThumbnailCacheKey(
asset.remote!,
type: type,
),
headers: {"Authorization": authToken},
);
}
//make isZoomed listener call instead
void isZoomedMethod() {
if (isZoomedListener.value) {
isZoomed.value = true;
} else {
isZoomed.value = false;
/// Original (large) image of a remote asset. Required asset.remote != null
ImageProvider originalImageProvider(Asset asset) {
return CachedNetworkImageProvider(
getImageUrl(asset.remote!),
cacheKey: getImageCacheKey(asset.remote!),
headers: {"Authorization": authToken},
);
}
/// Thumbnail image of a local asset. Required asset.local != null
ImageProvider localThumbnailImageProvider(Asset asset) {
return AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250),
);
}
/// Original (large) image of a local asset. Required asset.local != null
ImageProvider localImageProvider(Asset asset) {
return AssetEntityImageProvider(asset.local!);
}
void precacheNextImage(int index) {
if (index < assetList.length && index > 0) {
final asset = assetList[index];
if (asset.isLocal) {
// Preload the local asset
precacheImage(localImageProvider(asset), context);
} else {
// Probably load WEBP either way
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.WEBP,
),
context,
);
if (isLoadPreview.value) {
// Precache the JPEG thumbnail
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.JPEG,
),
context,
);
}
if (isLoadOriginal.value) {
// Preload the original asset
precacheImage(
originalImageProvider(asset),
context,
);
}
}
}
}
void showInfo() {
if (assetList[indexOfAsset.value].isRemote) {
showModalBottomSheet(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
);
}
}
@ -122,6 +202,28 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
void handleSwipeUpDown(DragUpdateDetails details) {
int sensitivity = 15;
int dxThreshhold = 50;
if (isZoomed.value) {
return;
}
// Check for delta from initial down point
final d = details.localPosition - localPosition;
// If the magnitude of the dx swipe is large, we probably didn't mean to go down
if (d.dx.abs() > dxThreshhold) {
return;
}
if (details.delta.dy > sensitivity) {
AutoRouter.of(context).pop();
} else if (details.delta.dy < -sensitivity) {
showInfo();
}
}
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
@ -150,61 +252,93 @@ class GalleryViewerPage extends HookConsumerWidget {
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
),
body: SafeArea(
child: PageView.builder(
controller: controller,
pageSnapping: true,
physics: isZoomed.value
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
child: PhotoViewGallery.builder(
scaleStateChangedCallback: (state) => isZoomed.value = state != PhotoViewScaleState.initial,
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const BouncingScrollPhysics() // Use bouncing physics for iOS
: const ImmichPageViewScrollPhysics() // Use heavy physics for Android
),
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (indexOfAsset.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
indexOfAsset.value = value;
HapticFeedback.selectionClick();
},
itemBuilder: (context, index) {
getAssetExif();
loadingBuilder: isLoadPreview.value ? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP),
httpHeaders: { 'Authorization': authToken },
progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),),
fit: BoxFit.contain,
);
if (assetList[index].isImage) {
if (isPlayingMotionVideo.value) {
return VideoViewerPage(
asset: assetList[index],
isMotionVideo: true,
onVideoEnded: () {
isPlayingMotionVideo.value = false;
},
);
} else {
return ImageViewerPage(
authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod,
isZoomedListener: isZoomedListener,
asset: assetList[index],
heroTag: assetList[index].id,
loadPreview: isLoadPreview.value,
loadOriginal: isLoadOriginal.value,
showExifSheet: showInfo,
);
}
return CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG),
httpHeaders: { 'Authorization': authToken },
fit: BoxFit.contain,
placeholder: (_, __) => webPThumbnail,
);
} else {
return GestureDetector(
onVerticalDragUpdate: (details) {
const int sensitivity = 15;
if (details.delta.dy > sensitivity) {
// swipe down
AutoRouter.of(context).pop();
} else if (details.delta.dy < -sensitivity) {
// swipe up
showInfo();
}
},
child: Hero(
tag: assetList[index].id,
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: false,
onVideoEnded: () {},
),
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
);
}
} : null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
}
}
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained,
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
),
);
}
@ -214,3 +348,19 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
}
class ImmichPageViewScrollPhysics extends ScrollPhysics {
const ImmichPageViewScrollPhysics({super.parent});
@override
ImmichPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) {
return ImmichPageViewScrollPhysics(parent: buildParent(ancestor)!);
}
@override
SpringDescription get spring => const SpringDescription(
mass: 100,
stiffness: 100,
damping: .90,
);
}

View file

@ -1,84 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
// ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget {
final String heroTag;
final Asset asset;
final String authToken;
final ValueNotifier<bool> isZoomedListener;
final void Function() isZoomedFunction;
final void Function()? showExifSheet;
final bool loadPreview;
final bool loadOriginal;
ImageViewerPage({
Key? key,
required this.heroTag,
required this.asset,
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.loadPreview,
required this.loadOriginal,
this.showExifSheet,
}) : super(key: key);
Asset? assetDetail;
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
getAssetExif() async {
if (asset.isRemote) {
assetDetail =
await ref.watch(assetServiceProvider).getAssetById(asset.id);
} else {
// TODO local exif parsing?
assetDetail = asset;
}
}
useEffect(
() {
getAssetExif();
return null;
},
[],
);
return Stack(
children: [
Center(
child: Hero(
tag: heroTag,
child: RemotePhotoView(
asset: asset,
authToken: authToken,
loadPreview: loadPreview,
loadOriginal: loadOriginal,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: (asset.isRemote && showExifSheet != null) ? showExifSheet! : () {},
),
),
),
if (downloadAssetStatus == DownloadAssetStatus.loading)
const Center(
child: ImmichLoadingIndicator(),
),
],
);
}
}

View file

@ -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]),

View file

@ -48,21 +48,6 @@ class _$AppRouter extends RootStackRouter {
child: GalleryViewerPage(
key: args.key, assetList: args.assetList, asset: args.asset));
},
ImageViewerRoute.name: (routeData) {
final args = routeData.argsAs<ImageViewerRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: ImageViewerPage(
key: args.key,
heroTag: args.heroTag,
asset: args.asset,
authToken: args.authToken,
isZoomedFunction: args.isZoomedFunction,
isZoomedListener: args.isZoomedListener,
loadPreview: args.loadPreview,
loadOriginal: args.loadOriginal,
showExifSheet: args.showExifSheet));
},
VideoViewerRoute.name: (routeData) {
final args = routeData.argsAs<VideoViewerRouteArgs>();
return MaterialPageX<dynamic>(
@ -204,8 +189,6 @@ class _$AppRouter extends RootStackRouter {
]),
RouteConfig(GalleryViewerRoute.name,
path: '/gallery-viewer-page', guards: [authGuard]),
RouteConfig(ImageViewerRoute.name,
path: '/image-viewer-page', guards: [authGuard]),
RouteConfig(VideoViewerRoute.name,
path: '/video-viewer-page', guards: [authGuard]),
RouteConfig(BackupControllerRoute.name,
@ -299,71 +282,6 @@ class GalleryViewerRouteArgs {
}
}
/// generated route for
/// [ImageViewerPage]
class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
ImageViewerRoute(
{Key? key,
required String heroTag,
required Asset asset,
required String authToken,
required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener,
required bool loadPreview,
required bool loadOriginal,
void Function()? showExifSheet})
: super(ImageViewerRoute.name,
path: '/image-viewer-page',
args: ImageViewerRouteArgs(
key: key,
heroTag: heroTag,
asset: asset,
authToken: authToken,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
loadPreview: loadPreview,
loadOriginal: loadOriginal,
showExifSheet: showExifSheet));
static const String name = 'ImageViewerRoute';
}
class ImageViewerRouteArgs {
const ImageViewerRouteArgs(
{this.key,
required this.heroTag,
required this.asset,
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.loadPreview,
required this.loadOriginal,
this.showExifSheet});
final Key? key;
final String heroTag;
final Asset asset;
final String authToken;
final void Function() isZoomedFunction;
final ValueNotifier<bool> isZoomedListener;
final bool loadPreview;
final bool loadOriginal;
final void Function()? showExifSheet;
@override
String toString() {
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}';
}
}
/// generated route for
/// [VideoViewerPage]
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {

View file

@ -0,0 +1,653 @@
library photo_view;
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_wrappers.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
export 'src/controller/photo_view_controller.dart';
export 'src/controller/photo_view_scalestate_controller.dart';
export 'src/core/photo_view_gesture_detector.dart'
show PhotoViewGestureDetectorScope, PhotoViewPageViewScrollPhysics;
export 'src/photo_view_computed_scale.dart';
export 'src/photo_view_scale_state.dart';
export 'src/utils/photo_view_hero_attributes.dart';
/// A [StatefulWidget] that contains all the photo view rendering elements.
///
/// Sample code to use within an image:
///
/// ```
/// PhotoView(
/// imageProvider: imageProvider,
/// loadingBuilder: (context, progress) => Center(
/// child: Container(
/// width: 20.0,
/// height: 20.0,
/// child: CircularProgressIndicator(
/// value: _progress == null
/// ? null
/// : _progress.cumulativeBytesLoaded /
/// _progress.expectedTotalBytes,
/// ),
/// ),
/// ),
/// backgroundDecoration: BoxDecoration(color: Colors.black),
/// gaplessPlayback: false,
/// customSize: MediaQuery.of(context).size,
/// heroAttributes: const HeroAttributes(
/// tag: "someTag",
/// transitionOnUserGestures: true,
/// ),
/// scaleStateChangedCallback: this.onScaleStateChanged,
/// enableRotation: true,
/// controller: controller,
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.8,
/// initialScale: PhotoViewComputedScale.contained,
/// basePosition: Alignment.center,
/// scaleStateCycle: scaleStateCycle
/// );
/// ```
///
/// You can customize to show an custom child instead of an image:
///
/// ```
/// PhotoView.customChild(
/// child: Container(
/// width: 220.0,
/// height: 250.0,
/// child: const Text(
/// "Hello there, this is a text",
/// )
/// ),
/// childSize: const Size(220.0, 250.0),
/// backgroundDecoration: BoxDecoration(color: Colors.black),
/// gaplessPlayback: false,
/// customSize: MediaQuery.of(context).size,
/// heroAttributes: const HeroAttributes(
/// tag: "someTag",
/// transitionOnUserGestures: true,
/// ),
/// scaleStateChangedCallback: this.onScaleStateChanged,
/// enableRotation: true,
/// controller: controller,
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.8,
/// initialScale: PhotoViewComputedScale.contained,
/// basePosition: Alignment.center,
/// scaleStateCycle: scaleStateCycle
/// );
/// ```
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
///
/// Sample using [maxScale], [minScale] and [initialScale]
///
/// ```
/// PhotoView(
/// imageProvider: imageProvider,
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.8,
/// initialScale: PhotoViewComputedScale.contained * 1.1,
/// );
/// ```
///
/// [customSize] is used to define the viewPort size in which the image will be
/// scaled to. This argument is rarely used. By default is the size that this widget assumes.
///
/// The argument [gaplessPlayback] is used to continue showing the old image
/// (`true`), or briefly show nothing (`false`), when the [imageProvider]
/// changes.By default it's set to `false`.
///
/// To use within an hero animation, specify [heroAttributes]. When
/// [heroAttributes] is specified, the image provider retrieval process should
/// be sync.
///
/// Sample using hero animation:
/// ```
/// // screen1
/// ...
/// Hero(
/// tag: "someTag",
/// child: Image.asset(
/// "assets/large-image.jpg",
/// width: 150.0
/// ),
/// )
/// // screen2
/// ...
/// child: PhotoView(
/// imageProvider: AssetImage("assets/large-image.jpg"),
/// heroAttributes: const HeroAttributes(tag: "someTag"),
/// )
/// ```
///
/// **Note: If you don't want to the zoomed image do not overlaps the size of the container, use [ClipRect](https://docs.flutter.io/flutter/widgets/ClipRect-class.html)**
///
/// ## Controllers
///
/// Controllers, when specified to PhotoView widget, enables the author(you) to listen for state updates through a `Stream` and change those values externally.
///
/// While [PhotoViewScaleStateController] is only responsible for the `scaleState`, [PhotoViewController] is responsible for all fields os [PhotoViewControllerValue].
///
/// To use them, pass a instance of those items on [controller] or [scaleStateController];
///
/// Since those follows the standard controller pattern found in widgets like [PageView] and [ScrollView], whoever instantiates it, should [dispose] it afterwards.
///
/// Example of [controller] usage, only listening for state changes:
///
/// ```
/// class _ExampleWidgetState extends State<ExampleWidget> {
///
/// PhotoViewController controller;
/// double scaleCopy;
///
/// @override
/// void initState() {
/// super.initState();
/// controller = PhotoViewController()
/// ..outputStateStream.listen(listener);
/// }
///
/// @override
/// void dispose() {
/// controller.dispose();
/// super.dispose();
/// }
///
/// void listener(PhotoViewControllerValue value){
/// setState((){
/// scaleCopy = value.scale;
/// })
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Stack(
/// children: <Widget>[
/// Positioned.fill(
/// child: PhotoView(
/// imageProvider: AssetImage("assets/pudim.png"),
/// controller: controller,
/// );
/// ),
/// Text("Scale applied: $scaleCopy")
/// ],
/// );
/// }
/// }
/// ```
///
/// An example of [scaleStateController] with state changes:
/// ```
/// class _ExampleWidgetState extends State<ExampleWidget> {
///
/// PhotoViewScaleStateController scaleStateController;
///
/// @override
/// void initState() {
/// super.initState();
/// scaleStateController = PhotoViewScaleStateController();
/// }
///
/// @override
/// void dispose() {
/// scaleStateController.dispose();
/// super.dispose();
/// }
///
/// void goBack(){
/// scaleStateController.scaleState = PhotoViewScaleState.originalSize;
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Stack(
/// children: <Widget>[
/// Positioned.fill(
/// child: PhotoView(
/// imageProvider: AssetImage("assets/pudim.png"),
/// scaleStateController: scaleStateController,
/// );
/// ),
/// FlatButton(
/// child: Text("Go to original size"),
/// onPressed: goBack,
/// );
/// ],
/// );
/// }
/// }
/// ```
///
class PhotoView extends StatefulWidget {
/// Creates a widget that displays a zoomable image.
///
/// To show an image from the network or from an asset bundle, use their respective
/// image providers, ie: [AssetImage] or [NetworkImage]
///
/// Internally, the image is rendered within an [Image] widget.
const PhotoView({
Key? key,
required this.imageProvider,
this.loadingBuilder,
this.backgroundDecoration,
this.wantKeepAlive = false,
this.gaplessPlayback = false,
this.heroAttributes,
this.scaleStateChangedCallback,
this.enableRotation = false,
this.controller,
this.scaleStateController,
this.maxScale,
this.minScale,
this.initialScale,
this.basePosition,
this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.customSize,
this.gestureDetectorBehavior,
this.tightMode,
this.filterQuality,
this.disableGestures,
this.errorBuilder,
this.enablePanAlways,
}) : child = null,
childSize = null,
super(key: key);
/// Creates a widget that displays a zoomable child.
///
/// It has been created to resemble [PhotoView] behavior within widgets that aren't an image, such as [Container], [Text] or a svg.
///
/// Instead of a [imageProvider], this constructor will receive a [child] and a [childSize].
///
const PhotoView.customChild({
Key? key,
required this.child,
this.childSize,
this.backgroundDecoration,
this.wantKeepAlive = false,
this.heroAttributes,
this.scaleStateChangedCallback,
this.enableRotation = false,
this.controller,
this.scaleStateController,
this.maxScale,
this.minScale,
this.initialScale,
this.basePosition,
this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.customSize,
this.gestureDetectorBehavior,
this.tightMode,
this.filterQuality,
this.disableGestures,
this.enablePanAlways,
}) : errorBuilder = null,
imageProvider = null,
gaplessPlayback = false,
loadingBuilder = null,
super(key: key);
/// Given a [imageProvider] it resolves into an zoomable image widget using. It
/// is required
final ImageProvider? imageProvider;
/// While [imageProvider] is not resolved, [loadingBuilder] is called by [PhotoView]
/// into the screen, by default it is a centered [CircularProgressIndicator]
final LoadingBuilder? loadingBuilder;
/// Show loadFailedChild when the image failed to load
final ImageErrorWidgetBuilder? errorBuilder;
/// Changes the background behind image, defaults to `Colors.black`.
final BoxDecoration? backgroundDecoration;
/// This is used to keep the state of an image in the gallery (e.g. scale state).
/// `false` -> resets the state (default)
/// `true` -> keeps the state
final bool wantKeepAlive;
/// This is used to continue showing the old image (`true`), or briefly show
/// nothing (`false`), when the `imageProvider` changes. By default it's set
/// to `false`.
final bool gaplessPlayback;
/// Attributes that are going to be passed to [PhotoViewCore]'s
/// [Hero]. Leave this property undefined if you don't want a hero animation.
final PhotoViewHeroAttributes? heroAttributes;
/// Defines the size of the scaling base of the image inside [PhotoView],
/// by default it is `MediaQuery.of(context).size`.
final Size? customSize;
/// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in.
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
/// A flag that enables the rotation gesture support
final bool enableRotation;
/// The specified custom child to be shown instead of a image
final Widget? child;
/// The size of the custom [child]. [PhotoView] uses this value to compute the relation between the child and the container's size to calculate the scale value.
final Size? childSize;
/// Defines the maximum size in which the image will be allowed to assume, it
/// is proportional to the original image size. Can be either a double (absolute value) or a
/// [PhotoViewComputedScale], that can be multiplied by a double
final dynamic maxScale;
/// Defines the minimum size in which the image will be allowed to assume, it
/// is proportional to the original image size. Can be either a double (absolute value) or a
/// [PhotoViewComputedScale], that can be multiplied by a double
final dynamic minScale;
/// Defines the initial size in which the image will be assume in the mounting of the component, it
/// is proportional to the original image size. Can be either a double (absolute value) or a
/// [PhotoViewComputedScale], that can be multiplied by a double
final dynamic initialScale;
/// A way to control PhotoView transformation factors externally and listen to its updates
final PhotoViewControllerBase? controller;
/// A way to control PhotoViewScaleState value externally and listen to its updates
final PhotoViewScaleStateController? scaleStateController;
/// The alignment of the scale origin in relation to the widget size. Default is [Alignment.center]
final Alignment? basePosition;
/// Defines de next [PhotoViewScaleState] given the actual one. Default is [defaultScaleStateCycle]
final ScaleStateCycle? scaleStateCycle;
/// A pointer that will trigger a tap has stopped contacting the screen at a
/// particular location.
final PhotoViewImageTapUpCallback? onTapUp;
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
final PhotoViewImageTapDownCallback? onTapDown;
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
final PhotoViewImageDragStartCallback? onDragStart;
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
final PhotoViewImageDragEndCallback? onDragEnd;
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
final PhotoViewImageDragUpdateCallback? onDragUpdate;
/// A pointer that will trigger a scale has stopped contacting the screen at a
/// particular location.
final PhotoViewImageScaleEndCallback? onScaleEnd;
/// [HitTestBehavior] to be passed to the internal gesture detector.
final HitTestBehavior? gestureDetectorBehavior;
/// Enables tight mode, making background container assume the size of the image/child.
/// Useful when inside a [Dialog]
final bool? tightMode;
/// Quality levels for image filters.
final FilterQuality? filterQuality;
// Removes gesture detector if `true`.
// Useful when custom gesture detector is used in child widget.
final bool? disableGestures;
/// Enable pan the widget even if it's smaller than the hole parent widget.
/// Useful when you want to drag a widget without restrictions.
final bool? enablePanAlways;
bool get _isCustomChild {
return child != null;
}
@override
State<StatefulWidget> createState() {
return _PhotoViewState();
}
}
class _PhotoViewState extends State<PhotoView>
with AutomaticKeepAliveClientMixin {
// image retrieval
// controller
late bool _controlledController;
late PhotoViewControllerBase _controller;
late bool _controlledScaleStateController;
late PhotoViewScaleStateController _scaleStateController;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_controlledController = true;
_controller = PhotoViewController();
} else {
_controlledController = false;
_controller = widget.controller!;
}
if (widget.scaleStateController == null) {
_controlledScaleStateController = true;
_scaleStateController = PhotoViewScaleStateController();
} else {
_controlledScaleStateController = false;
_scaleStateController = widget.scaleStateController!;
}
_scaleStateController.outputScaleStateStream.listen(scaleStateListener);
}
@override
void didUpdateWidget(PhotoView oldWidget) {
if (widget.controller == null) {
if (!_controlledController) {
_controlledController = true;
_controller = PhotoViewController();
}
} else {
_controlledController = false;
_controller = widget.controller!;
}
if (widget.scaleStateController == null) {
if (!_controlledScaleStateController) {
_controlledScaleStateController = true;
_scaleStateController = PhotoViewScaleStateController();
}
} else {
_controlledScaleStateController = false;
_scaleStateController = widget.scaleStateController!;
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
if (_controlledController) {
_controller.dispose();
}
if (_controlledScaleStateController) {
_scaleStateController.dispose();
}
super.dispose();
}
void scaleStateListener(PhotoViewScaleState scaleState) {
if (widget.scaleStateChangedCallback != null) {
widget.scaleStateChangedCallback!(_scaleStateController.scaleState);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return LayoutBuilder(
builder: (
BuildContext context,
BoxConstraints constraints,
) {
final computedOuterSize = widget.customSize ?? constraints.biggest;
final backgroundDecoration = widget.backgroundDecoration ??
const BoxDecoration(color: Colors.black);
return widget._isCustomChild
? CustomChildWrapper(
childSize: widget.childSize,
backgroundDecoration: backgroundDecoration,
heroAttributes: widget.heroAttributes,
scaleStateChangedCallback: widget.scaleStateChangedCallback,
enableRotation: widget.enableRotation,
controller: _controller,
scaleStateController: _scaleStateController,
maxScale: widget.maxScale,
minScale: widget.minScale,
initialScale: widget.initialScale,
basePosition: widget.basePosition,
scaleStateCycle: widget.scaleStateCycle,
onTapUp: widget.onTapUp,
onTapDown: widget.onTapDown,
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onScaleEnd: widget.onScaleEnd,
outerSize: computedOuterSize,
gestureDetectorBehavior: widget.gestureDetectorBehavior,
tightMode: widget.tightMode,
filterQuality: widget.filterQuality,
disableGestures: widget.disableGestures,
enablePanAlways: widget.enablePanAlways,
child: widget.child,
)
: ImageWrapper(
imageProvider: widget.imageProvider!,
loadingBuilder: widget.loadingBuilder,
backgroundDecoration: backgroundDecoration,
gaplessPlayback: widget.gaplessPlayback,
heroAttributes: widget.heroAttributes,
scaleStateChangedCallback: widget.scaleStateChangedCallback,
enableRotation: widget.enableRotation,
controller: _controller,
scaleStateController: _scaleStateController,
maxScale: widget.maxScale,
minScale: widget.minScale,
initialScale: widget.initialScale,
basePosition: widget.basePosition,
scaleStateCycle: widget.scaleStateCycle,
onTapUp: widget.onTapUp,
onTapDown: widget.onTapDown,
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onScaleEnd: widget.onScaleEnd,
outerSize: computedOuterSize,
gestureDetectorBehavior: widget.gestureDetectorBehavior,
tightMode: widget.tightMode,
filterQuality: widget.filterQuality,
disableGestures: widget.disableGestures,
errorBuilder: widget.errorBuilder,
enablePanAlways: widget.enablePanAlways,
);
},
);
}
@override
bool get wantKeepAlive => widget.wantKeepAlive;
}
/// The default [ScaleStateCycle]
PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) {
switch (actual) {
case PhotoViewScaleState.initial:
return PhotoViewScaleState.covering;
case PhotoViewScaleState.covering:
return PhotoViewScaleState.originalSize;
case PhotoViewScaleState.originalSize:
return PhotoViewScaleState.initial;
case PhotoViewScaleState.zoomedIn:
case PhotoViewScaleState.zoomedOut:
return PhotoViewScaleState.initial;
default:
return PhotoViewScaleState.initial;
}
}
/// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one
/// It is used internally to walk in the "doubletap gesture cycle".
/// It is passed to [PhotoView.scaleStateCycle]
typedef ScaleStateCycle = PhotoViewScaleState Function(
PhotoViewScaleState actual,
);
/// A type definition for a callback when the user taps up the photoview region
typedef PhotoViewImageTapUpCallback = Function(
BuildContext context,
TapUpDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user taps down the photoview region
typedef PhotoViewImageTapDownCallback = Function(
BuildContext context,
TapDownDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user drags up
typedef PhotoViewImageDragStartCallback = Function(
BuildContext context,
DragStartDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user drags
typedef PhotoViewImageDragUpdateCallback = Function(
BuildContext context,
DragUpdateDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user taps down the photoview region
typedef PhotoViewImageDragEndCallback = Function(
BuildContext context,
DragEndDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when a user finished scale
typedef PhotoViewImageScaleEndCallback = Function(
BuildContext context,
ScaleEndDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback to show a widget while the image is loading, a [ImageChunkEvent] is passed to inform progress
typedef LoadingBuilder = Widget Function(
BuildContext context,
ImageChunkEvent? event,
);

View file

@ -0,0 +1,446 @@
library photo_view_gallery;
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
show
LoadingBuilder,
PhotoView,
PhotoViewImageTapDownCallback,
PhotoViewImageTapUpCallback,
PhotoViewImageDragStartCallback,
PhotoViewImageDragEndCallback,
PhotoViewImageDragUpdateCallback,
PhotoViewImageScaleEndCallback,
ScaleStateCycle;
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
/// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery]
typedef PhotoViewGalleryPageChangedCallback = void Function(int index);
/// A type definition for a [Function] that defines a page in [PhotoViewGallery.build]
typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function(
BuildContext context,
int index,
);
/// A [StatefulWidget] that shows multiple [PhotoView] widgets in a [PageView]
///
/// Some of [PhotoView] constructor options are passed direct to [PhotoViewGallery] constructor. Those options will affect the gallery in a whole.
///
/// Some of the options may be defined to each image individually, such as `initialScale` or `PhotoViewHeroAttributes`. Those must be passed via each [PhotoViewGalleryPageOptions].
///
/// Example of usage as a list of options:
/// ```
/// PhotoViewGallery(
/// pageOptions: <PhotoViewGalleryPageOptions>[
/// PhotoViewGalleryPageOptions(
/// imageProvider: AssetImage("assets/gallery1.jpg"),
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag1"),
/// ),
/// PhotoViewGalleryPageOptions(
/// imageProvider: AssetImage("assets/gallery2.jpg"),
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag2"),
/// maxScale: PhotoViewComputedScale.contained * 0.3
/// ),
/// PhotoViewGalleryPageOptions(
/// imageProvider: AssetImage("assets/gallery3.jpg"),
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.1,
/// heroAttributes: const HeroAttributes(tag: "tag3"),
/// ),
/// ],
/// loadingBuilder: (context, progress) => Center(
/// child: Container(
/// width: 20.0,
/// height: 20.0,
/// child: CircularProgressIndicator(
/// value: _progress == null
/// ? null
/// : _progress.cumulativeBytesLoaded /
/// _progress.expectedTotalBytes,
/// ),
/// ),
/// ),
/// backgroundDecoration: widget.backgroundDecoration,
/// pageController: widget.pageController,
/// onPageChanged: onPageChanged,
/// )
/// ```
///
/// Example of usage with builder pattern:
/// ```
/// PhotoViewGallery.builder(
/// scrollPhysics: const BouncingScrollPhysics(),
/// builder: (BuildContext context, int index) {
/// return PhotoViewGalleryPageOptions(
/// imageProvider: AssetImage(widget.galleryItems[index].image),
/// initialScale: PhotoViewComputedScale.contained * 0.8,
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.1,
/// heroAttributes: HeroAttributes(tag: galleryItems[index].id),
/// );
/// },
/// itemCount: galleryItems.length,
/// loadingBuilder: (context, progress) => Center(
/// child: Container(
/// width: 20.0,
/// height: 20.0,
/// child: CircularProgressIndicator(
/// value: _progress == null
/// ? null
/// : _progress.cumulativeBytesLoaded /
/// _progress.expectedTotalBytes,
/// ),
/// ),
/// ),
/// backgroundDecoration: widget.backgroundDecoration,
/// pageController: widget.pageController,
/// onPageChanged: onPageChanged,
/// )
/// ```
class PhotoViewGallery extends StatefulWidget {
/// Construct a gallery with static items through a list of [PhotoViewGalleryPageOptions].
const PhotoViewGallery({
Key? key,
required this.pageOptions,
this.loadingBuilder,
this.backgroundDecoration,
this.wantKeepAlive = false,
this.gaplessPlayback = false,
this.reverse = false,
this.pageController,
this.onPageChanged,
this.scaleStateChangedCallback,
this.enableRotation = false,
this.scrollPhysics,
this.scrollDirection = Axis.horizontal,
this.customSize,
this.allowImplicitScrolling = false,
}) : itemCount = null,
builder = null,
super(key: key);
/// Construct a gallery with dynamic items.
///
/// The builder must return a [PhotoViewGalleryPageOptions].
const PhotoViewGallery.builder({
Key? key,
required this.itemCount,
required this.builder,
this.loadingBuilder,
this.backgroundDecoration,
this.wantKeepAlive = false,
this.gaplessPlayback = false,
this.reverse = false,
this.pageController,
this.onPageChanged,
this.scaleStateChangedCallback,
this.enableRotation = false,
this.scrollPhysics,
this.scrollDirection = Axis.horizontal,
this.customSize,
this.allowImplicitScrolling = false,
}) : pageOptions = null,
assert(itemCount != null),
assert(builder != null),
super(key: key);
/// A list of options to describe the items in the gallery
final List<PhotoViewGalleryPageOptions>? pageOptions;
/// The count of items in the gallery, only used when constructed via [PhotoViewGallery.builder]
final int? itemCount;
/// Called to build items for the gallery when using [PhotoViewGallery.builder]
final PhotoViewGalleryBuilder? builder;
/// [ScrollPhysics] for the internal [PageView]
final ScrollPhysics? scrollPhysics;
/// Mirror to [PhotoView.loadingBuilder]
final LoadingBuilder? loadingBuilder;
/// Mirror to [PhotoView.backgroundDecoration]
final BoxDecoration? backgroundDecoration;
/// Mirror to [PhotoView.wantKeepAlive]
final bool wantKeepAlive;
/// Mirror to [PhotoView.gaplessPlayback]
final bool gaplessPlayback;
/// Mirror to [PageView.reverse]
final bool reverse;
/// An object that controls the [PageView] inside [PhotoViewGallery]
final PageController? pageController;
/// An callback to be called on a page change
final PhotoViewGalleryPageChangedCallback? onPageChanged;
/// Mirror to [PhotoView.scaleStateChangedCallback]
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
/// Mirror to [PhotoView.enableRotation]
final bool enableRotation;
/// Mirror to [PhotoView.customSize]
final Size? customSize;
/// The axis along which the [PageView] scrolls. Mirror to [PageView.scrollDirection]
final Axis scrollDirection;
/// When user attempts to move it to the next element, focus will traverse to the next page in the page view.
final bool allowImplicitScrolling;
bool get _isBuilder => builder != null;
@override
State<StatefulWidget> createState() {
return _PhotoViewGalleryState();
}
}
class _PhotoViewGalleryState extends State<PhotoViewGallery> {
late final PageController _controller =
widget.pageController ?? PageController();
void scaleStateChangedCallback(PhotoViewScaleState scaleState) {
if (widget.scaleStateChangedCallback != null) {
widget.scaleStateChangedCallback!(scaleState);
}
}
int get actualPage {
return _controller.hasClients ? _controller.page!.floor() : 0;
}
int get itemCount {
if (widget._isBuilder) {
return widget.itemCount!;
}
return widget.pageOptions!.length;
}
@override
Widget build(BuildContext context) {
// Enable corner hit test
return PhotoViewGestureDetectorScope(
axis: widget.scrollDirection,
child: PageView.builder(
reverse: widget.reverse,
controller: _controller,
onPageChanged: widget.onPageChanged,
itemCount: itemCount,
itemBuilder: _buildItem,
scrollDirection: widget.scrollDirection,
physics: widget.scrollPhysics,
allowImplicitScrolling: widget.allowImplicitScrolling,
),
);
}
Widget _buildItem(BuildContext context, int index) {
final pageOption = _buildPageOption(context, index);
final isCustomChild = pageOption.child != null;
final PhotoView photoView = isCustomChild
? PhotoView.customChild(
key: ObjectKey(index),
childSize: pageOption.childSize,
backgroundDecoration: widget.backgroundDecoration,
wantKeepAlive: widget.wantKeepAlive,
controller: pageOption.controller,
scaleStateController: pageOption.scaleStateController,
customSize: widget.customSize,
heroAttributes: pageOption.heroAttributes,
scaleStateChangedCallback: scaleStateChangedCallback,
enableRotation: widget.enableRotation,
initialScale: pageOption.initialScale,
minScale: pageOption.minScale,
maxScale: pageOption.maxScale,
scaleStateCycle: pageOption.scaleStateCycle,
onTapUp: pageOption.onTapUp,
onTapDown: pageOption.onTapDown,
onDragStart: pageOption.onDragStart,
onDragEnd: pageOption.onDragEnd,
onDragUpdate: pageOption.onDragUpdate,
onScaleEnd: pageOption.onScaleEnd,
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
tightMode: pageOption.tightMode,
filterQuality: pageOption.filterQuality,
basePosition: pageOption.basePosition,
disableGestures: pageOption.disableGestures,
child: pageOption.child,
)
: PhotoView(
key: ObjectKey(index),
imageProvider: pageOption.imageProvider,
loadingBuilder: widget.loadingBuilder,
backgroundDecoration: widget.backgroundDecoration,
wantKeepAlive: widget.wantKeepAlive,
controller: pageOption.controller,
scaleStateController: pageOption.scaleStateController,
customSize: widget.customSize,
gaplessPlayback: widget.gaplessPlayback,
heroAttributes: pageOption.heroAttributes,
scaleStateChangedCallback: scaleStateChangedCallback,
enableRotation: widget.enableRotation,
initialScale: pageOption.initialScale,
minScale: pageOption.minScale,
maxScale: pageOption.maxScale,
scaleStateCycle: pageOption.scaleStateCycle,
onTapUp: pageOption.onTapUp,
onTapDown: pageOption.onTapDown,
onDragStart: pageOption.onDragStart,
onDragEnd: pageOption.onDragEnd,
onDragUpdate: pageOption.onDragUpdate,
onScaleEnd: pageOption.onScaleEnd,
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
tightMode: pageOption.tightMode,
filterQuality: pageOption.filterQuality,
basePosition: pageOption.basePosition,
disableGestures: pageOption.disableGestures,
errorBuilder: pageOption.errorBuilder,
);
return ClipRect(
child: photoView,
);
}
PhotoViewGalleryPageOptions _buildPageOption(BuildContext context, int index) {
if (widget._isBuilder) {
return widget.builder!(context, index);
}
return widget.pageOptions![index];
}
}
/// A helper class that wraps individual options of a page in [PhotoViewGallery]
///
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
///
class PhotoViewGalleryPageOptions {
PhotoViewGalleryPageOptions({
Key? key,
required this.imageProvider,
this.heroAttributes,
this.minScale,
this.maxScale,
this.initialScale,
this.controller,
this.scaleStateController,
this.basePosition,
this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.gestureDetectorBehavior,
this.tightMode,
this.filterQuality,
this.disableGestures,
this.errorBuilder,
}) : child = null,
childSize = null,
assert(imageProvider != null);
PhotoViewGalleryPageOptions.customChild({
required this.child,
this.childSize,
this.heroAttributes,
this.minScale,
this.maxScale,
this.initialScale,
this.controller,
this.scaleStateController,
this.basePosition,
this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.gestureDetectorBehavior,
this.tightMode,
this.filterQuality,
this.disableGestures,
}) : errorBuilder = null,
imageProvider = null;
/// Mirror to [PhotoView.imageProvider]
final ImageProvider? imageProvider;
/// Mirror to [PhotoView.heroAttributes]
final PhotoViewHeroAttributes? heroAttributes;
/// Mirror to [PhotoView.minScale]
final dynamic minScale;
/// Mirror to [PhotoView.maxScale]
final dynamic maxScale;
/// Mirror to [PhotoView.initialScale]
final dynamic initialScale;
/// Mirror to [PhotoView.controller]
final PhotoViewController? controller;
/// Mirror to [PhotoView.scaleStateController]
final PhotoViewScaleStateController? scaleStateController;
/// Mirror to [PhotoView.basePosition]
final Alignment? basePosition;
/// Mirror to [PhotoView.child]
final Widget? child;
/// Mirror to [PhotoView.childSize]
final Size? childSize;
/// Mirror to [PhotoView.scaleStateCycle]
final ScaleStateCycle? scaleStateCycle;
/// Mirror to [PhotoView.onTapUp]
final PhotoViewImageTapUpCallback? onTapUp;
/// Mirror to [PhotoView.onDragUp]
final PhotoViewImageDragStartCallback? onDragStart;
/// Mirror to [PhotoView.onDragDown]
final PhotoViewImageDragEndCallback? onDragEnd;
/// Mirror to [PhotoView.onDraUpdate]
final PhotoViewImageDragUpdateCallback? onDragUpdate;
/// Mirror to [PhotoView.onTapDown]
final PhotoViewImageTapDownCallback? onTapDown;
/// Mirror to [PhotoView.onScaleEnd]
final PhotoViewImageScaleEndCallback? onScaleEnd;
/// Mirror to [PhotoView.gestureDetectorBehavior]
final HitTestBehavior? gestureDetectorBehavior;
/// Mirror to [PhotoView.tightMode]
final bool? tightMode;
/// Mirror to [PhotoView.disableGestures]
final bool? disableGestures;
/// Quality levels for image filters.
final FilterQuality? filterQuality;
/// Mirror to [PhotoView.errorBuilder]
final ImageErrorWidgetBuilder? errorBuilder;
}

View file

@ -0,0 +1,291 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
/// The interface in which controllers will be implemented.
///
/// It concerns storing the state ([PhotoViewControllerValue]) and streaming its updates.
/// [PhotoViewImageWrapper] will respond to user gestures setting thew fields in the instance of a controller.
///
/// Any instance of a controller must be disposed after unmount. So if you instantiate a [PhotoViewController] or your custom implementation, do not forget to dispose it when not using it anymore.
///
/// The controller exposes value fields like [scale] or [rotationFocus]. Usually those fields will be only getters and setters serving as hooks to the internal [PhotoViewControllerValue].
///
/// The default implementation used by [PhotoView] is [PhotoViewController].
///
/// This was created to allow customization (you can create your own controller class)
///
/// Previously it controlled `scaleState` as well, but duw to some [concerns](https://github.com/renancaraujo/photo_view/issues/127)
/// [ScaleStateListener is responsible for tat value now
///
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
///
abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
/// The output for state/value updates. Usually a broadcast [Stream]
Stream<T> get outputStateStream;
/// The state value before the last change or the initial state if the state has not been changed.
late T prevValue;
/// The actual state value
late T value;
/// Resets the state to the initial value;
void reset();
/// Closes streams and removes eventual listeners.
void dispose();
/// Add a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputStateStream]
void addIgnorableListener(VoidCallback callback);
/// Remove a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputStateStream]
void removeIgnorableListener(VoidCallback callback);
/// The position of the image in the screen given its offset after pan gestures.
late Offset position;
/// The scale factor to transform the child (image or a customChild).
late double? scale;
/// Nevermind this method :D, look away
void setScaleInvisibly(double? scale);
/// The rotation factor to transform the child (image or a customChild).
late double rotation;
/// The center of the rotation transformation. It is a coordinate referring to the absolute dimensions of the image.
Offset? rotationFocusPoint;
/// Update multiple fields of the state with only one update streamed.
void updateMultiple({
Offset? position,
double? scale,
double? rotation,
Offset? rotationFocusPoint,
});
}
/// The state value stored and streamed by [PhotoViewController].
@immutable
class PhotoViewControllerValue {
const PhotoViewControllerValue({
required this.position,
required this.scale,
required this.rotation,
required this.rotationFocusPoint,
});
final Offset position;
final double? scale;
final double rotation;
final Offset? rotationFocusPoint;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PhotoViewControllerValue &&
runtimeType == other.runtimeType &&
position == other.position &&
scale == other.scale &&
rotation == other.rotation &&
rotationFocusPoint == other.rotationFocusPoint;
@override
int get hashCode =>
position.hashCode ^
scale.hashCode ^
rotation.hashCode ^
rotationFocusPoint.hashCode;
@override
String toString() {
return 'PhotoViewControllerValue{position: $position, scale: $scale, rotation: $rotation, rotationFocusPoint: $rotationFocusPoint}';
}
}
/// The default implementation of [PhotoViewControllerBase].
///
/// Containing a [ValueNotifier] it stores the state in the [value] field and streams
/// updates via [outputStateStream].
///
/// For details of fields and methods, check [PhotoViewControllerBase].
///
class PhotoViewController
implements PhotoViewControllerBase<PhotoViewControllerValue> {
PhotoViewController({
Offset initialPosition = Offset.zero,
double initialRotation = 0.0,
double? initialScale,
}) : _valueNotifier = IgnorableValueNotifier(
PhotoViewControllerValue(
position: initialPosition,
rotation: initialRotation,
scale: initialScale,
rotationFocusPoint: null,
),
),
super() {
initial = value;
prevValue = initial;
_valueNotifier.addListener(_changeListener);
_outputCtrl = StreamController<PhotoViewControllerValue>.broadcast();
_outputCtrl.sink.add(initial);
}
final IgnorableValueNotifier<PhotoViewControllerValue> _valueNotifier;
late PhotoViewControllerValue initial;
late StreamController<PhotoViewControllerValue> _outputCtrl;
@override
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
@override
late PhotoViewControllerValue prevValue;
@override
void reset() {
value = initial;
}
void _changeListener() {
_outputCtrl.sink.add(value);
}
@override
void addIgnorableListener(VoidCallback callback) {
_valueNotifier.addIgnorableListener(callback);
}
@override
void removeIgnorableListener(VoidCallback callback) {
_valueNotifier.removeIgnorableListener(callback);
}
@override
void dispose() {
_outputCtrl.close();
_valueNotifier.dispose();
}
@override
set position(Offset position) {
if (value.position == position) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
Offset get position => value.position;
@override
set scale(double? scale) {
if (value.scale == scale) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
double? get scale => value.scale;
@override
void setScaleInvisibly(double? scale) {
if (value.scale == scale) {
return;
}
prevValue = value;
_valueNotifier.updateIgnoring(
PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
),
);
}
@override
set rotation(double rotation) {
if (value.rotation == rotation) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
double get rotation => value.rotation;
@override
set rotationFocusPoint(Offset? rotationFocusPoint) {
if (value.rotationFocusPoint == rotationFocusPoint) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
Offset? get rotationFocusPoint => value.rotationFocusPoint;
@override
void updateMultiple({
Offset? position,
double? scale,
double? rotation,
Offset? rotationFocusPoint,
}) {
prevValue = value;
value = PhotoViewControllerValue(
position: position ?? value.position,
scale: scale ?? value.scale,
rotation: rotation ?? value.rotation,
rotationFocusPoint: rotationFocusPoint ?? value.rotationFocusPoint,
);
}
@override
PhotoViewControllerValue get value => _valueNotifier.value;
@override
set value(PhotoViewControllerValue newValue) {
if (_valueNotifier.value == newValue) {
return;
}
_valueNotifier.value = newValue;
}
}

View file

@ -0,0 +1,214 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
show
PhotoViewControllerBase,
PhotoViewScaleState,
PhotoViewScaleStateController,
ScaleStateCycle;
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
/// A class to hold internal layout logic to sync both controller states
///
/// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers.
mixin PhotoViewControllerDelegate on State<PhotoViewCore> {
PhotoViewControllerBase get controller => widget.controller;
PhotoViewScaleStateController get scaleStateController =>
widget.scaleStateController;
ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries;
ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle;
Alignment get basePosition => widget.basePosition;
Function(double prevScale, double nextScale)? _animateScale;
/// Mark if scale need recalculation, useful for scale boundaries changes.
bool markNeedsScaleRecalc = true;
void initDelegate() {
controller.addIgnorableListener(_blindScaleListener);
scaleStateController.addIgnorableListener(_blindScaleStateListener);
}
void _blindScaleStateListener() {
if (!scaleStateController.hasChanged) {
return;
}
if (_animateScale == null || scaleStateController.isZooming) {
controller.setScaleInvisibly(scale);
return;
}
final double prevScale = controller.scale ??
getScaleForScaleState(
scaleStateController.prevScaleState,
scaleBoundaries,
);
final double nextScale = getScaleForScaleState(
scaleStateController.scaleState,
scaleBoundaries,
);
_animateScale!(prevScale, nextScale);
}
void addAnimateOnScaleStateUpdate(
void Function(double prevScale, double nextScale) animateScale,
) {
_animateScale = animateScale;
}
void _blindScaleListener() {
if (!widget.enablePanAlways) {
controller.position = clampPosition();
}
if (controller.scale == controller.prevValue.scale) {
return;
}
final PhotoViewScaleState newScaleState =
(scale > scaleBoundaries.initialScale)
? PhotoViewScaleState.zoomedIn
: PhotoViewScaleState.zoomedOut;
scaleStateController.setInvisibly(newScaleState);
}
Offset get position => controller.position;
double get scale {
// for figuring out initial scale
final needsRecalc = markNeedsScaleRecalc &&
!scaleStateController.scaleState.isScaleStateZooming;
final scaleExistsOnController = controller.scale != null;
if (needsRecalc || !scaleExistsOnController) {
final newScale = getScaleForScaleState(
scaleStateController.scaleState,
scaleBoundaries,
);
markNeedsScaleRecalc = false;
scale = newScale;
return newScale;
}
return controller.scale!;
}
set scale(double scale) => controller.setScaleInvisibly(scale);
void updateMultiple({
Offset? position,
double? scale,
double? rotation,
Offset? rotationFocusPoint,
}) {
controller.updateMultiple(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
void updateScaleStateFromNewScale(double newScale) {
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
if (scale != scaleBoundaries.initialScale) {
newScaleState = (newScale > scaleBoundaries.initialScale)
? PhotoViewScaleState.zoomedIn
: PhotoViewScaleState.zoomedOut;
}
scaleStateController.setInvisibly(newScaleState);
}
void nextScaleState() {
final PhotoViewScaleState scaleState = scaleStateController.scaleState;
if (scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.zoomedOut) {
scaleStateController.scaleState = scaleStateCycle(scaleState);
return;
}
final double originalScale = getScaleForScaleState(
scaleState,
scaleBoundaries,
);
double prevScale = originalScale;
PhotoViewScaleState prevScaleState = scaleState;
double nextScale = originalScale;
PhotoViewScaleState nextScaleState = scaleState;
do {
prevScale = nextScale;
prevScaleState = nextScaleState;
nextScaleState = scaleStateCycle(prevScaleState);
nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
} while (prevScale == nextScale && scaleState != nextScaleState);
if (originalScale == nextScale) {
return;
}
scaleStateController.scaleState = nextScaleState;
}
CornersRange cornersX({double? scale}) {
final double s = scale ?? this.scale;
final double computedWidth = scaleBoundaries.childSize.width * s;
final double screenWidth = scaleBoundaries.outerSize.width;
final double positionX = basePosition.x;
final double widthDiff = computedWidth - screenWidth;
final double minX = ((positionX - 1).abs() / 2) * widthDiff * -1;
final double maxX = ((positionX + 1).abs() / 2) * widthDiff;
return CornersRange(minX, maxX);
}
CornersRange cornersY({double? scale}) {
final double s = scale ?? this.scale;
final double computedHeight = scaleBoundaries.childSize.height * s;
final double screenHeight = scaleBoundaries.outerSize.height;
final double positionY = basePosition.y;
final double heightDiff = computedHeight - screenHeight;
final double minY = ((positionY - 1).abs() / 2) * heightDiff * -1;
final double maxY = ((positionY + 1).abs() / 2) * heightDiff;
return CornersRange(minY, maxY);
}
Offset clampPosition({Offset? position, double? scale}) {
final double s = scale ?? this.scale;
final Offset p = position ?? this.position;
final double computedWidth = scaleBoundaries.childSize.width * s;
final double computedHeight = scaleBoundaries.childSize.height * s;
final double screenWidth = scaleBoundaries.outerSize.width;
final double screenHeight = scaleBoundaries.outerSize.height;
double finalX = 0.0;
if (screenWidth < computedWidth) {
final cornersX = this.cornersX(scale: s);
finalX = p.dx.clamp(cornersX.min, cornersX.max);
}
double finalY = 0.0;
if (screenHeight < computedHeight) {
final cornersY = this.cornersY(scale: s);
finalY = p.dy.clamp(cornersY.min, cornersY.max);
}
return Offset(finalX, finalY);
}
@override
void dispose() {
_animateScale = null;
controller.removeIgnorableListener(_blindScaleListener);
scaleStateController.removeIgnorableListener(_blindScaleStateListener);
super.dispose();
}
}

View file

@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/widgets.dart' show VoidCallback;
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
typedef ScaleStateListener = void Function(double prevScale, double nextScale);
/// A controller responsible only by [scaleState].
///
/// Scale state is a common value with represents the step in which the [PhotoView.scaleStateCycle] is.
/// This cycle is triggered by the "doubleTap" gesture.
///
/// Any change in its [scaleState] should animate the scale of image/content.
///
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
///
/// The updates should be done via [scaleState] setter and the updated listened via [outputScaleStateStream]
///
class PhotoViewScaleStateController {
late final IgnorableValueNotifier<PhotoViewScaleState> _scaleStateNotifier =
IgnorableValueNotifier(PhotoViewScaleState.initial)
..addListener(_scaleStateChangeListener);
final StreamController<PhotoViewScaleState> _outputScaleStateCtrl =
StreamController<PhotoViewScaleState>.broadcast()
..sink.add(PhotoViewScaleState.initial);
/// The output for state/value updates
Stream<PhotoViewScaleState> get outputScaleStateStream =>
_outputScaleStateCtrl.stream;
/// The state value before the last change or the initial state if the state has not been changed.
PhotoViewScaleState prevScaleState = PhotoViewScaleState.initial;
/// The actual state value
PhotoViewScaleState get scaleState => _scaleStateNotifier.value;
/// Updates scaleState and notify all listeners (and the stream)
set scaleState(PhotoViewScaleState newValue) {
if (_scaleStateNotifier.value == newValue) {
return;
}
prevScaleState = _scaleStateNotifier.value;
_scaleStateNotifier.value = newValue;
}
/// Checks if its actual value is different than previousValue
bool get hasChanged => prevScaleState != scaleState;
/// Check if is `zoomedIn` & `zoomedOut`
bool get isZooming =>
scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.zoomedOut;
/// Resets the state to the initial value;
void reset() {
prevScaleState = scaleState;
scaleState = PhotoViewScaleState.initial;
}
/// Closes streams and removes eventual listeners
void dispose() {
_outputScaleStateCtrl.close();
_scaleStateNotifier.dispose();
}
/// Nevermind this method :D, look away
/// Seriously: It is used to change scale state without trigging updates on the []
void setInvisibly(PhotoViewScaleState newValue) {
if (_scaleStateNotifier.value == newValue) {
return;
}
prevScaleState = _scaleStateNotifier.value;
_scaleStateNotifier.updateIgnoring(newValue);
}
void _scaleStateChangeListener() {
_outputScaleStateCtrl.sink.add(scaleState);
}
/// Add a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputScaleStateStream]
void addIgnorableListener(VoidCallback callback) {
_scaleStateNotifier.addIgnorableListener(callback);
}
/// Remove a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputScaleStateStream]
void removeIgnorableListener(VoidCallback callback) {
_scaleStateNotifier.removeIgnorableListener(callback);
}
}

View file

@ -0,0 +1,461 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
show
PhotoViewScaleState,
PhotoViewHeroAttributes,
PhotoViewImageTapDownCallback,
PhotoViewImageTapUpCallback,
PhotoViewImageScaleEndCallback,
PhotoViewImageDragEndCallback,
PhotoViewImageDragStartCallback,
PhotoViewImageDragUpdateCallback,
ScaleStateCycle;
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_hit_corners.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
const _defaultDecoration = BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 1.0),
);
/// Internal widget in which controls all animations lifecycle, core responses
/// to user gestures, updates to the controller state and mounts the entire PhotoView Layout
class PhotoViewCore extends StatefulWidget {
const PhotoViewCore({
Key? key,
required this.imageProvider,
required this.backgroundDecoration,
required this.gaplessPlayback,
required this.heroAttributes,
required this.enableRotation,
required this.onTapUp,
required this.onTapDown,
required this.onDragStart,
required this.onDragEnd,
required this.onDragUpdate,
required this.onScaleEnd,
required this.gestureDetectorBehavior,
required this.controller,
required this.scaleBoundaries,
required this.scaleStateCycle,
required this.scaleStateController,
required this.basePosition,
required this.tightMode,
required this.filterQuality,
required this.disableGestures,
required this.enablePanAlways,
}) : customChild = null,
super(key: key);
const PhotoViewCore.customChild({
Key? key,
required this.customChild,
required this.backgroundDecoration,
this.heroAttributes,
required this.enableRotation,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.gestureDetectorBehavior,
required this.controller,
required this.scaleBoundaries,
required this.scaleStateCycle,
required this.scaleStateController,
required this.basePosition,
required this.tightMode,
required this.filterQuality,
required this.disableGestures,
required this.enablePanAlways,
}) : imageProvider = null,
gaplessPlayback = false,
super(key: key);
final Decoration? backgroundDecoration;
final ImageProvider? imageProvider;
final bool? gaplessPlayback;
final PhotoViewHeroAttributes? heroAttributes;
final bool enableRotation;
final Widget? customChild;
final PhotoViewControllerBase controller;
final PhotoViewScaleStateController scaleStateController;
final ScaleBoundaries scaleBoundaries;
final ScaleStateCycle scaleStateCycle;
final Alignment basePosition;
final PhotoViewImageTapUpCallback? onTapUp;
final PhotoViewImageTapDownCallback? onTapDown;
final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate;
final HitTestBehavior? gestureDetectorBehavior;
final bool tightMode;
final bool disableGestures;
final bool enablePanAlways;
final FilterQuality filterQuality;
@override
State<StatefulWidget> createState() {
return PhotoViewCoreState();
}
bool get hasCustomChild => customChild != null;
}
class PhotoViewCoreState extends State<PhotoViewCore>
with
TickerProviderStateMixin,
PhotoViewControllerDelegate,
HitCornersDetector {
Offset? _normalizedPosition;
double? _scaleBefore;
double? _rotationBefore;
late final AnimationController _scaleAnimationController;
Animation<double>? _scaleAnimation;
late final AnimationController _positionAnimationController;
Animation<Offset>? _positionAnimation;
late final AnimationController _rotationAnimationController =
AnimationController(vsync: this)..addListener(handleRotationAnimation);
Animation<double>? _rotationAnimation;
PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes;
late ScaleBoundaries cachedScaleBoundaries = widget.scaleBoundaries;
void handleScaleAnimation() {
scale = _scaleAnimation!.value;
}
void handlePositionAnimate() {
controller.position = _positionAnimation!.value;
}
void handleRotationAnimation() {
controller.rotation = _rotationAnimation!.value;
}
void onScaleStart(ScaleStartDetails details) {
_rotationBefore = controller.rotation;
_scaleBefore = scale;
_normalizedPosition = details.focalPoint - controller.position;
_scaleAnimationController.stop();
_positionAnimationController.stop();
_rotationAnimationController.stop();
}
void onScaleUpdate(ScaleUpdateDetails details) {
final double newScale = _scaleBefore! * details.scale;
final Offset delta = details.focalPoint - _normalizedPosition!;
updateScaleStateFromNewScale(newScale);
updateMultiple(
scale: newScale,
position: widget.enablePanAlways
? delta
: clampPosition(position: delta * details.scale),
rotation:
widget.enableRotation ? _rotationBefore! + details.rotation : null,
rotationFocusPoint: widget.enableRotation ? details.focalPoint : null,
);
}
void onScaleEnd(ScaleEndDetails details) {
final double s = scale;
final Offset p = controller.position;
final double maxScale = scaleBoundaries.maxScale;
final double minScale = scaleBoundaries.minScale;
widget.onScaleEnd?.call(context, details, controller.value);
//animate back to maxScale if gesture exceeded the maxScale specified
if (s > maxScale) {
final double scaleComebackRatio = maxScale / s;
animateScale(s, maxScale);
final Offset clampedPosition = clampPosition(
position: p * scaleComebackRatio,
scale: maxScale,
);
animatePosition(p, clampedPosition);
return;
}
//animate back to minScale if gesture fell smaller than the minScale specified
if (s < minScale) {
final double scaleComebackRatio = minScale / s;
animateScale(s, minScale);
animatePosition(
p,
clampPosition(
position: p * scaleComebackRatio,
scale: minScale,
),
);
return;
}
// get magnitude from gesture velocity
final double magnitude = details.velocity.pixelsPerSecond.distance;
// animate velocity only if there is no scale change and a significant magnitude
if (_scaleBefore! / s == 1.0 && magnitude >= 400.0) {
final Offset direction = details.velocity.pixelsPerSecond / magnitude;
animatePosition(
p,
clampPosition(position: p + direction * 100.0),
);
}
}
void onDoubleTap() {
nextScaleState();
}
void animateScale(double from, double to) {
_scaleAnimation = Tween<double>(
begin: from,
end: to,
).animate(_scaleAnimationController);
_scaleAnimationController
..value = 0.0
..fling(velocity: 0.4);
}
void animatePosition(Offset from, Offset to) {
_positionAnimation = Tween<Offset>(begin: from, end: to)
.animate(_positionAnimationController);
_positionAnimationController
..value = 0.0
..fling(velocity: 0.4);
}
void animateRotation(double from, double to) {
_rotationAnimation = Tween<double>(begin: from, end: to)
.animate(_rotationAnimationController);
_rotationAnimationController
..value = 0.0
..fling(velocity: 0.4);
}
void onAnimationStatus(AnimationStatus status) {
if (status == AnimationStatus.completed) {
onAnimationStatusCompleted();
}
}
/// Check if scale is equal to initial after scale animation update
void onAnimationStatusCompleted() {
if (scaleStateController.scaleState != PhotoViewScaleState.initial &&
scale == scaleBoundaries.initialScale) {
scaleStateController.setInvisibly(PhotoViewScaleState.initial);
}
}
@override
void initState() {
super.initState();
initDelegate();
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
cachedScaleBoundaries = widget.scaleBoundaries;
_scaleAnimationController = AnimationController(vsync: this)
..addListener(handleScaleAnimation)
..addStatusListener(onAnimationStatus);
_positionAnimationController = AnimationController(vsync: this)
..addListener(handlePositionAnimate);
}
void animateOnScaleStateUpdate(double prevScale, double nextScale) {
animateScale(prevScale, nextScale);
animatePosition(controller.position, Offset.zero);
animateRotation(controller.rotation, 0.0);
}
@override
void dispose() {
_scaleAnimationController.removeStatusListener(onAnimationStatus);
_scaleAnimationController.dispose();
_positionAnimationController.dispose();
_rotationAnimationController.dispose();
super.dispose();
}
void onTapUp(TapUpDetails details) {
widget.onTapUp?.call(context, details, controller.value);
}
void onTapDown(TapDownDetails details) {
widget.onTapDown?.call(context, details, controller.value);
}
@override
Widget build(BuildContext context) {
// Check if we need a recalc on the scale
if (widget.scaleBoundaries != cachedScaleBoundaries) {
markNeedsScaleRecalc = true;
cachedScaleBoundaries = widget.scaleBoundaries;
}
return StreamBuilder(
stream: controller.outputStateStream,
initialData: controller.prevValue,
builder: (
BuildContext context,
AsyncSnapshot<PhotoViewControllerValue> snapshot,
) {
if (snapshot.hasData) {
final PhotoViewControllerValue value = snapshot.data!;
final useImageScale = widget.filterQuality != FilterQuality.none;
final computedScale = useImageScale ? 1.0 : scale;
final matrix = Matrix4.identity()
..translate(value.position.dx, value.position.dy)
..scale(computedScale)
..rotateZ(value.rotation);
final Widget customChildLayout = CustomSingleChildLayout(
delegate: _CenterWithOriginalSizeDelegate(
scaleBoundaries.childSize,
basePosition,
useImageScale,
),
child: _buildHero(),
);
final child = Container(
constraints: widget.tightMode
? BoxConstraints.tight(scaleBoundaries.childSize * scale)
: null,
decoration: widget.backgroundDecoration ?? _defaultDecoration,
child: Center(
child: Transform(
transform: matrix,
alignment: basePosition,
child: customChildLayout,
),
),
);
if (widget.disableGestures) {
return child;
}
return PhotoViewGestureDetector(
onDoubleTap: nextScaleState,
onScaleStart: onScaleStart,
onScaleUpdate: onScaleUpdate,
onScaleEnd: onScaleEnd,
onDragStart: widget.onDragStart != null
? (details) => widget.onDragStart!(context, details, value)
: null,
onDragEnd: widget.onDragEnd != null
? (details) => widget.onDragEnd!(context, details, value)
: null,
onDragUpdate: widget.onDragUpdate != null
? (details) => widget.onDragUpdate!(context, details, value)
: null,
hitDetector: this,
onTapUp: widget.onTapUp != null
? (details) => widget.onTapUp!(context, details, value)
: null,
onTapDown: widget.onTapDown != null
? (details) => widget.onTapDown!(context, details, value)
: null,
child: child,
);
} else {
return Container();
}
},
);
}
Widget _buildHero() {
return heroAttributes != null
? Hero(
tag: heroAttributes!.tag,
createRectTween: heroAttributes!.createRectTween,
flightShuttleBuilder: heroAttributes!.flightShuttleBuilder,
placeholderBuilder: heroAttributes!.placeholderBuilder,
transitionOnUserGestures: heroAttributes!.transitionOnUserGestures,
child: _buildChild(),
)
: _buildChild();
}
Widget _buildChild() {
return widget.hasCustomChild
? widget.customChild!
: Image(
image: widget.imageProvider!,
gaplessPlayback: widget.gaplessPlayback ?? false,
filterQuality: widget.filterQuality,
width: scaleBoundaries.childSize.width * scale,
fit: BoxFit.contain,
);
}
}
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
const _CenterWithOriginalSizeDelegate(
this.subjectSize,
this.basePosition,
this.useImageScale,
);
final Size subjectSize;
final Alignment basePosition;
final bool useImageScale;
@override
Offset getPositionForChild(Size size, Size childSize) {
final childWidth = useImageScale ? childSize.width : subjectSize.width;
final childHeight = useImageScale ? childSize.height : subjectSize.height;
final halfWidth = (size.width - childWidth) / 2;
final halfHeight = (size.height - childHeight) / 2;
final double offsetX = halfWidth * (basePosition.x + 1);
final double offsetY = halfHeight * (basePosition.y + 1);
return Offset(offsetX, offsetY);
}
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return useImageScale
? const BoxConstraints()
: BoxConstraints.tight(subjectSize);
}
@override
bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) {
return oldDelegate != this;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _CenterWithOriginalSizeDelegate &&
runtimeType == other.runtimeType &&
subjectSize == other.subjectSize &&
basePosition == other.basePosition &&
useImageScale == other.useImageScale;
@override
int get hashCode =>
subjectSize.hashCode ^ basePosition.hashCode ^ useImageScale.hashCode;
}

View file

@ -0,0 +1,293 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'photo_view_hit_corners.dart';
/// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c)
/// for the gist
class PhotoViewGestureDetector extends StatelessWidget {
const PhotoViewGestureDetector({
Key? key,
this.hitDetector,
this.onScaleStart,
this.onScaleUpdate,
this.onScaleEnd,
this.onDoubleTap,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.child,
this.onTapUp,
this.onTapDown,
this.behavior,
}) : super(key: key);
final GestureDoubleTapCallback? onDoubleTap;
final HitCornersDetector? hitDetector;
final GestureScaleStartCallback? onScaleStart;
final GestureScaleUpdateCallback? onScaleUpdate;
final GestureScaleEndCallback? onScaleEnd;
final GestureDragEndCallback? onDragEnd;
final GestureDragStartCallback? onDragStart;
final GestureDragUpdateCallback? onDragUpdate;
final GestureTapUpCallback? onTapUp;
final GestureTapDownCallback? onTapDown;
final Widget? child;
final HitTestBehavior? behavior;
@override
Widget build(BuildContext context) {
final scope = PhotoViewGestureDetectorScope.of(context);
final Axis? axis = scope?.axis;
final touchSlopFactor = scope?.touchSlopFactor ?? 2;
final Map<Type, GestureRecognizerFactory> gestures =
<Type, GestureRecognizerFactory>{};
if (onTapDown != null || onTapUp != null) {
gestures[TapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp;
},
);
}
if (onDragStart != null || onDragEnd != null || onDragUpdate != null) {
gestures[VerticalDragGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(debugOwner: this),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = onDragStart
..onUpdate = onDragUpdate
..onEnd = onDragEnd;
},
);
}
gestures[DoubleTapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(debugOwner: this),
(DoubleTapGestureRecognizer instance) {
instance.onDoubleTap = onDoubleTap;
},
);
gestures[PhotoViewGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<PhotoViewGestureRecognizer>(
() => PhotoViewGestureRecognizer(
hitDetector: hitDetector,
debugOwner: this,
validateAxis: axis,
touchSlopFactor: touchSlopFactor,
),
(PhotoViewGestureRecognizer instance) {
instance
..onStart = onScaleStart
..onUpdate = onScaleUpdate
..onEnd = onScaleEnd;
},
);
return RawGestureDetector(
behavior: behavior,
gestures: gestures,
child: child,
);
}
}
class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
PhotoViewGestureRecognizer({
this.hitDetector,
Object? debugOwner,
this.validateAxis,
this.touchSlopFactor = 1,
PointerDeviceKind? kind,
}) : super(debugOwner: debugOwner, supportedDevices: null);
final HitCornersDetector? hitDetector;
final Axis? validateAxis;
final double touchSlopFactor;
Map<int, Offset> _pointerLocations = <int, Offset>{};
Offset? _initialFocalPoint;
Offset? _currentFocalPoint;
double? _initialSpan;
double? _currentSpan;
bool ready = true;
@override
void addAllowedPointer(PointerDownEvent event) {
if (ready) {
ready = false;
_pointerLocations = <int, Offset>{};
}
super.addAllowedPointer(event);
}
@override
void didStopTrackingLastPointer(int pointer) {
ready = true;
super.didStopTrackingLastPointer(pointer);
}
@override
void handleEvent(PointerEvent event) {
if (validateAxis != null) {
bool didChangeConfiguration = false;
if (event is PointerMoveEvent) {
if (!event.synthesized) {
_pointerLocations[event.pointer] = event.position;
}
} else if (event is PointerDownEvent) {
_pointerLocations[event.pointer] = event.position;
didChangeConfiguration = true;
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_pointerLocations.remove(event.pointer);
didChangeConfiguration = true;
}
_updateDistances();
if (didChangeConfiguration) {
// cf super._reconfigure
_initialFocalPoint = _currentFocalPoint;
_initialSpan = _currentSpan;
}
_decideIfWeAcceptEvent(event);
}
super.handleEvent(event);
}
void _updateDistances() {
// cf super._update
final int count = _pointerLocations.keys.length;
// Compute the focal point
Offset focalPoint = Offset.zero;
for (final int pointer in _pointerLocations.keys) {
focalPoint += _pointerLocations[pointer]!;
}
_currentFocalPoint =
count > 0 ? focalPoint / count.toDouble() : Offset.zero;
// Span is the average deviation from focal point. Horizontal and vertical
// spans are the average deviations from the focal point's horizontal and
// vertical coordinates, respectively.
double totalDeviation = 0.0;
for (final int pointer in _pointerLocations.keys) {
totalDeviation +=
(_currentFocalPoint! - _pointerLocations[pointer]!).distance;
}
_currentSpan = count > 0 ? totalDeviation / count : 0.0;
}
void _decideIfWeAcceptEvent(PointerEvent event) {
final move = _initialFocalPoint! - _currentFocalPoint!;
final bool shouldMove = validateAxis == Axis.vertical
? hitDetector!.shouldMove(move, Axis.vertical)
: hitDetector!.shouldMove(move, Axis.horizontal);
if (shouldMove || _pointerLocations.keys.length > 1) {
final double spanDelta = (_currentSpan! - _initialSpan!).abs();
final double focalPointDelta =
(_currentFocalPoint! - _initialFocalPoint!).distance;
// warning: do not compare `focalPointDelta` to `kPanSlop`
// `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop`
// and PhotoView recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView`
// setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0`
// setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView`
if (spanDelta > kScaleSlop ||
focalPointDelta > kTouchSlop * touchSlopFactor) {
acceptGesture(event.pointer);
}
}
}
}
/// An [InheritedWidget] responsible to give a axis aware scope to [PhotoViewGestureRecognizer].
///
/// When using this, PhotoView will test if the content zoomed has hit edge every time user pinches,
/// if so, it will let parent gesture detectors win the gesture arena
///
/// Useful when placing PhotoView inside a gesture sensitive context,
/// such as [PageView], [Dismissible], [BottomSheet].
///
/// Usage example:
/// ```
/// PhotoViewGestureDetectorScope(
/// axis: Axis.vertical,
/// child: PhotoView(
/// imageProvider: AssetImage("assets/pudim.jpg"),
/// ),
/// );
/// ```
class PhotoViewGestureDetectorScope extends InheritedWidget {
const PhotoViewGestureDetectorScope({
super.key,
this.axis,
this.touchSlopFactor = .2,
required Widget child,
}) : super(child: child);
static PhotoViewGestureDetectorScope? of(BuildContext context) {
final PhotoViewGestureDetectorScope? scope = context
.dependOnInheritedWidgetOfExactType<PhotoViewGestureDetectorScope>();
return scope;
}
final Axis? axis;
// in [0, 1[
// 0: most reactive but will not let tap recognizers accept gestures
// <1: less reactive but gives the most leeway to other recognizers
// 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree
final double touchSlopFactor;
@override
bool updateShouldNotify(PhotoViewGestureDetectorScope oldWidget) {
return axis != oldWidget.axis && touchSlopFactor != oldWidget.touchSlopFactor;
}
}
// `PageView` contains a `Scrollable` which sets up a `HorizontalDragGestureRecognizer`
// this recognizer will win in the gesture arena when the drag distance reaches `kTouchSlop`
// we cannot change that, but we can prevent the scrollable from panning until this threshold is reached
// and let other recognizers accept the gesture instead
class PhotoViewPageViewScrollPhysics extends ScrollPhysics {
const PhotoViewPageViewScrollPhysics({
this.touchSlopFactor = 0.1,
ScrollPhysics? parent,
}) : super(parent: parent);
// in [0, 1]
// 0: most reactive but will not let PhotoView recognizers accept gestures
// 1: less reactive but gives the most leeway to PhotoView recognizers
final double touchSlopFactor;
@override
PhotoViewPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PhotoViewPageViewScrollPhysics(
touchSlopFactor: touchSlopFactor,
parent: buildParent(ancestor),
);
}
@override
double get dragStartDistanceMotionThreshold => kTouchSlop * touchSlopFactor;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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),
),
);
}
}

View file

@ -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;
}

View file

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

View file

@ -0,0 +1,109 @@
import 'package:flutter/foundation.dart';
/// A [ChangeNotifier] that has a second collection of listeners: the ignorable ones
///
/// Those listeners will be fired when [notifyListeners] fires and will be ignored
/// when [notifySomeListeners] fires.
///
/// The common collection of listeners inherited from [ChangeNotifier] will be fired
/// every time.
class IgnorableChangeNotifier extends ChangeNotifier {
ObserverList<VoidCallback>? _ignorableListeners =
ObserverList<VoidCallback>();
bool _debugAssertNotDisposed() {
assert(() {
if (_ignorableListeners == null) {
AssertionError([
'A $runtimeType was used after being disposed.',
'Once you have called dispose() on a $runtimeType, it can no longer be used.'
]);
}
return true;
}());
return true;
}
@override
bool get hasListeners {
return super.hasListeners || (_ignorableListeners?.isNotEmpty ?? false);
}
void addIgnorableListener(listener) {
assert(_debugAssertNotDisposed());
_ignorableListeners!.add(listener);
}
void removeIgnorableListener(listener) {
assert(_debugAssertNotDisposed());
_ignorableListeners!.remove(listener);
}
@override
void dispose() {
_ignorableListeners = null;
super.dispose();
}
@protected
@override
@visibleForTesting
void notifyListeners() {
super.notifyListeners();
if (_ignorableListeners != null) {
final List<VoidCallback> localListeners =
List<VoidCallback>.from(_ignorableListeners!);
for (VoidCallback listener in localListeners) {
try {
if (_ignorableListeners!.contains(listener)) {
listener();
}
} catch (exception, stack) {
FlutterError.reportError(
FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'Photoview library',
),
);
}
}
}
}
/// Ignores the ignoreables
@protected
void notifySomeListeners() {
super.notifyListeners();
}
}
/// Just like [ValueNotifier] except it extends [IgnorableChangeNotifier] which has
/// listeners that wont fire when [updateIgnoring] is called.
class IgnorableValueNotifier<T> extends IgnorableChangeNotifier
implements ValueListenable<T> {
IgnorableValueNotifier(this._value);
@override
T get value => _value;
T _value;
set value(T newValue) {
if (_value == newValue) {
return;
}
_value = newValue;
notifyListeners();
}
void updateIgnoring(T newValue) {
if (_value == newValue) {
return;
}
_value = newValue;
notifySomeListeners();
}
@override
String toString() => '${describeIdentity(this)}($value)';
}

View file

@ -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;
}

View file

@ -0,0 +1,145 @@
import 'dart:math' as math;
import 'dart:ui' show Size;
import "package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart";
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
/// Given a [PhotoViewScaleState], returns a scale value considering [scaleBoundaries].
double getScaleForScaleState(
PhotoViewScaleState scaleState,
ScaleBoundaries scaleBoundaries,
) {
switch (scaleState) {
case PhotoViewScaleState.initial:
case PhotoViewScaleState.zoomedIn:
case PhotoViewScaleState.zoomedOut:
return _clampSize(scaleBoundaries.initialScale, scaleBoundaries);
case PhotoViewScaleState.covering:
return _clampSize(
_scaleForCovering(
scaleBoundaries.outerSize,
scaleBoundaries.childSize,
),
scaleBoundaries,
);
case PhotoViewScaleState.originalSize:
return _clampSize(1.0, scaleBoundaries);
// Will never be reached
default:
return 0;
}
}
/// Internal class to wraps custom scale boundaries (min, max and initial)
/// Also, stores values regarding the two sizes: the container and teh child.
class ScaleBoundaries {
const ScaleBoundaries(
this._minScale,
this._maxScale,
this._initialScale,
this.outerSize,
this.childSize,
);
final dynamic _minScale;
final dynamic _maxScale;
final dynamic _initialScale;
final Size outerSize;
final Size childSize;
double get minScale {
assert(_minScale is double || _minScale is PhotoViewComputedScale);
if (_minScale == PhotoViewComputedScale.contained) {
return _scaleForContained(outerSize, childSize) *
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
}
if (_minScale == PhotoViewComputedScale.covered) {
return _scaleForCovering(outerSize, childSize) *
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
}
assert(_minScale >= 0.0);
return _minScale;
}
double get maxScale {
assert(_maxScale is double || _maxScale is PhotoViewComputedScale);
if (_maxScale == PhotoViewComputedScale.contained) {
return (_scaleForContained(outerSize, childSize) *
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier)
.clamp(minScale, double.infinity);
}
if (_maxScale == PhotoViewComputedScale.covered) {
return (_scaleForCovering(outerSize, childSize) *
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier)
.clamp(minScale, double.infinity);
}
return _maxScale.clamp(minScale, double.infinity);
}
double get initialScale {
assert(_initialScale is double || _initialScale is PhotoViewComputedScale);
if (_initialScale == PhotoViewComputedScale.contained) {
return _scaleForContained(outerSize, childSize) *
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier;
}
if (_initialScale == PhotoViewComputedScale.covered) {
return _scaleForCovering(outerSize, childSize) *
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier;
}
return _initialScale.clamp(minScale, maxScale);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScaleBoundaries &&
runtimeType == other.runtimeType &&
_minScale == other._minScale &&
_maxScale == other._maxScale &&
_initialScale == other._initialScale &&
outerSize == other.outerSize &&
childSize == other.childSize;
@override
int get hashCode =>
_minScale.hashCode ^
_maxScale.hashCode ^
_initialScale.hashCode ^
outerSize.hashCode ^
childSize.hashCode;
}
double _scaleForContained(Size size, Size childSize) {
final double imageWidth = childSize.width;
final double imageHeight = childSize.height;
final double screenWidth = size.width;
final double screenHeight = size.height;
return math.min(screenWidth / imageWidth, screenHeight / imageHeight);
}
double _scaleForCovering(Size size, Size childSize) {
final double imageWidth = childSize.width;
final double imageHeight = childSize.height;
final double screenWidth = size.width;
final double screenHeight = size.height;
return math.max(screenWidth / imageWidth, screenHeight / imageHeight);
}
double _clampSize(double size, ScaleBoundaries scaleBoundaries) {
return size.clamp(scaleBoundaries.minScale, scaleBoundaries.maxScale);
}
/// Simple class to store a min and a max value
class CornersRange {
const CornersRange(this.min, this.max);
final double min;
final double max;
}

View file

@ -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:

View file

@ -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