Sfoglia il codice sorgente

feat(mobile): Archive feature on mobile (#2258)

* update asset to include isArchive property

* Not display archived assets on timeline

* replace share button to archive button

* Added archive page

* Add bottom nav bar

* clean up homepage

* remove deadcode

* improve on sync is archive

* show archive asset correctly

* better merge condition

* Added back renderList to re-rendering don't jump around

* Better way to handle showing archive assets

* complete ArchiveSelectionNotifier

* toggle archive

* remove deadcode

* fix unit tests

* update assets in DB when changing assets

* update asset state to reflect archived status

* allow to archive assets via multi-select from timeline

* fixed logic

* Add options to bulk unarchive

* regenerate api

* Change position of toast message

---------

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
Alex 2 anni fa
parent
commit
2e5cd986dd
27 ha cambiato i file con 522 aggiunte e 113 eliminazioni
  1. 6 2
      mobile/assets/i18n/en-US.json
  2. 8 8
      mobile/lib/main.dart
  3. 12 4
      mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart
  4. 7 6
      mobile/lib/modules/album/views/library_page.dart
  5. 0 0
      mobile/lib/modules/archive/models/store_model_here.txt
  6. 55 0
      mobile/lib/modules/archive/providers/archive_asset_provider.dart
  7. 0 0
      mobile/lib/modules/archive/providers/store_providers_here.txt
  8. 0 0
      mobile/lib/modules/archive/services/store_services_here.txt
  9. 0 0
      mobile/lib/modules/archive/ui/store_ui_here.txt
  10. 124 0
      mobile/lib/modules/archive/views/archive_page.dart
  11. 9 31
      mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
  12. 73 9
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  13. 1 1
      mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart
  14. 3 4
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  15. 7 0
      mobile/lib/modules/home/ui/control_bottom_app_bar.dart
  16. 21 5
      mobile/lib/modules/home/views/home_page.dart
  17. 8 0
      mobile/lib/routing/router.dart
  18. 26 0
      mobile/lib/routing/router.gr.dart
  19. 15 4
      mobile/lib/shared/models/asset.dart
  20. 88 33
      mobile/lib/shared/models/asset.g.dart
  21. 40 1
      mobile/lib/shared/providers/asset.provider.dart
  22. 12 1
      mobile/lib/shared/services/asset.service.dart
  23. 1 1
      mobile/makefile
  24. 1 0
      mobile/test/asset_grid_data_structure_test.dart
  25. 1 0
      mobile/test/favorite_provider_test.dart
  26. 1 0
      mobile/test/sync_service_test.dart
  27. 3 3
      server/openapi-generator/templates/mobile/serialization/native/native_class.mustache

+ 6 - 2
mobile/assets/i18n/en-US.json

@@ -111,6 +111,7 @@
   "control_bottom_app_bar_create_new_album": "Create new album",
   "control_bottom_app_bar_delete": "Delete",
   "control_bottom_app_bar_favorite": "Favorite",
+  "control_bottom_app_bar_archive": "Archive",
   "control_bottom_app_bar_share": "Share",
   "create_album_page_untitled": "Untitled",
   "create_shared_album_page_create": "Create",
@@ -139,6 +140,7 @@
   "home_page_add_to_album_success": "Added {added} assets to album {album}.",
   "home_page_building_timeline": "Building the timeline",
   "home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
+  "home_page_archive_err_local": "Can not archive local assets yet, skipping",
   "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
   "image_viewer_page_state_provider_download_error": "Download Error",
   "image_viewer_page_state_provider_download_success": "Download Success",
@@ -147,6 +149,7 @@
   "library_page_favorites": "Favorites",
   "library_page_new_album": "New album",
   "library_page_sharing": "Sharing",
+  "library_page_archive": "Archive",
   "library_page_sort_created": "Most recently created",
   "library_page_sort_title": "Album title",
   "login_form_api_exception": "API exception. Please check the server URL and try again.",
@@ -268,5 +271,6 @@
   "advanced_settings_troubleshooting_title": "Troubleshooting",
   "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
   "description_input_submit_error": "Error updating description, check the log for more details",
-  "description_input_hint_text": "Add description..."
-}
+  "description_input_hint_text": "Add description...",
+  "archive_page_title": "Archive ({})"
+}

+ 8 - 8
mobile/lib/main.dart

@@ -192,15 +192,18 @@ class ImmichAppState extends ConsumerState<ImmichApp>
     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
 
     // Sets the navigation bar color
-    SystemUiOverlayStyle overlayStyle = const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent);
+    SystemUiOverlayStyle overlayStyle = const SystemUiOverlayStyle(
+      systemNavigationBarColor: Colors.transparent,
+    );
     if (Platform.isAndroid) {
       // Android 8 does not support transparent app bars
       final info = await DeviceInfoPlugin().androidInfo;
       if (info.version.sdkInt <= 26) {
-        overlayStyle = MediaQuery.of(context).platformBrightness == Brightness.light
-            ? SystemUiOverlayStyle.light
-            : SystemUiOverlayStyle.dark;
-      } 
+        overlayStyle =
+            MediaQuery.of(context).platformBrightness == Brightness.light
+                ? SystemUiOverlayStyle.light
+                : SystemUiOverlayStyle.dark;
+      }
     }
     SystemChrome.setSystemUIOverlayStyle(overlayStyle);
   }
@@ -213,9 +216,6 @@ class ImmichAppState extends ConsumerState<ImmichApp>
       // needs to be delayed so that EasyLocalization is working
       ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
     });
-
-
-
   }
 
   @override

+ 12 - 4
mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart

@@ -51,14 +51,14 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
           ImmichToast.show(
             context: context,
             msg: 'add_to_album_bottom_sheet_already_exists'.tr(
-              namedArgs: { "album": album.name },
+              namedArgs: {"album": album.name},
             ),
           );
         } else {
           ImmichToast.show(
             context: context,
             msg: 'add_to_album_bottom_sheet_added'.tr(
-              namedArgs: { "album": album.name },
+              namedArgs: {"album": album.name},
             ),
           );
         }
@@ -71,6 +71,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
     }
 
     return Card(
+      elevation: 0,
       shape: const RoundedRectangleBorder(
         borderRadius: BorderRadius.only(
           topLeft: Radius.circular(15),
@@ -99,8 +100,15 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
                         style: Theme.of(context).textTheme.displayMedium,
                       ),
                       TextButton.icon(
-                        icon: const Icon(Icons.add),
-                        label: Text('common_create_new_album'.tr()),
+                        icon: Icon(
+                          Icons.add,
+                          color: Theme.of(context).primaryColor,
+                        ),
+                        label: Text(
+                          'common_create_new_album'.tr(),
+                          style:
+                              TextStyle(color: Theme.of(context).primaryColor),
+                        ),
                         onPressed: () {
                           ref
                               .watch(assetSelectionProvider.notifier)

+ 7 - 6
mobile/lib/modules/album/views/library_page.dart

@@ -43,7 +43,8 @@ class LibraryPage extends HookConsumerWidget {
       );
     }
 
-    final selectedAlbumSortOrder = useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
+    final selectedAlbumSortOrder =
+        useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
 
     List<Album> sortedAlbums() {
       if (selectedAlbumSortOrder.value == 0) {
@@ -179,13 +180,13 @@ class LibraryPage extends HookConsumerWidget {
               label,
               style: TextStyle(
                 fontWeight: FontWeight.bold,
-                fontSize: 12.0,
-                color: isDarkMode ? Colors.white : Colors.black,
+                fontSize: 13.0,
+                color: isDarkMode ? Colors.white : Colors.grey[800],
               ),
             ),
           ),
           style: OutlinedButton.styleFrom(
-            padding: const EdgeInsets.all(12),
+            padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
             backgroundColor: isDarkMode ? Colors.grey[900] : Colors.grey[50],
             side: BorderSide(
               color: isDarkMode ? Colors.grey[800]! : Colors.grey[300]!,
@@ -225,8 +226,8 @@ class LibraryPage extends HookConsumerWidget {
                   }),
                   const SizedBox(width: 12.0),
                   buildLibraryNavButton(
-                      "library_page_sharing".tr(), Icons.group_outlined, () {
-                    AutoRouter.of(context).navigate(const SharingRoute());
+                      "library_page_archive".tr(), Icons.archive_outlined, () {
+                    AutoRouter.of(context).navigate(const ArchiveRoute());
                   }),
                 ],
               ),

+ 0 - 0
mobile/lib/modules/archive/models/store_model_here.txt


+ 55 - 0
mobile/lib/modules/archive/providers/archive_asset_provider.dart

@@ -0,0 +1,55 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:isar/isar.dart';
+
+class ArchiveSelectionNotifier extends StateNotifier<Set<int>> {
+  ArchiveSelectionNotifier(this.db, this.assetNotifier) : super({}) {
+    state = db.assets
+        .filter()
+        .isArchivedEqualTo(true)
+        .findAllSync()
+        .map((e) => e.id)
+        .toSet();
+  }
+
+  final Isar db;
+  final AssetNotifier assetNotifier;
+
+  void _setArchiveForAssetId(int id, bool archive) {
+    if (!archive) {
+      state = state.difference({id});
+    } else {
+      state = state.union({id});
+    }
+  }
+
+  bool _isArchive(int id) {
+    return state.contains(id);
+  }
+
+  Future<void> toggleArchive(Asset asset) async {
+    if (!asset.isRemote) return;
+
+    _setArchiveForAssetId(asset.id, !_isArchive(asset.id));
+
+    await assetNotifier.toggleArchive(
+      [asset],
+      state.contains(asset.id),
+    );
+  }
+
+  Future<void> addToArchives(Iterable<Asset> assets) {
+    state = state.union(assets.map((a) => a.id).toSet());
+    return assetNotifier.toggleArchive(assets, true);
+  }
+}
+
+final archiveProvider =
+    StateNotifierProvider<ArchiveSelectionNotifier, Set<int>>((ref) {
+  return ArchiveSelectionNotifier(
+    ref.watch(dbProvider),
+    ref.watch(assetProvider.notifier),
+  );
+});

+ 0 - 0
mobile/lib/modules/archive/providers/store_providers_here.txt


+ 0 - 0
mobile/lib/modules/archive/services/store_services_here.txt


+ 0 - 0
mobile/lib/modules/archive/ui/store_ui_here.txt


+ 124 - 0
mobile/lib/modules/archive/views/archive_page.dart

@@ -0,0 +1,124 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
+import 'package:fluttertoast/fluttertoast.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/models/user.dart';
+import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:isar/isar.dart';
+
+class ArchivePage extends HookConsumerWidget {
+  const ArchivePage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final User me = Store.get(StoreKey.currentUser);
+    final query = ref
+        .watch(dbProvider)
+        .assets
+        .filter()
+        .ownerIdEqualTo(me.isarId)
+        .isArchivedEqualTo(true);
+    final stream = query.watch();
+    final archivedAssets = useState<List<Asset>>([]);
+    final selectionEnabledHook = useState(false);
+    final selection = useState(<Asset>{});
+
+    useEffect(
+      () {
+        query.findAll().then((value) => archivedAssets.value = value);
+        final subscription = stream.listen((e) {
+          archivedAssets.value = e;
+        });
+        // Cancel the subscription when the widget is disposed
+        return subscription.cancel;
+      },
+      [],
+    );
+
+    void selectionListener(
+      bool multiselect,
+      Set<Asset> selectedAssets,
+    ) {
+      selectionEnabledHook.value = multiselect;
+      selection.value = selectedAssets;
+    }
+
+    AppBar buildAppBar() {
+      return AppBar(
+        leading: IconButton(
+          onPressed: () => AutoRouter.of(context).pop(),
+          icon: const Icon(Icons.arrow_back_ios_rounded),
+        ),
+        centerTitle: true,
+        automaticallyImplyLeading: false,
+        title: const Text(
+          'archive_page_title',
+        ).tr(args: [archivedAssets.value.length.toString()]),
+      );
+    }
+
+    Widget buildBottomBar() {
+      return Align(
+        alignment: Alignment.bottomCenter,
+        child: SizedBox(
+          height: 64,
+          child: Card(
+            child: Column(
+              children: [
+                ListTile(
+                  shape: RoundedRectangleBorder(
+                    borderRadius: BorderRadius.circular(10),
+                  ),
+                  leading: const Icon(
+                    Icons.unarchive_rounded,
+                  ),
+                  title:
+                      const Text("Unarchive", style: TextStyle(fontSize: 14)),
+                  onTap: () {
+                    if (selection.value.isNotEmpty) {
+                      ref
+                          .watch(assetProvider.notifier)
+                          .toggleArchive(selection.value, false);
+
+                      final assetOrAssets =
+                          selection.value.length > 1 ? 'assets' : 'asset';
+                      ImmichToast.show(
+                        context: context,
+                        msg:
+                            'Moved ${selection.value.length} $assetOrAssets to library',
+                        gravity: ToastGravity.CENTER,
+                      );
+                    }
+
+                    selectionEnabledHook.value = false;
+                  },
+                )
+              ],
+            ),
+          ),
+        ),
+      );
+    }
+
+    return Scaffold(
+      appBar: buildAppBar(),
+      body: Stack(
+        children: [
+          ImmichAssetGrid(
+            assets: archivedAssets.value,
+            listener: selectionListener,
+            selectionActive: selectionEnabledHook.value,
+          ),
+          if (selectionEnabledHook.value) buildBottomBar()
+        ],
+      ),
+    );
+  }
+}

+ 9 - 31
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -9,8 +9,6 @@ class TopControlAppBar extends HookConsumerWidget {
     required this.asset,
     required this.onMoreInfoPressed,
     required this.onDownloadPressed,
-    required this.onSharePressed,
-    required this.onDeletePressed,
     required this.onAddToAlbumPressed,
     required this.onToggleMotionVideo,
     required this.isPlayingMotionVideo,
@@ -22,10 +20,8 @@ class TopControlAppBar extends HookConsumerWidget {
   final Function onMoreInfoPressed;
   final VoidCallback? onDownloadPressed;
   final VoidCallback onToggleMotionVideo;
-  final VoidCallback onDeletePressed;
   final VoidCallback onAddToAlbumPressed;
   final VoidCallback onFavorite;
-  final Function onSharePressed;
   final bool isPlayingMotionVideo;
   final bool isFavorite;
 
@@ -34,15 +30,15 @@ class TopControlAppBar extends HookConsumerWidget {
     const double iconSize = 18.0;
 
     Widget buildFavoriteButton() {
-        return IconButton(
-          onPressed: () {
-            onFavorite();
-          },
-          icon: Icon(
-            isFavorite ? Icons.star : Icons.star_border,
-            color: Colors.grey[200],
-          ),
-        );
+      return IconButton(
+        onPressed: () {
+          onFavorite();
+        },
+        icon: Icon(
+          isFavorite ? Icons.star : Icons.star_border,
+          color: Colors.grey[200],
+        ),
+      );
     }
 
     return AppBar(
@@ -86,15 +82,6 @@ class TopControlAppBar extends HookConsumerWidget {
               color: Colors.grey[200],
             ),
           ),
-        IconButton(
-          onPressed: () {
-            onSharePressed();
-          },
-          icon: Icon(
-            Icons.ios_share_rounded,
-            color: Colors.grey[200],
-          ),
-        ),
         if (asset.isRemote)
           IconButton(
             onPressed: () {
@@ -105,15 +92,6 @@ class TopControlAppBar extends HookConsumerWidget {
               color: Colors.grey[200],
             ),
           ),
-        IconButton(
-          onPressed: () {
-            onDeletePressed();
-          },
-          icon: Icon(
-            Icons.delete_outline_rounded,
-            color: Colors.grey[200],
-          ),
-        ),
         IconButton(
           onPressed: () {
             onMoreInfoPressed();

+ 73 - 9
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -231,11 +231,10 @@ class GalleryViewerPage extends HookConsumerWidget {
 
     void addToAlbum(Asset addToAlbumAsset) {
       showModalBottomSheet(
+        elevation: 0,
         shape: RoundedRectangleBorder(
           borderRadius: BorderRadius.circular(15.0),
         ),
-        barrierColor: Colors.transparent,
-        backgroundColor: Colors.transparent,
         context: context,
         builder: (BuildContext _) {
           return AddToAlbumBottomSheet(
@@ -267,6 +266,19 @@ class GalleryViewerPage extends HookConsumerWidget {
       }
     }
 
+    shareAsset() {
+      ref
+          .watch(imageViewerStateProvider.notifier)
+          .shareAsset(assetList[indexOfAsset.value], context);
+    }
+
+    handleArchive(Asset asset) {
+      ref
+          .watch(assetProvider.notifier)
+          .toggleArchive([asset], !asset.isArchived);
+      AutoRouter.of(context).pop();
+    }
+
     buildAppBar() {
       final show = (showAppBar.value || // onTap has the final say
               (showAppBar.value && !isZoomed.value)) &&
@@ -297,16 +309,9 @@ class GalleryViewerPage extends HookConsumerWidget {
                           context,
                         );
                   },
-            onSharePressed: () {
-              ref
-                  .watch(imageViewerStateProvider.notifier)
-                  .shareAsset(assetList[indexOfAsset.value], context);
-            },
             onToggleMotionVideo: (() {
               isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
             }),
-            onDeletePressed: () =>
-                handleDelete((assetList[indexOfAsset.value])),
             onAddToAlbumPressed: () =>
                 addToAlbum(assetList[indexOfAsset.value]),
           ),
@@ -314,6 +319,59 @@ class GalleryViewerPage extends HookConsumerWidget {
       );
     }
 
+    buildBottomBar() {
+      final show = (showAppBar.value || // onTap has the final say
+              (showAppBar.value && !isZoomed.value)) &&
+          !isPlayingVideo.value;
+      final currentAsset = assetList[indexOfAsset.value];
+
+      return AnimatedOpacity(
+        duration: const Duration(milliseconds: 100),
+        opacity: show ? 1.0 : 0.0,
+        child: BottomNavigationBar(
+          backgroundColor: Colors.black.withOpacity(0.4),
+          unselectedIconTheme: const IconThemeData(color: Colors.white),
+          selectedIconTheme: const IconThemeData(color: Colors.white),
+          unselectedLabelStyle: const TextStyle(color: Colors.black),
+          selectedLabelStyle: const TextStyle(color: Colors.black),
+          showSelectedLabels: false,
+          showUnselectedLabels: false,
+          items: [
+            const BottomNavigationBarItem(
+              icon: Icon(Icons.ios_share_rounded),
+              label: 'Share',
+              tooltip: 'Share',
+            ),
+            BottomNavigationBarItem(
+              icon: currentAsset.isArchived
+                  ? const Icon(Icons.unarchive_rounded)
+                  : const Icon(Icons.archive_outlined),
+              label: 'Archive',
+              tooltip: 'Archive',
+            ),
+            const BottomNavigationBarItem(
+              icon: Icon(Icons.delete_outline),
+              label: 'Delete',
+              tooltip: 'Delete',
+            ),
+          ],
+          onTap: (index) {
+            switch (index) {
+              case 0:
+                shareAsset();
+                break;
+              case 1:
+                handleArchive(assetList[indexOfAsset.value]);
+                break;
+              case 2:
+                handleDelete(assetList[indexOfAsset.value]);
+                break;
+            }
+          },
+        ),
+      );
+    }
+
     return Scaffold(
       backgroundColor: Colors.black,
       body: WillPopScope(
@@ -481,6 +539,12 @@ class GalleryViewerPage extends HookConsumerWidget {
               right: 0,
               child: buildAppBar(),
             ),
+            Positioned(
+              bottom: 0,
+              left: 0,
+              right: 0,
+              child: buildBottomBar(),
+            ),
           ],
         ),
       ),

+ 1 - 1
mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart

@@ -109,7 +109,7 @@ class RenderList {
 
     final groups = _groupAssets(allAssets, groupBy);
 
-    groups.entries.sortedBy((e) =>e.key).reversed.forEach((entry) {
+    groups.entries.sortedBy((e) => e.key).reversed.forEach((entry) {
       final date = entry.key;
       final assets = entry.value;
 

+ 3 - 4
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -50,10 +50,9 @@ class ImmichAssetGrid extends HookConsumerWidget {
           // Unfortunately, using the transition animation itself didn't
           // seem to work reliably. So instead, wait until the duration of the
           // animation has elapsed to re-enable the hero animations
-          Future.delayed(transitionDuration)
-              .then((_) {
-                enableHeroAnimations.value = true;
-              });
+          Future.delayed(transitionDuration).then((_) {
+            enableHeroAnimations.value = true;
+          });
         }
         return null;
       },

+ 7 - 0
mobile/lib/modules/home/ui/control_bottom_app_bar.dart

@@ -9,6 +9,7 @@ import 'package:immich_mobile/shared/models/album.dart';
 class ControlBottomAppBar extends ConsumerWidget {
   final Function onShare;
   final Function onFavorite;
+  final Function onArchive;
   final Function onDelete;
   final Function(Album album) onAddToAlbum;
   final void Function() onCreateNewAlbum;
@@ -20,6 +21,7 @@ class ControlBottomAppBar extends ConsumerWidget {
     Key? key,
     required this.onShare,
     required this.onFavorite,
+    required this.onArchive,
     required this.onDelete,
     required this.sharedAlbums,
     required this.albums,
@@ -62,6 +64,11 @@ class ControlBottomAppBar extends ConsumerWidget {
               );
             },
           ),
+          ControlBoxButton(
+            iconData: Icons.archive,
+            label: "control_bottom_app_bar_archive".tr(),
+            onPressed: () => onArchive(),
+          ),
         ],
       );
     }

+ 21 - 5
mobile/lib/modules/home/views/home_page.dart

@@ -94,7 +94,6 @@ class HomePage extends HookConsumerWidget {
           barrierDismissible: false,
         );
 
-        // ref.watch(shareServiceProvider).shareAssets(selection.value.toList());
         selectionEnabledHook.value = false;
       }
 
@@ -132,6 +131,24 @@ class HomePage extends HookConsumerWidget {
         selectionEnabledHook.value = false;
       }
 
+      void onArchiveAsset() {
+        final remoteAssets = remoteOnlySelection(
+          localErrorMessage: 'home_page_archive_err_local'.tr(),
+        );
+        if (remoteAssets.isNotEmpty) {
+          ref.watch(assetProvider.notifier).toggleArchive(remoteAssets, true);
+
+          final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
+          ImmichToast.show(
+            context: context,
+            msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive',
+            gravity: ToastGravity.CENTER,
+          );
+        }
+
+        selectionEnabledHook.value = false;
+      }
+
       void onDelete() {
         ref.watch(assetProvider.notifier).deleteAssets(selection.value);
         selectionEnabledHook.value = false;
@@ -265,7 +282,7 @@ class HomePage extends HookConsumerWidget {
                 ? buildLoadingIndicator()
                 : ImmichAssetGrid(
                     renderList: ref.watch(assetProvider).renderList!,
-                    assets: ref.watch(assetProvider).allAssets,
+                    assets: ref.read(assetProvider).allAssets,
                     assetsPerRow: appSettingService
                         .getSetting(AppSettingsEnum.tilesPerRow),
                     showStorageIndicator: appSettingService
@@ -278,6 +295,7 @@ class HomePage extends HookConsumerWidget {
               ControlBottomAppBar(
                 onShare: onShareAssets,
                 onFavorite: onFavoriteAssets,
+                onArchive: onArchiveAsset,
                 onDelete: onDelete,
                 onAddToAlbum: onAddToAlbum,
                 albums: albums,
@@ -291,9 +309,7 @@ class HomePage extends HookConsumerWidget {
 
     return Scaffold(
       appBar: !selectionEnabledHook.value
-          ? HomePageAppBar(
-              onPopBack: reloadAllAsset,
-            )
+          ? HomePageAppBar(onPopBack: reloadAllAsset)
           : null,
       drawer: const ProfileDrawer(),
       body: buildBody(),

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

@@ -9,6 +9,7 @@ import 'package:immich_mobile/modules/album/views/library_page.dart';
 import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
 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/archive/views/archive_page.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
@@ -128,6 +129,13 @@ part 'router.gr.dart';
     AutoRoute(
       page: AppLogDetailPage,
     ),
+    AutoRoute(
+      page: ArchivePage,
+      guards: [
+        AuthGuard,
+        DuplicateGuard,
+      ],
+    ),
   ],
 )
 class AppRouter extends _$AppRouter {

+ 26 - 0
mobile/lib/routing/router.gr.dart

@@ -240,6 +240,12 @@ class _$AppRouter extends RootStackRouter {
         ),
       );
     },
+    ArchiveRoute.name: (routeData) {
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: const ArchivePage(),
+      );
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
         routeData: routeData,
@@ -499,6 +505,14 @@ class _$AppRouter extends RootStackRouter {
           AppLogDetailRoute.name,
           path: '/app-log-detail-page',
         ),
+        RouteConfig(
+          ArchiveRoute.name,
+          path: '/archive-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
       ];
 }
 
@@ -1022,6 +1036,18 @@ class AppLogDetailRouteArgs {
   }
 }
 
+/// generated route for
+/// [ArchivePage]
+class ArchiveRoute extends PageRouteInfo<void> {
+  const ArchiveRoute()
+      : super(
+          ArchiveRoute.name,
+          path: '/archive-page',
+        );
+
+  static const String name = 'ArchiveRoute';
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {

+ 15 - 4
mobile/lib/shared/models/asset.dart

@@ -29,7 +29,8 @@ class Asset {
         ownerId = fastHash(remote.ownerId),
         exifInfo =
             remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
-        isFavorite = remote.isFavorite;
+        isFavorite = remote.isFavorite,
+        isArchived = remote.isArchived;
 
   Asset.local(AssetEntity local)
       : localId = local.id,
@@ -44,6 +45,7 @@ class Asset {
         fileModifiedAt = local.modifiedDateTime,
         updatedAt = local.modifiedDateTime,
         isFavorite = local.isFavorite,
+        isArchived = false,
         fileCreatedAt = local.createDateTime {
     if (fileCreatedAt.year == 1970) {
       fileCreatedAt = fileModifiedAt;
@@ -70,6 +72,7 @@ class Asset {
     this.exifInfo,
     required this.isFavorite,
     required this.isLocal,
+    required this.isArchived,
   });
 
   @ignore
@@ -132,6 +135,8 @@ class Asset {
 
   bool isLocal;
 
+  bool isArchived;
+
   @ignore
   ExifInfo? exifInfo;
 
@@ -168,7 +173,8 @@ class Asset {
         fileName == other.fileName &&
         livePhotoVideoId == other.livePhotoVideoId &&
         isFavorite == other.isFavorite &&
-        isLocal == other.isLocal;
+        isLocal == other.isLocal &&
+        isArchived == other.isArchived;
   }
 
   @override
@@ -189,7 +195,8 @@ class Asset {
       fileName.hashCode ^
       livePhotoVideoId.hashCode ^
       isFavorite.hashCode ^
-      isLocal.hashCode;
+      isLocal.hashCode ^
+      isArchived.hashCode;
 
   bool updateFromAssetEntity(AssetEntity ae) {
     // TODO check more fields;
@@ -217,6 +224,9 @@ class Asset {
     height ??= a.height;
     exifInfo ??= a.exifInfo;
     exifInfo?.id = id;
+    if (!isRemote) {
+      isArchived = a.isArchived;
+    }
     return this;
   }
 
@@ -271,7 +281,8 @@ class Asset {
   "isFavorite": $isFavorite, 
   "isLocal": $isLocal,
   "width": ${width ?? "N/A"},
-  "height": ${height ?? "N/A"}
+  "height": ${height ?? "N/A"},
+  "isArchived": $isArchived
 }""";
   }
 }

+ 88 - 33
mobile/lib/shared/models/asset.g.dart

@@ -47,49 +47,54 @@ const AssetSchema = CollectionSchema(
       name: r'height',
       type: IsarType.int,
     ),
-    r'isFavorite': PropertySchema(
+    r'isArchived': PropertySchema(
       id: 6,
+      name: r'isArchived',
+      type: IsarType.bool,
+    ),
+    r'isFavorite': PropertySchema(
+      id: 7,
       name: r'isFavorite',
       type: IsarType.bool,
     ),
     r'isLocal': PropertySchema(
-      id: 7,
+      id: 8,
       name: r'isLocal',
       type: IsarType.bool,
     ),
     r'livePhotoVideoId': PropertySchema(
-      id: 8,
+      id: 9,
       name: r'livePhotoVideoId',
       type: IsarType.string,
     ),
     r'localId': PropertySchema(
-      id: 9,
+      id: 10,
       name: r'localId',
       type: IsarType.string,
     ),
     r'ownerId': PropertySchema(
-      id: 10,
+      id: 11,
       name: r'ownerId',
       type: IsarType.long,
     ),
     r'remoteId': PropertySchema(
-      id: 11,
+      id: 12,
       name: r'remoteId',
       type: IsarType.string,
     ),
     r'type': PropertySchema(
-      id: 12,
+      id: 13,
       name: r'type',
       type: IsarType.byte,
       enumMap: _AssettypeEnumValueMap,
     ),
     r'updatedAt': PropertySchema(
-      id: 13,
+      id: 14,
       name: r'updatedAt',
       type: IsarType.dateTime,
     ),
     r'width': PropertySchema(
-      id: 14,
+      id: 15,
       name: r'width',
       type: IsarType.int,
     )
@@ -175,15 +180,16 @@ void _assetSerialize(
   writer.writeDateTime(offsets[3], object.fileModifiedAt);
   writer.writeString(offsets[4], object.fileName);
   writer.writeInt(offsets[5], object.height);
-  writer.writeBool(offsets[6], object.isFavorite);
-  writer.writeBool(offsets[7], object.isLocal);
-  writer.writeString(offsets[8], object.livePhotoVideoId);
-  writer.writeString(offsets[9], object.localId);
-  writer.writeLong(offsets[10], object.ownerId);
-  writer.writeString(offsets[11], object.remoteId);
-  writer.writeByte(offsets[12], object.type.index);
-  writer.writeDateTime(offsets[13], object.updatedAt);
-  writer.writeInt(offsets[14], object.width);
+  writer.writeBool(offsets[6], object.isArchived);
+  writer.writeBool(offsets[7], object.isFavorite);
+  writer.writeBool(offsets[8], object.isLocal);
+  writer.writeString(offsets[9], object.livePhotoVideoId);
+  writer.writeString(offsets[10], object.localId);
+  writer.writeLong(offsets[11], object.ownerId);
+  writer.writeString(offsets[12], object.remoteId);
+  writer.writeByte(offsets[13], object.type.index);
+  writer.writeDateTime(offsets[14], object.updatedAt);
+  writer.writeInt(offsets[15], object.width);
 }
 
 Asset _assetDeserialize(
@@ -199,16 +205,17 @@ Asset _assetDeserialize(
     fileModifiedAt: reader.readDateTime(offsets[3]),
     fileName: reader.readString(offsets[4]),
     height: reader.readIntOrNull(offsets[5]),
-    isFavorite: reader.readBool(offsets[6]),
-    isLocal: reader.readBool(offsets[7]),
-    livePhotoVideoId: reader.readStringOrNull(offsets[8]),
-    localId: reader.readString(offsets[9]),
-    ownerId: reader.readLong(offsets[10]),
-    remoteId: reader.readStringOrNull(offsets[11]),
-    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
+    isArchived: reader.readBool(offsets[6]),
+    isFavorite: reader.readBool(offsets[7]),
+    isLocal: reader.readBool(offsets[8]),
+    livePhotoVideoId: reader.readStringOrNull(offsets[9]),
+    localId: reader.readString(offsets[10]),
+    ownerId: reader.readLong(offsets[11]),
+    remoteId: reader.readStringOrNull(offsets[12]),
+    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
         AssetType.other,
-    updatedAt: reader.readDateTime(offsets[13]),
-    width: reader.readIntOrNull(offsets[14]),
+    updatedAt: reader.readDateTime(offsets[14]),
+    width: reader.readIntOrNull(offsets[15]),
   );
   object.id = id;
   return object;
@@ -238,19 +245,21 @@ P _assetDeserializeProp<P>(
     case 7:
       return (reader.readBool(offset)) as P;
     case 8:
-      return (reader.readStringOrNull(offset)) as P;
+      return (reader.readBool(offset)) as P;
     case 9:
-      return (reader.readString(offset)) as P;
+      return (reader.readStringOrNull(offset)) as P;
     case 10:
-      return (reader.readLong(offset)) as P;
+      return (reader.readString(offset)) as P;
     case 11:
-      return (reader.readStringOrNull(offset)) as P;
+      return (reader.readLong(offset)) as P;
     case 12:
+      return (reader.readStringOrNull(offset)) as P;
+    case 13:
       return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
           AssetType.other) as P;
-    case 13:
-      return (reader.readDateTime(offset)) as P;
     case 14:
+      return (reader.readDateTime(offset)) as P;
+    case 15:
       return (reader.readIntOrNull(offset)) as P;
     default:
       throw IsarError('Unknown property with id $propertyId');
@@ -1024,6 +1033,16 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> isArchivedEqualTo(
+      bool value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'isArchived',
+        value: value,
+      ));
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterFilterCondition> isFavoriteEqualTo(
       bool value) {
     return QueryBuilder.apply(this, (query) {
@@ -1771,6 +1790,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsArchived() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isArchived', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsArchivedDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isArchived', Sort.desc);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsFavorite() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'isFavorite', Sort.asc);
@@ -1965,6 +1996,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsArchived() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isArchived', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsArchivedDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isArchived', Sort.desc);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsFavorite() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'isFavorite', Sort.asc);
@@ -2112,6 +2155,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QDistinct> distinctByIsArchived() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'isArchived');
+    });
+  }
+
   QueryBuilder<Asset, Asset, QDistinct> distinctByIsFavorite() {
     return QueryBuilder.apply(this, (query) {
       return query.addDistinctBy(r'isFavorite');
@@ -2214,6 +2263,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
     });
   }
 
+  QueryBuilder<Asset, bool, QQueryOperations> isArchivedProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'isArchived');
+    });
+  }
+
   QueryBuilder<Asset, bool, QQueryOperations> isFavoriteProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'isFavorite');

+ 40 - 1
mobile/lib/shared/providers/asset.provider.dart

@@ -1,3 +1,4 @@
+import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/services/album.service.dart';
 import 'package:immich_mobile/shared/models/exif_info.dart';
@@ -19,6 +20,8 @@ import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 
+/// State does not contain archived assets.
+/// Use database provider if you want to access the isArchived assets
 class AssetsState {
   final List<Asset> allAssets;
   final RenderList? renderList;
@@ -76,6 +79,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
       GroupAssetsBy
           .values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
     );
+
     state = await AssetsState.fromAssetList(newAssetList)
         .withRenderDataStructure(layout);
   }
@@ -112,6 +116,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
       }
       final bool newRemote = await _assetService.refreshRemoteAssets();
       final bool newLocal = await _albumService.refreshDeviceAlbums();
+      debugPrint("newRemote: $newRemote, newLocal: $newLocal");
       log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
       stopwatch.reset();
       if (!newRemote &&
@@ -139,6 +144,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
   Future<List<Asset>> _getUserAssets(int userId) => _db.assets
       .filter()
       .ownerIdEqualTo(userId)
+      .isArchivedEqualTo(false)
       .sortByFileCreatedAtDesc()
       .findAll();
 
@@ -224,13 +230,46 @@ class AssetNotifier extends StateNotifier<AssetsState> {
     }
 
     final index = state.allAssets.indexWhere((a) => asset.id == a.id);
-    if (index > 0) {
+    if (index != -1) {
       state.allAssets[index] = newAsset;
       _updateAssetsState(state.allAssets);
     }
 
     return newAsset.isFavorite;
   }
+
+  Future<void> toggleArchive(Iterable<Asset> assets, bool status) async {
+    final newAssets = await Future.wait(
+      assets.map((a) => _assetService.changeArchiveStatus(a, status)),
+    );
+    int i = 0;
+    bool unArchived = false;
+    for (Asset oldAsset in assets) {
+      final newAsset = newAssets[i++];
+      if (newAsset == null) {
+        log.severe("Change archive status failed for asset ${oldAsset.id}");
+        continue;
+      }
+      final index = state.allAssets.indexWhere((a) => oldAsset.id == a.id);
+      if (newAsset.isArchived) {
+        // remove from state
+        if (index != -1) {
+          state.allAssets.removeAt(index);
+        }
+      } else {
+        // add to state is difficult because the list is sorted
+        unArchived = true;
+      }
+    }
+    if (unArchived) {
+      final User me = Store.get(StoreKey.currentUser);
+      await _stateUpdateLock.run(
+        () async => _updateAssetsState(await _getUserAssets(me.isarId)),
+      );
+    } else {
+      _updateAssetsState(state.allAssets);
+    }
+  }
 }
 
 final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {

+ 12 - 1
mobile/lib/shared/services/asset.service.dart

@@ -121,10 +121,21 @@ class AssetService {
   ) async {
     final dto =
         await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto);
-    return dto == null ? null : Asset.remote(dto);
+    if (dto != null) {
+      final updated = Asset.remote(dto).updateFromDb(asset);
+      if (updated.isInDb) {
+        await _db.writeTxn(() => updated.put(_db));
+      }
+      return updated;
+    }
+    return null;
   }
 
   Future<Asset?> changeFavoriteStatus(Asset asset, bool isFavorite) {
     return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite));
   }
+
+  Future<Asset?> changeArchiveStatus(Asset asset, bool isArchive) {
+    return updateAsset(asset, UpdateAssetDto(isArchived: isArchive));
+  }
 }

+ 1 - 1
mobile/makefile

@@ -1,5 +1,5 @@
 build:
-	flutter packages pub run build_runner build
+	flutter packages pub run build_runner build --delete-conflicting-outputs
 
 watch:
 	flutter packages pub run build_runner watch --delete-conflicting-outputs

+ 1 - 0
mobile/test/asset_grid_data_structure_test.dart

@@ -24,6 +24,7 @@ void main() {
         fileName: '',
         isFavorite: false,
         isLocal: false,
+        isArchived: false,
       ),
     );
   }

+ 1 - 0
mobile/test/favorite_provider_test.dart

@@ -26,6 +26,7 @@ Asset _getTestAsset(int id, bool favorite) {
     type: AssetType.image,
     fileName: '',
     isFavorite: favorite,
+    isArchived: false,
   );
   a.id = id;
   return a;

+ 1 - 0
mobile/test/sync_service_test.dart

@@ -32,6 +32,7 @@ void main() {
       fileName: localId,
       isFavorite: false,
       isLocal: isLocal,
+      isArchived: false,
     );
   }
 

+ 3 - 3
server/openapi-generator/templates/mobile/serialization/native/native_class.mustache

@@ -141,7 +141,7 @@ class {{{classname}}} {
         {{{name}}}: json[r'{{{baseName}}}'] is List
           ? (json[r'{{{baseName}}}'] as List).map((e) =>
               {{#items.complexType}}
-              {{items.complexType}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}
+              {{items.complexType}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{#uniqueItems}}.toSet(){{/uniqueItems}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}
               {{/items.complexType}}
               {{^items.complexType}}
               e == null ? {{#items.isNullable}}null{{/items.isNullable}}{{^items.isNullable}}const  <{{items.items.dataType}}>[]{{/items.isNullable}} : (e as List).cast<{{items.items.dataType}}>()
@@ -150,7 +150,7 @@ class {{{classname}}} {
           :  {{#isNullable}}null{{/isNullable}}{{^isNullable}}const []{{/isNullable}},
             {{/items.isArray}}
             {{^items.isArray}}
-        {{{name}}}: {{{complexType}}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
+        {{{name}}}: {{{complexType}}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{#uniqueItems}}.toSet(){{/uniqueItems}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
             {{/items.isArray}}
           {{/isArray}}
           {{^isArray}}
@@ -197,7 +197,7 @@ class {{{classname}}} {
         {{^complexType}}
           {{#isArray}}
             {{#isEnum}}
-        {{{name}}}: {{{items.datatypeWithEnum}}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
+        {{{name}}}: {{{items.datatypeWithEnum}}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{#uniqueItems}}.toSet(){{/uniqueItems}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
             {{/isEnum}}
             {{^isEnum}}
         {{{name}}}: json[r'{{{baseName}}}'] is {{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}