Przeglądaj źródła

Enable swiping between assets (#381)

Enable swiping between assets (#381)

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Malte Kiefer <59220985+MalteKiefer@users.noreply.github.com>
Co-authored-by: Matthias Rupp <matthias.rupp@posteo.de>
Stevenson Chittumuri 3 lat temu
rodzic
commit
8c184dc4d4

+ 1 - 1
mobile/.gitignore

@@ -24,7 +24,7 @@
 
 # Flutter/Dart/Pub related
 **/doc/api/
-**/ios/Flutter/.last_build_id
+**/ios/
 .dart_tool/
 .flutter-plugins
 .flutter-plugins-dependencies

+ 0 - 1
mobile/lib/main.dart

@@ -17,7 +17,6 @@ import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
-
 import 'constants/hive_box.dart';
 
 void main() async {

+ 3 - 1
mobile/lib/modules/album/providers/album.provider.dart

@@ -20,7 +20,9 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
   }
 
   Future<AlbumResponseDto?> createAlbum(
-      String albumTitle, Set<AssetResponseDto> assets) async {
+    String albumTitle,
+    Set<AssetResponseDto> assets,
+  ) async {
     AlbumResponseDto? album =
         await _albumService.createAlbum(albumTitle, assets, []);
 

+ 13 - 20
mobile/lib/modules/album/ui/album_viewer_thumbnail.dart

@@ -12,8 +12,13 @@ import 'package:openapi/api.dart';
 
 class AlbumViewerThumbnail extends HookConsumerWidget {
   final AssetResponseDto asset;
+  final List<AssetResponseDto> assetList;
 
-  const AlbumViewerThumbnail({Key? key, required this.asset}) : super(key: key);
+  const AlbumViewerThumbnail({
+    Key? key,
+    required this.asset,
+    required this.assetList,
+  }) : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -28,25 +33,13 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
         ref.watch(assetSelectionProvider).isMultiselectEnable;
 
     _viewAsset() {
-      if (asset.type == AssetTypeEnum.IMAGE) {
-        AutoRouter.of(context).push(
-          ImageViewerRoute(
-            imageUrl:
-                '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
-            heroTag: asset.id,
-            thumbnailUrl: thumbnailRequestUrl,
-            asset: asset,
-          ),
-        );
-      } else {
-        AutoRouter.of(context).push(
-          VideoViewerRoute(
-            videoUrl:
-                '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
-            asset: asset,
-          ),
-        );
-      }
+      AutoRouter.of(context).push(
+        GalleryViewerRoute(
+          asset: asset,
+          assetList: assetList,
+          thumbnailRequestUrl: thumbnailRequestUrl,
+        ),
+      );
     }
 
     BoxBorder drawBorderColor() {

+ 5 - 4
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -29,9 +29,7 @@ class AlbumViewerPage extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
     FocusNode titleFocusNode = useFocusNode();
     ScrollController scrollController = useScrollController();
-
-    AsyncValue<AlbumResponseDto?> albumInfo =
-        ref.watch(sharedAlbumDetailProvider(albumId));
+    var albumInfo = ref.watch(sharedAlbumDetailProvider(albumId));
 
     final userId = ref.watch(authenticationProvider).userId;
 
@@ -200,7 +198,10 @@ class AlbumViewerPage extends HookConsumerWidget {
             ),
             delegate: SliverChildBuilderDelegate(
               (BuildContext context, int index) {
-                return AlbumViewerThumbnail(asset: albumInfo.assets[index]);
+                return AlbumViewerThumbnail(
+                  asset: albumInfo.assets[index],
+                  assetList: albumInfo.assets,
+                );
               },
               childCount: albumInfo.assets.length,
             ),

+ 16 - 4
mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart

@@ -15,7 +15,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
   @override
   Widget build(BuildContext context) {
     bool allowMoving = _status == _RemoteImageStatus.full;
-
     return PhotoView(
       imageProvider: _imageProvider,
       minScale: PhotoViewComputedScale.contained,
@@ -32,8 +31,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
     PhotoViewControllerValue controllerValue,
   ) {
     // Disable swipe events when zoomed in
-    if (_zoomedIn) return;
-
+    if (_zoomedIn) {
+      return;
+    }
     if (controllerValue.position.dy > swipeThreshold) {
       widget.onSwipeDown();
     } else if (controllerValue.position.dy < -swipeThreshold) {
@@ -42,7 +42,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
   }
 
   void _scaleStateChanged(PhotoViewScaleState state) {
-    _zoomedIn = state == PhotoViewScaleState.zoomedIn;
+    // _onScaleListener;
+    _zoomedIn = state != PhotoViewScaleState.initial;
+    if (_zoomedIn) {
+      widget.isZoomedListener.value = true;
+    } else {
+      widget.isZoomedListener.value = false;
+    }
+    widget.isZoomedFunction();
   }
 
   CachedNetworkImageProvider _authorizedImageProvider(String url) {
@@ -107,6 +114,8 @@ class RemotePhotoView extends StatefulWidget {
     required this.thumbnailUrl,
     required this.imageUrl,
     required this.authToken,
+    required this.isZoomedFunction,
+    required this.isZoomedListener,
     required this.onSwipeDown,
     required this.onSwipeUp,
   }) : super(key: key);
@@ -117,6 +126,9 @@ class RemotePhotoView extends StatefulWidget {
 
   final void Function() onSwipeDown;
   final void Function() onSwipeUp;
+  final void Function() isZoomedFunction;
+
+  final ValueNotifier<bool> isZoomedListener;
 
   @override
   State<StatefulWidget> createState() {

+ 134 - 0
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -0,0 +1,134 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
+import 'package:hive/hive.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/hive_box.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:openapi/api.dart';
+
+// ignore: must_be_immutable
+class GalleryViewerPage extends HookConsumerWidget {
+  late List<AssetResponseDto> assetList;
+  final AssetResponseDto asset;
+  final String thumbnailRequestUrl;
+
+  GalleryViewerPage({
+    Key? key,
+    required this.assetList,
+    required this.asset,
+    required this.thumbnailRequestUrl,
+  }) : super(key: key);
+
+  AssetResponseDto? assetDetail;
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final Box<dynamic> box = Hive.box(userInfoBox);
+
+    int indexOfAsset = assetList.indexOf(asset);
+
+    @override
+    void initState(int index) {
+      indexOfAsset = index;
+    }
+
+    PageController controller =
+        PageController(initialPage: assetList.indexOf(asset));
+
+    getAssetExif() async {
+      assetDetail = await ref
+          .watch(assetServiceProvider)
+          .getAssetById(assetList[indexOfAsset].id);
+    }
+
+    void showInfo() {
+      showModalBottomSheet(
+        backgroundColor: Colors.black,
+        barrierColor: Colors.transparent,
+        isScrollControlled: false,
+        context: context,
+        builder: (context) {
+          return ExifBottomSheet(assetDetail: assetDetail!);
+        },
+      );
+    }
+
+    final isZoomed = useState<bool>(false);
+    ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
+
+    //make isZoomed listener call instead
+    void isZoomedMethod() {
+      if (isZoomedListener.value) {
+        isZoomed.value = true;
+      } else {
+        isZoomed.value = false;
+      }
+    }
+
+    return Scaffold(
+      backgroundColor: Colors.black,
+      appBar: TopControlAppBar(
+        asset: assetList[indexOfAsset],
+        onMoreInfoPressed: () {
+          showInfo();
+        },
+        onDownloadPressed: () {
+          ref
+              .watch(imageViewerStateProvider.notifier)
+              .downloadAsset(assetList[indexOfAsset], context);
+        },
+      ),
+      body: SafeArea(
+        child: PageView.builder(
+          controller: controller,
+          pageSnapping: true,
+          physics: isZoomed.value
+              ? const NeverScrollableScrollPhysics()
+              : const BouncingScrollPhysics(),
+          itemCount: assetList.length,
+          scrollDirection: Axis.horizontal,
+          itemBuilder: (context, index) {
+            initState(index);
+            getAssetExif();
+            if (assetList[index].type == AssetTypeEnum.IMAGE) {
+              return ImageViewerPage(
+                thumbnailUrl:
+                    '${box.get(serverEndpointKey)}/asset/thumbnail/${assetList[index].id}',
+                imageUrl:
+                    '${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}&isThumb=false',
+                authToken: 'Bearer ${box.get(accessTokenKey)}',
+                isZoomedFunction: isZoomedMethod,
+                isZoomedListener: isZoomedListener,
+                asset: assetList[index],
+                heroTag: assetList[index].id,
+              );
+            } else {
+              return SwipeDetector(
+                onSwipeDown: (_) {
+                  AutoRouter.of(context).pop();
+                },
+                onSwipeUp: (_) {
+                  showInfo();
+                },
+                child: Hero(
+                  tag: assetList[index].id,
+                  child: VideoViewerPage(
+                    asset: assetList[index],
+                    videoUrl:
+                        '${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}',
+                  ),
+                ),
+              );
+            }
+          },
+        ),
+      ),
+    );
+  }
+}

+ 35 - 46
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart

@@ -1,15 +1,12 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hive/hive.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/hive_box.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/download_loading_indicator.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
-import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
 import 'package:openapi/api.dart';
 
@@ -19,8 +16,9 @@ class ImageViewerPage extends HookConsumerWidget {
   final String heroTag;
   final String thumbnailUrl;
   final AssetResponseDto asset;
-
-  AssetResponseDto? assetDetail;
+  final String authToken;
+  final ValueNotifier<bool> isZoomedListener;
+  final void Function() isZoomedFunction;
 
   ImageViewerPage({
     Key? key,
@@ -28,19 +26,30 @@ class ImageViewerPage extends HookConsumerWidget {
     required this.heroTag,
     required this.thumbnailUrl,
     required this.asset,
+    required this.authToken,
+    required this.isZoomedFunction,
+    required this.isZoomedListener,
   }) : super(key: key);
 
+  AssetResponseDto? assetDetail;
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final downloadAssetStatus =
         ref.watch(imageViewerStateProvider).downloadAssetStatus;
-    var box = Hive.box(userInfoBox);
 
     getAssetExif() async {
       assetDetail =
           await ref.watch(assetServiceProvider).getAssetById(asset.id);
     }
 
+    useEffect(
+      () {
+        getAssetExif();
+        return null;
+      },
+      [],
+    );
+
     showInfo() {
       showModalBottomSheet(
         backgroundColor: Colors.black,
@@ -48,52 +57,32 @@ class ImageViewerPage extends HookConsumerWidget {
         isScrollControlled: false,
         context: context,
         builder: (context) {
-          return ExifBottomSheet(assetDetail: assetDetail!);
+          return ExifBottomSheet(assetDetail: assetDetail ?? asset);
         },
       );
     }
 
-    useEffect(
-      () {
-        getAssetExif();
-        return null;
-      },
-      [],
-    );
-
-    return Scaffold(
-      backgroundColor: Colors.black,
-      appBar: TopControlAppBar(
-        asset: asset,
-        onMoreInfoPressed: showInfo,
-        onDownloadPressed: () {
-          ref
-              .watch(imageViewerStateProvider.notifier)
-              .downloadAsset(asset, context);
-        },
-      ),
-      body: SafeArea(
-        child: Stack(
-          children: [
-            Center(
-              child: Hero(
-                tag: heroTag,
-                child: RemotePhotoView(
-                  thumbnailUrl: thumbnailUrl,
-                  imageUrl: imageUrl,
-                  authToken: "Bearer ${box.get(accessTokenKey)}",
-                  onSwipeDown: () => AutoRouter.of(context).pop(),
-                  onSwipeUp: () => showInfo(),
-                ),
-              ),
+    return Stack(
+      children: [
+        Center(
+          child: Hero(
+            tag: heroTag,
+            child: RemotePhotoView(
+              thumbnailUrl: thumbnailUrl,
+              imageUrl: imageUrl,
+              authToken: authToken,
+              isZoomedFunction: isZoomedFunction,
+              isZoomedListener: isZoomedListener,
+              onSwipeDown: () => AutoRouter.of(context).pop(),
+              onSwipeUp: () => showInfo(),
             ),
-            if (downloadAssetStatus == DownloadAssetStatus.loading)
-              const Center(
-                child: DownloadLoadingIndicator(),
-              ),
-          ],
+          ),
         ),
-      ),
+        if (downloadAssetStatus == DownloadAssetStatus.loading)
+          const Center(
+            child: DownloadLoadingIndicator(),
+          ),
+      ],
     );
   }
 }

+ 22 - 72
mobile/lib/modules/asset_viewer/views/video_viewer_page.dart

@@ -1,7 +1,4 @@
-import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
 import 'package:hive/hive.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
@@ -9,9 +6,6 @@ import 'package:chewie/chewie.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/download_loading_indicator.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/home/services/asset.service.dart';
 import 'package:openapi/api.dart';
 import 'package:video_player/video_player.dart';
 
@@ -31,66 +25,17 @@ class VideoViewerPage extends HookConsumerWidget {
 
     String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
 
-    void showInfo() {
-      showModalBottomSheet(
-        backgroundColor: Colors.black,
-        barrierColor: Colors.transparent,
-        isScrollControlled: false,
-        context: context,
-        builder: (context) {
-          return ExifBottomSheet(assetDetail: assetDetail!);
-        },
-      );
-    }
-
-    getAssetExif() async {
-      assetDetail =
-          await ref.watch(assetServiceProvider).getAssetById(asset.id);
-    }
-
-    useEffect(
-      () {
-        getAssetExif();
-        return null;
-      },
-      [],
-    );
-
-    return Scaffold(
-      backgroundColor: Colors.black,
-      appBar: TopControlAppBar(
-        asset: asset,
-        onMoreInfoPressed: () {
-          showInfo();
-        },
-        onDownloadPressed: () {
-          ref
-              .watch(imageViewerStateProvider.notifier)
-              .downloadAsset(asset, context);
-        },
-      ),
-      body: SwipeDetector(
-        onSwipeDown: (_) {
-          AutoRouter.of(context).pop();
-        },
-        onSwipeUp: (_) {
-          showInfo();
-        },
-        child: SafeArea(
-          child: Stack(
-            children: [
-              VideoThumbnailPlayer(
-                url: videoUrl,
-                jwtToken: jwtToken,
-              ),
-              if (downloadAssetStatus == DownloadAssetStatus.loading)
-                const Center(
-                  child: DownloadLoadingIndicator(),
-                ),
-            ],
-          ),
+    return Stack(
+      children: [
+        VideoThumbnailPlayer(
+          url: videoUrl,
+          jwtToken: jwtToken,
         ),
-      ),
+        if (downloadAssetStatus == DownloadAssetStatus.loading)
+          const Center(
+            child: DownloadLoadingIndicator(),
+          ),
+      ],
     );
   }
 }
@@ -134,10 +79,13 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
   _createChewieController() {
     chewieController = ChewieController(
       showOptions: true,
-      showControlsOnInitialize: false,
+      showControlsOnInitialize: true,
       videoPlayerController: videoPlayerController,
       autoPlay: true,
-      autoInitialize: false,
+      autoInitialize: true,
+      allowFullScreen: true,
+      showControls: true,
+      hideControlsTimer: const Duration(seconds: 5),
     );
   }
 
@@ -157,11 +105,13 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
               controller: chewieController!,
             ),
           )
-        : const SizedBox(
-            width: 75,
-            height: 75,
-            child: CircularProgressIndicator.adaptive(
-              strokeWidth: 2,
+        : const Center(
+            child: SizedBox(
+              width: 75,
+              height: 75,
+              child: CircularProgressIndicator.adaptive(
+                strokeWidth: 2,
+              ),
             ),
           );
   }

+ 4 - 0
mobile/lib/modules/backup/providers/backup.provider.dart

@@ -162,6 +162,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
         onlyAll: true,
         type: RequestType.common,
       );
+
+      if (list.isEmpty) {
+        return;
+      }
       AssetPathEntity albumHasAllAssets = list.first;
 
       backupAlbumInfoBox.put(

+ 13 - 3
mobile/lib/modules/home/ui/image_grid.dart

@@ -3,10 +3,18 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
 import 'package:openapi/api.dart';
 
+// ignore: must_be_immutable
 class ImageGrid extends ConsumerWidget {
   final List<AssetResponseDto> assetGroup;
+  final List<AssetResponseDto> sortedAssetGroup;
 
-  const ImageGrid({Key? key, required this.assetGroup}) : super(key: key);
+  ImageGrid({
+    Key? key,
+    required this.assetGroup,
+    required this.sortedAssetGroup,
+  }) : super(key: key);
+
+  List<AssetResponseDto> imageSortedList = [];
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -19,12 +27,14 @@ class ImageGrid extends ConsumerWidget {
       delegate: SliverChildBuilderDelegate(
         (BuildContext context, int index) {
           var assetType = assetGroup[index].type;
-
           return GestureDetector(
             onTap: () {},
             child: Stack(
               children: [
-                ThumbnailImage(asset: assetGroup[index]),
+                ThumbnailImage(
+                  asset: assetGroup[index],
+                  assetList: sortedAssetGroup,
+                ),
                 if (assetType != AssetTypeEnum.IMAGE)
                   Positioned(
                     top: 5,

+ 11 - 21
mobile/lib/modules/home/ui/thumbnail_image.dart

@@ -13,8 +13,10 @@ import 'package:openapi/api.dart';
 
 class ThumbnailImage extends HookConsumerWidget {
   final AssetResponseDto asset;
+  final List<AssetResponseDto> assetList;
 
-  const ThumbnailImage({Key? key, required this.asset}) : super(key: key);
+  const ThumbnailImage({Key? key, required this.asset, required this.assetList})
+      : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -60,29 +62,17 @@ class ThumbnailImage extends HookConsumerWidget {
               .watch(homePageStateProvider.notifier)
               .addSingleSelectedItem(asset);
         } else {
-          if (asset.type == AssetTypeEnum.IMAGE) {
-            AutoRouter.of(context).push(
-              ImageViewerRoute(
-                imageUrl:
-                    '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
-                heroTag: asset.id,
-                thumbnailUrl: thumbnailRequestUrl,
-                asset: asset,
-              ),
-            );
-          } else {
-            AutoRouter.of(context).push(
-              VideoViewerRoute(
-                videoUrl:
-                    '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
-                asset: asset,
-              ),
-            );
-          }
+          AutoRouter.of(context).push(
+            GalleryViewerRoute(
+              assetList: assetList,
+              thumbnailRequestUrl: thumbnailRequestUrl,
+              asset: asset,
+            ),
+          );
         }
       },
       onLongPress: () {
-        // Enable multi selecte function
+        // Enable multi select function
         ref.watch(homePageStateProvider.notifier).enableMultiSelect({asset});
         HapticFeedback.heavyImpact();
       },

+ 13 - 1
mobile/lib/modules/home/views/home_page.dart

@@ -10,9 +10,11 @@ import 'package:immich_mobile/modules/home/ui/image_grid.dart';
 import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
 import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
+
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
+import 'package:openapi/api.dart';
 
 class HomePage extends HookConsumerWidget {
   const HomePage({Key? key}) : super(key: key);
@@ -25,6 +27,13 @@ class HomePage extends HookConsumerWidget {
     var isMultiSelectEnable =
         ref.watch(homePageStateProvider).isMultiSelectEnable;
     var homePageState = ref.watch(homePageStateProvider);
+    List<AssetResponseDto> sortedAssetList = [];
+    // set sorted List
+    for (var group in assetGroupByDateTime.values) {
+      for (var value in group) {
+        sortedAssetList.add(value);
+      }
+    }
 
     useEffect(
       () {
@@ -73,7 +82,10 @@ class HomePage extends HookConsumerWidget {
           );
 
           imageGridGroup.add(
-            ImageGrid(assetGroup: immichAssetList),
+            ImageGrid(
+              assetGroup: immichAssetList,
+              sortedAssetGroup: sortedAssetList,
+            ),
           );
 
           lastMonth = currentMonth;

+ 22 - 8
mobile/lib/modules/search/views/search_result_page.dart

@@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
 import 'package:immich_mobile/modules/search/providers/search_result_page.provider.dart';
 import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
+import 'package:openapi/api.dart';
 
 class SearchResultPage extends HookConsumerWidget {
   const SearchResultPage({Key? key, required this.searchTerm})
@@ -27,7 +28,9 @@ class SearchResultPage extends HookConsumerWidget {
 
     final List<Widget> imageGridGroup = [];
 
-    late FocusNode searchFocusNode;
+    FocusNode? searchFocusNode;
+
+    List<AssetResponseDto> sortedAssetList = [];
 
     useEffect(
       () {
@@ -37,14 +40,14 @@ class SearchResultPage extends HookConsumerWidget {
           Duration.zero,
           () => ref.read(searchResultPageProvider.notifier).search(searchTerm),
         );
-        return () => searchFocusNode.dispose();
+        return () => searchFocusNode?.dispose();
       },
       [],
     );
 
     _onSearchSubmitted(String newSearchTerm) {
       debugPrint("Re-Search with $newSearchTerm");
-      searchFocusNode.unfocus();
+      searchFocusNode?.unfocus();
       isNewSearch.value = false;
       currentSearchTerm.value = newSearchTerm;
       ref.watch(searchResultPageProvider.notifier).search(newSearchTerm);
@@ -58,7 +61,7 @@ class SearchResultPage extends HookConsumerWidget {
         onTap: () {
           searchTermController.clear();
           ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
-          searchFocusNode.requestFocus();
+          searchFocusNode?.requestFocus();
         },
         textInputAction: TextInputAction.search,
         onSubmitted: (searchTerm) {
@@ -131,7 +134,12 @@ class SearchResultPage extends HookConsumerWidget {
       if (searchResultPageState.isSuccess) {
         if (searchResultPageState.searchResult.isNotEmpty) {
           int? lastMonth;
-
+          // set sorted List
+          for (var group in assetGroupByDateTime.values) {
+            for (var value in group) {
+              sortedAssetList.add(value);
+            }
+          }
           assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
             DateTime parseDateGroup = DateTime.parse(dateGroup);
             int currentMonth = parseDateGroup.month;
@@ -154,7 +162,10 @@ class SearchResultPage extends HookConsumerWidget {
             );
 
             imageGridGroup.add(
-              ImageGrid(assetGroup: immichAssetList),
+              ImageGrid(
+                assetGroup: immichAssetList,
+                sortedAssetGroup: sortedAssetList,
+              ),
             );
 
             lastMonth = currentMonth;
@@ -193,7 +204,7 @@ class SearchResultPage extends HookConsumerWidget {
         title: GestureDetector(
           onTap: () {
             isNewSearch.value = true;
-            searchFocusNode.requestFocus();
+            searchFocusNode?.requestFocus();
           },
           child: isNewSearch.value ? _buildTextField() : _buildChip(),
         ),
@@ -201,7 +212,10 @@ class SearchResultPage extends HookConsumerWidget {
       ),
       body: GestureDetector(
         onTap: () {
-          searchFocusNode.unfocus();
+          if (searchFocusNode != null) {
+            searchFocusNode?.unfocus();
+          }
+
           ref.watch(searchPageStateProvider.notifier).disableSearch();
         },
         child: Stack(

+ 3 - 0
mobile/lib/routing/router.dart

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/views/library_page.dart';
+import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
 import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
 import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
 import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
@@ -47,6 +48,7 @@ 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]),
@@ -78,6 +80,7 @@ part 'router.gr.dart';
   ],
 )
 class AppRouter extends _$AppRouter {
+  // ignore: unused_field
   final ApiService _apiService;
 
   AppRouter(this._apiService) : super(authGuard: AuthGuard(_apiService));

+ 75 - 5
mobile/lib/routing/router.gr.dart

@@ -41,6 +41,16 @@ class _$AppRouter extends RootStackRouter {
           opaque: true,
           barrierDismissible: false);
     },
+    GalleryViewerRoute.name: (routeData) {
+      final args = routeData.argsAs<GalleryViewerRouteArgs>();
+      return MaterialPageX<dynamic>(
+          routeData: routeData,
+          child: GalleryViewerPage(
+              key: args.key,
+              assetList: args.assetList,
+              asset: args.asset,
+              thumbnailRequestUrl: args.thumbnailRequestUrl));
+    },
     ImageViewerRoute.name: (routeData) {
       final args = routeData.argsAs<ImageViewerRouteArgs>();
       return MaterialPageX<dynamic>(
@@ -50,7 +60,10 @@ class _$AppRouter extends RootStackRouter {
               imageUrl: args.imageUrl,
               heroTag: args.heroTag,
               thumbnailUrl: args.thumbnailUrl,
-              asset: args.asset));
+              asset: args.asset,
+              authToken: args.authToken,
+              isZoomedFunction: args.isZoomedFunction,
+              isZoomedListener: args.isZoomedListener));
     },
     VideoViewerRoute.name: (routeData) {
       final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -174,6 +187,8 @@ class _$AppRouter extends RootStackRouter {
                   parent: TabControllerRoute.name,
                   guards: [authGuard])
             ]),
+        RouteConfig(GalleryViewerRoute.name,
+            path: '/gallery-viewer-page', guards: [authGuard]),
         RouteConfig(ImageViewerRoute.name,
             path: '/image-viewer-page', guards: [authGuard]),
         RouteConfig(VideoViewerRoute.name,
@@ -237,6 +252,46 @@ class TabControllerRoute extends PageRouteInfo<void> {
   static const String name = 'TabControllerRoute';
 }
 
+/// generated route for
+/// [GalleryViewerPage]
+class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
+  GalleryViewerRoute(
+      {Key? key,
+      required List<AssetResponseDto> assetList,
+      required AssetResponseDto asset,
+      required String thumbnailRequestUrl})
+      : super(GalleryViewerRoute.name,
+            path: '/gallery-viewer-page',
+            args: GalleryViewerRouteArgs(
+                key: key,
+                assetList: assetList,
+                asset: asset,
+                thumbnailRequestUrl: thumbnailRequestUrl));
+
+  static const String name = 'GalleryViewerRoute';
+}
+
+class GalleryViewerRouteArgs {
+  const GalleryViewerRouteArgs(
+      {this.key,
+      required this.assetList,
+      required this.asset,
+      required this.thumbnailRequestUrl});
+
+  final Key? key;
+
+  final List<AssetResponseDto> assetList;
+
+  final AssetResponseDto asset;
+
+  final String thumbnailRequestUrl;
+
+  @override
+  String toString() {
+    return 'GalleryViewerRouteArgs{key: $key, assetList: $assetList, asset: $asset, thumbnailRequestUrl: $thumbnailRequestUrl}';
+  }
+}
+
 /// generated route for
 /// [ImageViewerPage]
 class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
@@ -245,7 +300,10 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
       required String imageUrl,
       required String heroTag,
       required String thumbnailUrl,
-      required AssetResponseDto asset})
+      required AssetResponseDto asset,
+      required String authToken,
+      required void Function() isZoomedFunction,
+      required ValueNotifier<bool> isZoomedListener})
       : super(ImageViewerRoute.name,
             path: '/image-viewer-page',
             args: ImageViewerRouteArgs(
@@ -253,7 +311,10 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
                 imageUrl: imageUrl,
                 heroTag: heroTag,
                 thumbnailUrl: thumbnailUrl,
-                asset: asset));
+                asset: asset,
+                authToken: authToken,
+                isZoomedFunction: isZoomedFunction,
+                isZoomedListener: isZoomedListener));
 
   static const String name = 'ImageViewerRoute';
 }
@@ -264,7 +325,10 @@ class ImageViewerRouteArgs {
       required this.imageUrl,
       required this.heroTag,
       required this.thumbnailUrl,
-      required this.asset});
+      required this.asset,
+      required this.authToken,
+      required this.isZoomedFunction,
+      required this.isZoomedListener});
 
   final Key? key;
 
@@ -276,9 +340,15 @@ class ImageViewerRouteArgs {
 
   final AssetResponseDto asset;
 
+  final String authToken;
+
+  final void Function() isZoomedFunction;
+
+  final ValueNotifier<bool> isZoomedListener;
+
   @override
   String toString() {
-    return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset}';
+    return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener}';
   }
 }
 

+ 64 - 51
mobile/openapi/lib/model/asset_response_dto.dart

@@ -76,69 +76,72 @@ class AssetResponseDto {
   SmartInfoResponseDto? smartInfo;
 
   @override
-  bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
-     other.type == type &&
-     other.id == id &&
-     other.deviceAssetId == deviceAssetId &&
-     other.ownerId == ownerId &&
-     other.deviceId == deviceId &&
-     other.originalPath == originalPath &&
-     other.resizePath == resizePath &&
-     other.createdAt == createdAt &&
-     other.modifiedAt == modifiedAt &&
-     other.isFavorite == isFavorite &&
-     other.mimeType == mimeType &&
-     other.duration == duration &&
-     other.webpPath == webpPath &&
-     other.encodedVideoPath == encodedVideoPath &&
-     other.exifInfo == exifInfo &&
-     other.smartInfo == smartInfo;
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is AssetResponseDto &&
+          other.type == type &&
+          other.id == id &&
+          other.deviceAssetId == deviceAssetId &&
+          other.ownerId == ownerId &&
+          other.deviceId == deviceId &&
+          other.originalPath == originalPath &&
+          other.resizePath == resizePath &&
+          other.createdAt == createdAt &&
+          other.modifiedAt == modifiedAt &&
+          other.isFavorite == isFavorite &&
+          other.mimeType == mimeType &&
+          other.duration == duration &&
+          other.webpPath == webpPath &&
+          other.encodedVideoPath == encodedVideoPath &&
+          other.exifInfo == exifInfo &&
+          other.smartInfo == smartInfo;
 
   @override
   int get hashCode =>
-    // ignore: unnecessary_parenthesis
-    (type.hashCode) +
-    (id.hashCode) +
-    (deviceAssetId.hashCode) +
-    (ownerId.hashCode) +
-    (deviceId.hashCode) +
-    (originalPath.hashCode) +
-    (resizePath == null ? 0 : resizePath!.hashCode) +
-    (createdAt.hashCode) +
-    (modifiedAt.hashCode) +
-    (isFavorite.hashCode) +
-    (mimeType == null ? 0 : mimeType!.hashCode) +
-    (duration.hashCode) +
-    (webpPath == null ? 0 : webpPath!.hashCode) +
-    (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
-    (exifInfo == null ? 0 : exifInfo!.hashCode) +
-    (smartInfo == null ? 0 : smartInfo!.hashCode);
+      // ignore: unnecessary_parenthesis
+      (type.hashCode) +
+      (id.hashCode) +
+      (deviceAssetId.hashCode) +
+      (ownerId.hashCode) +
+      (deviceId.hashCode) +
+      (originalPath.hashCode) +
+      (resizePath == null ? 0 : resizePath!.hashCode) +
+      (createdAt.hashCode) +
+      (modifiedAt.hashCode) +
+      (isFavorite.hashCode) +
+      (mimeType == null ? 0 : mimeType!.hashCode) +
+      (duration.hashCode) +
+      (webpPath == null ? 0 : webpPath!.hashCode) +
+      (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
+      (exifInfo == null ? 0 : exifInfo!.hashCode) +
+      (smartInfo == null ? 0 : smartInfo!.hashCode);
 
   @override
-  String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
+  String toString() =>
+      'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
-      _json[r'type'] = type;
-      _json[r'id'] = id;
-      _json[r'deviceAssetId'] = deviceAssetId;
-      _json[r'ownerId'] = ownerId;
-      _json[r'deviceId'] = deviceId;
-      _json[r'originalPath'] = originalPath;
+    _json[r'type'] = type;
+    _json[r'id'] = id;
+    _json[r'deviceAssetId'] = deviceAssetId;
+    _json[r'ownerId'] = ownerId;
+    _json[r'deviceId'] = deviceId;
+    _json[r'originalPath'] = originalPath;
     if (resizePath != null) {
       _json[r'resizePath'] = resizePath;
     } else {
       _json[r'resizePath'] = null;
     }
-      _json[r'createdAt'] = createdAt;
-      _json[r'modifiedAt'] = modifiedAt;
-      _json[r'isFavorite'] = isFavorite;
+    _json[r'createdAt'] = createdAt;
+    _json[r'modifiedAt'] = modifiedAt;
+    _json[r'isFavorite'] = isFavorite;
     if (mimeType != null) {
       _json[r'mimeType'] = mimeType;
     } else {
       _json[r'mimeType'] = null;
     }
-      _json[r'duration'] = duration;
+    _json[r'duration'] = duration;
     if (webpPath != null) {
       _json[r'webpPath'] = webpPath;
     } else {
@@ -174,8 +177,10 @@ class AssetResponseDto {
       // Note 2: this code is stripped in release mode!
       assert(() {
         requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
+          assert(json.containsKey(key),
+              'Required key "AssetResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null,
+              'Required key "AssetResponseDto[$key]" has a null value in JSON.');
         });
         return true;
       }());
@@ -202,7 +207,10 @@ class AssetResponseDto {
     return null;
   }
 
-  static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+  static List<AssetResponseDto>? listFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final result = <AssetResponseDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
@@ -230,12 +238,18 @@ class AssetResponseDto {
   }
 
   // maps a json object with a list of AssetResponseDto-objects as value to a dart map
-  static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+  static Map<String, List<AssetResponseDto>> mapListFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final map = <String, List<AssetResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
-        final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
+        final value = AssetResponseDto.listFromJson(
+          entry.value,
+          growable: growable,
+        );
         if (value != null) {
           map[entry.key] = value;
         }
@@ -262,4 +276,3 @@ class AssetResponseDto {
     'encodedVideoPath',
   };
 }
-