diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 9835dc13da..a5d9db8618 100644 --- a/mobile/assets/i18n/en-US.json +++ b/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..." -} \ No newline at end of file + "description_input_hint_text": "Add description...", + "archive_page_title": "Archive ({})" +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 19030935ef..97a886a925 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -192,15 +192,18 @@ class ImmichAppState extends ConsumerState 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 // needs to be delayed so that EasyLocalization is working ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); }); - - - } @override diff --git a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart index 263a9d46de..8834b9827f 100644 --- a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart +++ b/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) diff --git a/mobile/lib/modules/album/views/library_page.dart b/mobile/lib/modules/album/views/library_page.dart index 5dde7d5548..447ccc5bff 100644 --- a/mobile/lib/modules/album/views/library_page.dart +++ b/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 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()); }), ], ), diff --git a/mobile/lib/modules/archive/models/store_model_here.txt b/mobile/lib/modules/archive/models/store_model_here.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mobile/lib/modules/archive/providers/archive_asset_provider.dart b/mobile/lib/modules/archive/providers/archive_asset_provider.dart new file mode 100644 index 0000000000..95d2da98dc --- /dev/null +++ b/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> { + 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 toggleArchive(Asset asset) async { + if (!asset.isRemote) return; + + _setArchiveForAssetId(asset.id, !_isArchive(asset.id)); + + await assetNotifier.toggleArchive( + [asset], + state.contains(asset.id), + ); + } + + Future addToArchives(Iterable assets) { + state = state.union(assets.map((a) => a.id).toSet()); + return assetNotifier.toggleArchive(assets, true); + } +} + +final archiveProvider = + StateNotifierProvider>((ref) { + return ArchiveSelectionNotifier( + ref.watch(dbProvider), + ref.watch(assetProvider.notifier), + ); +}); diff --git a/mobile/lib/modules/archive/providers/store_providers_here.txt b/mobile/lib/modules/archive/providers/store_providers_here.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mobile/lib/modules/archive/services/store_services_here.txt b/mobile/lib/modules/archive/services/store_services_here.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mobile/lib/modules/archive/ui/store_ui_here.txt b/mobile/lib/modules/archive/ui/store_ui_here.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mobile/lib/modules/archive/views/archive_page.dart b/mobile/lib/modules/archive/views/archive_page.dart new file mode 100644 index 0000000000..921b55816e --- /dev/null +++ b/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>([]); + final selectionEnabledHook = useState(false); + final selection = useState({}); + + 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 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() + ], + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 725ad63c3b..6bfdff7487 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/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(); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 41e704d134..b9b3f8e0ed 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/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(), + ), ], ), ), diff --git a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart b/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart index 90253d09d5..b787ab783c 100644 --- a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart +++ b/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; diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index 5b28ad3ba1..021418df25 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/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; }, diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 6341081b27..1204b8b53c 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/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(), + ), ], ); } diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 2bc8c04aa4..f6c4ea2d4e 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/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(), diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index d4f2b8a799..34b8d9132e 100644 --- a/mobile/lib/routing/router.dart +++ b/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 { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index d3ca49dd4f..b574068ceb 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -240,6 +240,12 @@ class _$AppRouter extends RootStackRouter { ), ); }, + ArchiveRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, + child: const ArchivePage(), + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( 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 { + const ArchiveRoute() + : super( + ArchiveRoute.name, + path: '/archive-page', + ); + + static const String name = 'ArchiveRoute'; +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 12fe6a0f52..ed88380951 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/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 }"""; } } diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index a0f919f88c..a67c01b126 100644 Binary files a/mobile/lib/shared/models/asset.g.dart and b/mobile/lib/shared/models/asset.g.dart differ diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index b20578ca4c..cbe3a91b61 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/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 allAssets; final RenderList? renderList; @@ -76,6 +79,7 @@ class AssetNotifier extends StateNotifier { GroupAssetsBy .values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)], ); + state = await AssetsState.fromAssetList(newAssetList) .withRenderDataStructure(layout); } @@ -112,6 +116,7 @@ class AssetNotifier extends StateNotifier { } 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 { Future> _getUserAssets(int userId) => _db.assets .filter() .ownerIdEqualTo(userId) + .isArchivedEqualTo(false) .sortByFileCreatedAtDesc() .findAll(); @@ -224,13 +230,46 @@ class AssetNotifier extends StateNotifier { } 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 toggleArchive(Iterable 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((ref) { diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index ffebaf0536..4d53f7be9d 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/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 changeFavoriteStatus(Asset asset, bool isFavorite) { return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite)); } + + Future changeArchiveStatus(Asset asset, bool isArchive) { + return updateAsset(asset, UpdateAssetDto(isArchived: isArchive)); + } } diff --git a/mobile/makefile b/mobile/makefile index 8a9eeb592b..f4ce1450d5 100644 --- a/mobile/makefile +++ b/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 diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index 196a6cd1e6..875abe1fdd 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -24,6 +24,7 @@ void main() { fileName: '', isFavorite: false, isLocal: false, + isArchived: false, ), ); } diff --git a/mobile/test/favorite_provider_test.dart b/mobile/test/favorite_provider_test.dart index 99db1f25fc..505187a7d7 100644 --- a/mobile/test/favorite_provider_test.dart +++ b/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; diff --git a/mobile/test/sync_service_test.dart b/mobile/test/sync_service_test.dart index 8fec43f9c4..edcc0851b5 100644 --- a/mobile/test/sync_service_test.dart +++ b/mobile/test/sync_service_test.dart @@ -32,6 +32,7 @@ void main() { fileName: localId, isFavorite: false, isLocal: isLocal, + isArchived: false, ); } diff --git a/server/openapi-generator/templates/mobile/serialization/native/native_class.mustache b/server/openapi-generator/templates/mobile/serialization/native/native_class.mustache index f7926db2c4..4cde0cd9b5 100644 --- a/server/openapi-generator/templates/mobile/serialization/native/native_class.mustache +++ b/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}}