diff --git a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
deleted file mode 100644
index 5006209591..0000000000
--- a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
+++ /dev/null
@@ -1,205 +0,0 @@
-import 'package:cached_network_image/cached_network_image.dart';
-import 'package:flutter/material.dart';
-import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/utils/image_url_builder.dart';
-import 'package:openapi/api.dart';
-import 'package:photo_manager/photo_manager.dart'
-    show AssetEntityImageProvider, ThumbnailSize;
-import 'package:photo_view/photo_view.dart';
-
-enum _RemoteImageStatus { empty, thumbnail, preview, full }
-
-class _RemotePhotoViewState extends State<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();
-  }
-}
diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
index 393d0fcba9..2ee580450a 100644
--- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
+++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
@@ -1,4 +1,7 @@
+import 'dart:io';
+
 import 'package:auto_route/auto_route.dart';
+import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
@@ -9,14 +12,21 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
-import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
 import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+import 'package:photo_manager/photo_manager.dart';
+import 'package:openapi/api.dart' as api;
 
 // ignore: must_be_immutable
 class GalleryViewerPage extends HookConsumerWidget {
@@ -40,7 +50,8 @@ class GalleryViewerPage extends HookConsumerWidget {
     final isZoomed = useState<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,
+  );
+}
diff --git a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
deleted file mode 100644
index c2368dfc10..0000000000
--- a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
+++ /dev/null
@@ -1,84 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
-import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
-import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
-import 'package:immich_mobile/modules/home/services/asset.service.dart';
-import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
-
-// ignore: must_be_immutable
-class ImageViewerPage extends HookConsumerWidget {
-  final String heroTag;
-  final Asset asset;
-  final String authToken;
-  final ValueNotifier<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(),
-          ),
-      ],
-    );
-  }
-}
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index 313a53e5fb..873873524f 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -10,7 +10,6 @@ import 'package:immich_mobile/modules/album/views/select_additional_user_for_sha
 import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
 import 'package:immich_mobile/modules/album/views/sharing_page.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
-import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
 import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
@@ -52,7 +51,6 @@ part 'router.gr.dart';
       transitionsBuilder: TransitionsBuilders.fadeIn,
     ),
     AutoRoute(page: GalleryViewerPage, guards: [AuthGuard]),
-    AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
     AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
     AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
     AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index 897b532225..b307fc6cb5 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -48,21 +48,6 @@ class _$AppRouter extends RootStackRouter {
           child: GalleryViewerPage(
               key: args.key, assetList: args.assetList, asset: args.asset));
     },
-    ImageViewerRoute.name: (routeData) {
-      final args = routeData.argsAs<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> {
diff --git a/mobile/lib/shared/ui/photo_view/photo_view.dart b/mobile/lib/shared/ui/photo_view/photo_view.dart
new file mode 100644
index 0000000000..9a5a87aac1
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/photo_view.dart
@@ -0,0 +1,653 @@
+library photo_view;
+
+import 'package:flutter/material.dart';
+
+import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_wrappers.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
+
+export 'src/controller/photo_view_controller.dart';
+export 'src/controller/photo_view_scalestate_controller.dart';
+export 'src/core/photo_view_gesture_detector.dart'
+    show PhotoViewGestureDetectorScope, PhotoViewPageViewScrollPhysics;
+export 'src/photo_view_computed_scale.dart';
+export 'src/photo_view_scale_state.dart';
+export 'src/utils/photo_view_hero_attributes.dart';
+
+/// A [StatefulWidget] that contains all the photo view rendering elements.
+///
+/// Sample code to use within an image:
+///
+/// ```
+/// PhotoView(
+///  imageProvider: imageProvider,
+///  loadingBuilder: (context, progress) => Center(
+///            child: Container(
+///              width: 20.0,
+///              height: 20.0,
+///              child: CircularProgressIndicator(
+///                value: _progress == null
+///                    ? null
+///                    : _progress.cumulativeBytesLoaded /
+///                        _progress.expectedTotalBytes,
+///              ),
+///            ),
+///          ),
+///  backgroundDecoration: BoxDecoration(color: Colors.black),
+///  gaplessPlayback: false,
+///  customSize: MediaQuery.of(context).size,
+///  heroAttributes: const HeroAttributes(
+///   tag: "someTag",
+///   transitionOnUserGestures: true,
+///  ),
+///  scaleStateChangedCallback: this.onScaleStateChanged,
+///  enableRotation: true,
+///  controller:  controller,
+///  minScale: PhotoViewComputedScale.contained * 0.8,
+///  maxScale: PhotoViewComputedScale.covered * 1.8,
+///  initialScale: PhotoViewComputedScale.contained,
+///  basePosition: Alignment.center,
+///  scaleStateCycle: scaleStateCycle
+/// );
+/// ```
+///
+/// You can customize to show an custom child instead of an image:
+///
+/// ```
+/// PhotoView.customChild(
+///  child: Container(
+///    width: 220.0,
+///    height: 250.0,
+///    child: const Text(
+///      "Hello there, this is a text",
+///    )
+///  ),
+///  childSize: const Size(220.0, 250.0),
+///  backgroundDecoration: BoxDecoration(color: Colors.black),
+///  gaplessPlayback: false,
+///  customSize: MediaQuery.of(context).size,
+///  heroAttributes: const HeroAttributes(
+///   tag: "someTag",
+///   transitionOnUserGestures: true,
+///  ),
+///  scaleStateChangedCallback: this.onScaleStateChanged,
+///  enableRotation: true,
+///  controller:  controller,
+///  minScale: PhotoViewComputedScale.contained * 0.8,
+///  maxScale: PhotoViewComputedScale.covered * 1.8,
+///  initialScale: PhotoViewComputedScale.contained,
+///  basePosition: Alignment.center,
+///  scaleStateCycle: scaleStateCycle
+/// );
+/// ```
+/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
+///
+/// Sample using [maxScale], [minScale] and [initialScale]
+///
+/// ```
+/// PhotoView(
+///  imageProvider: imageProvider,
+///  minScale: PhotoViewComputedScale.contained * 0.8,
+///  maxScale: PhotoViewComputedScale.covered * 1.8,
+///  initialScale: PhotoViewComputedScale.contained * 1.1,
+/// );
+/// ```
+///
+/// [customSize] is used to define the viewPort size in which the image will be
+/// scaled to. This argument is rarely used. By default is the size that this widget assumes.
+///
+/// The argument [gaplessPlayback] is used to continue showing the old image
+/// (`true`), or briefly show nothing (`false`), when the [imageProvider]
+/// changes.By default it's set to `false`.
+///
+/// To use within an hero animation, specify [heroAttributes]. When
+/// [heroAttributes] is specified, the image provider retrieval process should
+/// be sync.
+///
+/// Sample using hero animation:
+/// ```
+/// // screen1
+///   ...
+///   Hero(
+///     tag: "someTag",
+///     child: Image.asset(
+///       "assets/large-image.jpg",
+///       width: 150.0
+///     ),
+///   )
+/// // screen2
+/// ...
+/// child: PhotoView(
+///   imageProvider: AssetImage("assets/large-image.jpg"),
+///   heroAttributes: const HeroAttributes(tag: "someTag"),
+/// )
+/// ```
+///
+/// **Note: If you don't want to the zoomed image do not overlaps the size of the container, use [ClipRect](https://docs.flutter.io/flutter/widgets/ClipRect-class.html)**
+///
+/// ## Controllers
+///
+/// Controllers, when specified to PhotoView widget, enables the author(you) to listen for state updates through a `Stream` and change those values externally.
+///
+/// While [PhotoViewScaleStateController] is only responsible for the `scaleState`, [PhotoViewController] is responsible for all fields os [PhotoViewControllerValue].
+///
+/// To use them, pass a instance of those items on [controller] or [scaleStateController];
+///
+/// Since those follows the standard controller pattern found in widgets like [PageView] and [ScrollView], whoever instantiates it, should [dispose] it afterwards.
+///
+/// Example of [controller] usage, only listening for state changes:
+///
+/// ```
+/// class _ExampleWidgetState extends State<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,
+);
diff --git a/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart b/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart
new file mode 100644
index 0000000000..6cbdb259c9
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart
@@ -0,0 +1,446 @@
+library photo_view_gallery;
+
+import 'package:flutter/material.dart';
+import 'package:flutter/widgets.dart';
+import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
+    show
+        LoadingBuilder,
+        PhotoView,
+        PhotoViewImageTapDownCallback,
+        PhotoViewImageTapUpCallback,
+        PhotoViewImageDragStartCallback,
+        PhotoViewImageDragEndCallback,
+        PhotoViewImageDragUpdateCallback,
+        PhotoViewImageScaleEndCallback,
+        ScaleStateCycle;
+
+import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
+
+/// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery]
+typedef PhotoViewGalleryPageChangedCallback = void Function(int index);
+
+/// A type definition for a [Function] that defines a page in [PhotoViewGallery.build]
+typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function(
+  BuildContext context, 
+  int index,
+);
+
+/// A [StatefulWidget] that shows multiple [PhotoView] widgets in a [PageView]
+///
+/// Some of [PhotoView] constructor options are passed direct to [PhotoViewGallery] constructor. Those options will affect the gallery in a whole.
+///
+/// Some of the options may be defined to each image individually, such as `initialScale` or `PhotoViewHeroAttributes`. Those must be passed via each [PhotoViewGalleryPageOptions].
+///
+/// Example of usage as a list of options:
+/// ```
+/// PhotoViewGallery(
+///   pageOptions: <PhotoViewGalleryPageOptions>[
+///     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;
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller.dart b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller.dart
new file mode 100644
index 0000000000..40e707351a
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller.dart
@@ -0,0 +1,291 @@
+import 'dart:async';
+
+import 'package:flutter/widgets.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
+
+/// The interface in which controllers will be implemented.
+///
+/// It concerns storing the state ([PhotoViewControllerValue]) and streaming its updates.
+/// [PhotoViewImageWrapper] will respond to user gestures setting thew fields in the instance of a controller.
+///
+/// Any instance of a controller must be disposed after unmount. So if you instantiate a [PhotoViewController] or your custom implementation, do not forget to dispose it when not using it anymore.
+///
+/// The controller exposes value fields like [scale] or [rotationFocus]. Usually those fields will be only getters and setters serving as hooks to the internal [PhotoViewControllerValue].
+///
+/// The default implementation used by [PhotoView] is [PhotoViewController].
+///
+/// This was created to allow customization (you can create your own controller class)
+///
+/// Previously it controlled `scaleState` as well, but duw to some [concerns](https://github.com/renancaraujo/photo_view/issues/127)
+/// [ScaleStateListener is responsible for tat value now
+///
+/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
+///
+abstract class PhotoViewControllerBase<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;
+  }
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart
new file mode 100644
index 0000000000..6be06a4a39
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart
@@ -0,0 +1,214 @@
+import 'package:flutter/widgets.dart';
+import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
+    show
+        PhotoViewControllerBase,
+        PhotoViewScaleState,
+        PhotoViewScaleStateController,
+        ScaleStateCycle;
+import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
+
+/// A  class to hold internal layout logic to sync both controller states
+///
+/// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers.
+mixin PhotoViewControllerDelegate on State<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();
+  }
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart
new file mode 100644
index 0000000000..dfd43a2492
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart
@@ -0,0 +1,98 @@
+import 'dart:async';
+import 'dart:ui';
+
+import 'package:flutter/widgets.dart' show VoidCallback;
+import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
+
+typedef ScaleStateListener = void Function(double prevScale, double nextScale);
+
+/// A controller responsible only by [scaleState].
+///
+/// Scale state is a common value with represents the step in which the [PhotoView.scaleStateCycle] is.
+/// This cycle is triggered by the "doubleTap" gesture.
+///
+/// Any change in its [scaleState] should animate the scale of image/content.
+///
+/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
+///
+/// The updates should be done via [scaleState] setter and the updated listened via [outputScaleStateStream]
+///
+class PhotoViewScaleStateController {
+  late final IgnorableValueNotifier<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);
+  }
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart b/mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart
new file mode 100644
index 0000000000..5728301482
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart
@@ -0,0 +1,461 @@
+import 'package:flutter/widgets.dart';
+import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
+    show
+        PhotoViewScaleState,
+        PhotoViewHeroAttributes,
+        PhotoViewImageTapDownCallback,
+        PhotoViewImageTapUpCallback,
+        PhotoViewImageScaleEndCallback,
+        PhotoViewImageDragEndCallback,
+        PhotoViewImageDragStartCallback,
+        PhotoViewImageDragUpdateCallback,
+        ScaleStateCycle;
+import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_hit_corners.dart';
+import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
+
+const _defaultDecoration = BoxDecoration(
+  color: Color.fromRGBO(0, 0, 0, 1.0),
+);
+
+/// Internal widget in which controls all animations lifecycle, core responses
+/// to user gestures, updates to  the controller state and mounts the entire PhotoView Layout
+class PhotoViewCore extends StatefulWidget {
+  const PhotoViewCore({
+    Key? key,
+    required this.imageProvider,
+    required this.backgroundDecoration,
+    required this.gaplessPlayback,
+    required this.heroAttributes,
+    required this.enableRotation,
+    required this.onTapUp,
+    required this.onTapDown,
+    required this.onDragStart,
+    required this.onDragEnd,
+    required this.onDragUpdate,
+    required this.onScaleEnd,
+    required this.gestureDetectorBehavior,
+    required this.controller,
+    required this.scaleBoundaries,
+    required this.scaleStateCycle,
+    required this.scaleStateController,
+    required this.basePosition,
+    required this.tightMode,
+    required this.filterQuality,
+    required this.disableGestures,
+    required this.enablePanAlways,
+  })  : customChild = null,
+        super(key: key);
+
+  const PhotoViewCore.customChild({
+    Key? key,
+    required this.customChild,
+    required this.backgroundDecoration,
+    this.heroAttributes,
+    required this.enableRotation,
+    this.onTapUp,
+    this.onTapDown,
+    this.onDragStart,
+    this.onDragEnd,
+    this.onDragUpdate,
+    this.onScaleEnd,
+    this.gestureDetectorBehavior,
+    required this.controller,
+    required this.scaleBoundaries,
+    required this.scaleStateCycle,
+    required this.scaleStateController,
+    required this.basePosition,
+    required this.tightMode,
+    required this.filterQuality,
+    required this.disableGestures,
+    required this.enablePanAlways,
+  })  : imageProvider = null,
+        gaplessPlayback = false,
+        super(key: key);
+
+  final Decoration? backgroundDecoration;
+  final ImageProvider? imageProvider;
+  final bool? gaplessPlayback;
+  final PhotoViewHeroAttributes? heroAttributes;
+  final bool enableRotation;
+  final Widget? customChild;
+
+  final PhotoViewControllerBase controller;
+  final PhotoViewScaleStateController scaleStateController;
+  final ScaleBoundaries scaleBoundaries;
+  final ScaleStateCycle scaleStateCycle;
+  final Alignment basePosition;
+
+  final PhotoViewImageTapUpCallback? onTapUp;
+  final PhotoViewImageTapDownCallback? onTapDown;
+  final PhotoViewImageScaleEndCallback? onScaleEnd;
+
+  final PhotoViewImageDragStartCallback? onDragStart;
+  final PhotoViewImageDragEndCallback? onDragEnd;
+  final PhotoViewImageDragUpdateCallback? onDragUpdate;
+
+  final HitTestBehavior? gestureDetectorBehavior;
+  final bool tightMode;
+  final bool disableGestures;
+  final bool enablePanAlways;
+
+  final FilterQuality filterQuality;
+
+  @override
+  State<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;
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart
new file mode 100644
index 0000000000..201ca20f41
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart
@@ -0,0 +1,293 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/widgets.dart';
+
+import 'photo_view_hit_corners.dart';
+
+/// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c)
+/// for the gist
+class PhotoViewGestureDetector extends StatelessWidget {
+  const PhotoViewGestureDetector({
+    Key? key,
+    this.hitDetector,
+    this.onScaleStart,
+    this.onScaleUpdate,
+    this.onScaleEnd,
+    this.onDoubleTap,
+    this.onDragStart,
+    this.onDragEnd,
+    this.onDragUpdate,
+    this.child,
+    this.onTapUp,
+    this.onTapDown,
+    this.behavior,
+  }) : super(key: key);
+
+  final GestureDoubleTapCallback? onDoubleTap;
+  final HitCornersDetector? hitDetector;
+
+  final GestureScaleStartCallback? onScaleStart;
+  final GestureScaleUpdateCallback? onScaleUpdate;
+  final GestureScaleEndCallback? onScaleEnd;
+
+  final GestureDragEndCallback? onDragEnd;
+  final GestureDragStartCallback? onDragStart;
+  final GestureDragUpdateCallback? onDragUpdate;
+
+  final GestureTapUpCallback? onTapUp;
+  final GestureTapDownCallback? onTapDown;
+
+  final Widget? child;
+
+  final HitTestBehavior? behavior;
+
+  @override
+  Widget build(BuildContext context) {
+    final scope = PhotoViewGestureDetectorScope.of(context);
+
+    final Axis? axis = scope?.axis;
+    final touchSlopFactor = scope?.touchSlopFactor ?? 2;
+
+    final Map<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;
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/core/photo_view_hit_corners.dart b/mobile/lib/shared/ui/photo_view/src/core/photo_view_hit_corners.dart
new file mode 100644
index 0000000000..3210aed8e2
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/core/photo_view_hit_corners.dart
@@ -0,0 +1,77 @@
+import 'package:flutter/widgets.dart';
+
+import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart'
+    show PhotoViewControllerDelegate;
+
+mixin HitCornersDetector on PhotoViewControllerDelegate {
+  HitCorners _hitCornersX() {
+    final double childWidth = scaleBoundaries.childSize.width * scale;
+    final double screenWidth = scaleBoundaries.outerSize.width;
+    if (screenWidth >= childWidth) {
+      return const HitCorners(true, true);
+    }
+    final x = -position.dx;
+    final cornersX = this.cornersX();
+    return HitCorners(x <= cornersX.min, x >= cornersX.max);
+  }
+
+  HitCorners _hitCornersY() {
+    final double childHeight = scaleBoundaries.childSize.height * scale;
+    final double screenHeight = scaleBoundaries.outerSize.height;
+    if (screenHeight >= childHeight) {
+      return const HitCorners(true, true);
+    }
+    final y = -position.dy;
+    final cornersY = this.cornersY();
+    return HitCorners(y <= cornersY.min, y >= cornersY.max);
+  }
+
+  bool _shouldMoveAxis(HitCorners hitCorners, double mainAxisMove, double crossAxisMove) {
+    if (mainAxisMove == 0) {
+      return false;
+    }
+    if (!hitCorners.hasHitAny) {
+      return true;
+    }
+    final axisBlocked = hitCorners.hasHitBoth ||
+        (hitCorners.hasHitMax ? mainAxisMove > 0 : mainAxisMove < 0);
+    if (axisBlocked) {
+      return false;
+    }
+    return true;
+  }
+
+  bool _shouldMoveX(Offset move) {
+    final hitCornersX = _hitCornersX();
+    final mainAxisMove = move.dx;
+    final crossAxisMove = move.dy;
+
+    return _shouldMoveAxis(hitCornersX, mainAxisMove, crossAxisMove);
+  }
+
+  bool _shouldMoveY(Offset move) {
+    final hitCornersY = _hitCornersY();
+    final mainAxisMove = move.dy;
+    final crossAxisMove = move.dx;
+
+    return _shouldMoveAxis(hitCornersY, mainAxisMove, crossAxisMove);
+  }
+
+  bool shouldMove(Offset move, Axis mainAxis) {
+    if (mainAxis == Axis.vertical) {
+      return _shouldMoveY(move);
+    }
+    return _shouldMoveX(move);
+  }
+}
+
+class HitCorners {
+  const HitCorners(this.hasHitMin, this.hasHitMax);
+
+  final bool hasHitMin;
+  final bool hasHitMax;
+
+  bool get hasHitAny => hasHitMin || hasHitMax;
+
+  bool get hasHitBoth => hasHitMin && hasHitMax;
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/photo_view_computed_scale.dart b/mobile/lib/shared/ui/photo_view/src/photo_view_computed_scale.dart
new file mode 100644
index 0000000000..a01db562c7
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/photo_view_computed_scale.dart
@@ -0,0 +1,36 @@
+/// A class that work as a enum. It overloads the operator `*` saving the double as a multiplier.
+///
+/// ```
+/// PhotoViewComputedScale.contained * 2
+/// ```
+///
+class PhotoViewComputedScale {
+  const PhotoViewComputedScale._internal(this._value, [this.multiplier = 1.0]);
+
+  final String _value;
+  final double multiplier;
+
+  @override
+  String toString() => 'Enum.$_value';
+
+  static const contained = PhotoViewComputedScale._internal('contained');
+  static const covered = PhotoViewComputedScale._internal('covered');
+
+  PhotoViewComputedScale operator *(double multiplier) {
+    return PhotoViewComputedScale._internal(_value, multiplier);
+  }
+
+  PhotoViewComputedScale operator /(double divider) {
+    return PhotoViewComputedScale._internal(_value, 1 / divider);
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is PhotoViewComputedScale &&
+          runtimeType == other.runtimeType &&
+          _value == other._value;
+
+  @override
+  int get hashCode => _value.hashCode;
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/photo_view_default_widgets.dart b/mobile/lib/shared/ui/photo_view/src/photo_view_default_widgets.dart
new file mode 100644
index 0000000000..339463b3f8
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/photo_view_default_widgets.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+
+class PhotoViewDefaultError extends StatelessWidget {
+  const PhotoViewDefaultError({Key? key, required this.decoration})
+      : super(key: key);
+
+  final BoxDecoration decoration;
+
+  @override
+  Widget build(BuildContext context) {
+    return DecoratedBox(
+      decoration: decoration,
+      child: Center(
+        child: Icon(
+          Icons.broken_image,
+          color: Colors.grey[400],
+          size: 40.0,
+        ),
+      ),
+    );
+  }
+}
+
+class PhotoViewDefaultLoading extends StatelessWidget {
+  const PhotoViewDefaultLoading({Key? key, this.event}) : super(key: key);
+
+  final ImageChunkEvent? event;
+
+  @override
+  Widget build(BuildContext context) {
+    final expectedBytes = event?.expectedTotalBytes;
+    final loadedBytes = event?.cumulativeBytesLoaded;
+    final value = loadedBytes != null && expectedBytes != null
+        ? loadedBytes / expectedBytes
+        : null;
+
+    return Center(
+      child: SizedBox(
+        width: 20.0,
+        height: 20.0,
+        child: CircularProgressIndicator(value: value),
+      ),
+    );
+  }
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/photo_view_scale_state.dart b/mobile/lib/shared/ui/photo_view/src/photo_view_scale_state.dart
new file mode 100644
index 0000000000..fc6d4db3f9
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/photo_view_scale_state.dart
@@ -0,0 +1,12 @@
+/// A way to represent the step of the "doubletap gesture cycle" in which PhotoView is.
+enum PhotoViewScaleState {
+  initial,
+  covering,
+  originalSize,
+  zoomedIn,
+  zoomedOut;
+
+  bool get isScaleStateZooming =>
+      this == PhotoViewScaleState.zoomedIn ||
+      this == PhotoViewScaleState.zoomedOut;
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart b/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart
new file mode 100644
index 0000000000..da80f18962
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart
@@ -0,0 +1,327 @@
+import 'package:flutter/widgets.dart';
+
+import '../photo_view.dart';
+import 'core/photo_view_core.dart';
+import 'photo_view_default_widgets.dart';
+import 'utils/photo_view_utils.dart';
+
+class ImageWrapper extends StatefulWidget {
+  const ImageWrapper({
+    Key? key,
+    required this.imageProvider,
+    required this.loadingBuilder,
+    required this.backgroundDecoration,
+    required this.gaplessPlayback,
+    required this.heroAttributes,
+    required this.scaleStateChangedCallback,
+    required this.enableRotation,
+    required this.controller,
+    required this.scaleStateController,
+    required this.maxScale,
+    required this.minScale,
+    required this.initialScale,
+    required this.basePosition,
+    required this.scaleStateCycle,
+    required this.onTapUp,
+    required this.onTapDown,
+    required this.onDragStart,
+    required this.onDragEnd,
+    required this.onDragUpdate,
+    required this.onScaleEnd,
+    required this.outerSize,
+    required this.gestureDetectorBehavior,
+    required this.tightMode,
+    required this.filterQuality,
+    required this.disableGestures,
+    required this.errorBuilder,
+    required this.enablePanAlways,
+  }) : super(key: key);
+
+  final ImageProvider imageProvider;
+  final LoadingBuilder? loadingBuilder;
+  final ImageErrorWidgetBuilder? errorBuilder;
+  final BoxDecoration backgroundDecoration;
+  final bool gaplessPlayback;
+  final PhotoViewHeroAttributes? heroAttributes;
+  final ValueChanged<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,
+    );
+  }
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart b/mobile/lib/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart
new file mode 100644
index 0000000000..95f6552be3
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart
@@ -0,0 +1,109 @@
+import 'package:flutter/foundation.dart';
+
+/// A [ChangeNotifier] that has a second collection of listeners: the ignorable ones
+///
+/// Those listeners will be fired when [notifyListeners] fires and will be ignored
+/// when [notifySomeListeners] fires.
+///
+/// The common collection of listeners inherited from [ChangeNotifier] will be fired
+/// every time.
+class IgnorableChangeNotifier extends ChangeNotifier {
+  ObserverList<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)';
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart b/mobile/lib/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart
new file mode 100644
index 0000000000..1fbbb73c33
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart
@@ -0,0 +1,28 @@
+import 'package:flutter/widgets.dart';
+
+/// Data class that holds the attributes that are going to be passed to
+/// [PhotoViewImageWrapper]'s [Hero].
+class PhotoViewHeroAttributes {
+  const PhotoViewHeroAttributes({
+    required this.tag,
+    this.createRectTween,
+    this.flightShuttleBuilder,
+    this.placeholderBuilder,
+    this.transitionOnUserGestures = false,
+  });
+
+  /// Mirror to [Hero.tag]
+  final Object tag;
+
+  /// Mirror to [Hero.createRectTween]
+  final CreateRectTween? createRectTween;
+
+  /// Mirror to [Hero.flightShuttleBuilder]
+  final HeroFlightShuttleBuilder? flightShuttleBuilder;
+
+  /// Mirror to [Hero.placeholderBuilder]
+  final HeroPlaceholderBuilder? placeholderBuilder;
+
+  /// Mirror to [Hero.transitionOnUserGestures]
+  final bool transitionOnUserGestures;
+}
diff --git a/mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart b/mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart
new file mode 100644
index 0000000000..d8329c7f8e
--- /dev/null
+++ b/mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart
@@ -0,0 +1,145 @@
+import 'dart:math' as math;
+import 'dart:ui' show Size;
+
+import "package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart";
+import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
+
+/// Given a [PhotoViewScaleState], returns a scale value considering [scaleBoundaries].
+double getScaleForScaleState(
+  PhotoViewScaleState scaleState,
+  ScaleBoundaries scaleBoundaries,
+) {
+  switch (scaleState) {
+    case PhotoViewScaleState.initial:
+    case PhotoViewScaleState.zoomedIn:
+    case PhotoViewScaleState.zoomedOut:
+      return _clampSize(scaleBoundaries.initialScale, scaleBoundaries);
+    case PhotoViewScaleState.covering:
+      return _clampSize(
+        _scaleForCovering(
+          scaleBoundaries.outerSize, 
+          scaleBoundaries.childSize,
+        ),
+        scaleBoundaries,
+      );
+    case PhotoViewScaleState.originalSize:
+      return _clampSize(1.0, scaleBoundaries);
+    // Will never be reached
+    default:
+      return 0;
+  }
+}
+
+/// Internal class to wraps custom scale boundaries (min, max and initial)
+/// Also, stores values regarding the two sizes: the container and teh child.
+class ScaleBoundaries {
+  const ScaleBoundaries(
+    this._minScale,
+    this._maxScale,
+    this._initialScale,
+    this.outerSize,
+    this.childSize,
+  );
+
+  final dynamic _minScale;
+  final dynamic _maxScale;
+  final dynamic _initialScale;
+  final Size outerSize;
+  final Size childSize;
+
+  double get minScale {
+    assert(_minScale is double || _minScale is PhotoViewComputedScale);
+    if (_minScale == PhotoViewComputedScale.contained) {
+      return _scaleForContained(outerSize, childSize) *
+          (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
+    }
+    if (_minScale == PhotoViewComputedScale.covered) {
+      return _scaleForCovering(outerSize, childSize) *
+          (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
+    }
+    assert(_minScale >= 0.0);
+    return _minScale;
+  }
+
+  double get maxScale {
+    assert(_maxScale is double || _maxScale is PhotoViewComputedScale);
+    if (_maxScale == PhotoViewComputedScale.contained) {
+      return (_scaleForContained(outerSize, childSize) *
+              (_maxScale as PhotoViewComputedScale) // ignore: avoid_as
+                  .multiplier)
+          .clamp(minScale, double.infinity);
+    }
+    if (_maxScale == PhotoViewComputedScale.covered) {
+      return (_scaleForCovering(outerSize, childSize) *
+              (_maxScale as PhotoViewComputedScale) // ignore: avoid_as
+                  .multiplier)
+          .clamp(minScale, double.infinity);
+    }
+    return _maxScale.clamp(minScale, double.infinity);
+  }
+
+  double get initialScale {
+    assert(_initialScale is double || _initialScale is PhotoViewComputedScale);
+    if (_initialScale == PhotoViewComputedScale.contained) {
+      return _scaleForContained(outerSize, childSize) *
+          (_initialScale as PhotoViewComputedScale) // ignore: avoid_as
+              .multiplier;
+    }
+    if (_initialScale == PhotoViewComputedScale.covered) {
+      return _scaleForCovering(outerSize, childSize) *
+          (_initialScale as PhotoViewComputedScale) // ignore: avoid_as
+              .multiplier;
+    }
+    return _initialScale.clamp(minScale, maxScale);
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is ScaleBoundaries &&
+          runtimeType == other.runtimeType &&
+          _minScale == other._minScale &&
+          _maxScale == other._maxScale &&
+          _initialScale == other._initialScale &&
+          outerSize == other.outerSize &&
+          childSize == other.childSize;
+
+  @override
+  int get hashCode =>
+      _minScale.hashCode ^
+      _maxScale.hashCode ^
+      _initialScale.hashCode ^
+      outerSize.hashCode ^
+      childSize.hashCode;
+}
+
+double _scaleForContained(Size size, Size childSize) {
+  final double imageWidth = childSize.width;
+  final double imageHeight = childSize.height;
+
+  final double screenWidth = size.width;
+  final double screenHeight = size.height;
+
+  return math.min(screenWidth / imageWidth, screenHeight / imageHeight);
+}
+
+double _scaleForCovering(Size size, Size childSize) {
+  final double imageWidth = childSize.width;
+  final double imageHeight = childSize.height;
+
+  final double screenWidth = size.width;
+  final double screenHeight = size.height;
+
+  return math.max(screenWidth / imageWidth, screenHeight / imageHeight);
+}
+
+double _clampSize(double size, ScaleBoundaries scaleBoundaries) {
+  return size.clamp(scaleBoundaries.minScale, scaleBoundaries.maxScale);
+}
+
+/// Simple class to store a min and a max value
+class CornersRange {
+  const CornersRange(this.min, this.max);
+  final double min;
+  final double max;
+}
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 3de8b9a02d..e8004e54e6 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -239,6 +239,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.2.3"
+  easy_image_viewer:
+    dependency: "direct main"
+    description:
+      name: easy_image_viewer
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.2.0"
   easy_localization:
     dependency: "direct main"
     description:
@@ -757,13 +764,6 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.5.0"
-  photo_view:
-    dependency: "direct main"
-    description:
-      name: photo_view
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "0.14.0"
   platform:
     dependency: transitive
     description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index b504762b69..79f7144664 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -23,7 +23,6 @@ dependencies:
   video_player: ^2.2.18
   chewie: ^1.3.5
   badges: ^2.0.2
-  photo_view: ^0.14.0
   socket_io_client: ^2.0.0-beta.4-nullsafety.0
   flutter_map: ^0.14.0
   flutter_udid: ^2.0.0
@@ -41,6 +40,7 @@ dependencies:
   collection: ^1.16.0
   http_parser: ^4.0.1
   flutter_web_auth: ^0.5.0
+  easy_image_viewer: ^1.2.0
 
   openapi:
     path: openapi