From c25556bb08a64eeda520be3c925e8dc86a3c5996 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Thu, 7 Dec 2023 16:38:22 +0100 Subject: [PATCH] feat(mobile): unify asset grid multiselect actions (#5407) * feat(mobile): unify asset grid multiselect actions * add favorite & archive page * show edit date&place on main photos screen * Reposition exit button * Sort favorite with the same order as other view --------- Co-authored-by: Alex Tran --- mobile/assets/i18n/en-US.json | 3 +- .../album/providers/album.provider.dart | 23 + .../providers/album_detail.provider.dart | 21 - .../providers/current_album.provider.dart | 6 + .../providers/shared_album.provider.dart | 16 +- .../modules/album/services/album.service.dart | 28 +- .../album/ui/add_to_album_bottom_sheet.dart | 3 - .../modules/album/ui/album_viewer_appbar.dart | 145 +----- .../album/views/album_viewer_page.dart | 138 +++--- .../modules/archive/views/archive_page.dart | 94 +--- .../asset_viewer/ui/top_control_app_bar.dart | 14 +- .../asset_viewer/views/gallery_viewer.dart | 28 +- .../favorite/providers/favorite_provider.dart | 2 +- .../favorite/views/favorites_page.dart | 85 +--- .../disable_multi_select_button.dart | 16 +- .../home/ui/asset_grid/immich_asset_grid.dart | 6 - .../ui/asset_grid/immich_asset_grid_view.dart | 6 - .../home/ui/asset_grid/thumbnail_image.dart | 6 - .../home/ui/control_bottom_app_bar.dart | 111 +++-- mobile/lib/modules/home/views/home_page.dart | 467 +++--------------- .../partner/views/partner_detail_page.dart | 54 +- mobile/lib/routing/router.gr.dart | 14 +- mobile/lib/shared/models/album.dart | 19 +- .../lib/shared/providers/asset.provider.dart | 6 +- .../ui/asset_grid/multiselect_grid.dart | 419 ++++++++++++++++ mobile/lib/utils/selection_handlers.dart | 6 +- 26 files changed, 768 insertions(+), 968 deletions(-) delete mode 100644 mobile/lib/modules/album/providers/album_detail.provider.dart create mode 100644 mobile/lib/modules/album/providers/current_album.provider.dart create mode 100644 mobile/lib/shared/ui/asset_grid/multiselect_grid.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 93436ab4e0..099cfd5752 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -31,7 +31,6 @@ "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", - "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", "asset_list_layout_settings_group_automatically": "Automatic", @@ -139,6 +138,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_unfavorite": "Unfavorite", "control_bottom_app_bar_share": "Share", "control_bottom_app_bar_share_to": "Share To", "control_bottom_app_bar_stack": "Stack", @@ -172,7 +172,6 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", - "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", diff --git a/mobile/lib/modules/album/providers/album.provider.dart b/mobile/lib/modules/album/providers/album.provider.dart index 48a2e8f1f7..5618ea91d4 100644 --- a/mobile/lib/modules/album/providers/album.provider.dart +++ b/mobile/lib/modules/album/providers/album.provider.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/utils/renderlist_generator.dart'; import 'package:isar/isar.dart'; class AlbumNotifier extends StateNotifier> { @@ -49,3 +51,24 @@ final albumProvider = ref.watch(dbProvider), ); }); + +final albumWatcher = + StreamProvider.autoDispose.family((ref, albumId) async* { + final db = ref.watch(dbProvider); + final a = await db.albums.get(albumId); + if (a != null) yield a; + await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) { + if (a != null) yield a; + } +}); + +final albumRenderlistProvider = + StreamProvider.autoDispose.family((ref, albumId) { + final album = ref.watch(albumWatcher(albumId)).value; + if (album != null) { + final query = + album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc(); + return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none); + } + return const Stream.empty(); +}); diff --git a/mobile/lib/modules/album/providers/album_detail.provider.dart b/mobile/lib/modules/album/providers/album_detail.provider.dart deleted file mode 100644 index 531c5c944a..0000000000 --- a/mobile/lib/modules/album/providers/album_detail.provider.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/album/services/album.service.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/shared/models/album.dart'; -import 'package:immich_mobile/shared/providers/user.provider.dart'; - -final albumDetailProvider = - StreamProvider.family((ref, albumId) async* { - final user = ref.watch(currentUserProvider); - if (user == null) return; - final AlbumService service = ref.watch(albumServiceProvider); - - await for (final a in service.watchAlbum(albumId)) { - if (a == null) { - throw Exception("Album with ID=$albumId does not exist anymore!"); - } - await for (final _ in a.watchRenderList(GroupAssetsBy.none)) { - yield a; - } - } -}); diff --git a/mobile/lib/modules/album/providers/current_album.provider.dart b/mobile/lib/modules/album/providers/current_album.provider.dart new file mode 100644 index 0000000000..9c72b5e3d2 --- /dev/null +++ b/mobile/lib/modules/album/providers/current_album.provider.dart @@ -0,0 +1,6 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/album.dart'; + +final currentAlbumProvider = StateProvider((ref) { + return null; +}); diff --git a/mobile/lib/modules/album/providers/shared_album.provider.dart b/mobile/lib/modules/album/providers/shared_album.provider.dart index f8084da00d..b864e855fb 100644 --- a/mobile/lib/modules/album/providers/shared_album.provider.dart +++ b/mobile/lib/modules/album/providers/shared_album.provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; @@ -11,7 +10,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:isar/isar.dart'; class SharedAlbumNotifier extends StateNotifier> { - SharedAlbumNotifier(this._albumService, Isar db, this._ref) : super([]) { + SharedAlbumNotifier(this._albumService, Isar db) : super([]) { final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc(); query.findAll().then((value) => state = value); _streamSub = query.watch().listen((data) => state = data); @@ -19,7 +18,6 @@ class SharedAlbumNotifier extends StateNotifier> { final AlbumService _albumService; late final StreamSubscription> _streamSub; - final Ref _ref; Future createSharedAlbum( String albumName, @@ -68,15 +66,8 @@ class SharedAlbumNotifier extends StateNotifier> { return result; } - Future setActivityEnabled(Album album, bool activityEnabled) async { - final result = - await _albumService.setActivityEnabled(album, activityEnabled); - - if (result) { - _ref.invalidate(albumDetailProvider(album.id)); - } - - return result; + Future setActivityEnabled(Album album, bool activityEnabled) { + return _albumService.setActivityEnabled(album, activityEnabled); } @override @@ -91,6 +82,5 @@ final sharedAlbumProvider = return SharedAlbumNotifier( ref.watch(albumServiceProvider), ref.watch(dbProvider), - ref, ); }); diff --git a/mobile/lib/modules/album/services/album.service.dart b/mobile/lib/modules/album/services/album.service.dart index 1f50a3667e..f66a30f314 100644 --- a/mobile/lib/modules/album/services/album.service.dart +++ b/mobile/lib/modules/album/services/album.service.dart @@ -219,11 +219,6 @@ class AlbumService { ); } - Stream watchAlbum(int albumId) async* { - yield await _db.albums.get(albumId); - yield* _db.albums.watchObject(albumId); - } - Future addAdditionalAssetToAlbum( Iterable assets, Album album, @@ -248,8 +243,12 @@ class AlbumService { } } - album.assets.addAll(successAssets); - await _db.writeTxn(() => album.assets.save()); + await _db.writeTxn(() async { + await album.assets.update(link: successAssets); + final a = await _db.albums.get(album.id); + // trigger watcher + await _db.albums.put(a!); + }); return AddAssetsResponse( alreadyInAlbum: duplicatedAssets, @@ -359,8 +358,12 @@ class AlbumService { ids: assets.map((asset) => asset.remoteId!).toList(), ), ); - album.assets.removeAll(assets); - await _db.writeTxn(() => album.assets.update(unlink: assets)); + await _db.writeTxn(() async { + await album.assets.update(unlink: assets); + final a = await _db.albums.get(album.id); + // trigger watcher + await _db.albums.put(a!); + }); return true; } catch (e) { @@ -380,7 +383,12 @@ class AlbumService { ); album.sharedUsers.remove(user); - await _db.writeTxn(() => album.sharedUsers.update(unlink: [user])); + await _db.writeTxn(() async { + await album.sharedUsers.update(unlink: [user]); + final a = await _db.albums.get(album.id); + // trigger watcher + await _db.albums.put(a!); + }); return true; } catch (e) { 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 25747177a5..2f1e6b1aae 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 @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; -import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; @@ -63,8 +62,6 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { ); } } - - ref.invalidate(albumDetailProvider(album.id)); context.pop(); } diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index 0e2fc74fb3..4025e4a210 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -5,14 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; -import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; -import 'package:immich_mobile/shared/ui/share_dialog.dart'; -import 'package:immich_mobile/shared/services/share.service.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; @@ -22,8 +18,6 @@ class AlbumViewerAppbar extends HookConsumerWidget Key? key, required this.album, required this.userId, - required this.selected, - required this.selectionDisabled, required this.titleFocusNode, this.onAddPhotos, this.onAddUsers, @@ -32,8 +26,6 @@ class AlbumViewerAppbar extends HookConsumerWidget final Album album; final String userId; - final Set selected; - final void Function() selectionDisabled; final FocusNode titleFocusNode; final Function(Album album)? onAddPhotos; final Function(Album album)? onAddUsers; @@ -144,109 +136,27 @@ class AlbumViewerAppbar extends HookConsumerWidget isProcessing.value = false; } - void onRemoveFromAlbumPressed() async { - isProcessing.value = true; - - bool isSuccess = - await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum( - album, - selected, - ); - - if (isSuccess) { - context.pop(); - selectionDisabled(); - ref.watch(albumProvider.notifier).getAllAlbums(); - ref.invalidate(albumDetailProvider(album.id)); - } else { - context.pop(); - ImmichToast.show( - context: context, - msg: "album_viewer_appbar_share_err_remove".tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - - isProcessing.value = false; - } - - void handleShareAssets( - WidgetRef ref, - BuildContext context, - Set selection, - ) { - showDialog( - context: context, - builder: (BuildContext buildContext) { - ref.watch(shareServiceProvider).shareAssets(selection.toList()).then( - (bool status) { - if (!status) { - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_share_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - buildContext.pop(); - }, - ); - return const ShareDialog(); - }, - barrierDismissible: false, - ); - } - - void onShareAssetsTo() async { - isProcessing.value = true; - handleShareAssets(ref, context, selected); - isProcessing.value = false; - } - buildBottomSheetActions() { - if (selected.isNotEmpty) { - return [ - ListTile( - leading: const Icon(Icons.ios_share_rounded), - title: const Text( - 'album_viewer_appbar_share_to', - style: TextStyle(fontWeight: FontWeight.w500), - ).tr(), - onTap: () => onShareAssetsTo(), - ), - album.ownerId == userId - ? ListTile( - leading: const Icon(Icons.delete_sweep_rounded), - title: const Text( - 'album_viewer_appbar_share_remove', - style: TextStyle(fontWeight: FontWeight.w500), - ).tr(), - onTap: () => onRemoveFromAlbumPressed(), - ) - : const SizedBox(), - ]; - } else { - return [ - album.ownerId == userId - ? ListTile( - leading: const Icon(Icons.delete_forever_rounded), - title: const Text( - 'album_viewer_appbar_share_delete', - style: TextStyle(fontWeight: FontWeight.w500), - ).tr(), - onTap: () => onDeleteAlbumPressed(), - ) - : ListTile( - leading: const Icon(Icons.person_remove_rounded), - title: const Text( - 'album_viewer_appbar_share_leave', - style: TextStyle(fontWeight: FontWeight.w500), - ).tr(), - onTap: () => onLeaveAlbumPressed(), - ), - ]; - } + return [ + album.ownerId == userId + ? ListTile( + leading: const Icon(Icons.delete_forever_rounded), + title: const Text( + 'album_viewer_appbar_share_delete', + style: TextStyle(fontWeight: FontWeight.w500), + ).tr(), + onTap: () => onDeleteAlbumPressed(), + ) + : ListTile( + leading: const Icon(Icons.person_remove_rounded), + title: const Text( + 'album_viewer_appbar_share_leave', + style: TextStyle(fontWeight: FontWeight.w500), + ).tr(), + onTap: () => onLeaveAlbumPressed(), + ), + ]; + // } } void buildBottomSheet() { @@ -308,10 +218,8 @@ class AlbumViewerAppbar extends HookConsumerWidget mainAxisSize: MainAxisSize.min, children: [ ...buildBottomSheetActions(), - if (selected.isEmpty && onAddPhotos != null) ...commonActions, - if (selected.isEmpty && - onAddPhotos != null && - userId == album.ownerId) + if (onAddPhotos != null) ...commonActions, + if (onAddPhotos != null && userId == album.ownerId) ...ownerActions, ], ), @@ -349,13 +257,7 @@ class AlbumViewerAppbar extends HookConsumerWidget } buildLeadingButton() { - if (selected.isNotEmpty) { - return IconButton( - onPressed: selectionDisabled, - icon: const Icon(Icons.close_rounded), - splashRadius: 25, - ); - } else if (isEditAlbum) { + if (isEditAlbum) { return IconButton( onPressed: () async { bool isSuccess = await ref @@ -388,7 +290,6 @@ class AlbumViewerAppbar extends HookConsumerWidget return AppBar( elevation: 0, leading: buildLeadingButton(), - title: selected.isNotEmpty ? Text('${selected.length}') : null, centerTitle: false, actions: [ if (album.shared && (album.activityEnabled || comments != 0)) diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index 6d07c3b66a..e09649bcc3 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -1,23 +1,26 @@ -import 'dart:async'; - import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; -import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; +import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; @@ -29,39 +32,30 @@ class AlbumViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { FocusNode titleFocusNode = useFocusNode(); - final album = ref.watch(albumDetailProvider(albumId)); + final album = ref.watch(albumWatcher(albumId)); + album.whenData( + (value) => + Future((() => ref.read(currentAlbumProvider.notifier).state = value)), + ); final userId = ref.watch(authenticationProvider).userId; - final selection = useState>({}); - final multiSelectEnabled = useState(false); final isProcessing = useProcessingOverlay(); - useEffect( - () { - // Fetch album updates, e.g., cover image - ref.invalidate(albumDetailProvider(albumId)); - return null; - }, - [], - ); + Future onRemoveFromAlbumPressed(Iterable assets) async { + final a = album.valueOrNull; + final bool isSuccess = a != null && + await ref + .read(sharedAlbumProvider.notifier) + .removeAssetFromAlbum(a, assets); - Future onWillPop() async { - if (multiSelectEnabled.value) { - selection.value = {}; - multiSelectEnabled.value = false; - return false; + if (!isSuccess) { + ImmichToast.show( + context: context, + msg: "album_viewer_appbar_share_err_remove".tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); } - - return true; - } - - void selectionListener(bool active, Set selected) { - selection.value = selected; - multiSelectEnabled.value = selected.isNotEmpty; - } - - void disableSelection() { - selection.value = {}; - multiSelectEnabled.value = false; + return isSuccess; } /// Find out if the assets in album exist on the device @@ -80,15 +74,10 @@ class AlbumViewerPage extends HookConsumerWidget { // Check if there is new assets add isProcessing.value = true; - var addAssetsResult = - await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum( - returnPayload.selectedAssets, - albumInfo, - ); - - if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) { - ref.invalidate(albumDetailProvider(albumId)); - } + await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum( + returnPayload.selectedAssets, + albumInfo, + ); isProcessing.value = false; } @@ -102,14 +91,10 @@ class AlbumViewerPage extends HookConsumerWidget { if (sharedUserIds != null) { isProcessing.value = true; - var isSuccess = await ref + await ref .watch(albumServiceProvider) .addAdditionalUserToAlbum(sharedUserIds, album); - if (isSuccess) { - ref.invalidate(albumDetailProvider(album.id)); - } - isProcessing.value = false; } } @@ -193,10 +178,7 @@ class AlbumViewerPage extends HookConsumerWidget { Widget buildSharedUserIconsRow(Album album) { return GestureDetector( - onTap: () async { - await context.autoPush(AlbumOptionsRoute(album: album)); - ref.invalidate(albumDetailProvider(album.id)); - }, + onTap: () => context.autoPush(AlbumOptionsRoute(album: album)), child: SizedBox( height: 50, child: ListView.builder( @@ -244,42 +226,32 @@ class AlbumViewerPage extends HookConsumerWidget { } return Scaffold( - appBar: album.when( - data: (data) => AlbumViewerAppbar( - titleFocusNode: titleFocusNode, - album: data, - userId: userId, - selected: selection.value, - selectionDisabled: disableSelection, - onAddPhotos: onAddPhotosPressed, - onAddUsers: onAddUsersPressed, - onActivities: onActivitiesPressed, - ), - error: (error, stackTrace) => AppBar(title: const Text("Error")), - loading: () => AppBar(), - ), - body: album.widgetWhen( - onData: (data) => WillPopScope( - onWillPop: onWillPop, - child: GestureDetector( - onTap: () => titleFocusNode.unfocus(), - child: ImmichAssetGrid( - renderList: data.renderList, - listener: selectionListener, - selectionActive: multiSelectEnabled.value, - showMultiSelectIndicator: false, - topWidget: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - buildHeader(data), - if (data.isRemote) buildControlButton(data), - ], + appBar: ref.watch(multiselectProvider) + ? null + : album.when( + data: (data) => AlbumViewerAppbar( + titleFocusNode: titleFocusNode, + album: data, + userId: userId, + onAddPhotos: onAddPhotosPressed, + onAddUsers: onAddUsersPressed, + onActivities: onActivitiesPressed, ), - isOwner: userId == data.ownerId, - sharedAlbumId: - data.shared && data.activityEnabled ? data.remoteId : null, + error: (error, stackTrace) => AppBar(title: const Text("Error")), + loading: () => AppBar(), ), + body: album.widgetWhen( + onData: (data) => MultiselectGrid( + renderListProvider: albumRenderlistProvider(albumId), + topWidget: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildHeader(data), + if (data.isRemote) buildControlButton(data), + ], ), + onRemoveFromAlbum: onRemoveFromAlbumPressed, + editEnabled: data.ownerId == userId, ), ), ); diff --git a/mobile/lib/modules/archive/views/archive_page.dart b/mobile/lib/modules/archive/views/archive_page.dart index fb3cecc10f..481edcfe12 100644 --- a/mobile/lib/modules/archive/views/archive_page.dart +++ b/mobile/lib/modules/archive/views/archive_page.dart @@ -1,34 +1,19 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.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/ui/immich_loading_indicator.dart'; -import 'package:immich_mobile/utils/selection_handlers.dart'; +import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; +import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart'; class ArchivePage extends HookConsumerWidget { const ArchivePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final archivedAssets = ref.watch(archiveProvider); - final selectionEnabledHook = useState(false); - final selection = useState({}); - final processing = useState(false); - - void selectionListener( - bool multiselect, - Set selectedAssets, - ) { - selectionEnabledHook.value = multiselect; - selection.value = selectedAssets; - } - - AppBar buildAppBar(String count) { + AppBar buildAppBar() { + final archivedAssets = ref.watch(archiveProvider); + final count = archivedAssets.value?.totalAssets.toString() ?? "?"; return AppBar( leading: IconButton( onPressed: () => context.autoPop(), @@ -42,69 +27,14 @@ class ArchivePage extends HookConsumerWidget { ); } - Widget buildBottomBar() { - return SafeArea( - child: Align( - alignment: Alignment.bottomCenter, - child: SizedBox( - height: 64, - child: Card( - child: ListTile( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - leading: const Icon( - Icons.unarchive_rounded, - ), - title: Text( - 'control_bottom_app_bar_unarchive'.tr(), - style: const TextStyle(fontSize: 14), - ), - onTap: processing.value - ? null - : () async { - processing.value = true; - try { - await handleArchiveAssets( - ref, - context, - selection.value.toList(), - shouldArchive: false, - ); - } finally { - processing.value = false; - selectionEnabledHook.value = false; - } - }, - ), - ), - ), - ), - ); - } - return Scaffold( - appBar: archivedAssets.maybeWhen( - data: (data) => buildAppBar(data.totalAssets.toString()), - orElse: () => buildAppBar("?"), - ), - body: archivedAssets.widgetWhen( - onData: (data) => data.isEmpty - ? Center( - child: Text('archive_page_no_archived_assets'.tr()), - ) - : Stack( - children: [ - ImmichAssetGrid( - renderList: data, - listener: selectionListener, - selectionActive: selectionEnabledHook.value, - ), - if (selectionEnabledHook.value) buildBottomBar(), - if (processing.value) - const Center(child: ImmichLoadingIndicator()), - ], - ), + appBar: ref.watch(multiselectProvider) ? null : buildAppBar(), + body: MultiselectGrid( + renderListProvider: archiveProvider, + unarchive: true, + archiveEnabled: true, + deleteEnabled: true, + editEnabled: true, ), ); } 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 3e6dfe2ee8..52c15e03db 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 @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; +import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; @@ -17,8 +18,8 @@ class TopControlAppBar extends HookConsumerWidget { required this.onFavorite, required this.onUploadPressed, required this.isOwner, - required this.shareAlbumId, required this.onActivitiesPressed, + required this.isPartner, }) : super(key: key); final Asset asset; @@ -31,16 +32,17 @@ class TopControlAppBar extends HookConsumerWidget { final Function(Asset) onFavorite; final bool isPlayingMotionVideo; final bool isOwner; - final String? shareAlbumId; + final bool isPartner; @override Widget build(BuildContext context, WidgetRef ref) { const double iconSize = 22.0; final a = ref.watch(assetWatcher(asset)).value ?? asset; - final comments = shareAlbumId != null + final album = ref.watch(currentAlbumProvider); + final comments = album != null && album.remoteId != null ? ref.watch( activityStatisticsStateProvider( - (albumId: shareAlbumId!, assetId: asset.remoteId), + (albumId: album.remoteId!, assetId: asset.remoteId), ), ) : 0; @@ -169,8 +171,8 @@ class TopControlAppBar extends HookConsumerWidget { if (asset.livePhotoVideoId != null) buildLivePhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), - if (asset.isRemote && isOwner) buildAddToAlbumButtom(), - if (shareAlbumId != null) buildActivitiesButton(), + if (asset.isRemote && (isOwner || isPartner)) buildAddToAlbumButtom(), + if (album != null && album.shared) buildActivitiesButton(), buildMoreInfoButton(), ], ); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 558a350c1f..023a7c8dd4 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -9,6 +9,7 @@ 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/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; @@ -22,6 +23,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; +import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; import 'package:immich_mobile/shared/cache/original_image_provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/store.dart'; @@ -29,6 +31,7 @@ import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; @@ -49,8 +52,6 @@ class GalleryViewerPage extends HookConsumerWidget { final int initialIndex; final int heroOffset; final bool showStack; - final bool isOwner; - final String? sharedAlbumId; GalleryViewerPage({ super.key, @@ -59,8 +60,6 @@ class GalleryViewerPage extends HookConsumerWidget { required this.totalAssets, this.heroOffset = 0, this.showStack = false, - this.isOwner = true, - this.sharedAlbumId, }) : controller = PageController(initialPage: initialIndex); final PageController controller; @@ -94,10 +93,16 @@ class GalleryViewerPage extends HookConsumerWidget { final stackElements = showStack ? [currentAsset, ...stack] : []; // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id final isFromDto = currentAsset.id == Isar.autoIncrement; + final album = ref.watch(currentAlbumProvider); Asset asset() => stackIndex.value == -1 ? currentAsset : stackElements.elementAt(stackIndex.value); + final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId; + final isPartner = ref + .watch(partnerSharedWithProvider) + .map((e) => e.isarId) + .contains(asset().ownerId); bool isParent = stackIndex.value == -1 || stackIndex.value == 0; @@ -113,9 +118,8 @@ class GalleryViewerPage extends HookConsumerWidget { [], ); - void toggleFavorite(Asset asset) => ref - .watch(assetProvider.notifier) - .toggleFavorite([asset], !asset.isFavorite); + void toggleFavorite(Asset asset) => + ref.read(assetProvider.notifier).toggleFavorite([asset]); /// Original (large) image of a remote asset. Required asset.isRemote ImageProvider remoteOriginalProvider(Asset asset) => @@ -305,9 +309,7 @@ class GalleryViewerPage extends HookConsumerWidget { } handleArchive(Asset asset) { - ref - .watch(assetProvider.notifier) - .toggleArchive([asset], !asset.isArchived); + ref.watch(assetProvider.notifier).toggleArchive([asset]); if (isParent) { context.autoPop(); return; @@ -331,10 +333,10 @@ class GalleryViewerPage extends HookConsumerWidget { } handleActivities() { - if (sharedAlbumId != null) { + if (album != null && album.shared && album.remoteId != null) { context.autoPush( ActivitiesRoute( - albumId: sharedAlbumId!, + albumId: album.remoteId!, assetId: asset().remoteId, withAssetThumbs: false, isOwner: isOwner, @@ -353,6 +355,7 @@ class GalleryViewerPage extends HookConsumerWidget { color: Colors.black.withOpacity(0.4), child: TopControlAppBar( isOwner: isOwner, + isPartner: isPartner, isPlayingMotionVideo: isPlayingMotionVideo.value, asset: asset(), onMoreInfoPressed: showInfo, @@ -371,7 +374,6 @@ class GalleryViewerPage extends HookConsumerWidget { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), onAddToAlbumPressed: () => addToAlbum(asset()), - shareAlbumId: sharedAlbumId, onActivitiesPressed: handleActivities, ), ), diff --git a/mobile/lib/modules/favorite/providers/favorite_provider.dart b/mobile/lib/modules/favorite/providers/favorite_provider.dart index 0da6b3f8aa..bdaa2761a1 100644 --- a/mobile/lib/modules/favorite/providers/favorite_provider.dart +++ b/mobile/lib/modules/favorite/providers/favorite_provider.dart @@ -17,6 +17,6 @@ final favoriteAssetsProvider = StreamProvider((ref) { .filter() .isFavoriteEqualTo(true) .isTrashedEqualTo(false) - .sortByFileCreatedAt(); + .sortByFileCreatedAtDesc(); return renderListGenerator(query, ref); }); diff --git a/mobile/lib/modules/favorite/views/favorites_page.dart b/mobile/lib/modules/favorite/views/favorites_page.dart index 095297507b..8a7ccfc58a 100644 --- a/mobile/lib/modules/favorite/views/favorites_page.dart +++ b/mobile/lib/modules/favorite/views/favorites_page.dart @@ -1,31 +1,16 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/favorite/providers/favorite_provider.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/utils/selection_handlers.dart'; +import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; +import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart'; class FavoritesPage extends HookConsumerWidget { const FavoritesPage({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { - final selectionEnabledHook = useState(false); - final selection = useState({}); - final processing = useState(false); - - void selectionListener( - bool multiselect, - Set selectedAssets, - ) { - selectionEnabledHook.value = multiselect; - selection.value = selectedAssets; - } - AppBar buildAppBar() { return AppBar( leading: IconButton( @@ -40,66 +25,14 @@ class FavoritesPage extends HookConsumerWidget { ); } - void unfavorite() async { - try { - if (selection.value.isNotEmpty) { - await handleFavoriteAssets( - ref, - context, - selection.value.toList(), - shouldFavorite: false, - ); - } - } finally { - processing.value = false; - selectionEnabledHook.value = false; - } - } - - Widget buildBottomBar() { - return SafeArea( - child: Align( - alignment: Alignment.bottomCenter, - child: SizedBox( - height: 64, - child: Card( - child: ListTile( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - leading: const Icon( - Icons.star_border, - ), - title: const Text( - "Unfavorite", - style: TextStyle(fontSize: 14), - ), - onTap: processing.value ? null : unfavorite, - ), - ), - ), - ), - ); - } - return Scaffold( - appBar: buildAppBar(), - body: ref.watch(favoriteAssetsProvider).widgetWhen( - onData: (data) => data.isEmpty - ? Center( - child: Text('favorites_page_no_favorites'.tr()), - ) - : Stack( - children: [ - ImmichAssetGrid( - renderList: data, - selectionActive: selectionEnabledHook.value, - listener: selectionListener, - ), - if (selectionEnabledHook.value) buildBottomBar(), - ], - ), - ), + appBar: ref.watch(multiselectProvider) ? null : buildAppBar(), + body: MultiselectGrid( + renderListProvider: favoriteAssetsProvider, + favoriteEnabled: true, + editEnabled: true, + unfavorite: true, + ), ); } } diff --git a/mobile/lib/modules/home/ui/asset_grid/disable_multi_select_button.dart b/mobile/lib/modules/home/ui/asset_grid/disable_multi_select_button.dart index 98f932ab2c..bf792fab65 100644 --- a/mobile/lib/modules/home/ui/asset_grid/disable_multi_select_button.dart +++ b/mobile/lib/modules/home/ui/asset_grid/disable_multi_select_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; class DisableMultiSelectButton extends ConsumerWidget { const DisableMultiSelectButton({ @@ -13,24 +14,25 @@ class DisableMultiSelectButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Padding( + return Align( + alignment: Alignment.topLeft, + child: Padding( padding: const EdgeInsets.only(left: 16.0, top: 16.0), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: ElevatedButton.icon( - onPressed: () { - onPressed(); - }, + onPressed: () => onPressed(), icon: const Icon(Icons.close_rounded), label: Text( '$selectedItemCount', - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 18, + style: context.textTheme.titleMedium?.copyWith( + height: 2.5, + color: context.isDarkTheme ? Colors.black : Colors.white, ), ), ), ), + ), ); } } 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 562b7892c3..8695a39f88 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 @@ -33,8 +33,6 @@ class ImmichAssetGrid extends HookConsumerWidget { final bool shrinkWrap; final bool showDragScroll; final bool showStack; - final bool isOwner; - final String? sharedAlbumId; const ImmichAssetGrid({ super.key, @@ -55,8 +53,6 @@ class ImmichAssetGrid extends HookConsumerWidget { this.shrinkWrap = false, this.showDragScroll = true, this.showStack = false, - this.isOwner = true, - this.sharedAlbumId, }); @override @@ -121,8 +117,6 @@ class ImmichAssetGrid extends HookConsumerWidget { shrinkWrap: shrinkWrap, showDragScroll: showDragScroll, showStack: showStack, - isOwner: isOwner, - sharedAlbumId: sharedAlbumId, ), ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 77940d254a..6b302375a6 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -39,8 +39,6 @@ class ImmichAssetGridView extends StatefulWidget { final bool shrinkWrap; final bool showDragScroll; final bool showStack; - final bool isOwner; - final String? sharedAlbumId; const ImmichAssetGridView({ super.key, @@ -61,8 +59,6 @@ class ImmichAssetGridView extends StatefulWidget { this.shrinkWrap = false, this.showDragScroll = true, this.showStack = false, - this.isOwner = true, - this.sharedAlbumId, }); @override @@ -143,8 +139,6 @@ class ImmichAssetGridViewState extends State { showStorageIndicator: widget.showStorageIndicator, heroOffset: widget.heroOffset, showStack: widget.showStack, - isOwner: widget.isOwner, - sharedAlbumId: widget.sharedAlbumId, ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 694279c0d6..ca9a3b9322 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -14,14 +14,12 @@ class ThumbnailImage extends StatelessWidget { final int totalAssets; final bool showStorageIndicator; final bool showStack; - final bool isOwner; final bool useGrayBoxPlaceholder; final bool isSelected; final bool multiselectEnabled; final Function? onSelect; final Function? onDeselect; final int heroOffset; - final String? sharedAlbumId; const ThumbnailImage({ Key? key, @@ -31,8 +29,6 @@ class ThumbnailImage extends StatelessWidget { required this.totalAssets, this.showStorageIndicator = true, this.showStack = false, - this.isOwner = true, - this.sharedAlbumId, this.useGrayBoxPlaceholder = false, this.isSelected = false, this.multiselectEnabled = false, @@ -185,8 +181,6 @@ class ThumbnailImage extends StatelessWidget { totalAssets: totalAssets, heroOffset: heroOffset, showStack: showStack, - isOwner: isOwner, - sharedAlbumId: sharedAlbumId, ), ); } 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 6b0b91c33a..9f823b7732 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -2,6 +2,8 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; import 'package:immich_mobile/modules/home/models/selection_state.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; @@ -12,37 +14,39 @@ import 'package:immich_mobile/shared/models/album.dart'; class ControlBottomAppBar extends ConsumerWidget { final void Function(bool shareLocal) onShare; - final void Function() onFavorite; - final void Function() onArchive; - final void Function() onDelete; + final void Function()? onFavorite; + final void Function()? onArchive; + final void Function()? onDelete; final Function(Album album) onAddToAlbum; final void Function() onCreateNewAlbum; final void Function() onUpload; - final void Function() onStack; - final void Function() onEditTime; - final void Function() onEditLocation; + final void Function()? onStack; + final void Function()? onEditTime; + final void Function()? onEditLocation; + final void Function()? onRemoveFromAlbum; - final List albums; - final List sharedAlbums; final bool enabled; + final bool unfavorite; + final bool unarchive; final SelectionAssetState selectionAssetState; const ControlBottomAppBar({ Key? key, required this.onShare, - required this.onFavorite, - required this.onArchive, - required this.onDelete, - required this.sharedAlbums, - required this.albums, + this.onFavorite, + this.onArchive, + this.onDelete, required this.onAddToAlbum, required this.onCreateNewAlbum, required this.onUpload, - required this.onStack, - required this.onEditTime, - required this.onEditLocation, + this.onStack, + this.onEditTime, + this.onEditLocation, + this.onRemoveFromAlbum, this.selectionAssetState = const SelectionAssetState(), this.enabled = true, + this.unarchive = false, + this.unfavorite = false, }) : super(key: key); @override @@ -52,6 +56,8 @@ class ControlBottomAppBar extends ConsumerWidget { var hasLocal = selectionAssetState.hasLocal; final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); + final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); + final sharedAlbums = ref.watch(sharedAlbumProvider); List renderActionButtons() { return [ @@ -66,56 +72,73 @@ class ControlBottomAppBar extends ConsumerWidget { label: "control_bottom_app_bar_share_to".tr(), onPressed: enabled ? () => onShare(true) : null, ), - if (hasRemote) + if (hasRemote && onArchive != null) ControlBoxButton( - iconData: Icons.archive, - label: "control_bottom_app_bar_archive".tr(), + iconData: unarchive ? Icons.unarchive : Icons.archive, + label: (unarchive + ? "control_bottom_app_bar_unarchive" + : "control_bottom_app_bar_archive") + .tr(), onPressed: enabled ? onArchive : null, ), - if (hasRemote) + if (hasRemote && onFavorite != null) ControlBoxButton( - iconData: Icons.favorite_border_rounded, - label: "control_bottom_app_bar_favorite".tr(), + iconData: unfavorite + ? Icons.favorite_border_rounded + : Icons.favorite_rounded, + label: (unfavorite + ? "control_bottom_app_bar_unfavorite" + : "control_bottom_app_bar_favorite") + .tr(), onPressed: enabled ? onFavorite : null, ), - if (hasRemote) + if (hasRemote && onEditTime != null) ControlBoxButton( iconData: Icons.edit_calendar_outlined, label: "control_bottom_app_bar_edit_time".tr(), onPressed: enabled ? onEditTime : null, ), - if (hasRemote) + if (hasRemote && onEditLocation != null) ControlBoxButton( iconData: Icons.edit_location_alt_outlined, label: "control_bottom_app_bar_edit_location".tr(), onPressed: enabled ? onEditLocation : null, ), - ControlBoxButton( - iconData: Icons.delete_outline_rounded, - label: "control_bottom_app_bar_delete".tr(), - onPressed: enabled - ? () { - if (!trashEnabled) { - showDialog( - context: context, - builder: (BuildContext context) { - return DeleteDialog( - onDelete: onDelete, - ); - }, - ); - } else { - onDelete(); + if (onDelete != null) + ControlBoxButton( + iconData: Icons.delete_outline_rounded, + label: "control_bottom_app_bar_delete".tr(), + onPressed: enabled + ? () { + if (!trashEnabled) { + showDialog( + context: context, + builder: (BuildContext context) { + return DeleteDialog( + onDelete: onDelete!, + ); + }, + ); + } else { + onDelete!(); + } } - } - : null, - ), - if (!hasLocal && selectionAssetState.selectedCount > 1) + : null, + ), + if (!hasLocal && + selectionAssetState.selectedCount > 1 && + onStack != null) ControlBoxButton( iconData: Icons.filter_none_rounded, label: "control_bottom_app_bar_stack".tr(), onPressed: enabled ? onStack : null, ), + if (onRemoveFromAlbum != null) + ControlBoxButton( + iconData: Icons.delete_sweep_rounded, + label: 'album_viewer_appbar_share_remove'.tr(), + onPressed: enabled ? onRemoveFromAlbum : null, + ), if (hasLocal) ControlBoxButton( iconData: Icons.backup_outlined, diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index c351d5708c..54324e4f47 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -1,57 +1,30 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; -import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; -import 'package:immich_mobile/modules/album/services/album.service.dart'; -import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; -import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; -import 'package:immich_mobile/modules/home/models/selection_state.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; -import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; -import 'package:immich_mobile/modules/memories/ui/memory_lane.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/shared/models/album.dart'; -import 'package:immich_mobile/shared/models/asset.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/user.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; +import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart'; import 'package:immich_mobile/shared/ui/immich_app_bar.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; -import 'package:immich_mobile/shared/ui/immich_toast.dart'; -import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; -import 'package:immich_mobile/utils/selection_handlers.dart'; class HomePage extends HookConsumerWidget { const HomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { - final multiselectEnabled = ref.watch(multiselectProvider.notifier); - final selectionEnabledHook = useState(false); - final selectionAssetState = useState(const SelectionAssetState()); - - final selection = useState({}); - final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); - final sharedAlbums = ref.watch(sharedAlbumProvider); - final albumService = ref.watch(albumServiceProvider); final currentUser = ref.watch(currentUserProvider); final timelineUsers = ref.watch(timelineUsersIdsProvider); - final trashEnabled = - ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); - final tipOneOpacity = useState(0.0); final refreshCount = useState(0); - final processing = useProcessingOverlay(); useEffect( () { @@ -61,394 +34,82 @@ class HomePage extends HookConsumerWidget { ref.read(albumProvider.notifier).getAllAlbums(); ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); ref.read(serverInfoProvider.notifier).getServerInfo(); - - selectionEnabledHook.addListener(() { - multiselectEnabled.state = selectionEnabledHook.value; - }); - - return () { - // This does not work in tests - if (kReleaseMode) { - selectionEnabledHook.dispose(); - } - }; + return; }, [], ); + Widget buildLoadingIndicator() { + Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1); - Widget buildBody() { - void selectionListener( - bool multiselect, - Set selectedAssets, - ) { - selectionEnabledHook.value = multiselect; - selection.value = selectedAssets; - selectionAssetState.value = - SelectionAssetState.fromSelection(selectedAssets); - } - - errorBuilder(String? msg) => msg != null && msg.isNotEmpty - ? () => ImmichToast.show( - context: context, - msg: msg, - gravity: ToastGravity.BOTTOM, - ) - : null; - - Iterable remoteOnly( - Iterable assets, { - void Function()? errorCallback, - }) { - final bool onlyRemote = assets.every((e) => e.isRemote); - if (!onlyRemote) { - if (errorCallback != null) errorCallback(); - return assets.where((a) => a.isRemote); - } - return assets; - } - - Iterable ownedOnly( - Iterable assets, { - void Function()? errorCallback, - }) { - if (currentUser == null) return []; - final userId = currentUser.isarId; - final bool onlyOwned = assets.every((e) => e.ownerId == userId); - if (!onlyOwned) { - if (errorCallback != null) errorCallback(); - return assets.where((a) => a.ownerId == userId); - } - return assets; - } - - Iterable ownedRemoteSelection({ - String? localErrorMessage, - String? ownerErrorMessage, - }) { - final assets = selection.value; - return remoteOnly( - ownedOnly(assets, errorCallback: errorBuilder(ownerErrorMessage)), - errorCallback: errorBuilder(localErrorMessage), - ); - } - - Iterable remoteSelection({String? errorMessage}) => remoteOnly( - selection.value, - errorCallback: errorBuilder(errorMessage), - ); - - void onShareAssets(bool shareLocal) { - processing.value = true; - if (shareLocal) { - handleShareAssets(ref, context, selection.value.toList()); - } else { - final ids = - remoteSelection(errorMessage: "home_page_share_err_local".tr()) - .map((e) => e.remoteId!); - context.autoPush(SharedLinkEditRoute(assetsList: ids.toList())); - } - processing.value = false; - selectionEnabledHook.value = false; - } - - void onFavoriteAssets() async { - processing.value = true; - try { - final remoteAssets = ownedRemoteSelection( - localErrorMessage: 'home_page_favorite_err_local'.tr(), - ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), - ); - if (remoteAssets.isNotEmpty) { - await handleFavoriteAssets(ref, context, remoteAssets.toList()); - } - } finally { - processing.value = false; - selectionEnabledHook.value = false; - } - } - - void onArchiveAsset() async { - processing.value = true; - try { - final remoteAssets = ownedRemoteSelection( - localErrorMessage: 'home_page_archive_err_local'.tr(), - ownerErrorMessage: 'home_page_archive_err_partner'.tr(), - ); - await handleArchiveAssets(ref, context, remoteAssets.toList()); - } finally { - processing.value = false; - selectionEnabledHook.value = false; - } - } - - void onDelete() async { - processing.value = true; - try { - final toDelete = ownedOnly( - selection.value, - errorCallback: errorBuilder('home_page_delete_err_partner'.tr()), - ).toList(); - await ref - .read(assetProvider.notifier) - .deleteAssets(toDelete, force: !trashEnabled); - - final hasRemote = toDelete.any((a) => a.isRemote); - final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; - final trashOrRemoved = - !trashEnabled ? 'deleted permanently' : 'trashed'; - if (hasRemote) { - ImmichToast.show( - context: context, - msg: '${selection.value.length} $assetOrAssets $trashOrRemoved', - gravity: ToastGravity.BOTTOM, - ); - } - selectionEnabledHook.value = false; - } finally { - processing.value = false; - } - } - - void onUpload() { - processing.value = true; - selectionEnabledHook.value = false; - try { - ref.read(manualUploadProvider.notifier).uploadAssets( - context, - selection.value.where((a) => a.storage == AssetState.local), - ); - } finally { - processing.value = false; - } - } - - void onAddToAlbum(Album album) async { - processing.value = true; - try { - final Iterable assets = remoteSelection( - errorMessage: "home_page_add_to_album_err_local".tr(), - ); - if (assets.isEmpty) { - return; - } - final result = await albumService.addAdditionalAssetToAlbum( - assets, - album, - ); - - if (result != null) { - if (result.alreadyInAlbum.isNotEmpty) { - ImmichToast.show( - context: context, - msg: "home_page_add_to_album_conflicts".tr( - namedArgs: { - "album": album.name, - "added": result.successfullyAdded.toString(), - "failed": result.alreadyInAlbum.length.toString(), - }, - ), - ); - } else { - ImmichToast.show( - context: context, - msg: "home_page_add_to_album_success".tr( - namedArgs: { - "album": album.name, - "added": result.successfullyAdded.toString(), - }, - ), - toastType: ToastType.success, - ); - - ref.watch(albumProvider.notifier).getAllAlbums(); - ref.invalidate(albumDetailProvider(album.id)); - } - } - } finally { - processing.value = false; - selectionEnabledHook.value = false; - } - } - - void onCreateNewAlbum() async { - processing.value = true; - try { - final Iterable assets = remoteSelection( - errorMessage: "home_page_add_to_album_err_local".tr(), - ); - if (assets.isEmpty) { - return; - } - final result = - await albumService.createAlbumWithGeneratedName(assets); - - if (result != null) { - ref.watch(albumProvider.notifier).getAllAlbums(); - ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); - selectionEnabledHook.value = false; - - context.autoPush(AlbumViewerRoute(albumId: result.id)); - } - } finally { - processing.value = false; - } - } - - void onStack() async { - try { - processing.value = true; - if (!selectionEnabledHook.value || selection.value.length < 2) { - return; - } - final parent = selection.value.elementAt(0); - selection.value.remove(parent); - await ref.read(assetStackServiceProvider).updateStack( - parent, - childrenToAdd: selection.value.toList(), - ); - } finally { - processing.value = false; - selectionEnabledHook.value = false; - } - } - - void onEditTime() async { - try { - final remoteAssets = ownedRemoteSelection( - localErrorMessage: 'home_page_favorite_err_local'.tr(), - ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), - ); - if (remoteAssets.isNotEmpty) { - handleEditDateTime(ref, context, remoteAssets.toList()); - } - } finally { - selectionEnabledHook.value = false; - } - } - - void onEditLocation() async { - try { - final remoteAssets = ownedRemoteSelection( - localErrorMessage: 'home_page_favorite_err_local'.tr(), - ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), - ); - if (remoteAssets.isNotEmpty) { - handleEditLocation(ref, context, remoteAssets.toList()); - } - } finally { - selectionEnabledHook.value = false; - } - } - - Future refreshAssets() async { - final fullRefresh = refreshCount.value > 0; - await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); - if (timelineUsers.length > 1) { - await ref.read(assetProvider.notifier).getPartnerAssets(); - } - if (fullRefresh) { - // refresh was forced: user requested another refresh within 2 seconds - refreshCount.value = 0; - } else { - refreshCount.value++; - // set counter back to 0 if user does not request refresh again - Timer(const Duration(seconds: 4), () => refreshCount.value = 0); - } - } - - buildLoadingIndicator() { - Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1); - - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const ImmichLoadingIndicator(), - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Text( - 'home_page_building_timeline', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - color: context.primaryColor, - ), - ).tr(), - ), - AnimatedOpacity( - duration: const Duration(milliseconds: 500), - opacity: tipOneOpacity.value, - child: SizedBox( - width: 250, - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: const Text( - 'home_page_first_time_notice', - textAlign: TextAlign.justify, - style: TextStyle( - fontSize: 12, - ), - ).tr(), - ), - ), - ), - ], - ), - ); - } - - return SafeArea( - top: true, - bottom: false, - child: Stack( + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - ref - .watch( - timelineUsers.length > 1 - ? multiUserAssetsProvider(timelineUsers) - : assetsProvider(currentUser?.isarId), - ) - .when( - data: (data) => data.isEmpty - ? buildLoadingIndicator() - : ImmichAssetGrid( - renderList: data, - listener: selectionListener, - selectionActive: selectionEnabledHook.value, - onRefresh: refreshAssets, - topWidget: - (currentUser != null && currentUser.memoryEnabled) - ? const MemoryLane() - : const SizedBox(), - showStack: true, - ), - error: (error, _) => Center(child: Text(error.toString())), - loading: buildLoadingIndicator, + const ImmichLoadingIndicator(), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text( + 'home_page_building_timeline', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: context.primaryColor, + ), + ).tr(), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: tipOneOpacity.value, + child: SizedBox( + width: 250, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: const Text( + 'home_page_first_time_notice', + textAlign: TextAlign.justify, + style: TextStyle( + fontSize: 12, + ), + ).tr(), ), - if (selectionEnabledHook.value) - ControlBottomAppBar( - onShare: onShareAssets, - onFavorite: onFavoriteAssets, - onArchive: onArchiveAsset, - onDelete: onDelete, - onAddToAlbum: onAddToAlbum, - albums: albums, - sharedAlbums: sharedAlbums, - onCreateNewAlbum: onCreateNewAlbum, - onUpload: onUpload, - enabled: !processing.value, - selectionAssetState: selectionAssetState.value, - onStack: onStack, - onEditTime: onEditTime, - onEditLocation: onEditLocation, ), + ), ], ), ); } + Future refreshAssets() async { + final fullRefresh = refreshCount.value > 0; + await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); + if (timelineUsers.length > 1) { + await ref.read(assetProvider.notifier).getPartnerAssets(); + } + if (fullRefresh) { + // refresh was forced: user requested another refresh within 2 seconds + refreshCount.value = 0; + } else { + refreshCount.value++; + // set counter back to 0 if user does not request refresh again + Timer(const Duration(seconds: 4), () => refreshCount.value = 0); + } + } + + Widget buildBody() { + return MultiselectGrid( + renderListProvider: timelineUsers.length > 1 + ? multiUserAssetsProvider(timelineUsers) + : assetsProvider(currentUser?.isarId), + buildLoadingIndicator: buildLoadingIndicator, + onRefresh: refreshAssets, + stackEnabled: true, + archiveEnabled: true, + editEnabled: true, + ); + } + return Scaffold( - appBar: !selectionEnabledHook.value ? const ImmichAppBar() : null, + appBar: ref.watch(multiselectProvider) ? null : const ImmichAppBar(), body: buildBody(), ); } diff --git a/mobile/lib/modules/partner/views/partner_detail_page.dart b/mobile/lib/modules/partner/views/partner_detail_page.dart index 28d53646df..7ec48fd22d 100644 --- a/mobile/lib/modules/partner/views/partner_detail_page.dart +++ b/mobile/lib/modules/partner/views/partner_detail_page.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; +import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; class PartnerDetailPage extends HookConsumerWidget { @@ -15,7 +15,6 @@ class PartnerDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final assets = ref.watch(assetsProvider(partner.isarId)); final inTimeline = useState(partner.inTimeline); bool toggleInProcess = false; @@ -57,33 +56,30 @@ class PartnerDetailPage extends HookConsumerWidget { } return Scaffold( - appBar: AppBar( - title: Text(partner.name), - elevation: 0, - centerTitle: false, - actions: [ - IconButton( - onPressed: toggleInTimeline, - icon: Icon( - inTimeline.value ? Icons.collections : Icons.collections_outlined, + appBar: ref.watch(multiselectProvider) + ? null + : AppBar( + title: Text(partner.name), + elevation: 0, + centerTitle: false, + actions: [ + IconButton( + onPressed: toggleInTimeline, + icon: Icon( + inTimeline.value + ? Icons.collections + : Icons.collections_outlined, + ), + tooltip: "Show/hide photos on your main timeline", + ), + ], ), - tooltip: "Show/hide photos on your main timeline", - ), - ], - ), - body: assets.widgetWhen( - onData: (renderList) => renderList.isEmpty - ? Padding( - padding: const EdgeInsets.all(16), - child: Text( - "It seems ${partner.name} does not have any photos...\n" - "Or your server version does not match the app version."), - ) - : ImmichAssetGrid( - renderList: renderList, - onRefresh: () => - ref.read(assetProvider.notifier).getPartnerAssets(partner), - ), + body: MultiselectGrid( + renderListProvider: assetsProvider(partner.isarId), + onRefresh: () => + ref.read(assetProvider.notifier).getPartnerAssets(partner), + deleteEnabled: false, + favoriteEnabled: false, ), ); } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 05980054f5..af61a2b90b 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -72,8 +72,6 @@ class _$AppRouter extends RootStackRouter { totalAssets: args.totalAssets, heroOffset: args.heroOffset, showStack: args.showStack, - isOwner: args.isOwner, - sharedAlbumId: args.sharedAlbumId, ), transitionsBuilder: CustomTransitionsBuilders.zoomedPage, opaque: true, @@ -799,8 +797,6 @@ class GalleryViewerRoute extends PageRouteInfo { required int totalAssets, int heroOffset = 0, bool showStack = false, - bool isOwner = true, - String? sharedAlbumId, }) : super( GalleryViewerRoute.name, path: '/gallery-viewer-page', @@ -811,8 +807,6 @@ class GalleryViewerRoute extends PageRouteInfo { totalAssets: totalAssets, heroOffset: heroOffset, showStack: showStack, - isOwner: isOwner, - sharedAlbumId: sharedAlbumId, ), ); @@ -827,8 +821,6 @@ class GalleryViewerRouteArgs { required this.totalAssets, this.heroOffset = 0, this.showStack = false, - this.isOwner = true, - this.sharedAlbumId, }); final Key? key; @@ -843,13 +835,9 @@ class GalleryViewerRouteArgs { final bool showStack; - final bool isOwner; - - final String? sharedAlbumId; - @override String toString() { - return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner, sharedAlbumId: $sharedAlbumId}'; + return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}'; } } diff --git a/mobile/lib/shared/models/album.dart b/mobile/lib/shared/models/album.dart index 26b1ab16e8..3ed8f69eac 100644 --- a/mobile/lib/shared/models/album.dart +++ b/mobile/lib/shared/models/album.dart @@ -1,5 +1,4 @@ -import 'package:flutter/cupertino.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; +import 'package:flutter/foundation.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'; @@ -43,11 +42,6 @@ class Album { final IsarLinks sharedUsers = IsarLinks(); final IsarLinks assets = IsarLinks(); - RenderList _renderList = RenderList.empty(); - - @ignore - RenderList get renderList => _renderList; - @ignore bool get isRemote => remoteId != null; @@ -75,17 +69,6 @@ class Album { return name.join(' '); } - Stream watchRenderList(GroupAssetsBy groupAssetsBy) async* { - final query = - assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc(); - _renderList = await RenderList.fromQuery(query, groupAssetsBy); - yield _renderList; - await for (final _ in query.watchLazy()) { - _renderList = await RenderList.fromQuery(query, groupAssetsBy); - yield _renderList; - } - } - @override bool operator ==(other) { if (other is! Album) return false; diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 50c6018016..86bc35ccce 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -202,7 +202,8 @@ class AssetNotifier extends StateNotifier { return isSuccess ? remote : []; } - Future toggleFavorite(List assets, bool status) async { + Future toggleFavorite(List assets, [bool? status]) async { + status ??= !assets.every((a) => a.isFavorite); final newAssets = await _assetService.changeFavoriteStatus(assets, status); for (Asset? newAsset in newAssets) { if (newAsset == null) { @@ -212,7 +213,8 @@ class AssetNotifier extends StateNotifier { } } - Future toggleArchive(List assets, bool status) async { + Future toggleArchive(List assets, [bool? status]) async { + status ??= assets.every((a) => a.isArchived); final newAssets = await _assetService.changeArchiveStatus(assets, status); int i = 0; for (Asset oldAsset in assets) { diff --git a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart new file mode 100644 index 0000000000..8c8544740b --- /dev/null +++ b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart @@ -0,0 +1,419 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; +import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; +import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; +import 'package:immich_mobile/modules/home/models/selection_state.dart'; +import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; +import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/album.dart'; +import 'package:immich_mobile/shared/models/asset.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/user.provider.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; +import 'package:immich_mobile/utils/selection_handlers.dart'; + +class MultiselectGrid extends HookConsumerWidget { + const MultiselectGrid({ + Key? key, + required this.renderListProvider, + this.onRefresh, + this.buildLoadingIndicator, + this.onRemoveFromAlbum, + this.topWidget, + this.stackEnabled = false, + this.archiveEnabled = false, + this.deleteEnabled = true, + this.favoriteEnabled = true, + this.editEnabled = false, + this.unarchive = false, + this.unfavorite = false, + }) : super(key: key); + + final ProviderListenable> renderListProvider; + final Future Function()? onRefresh; + final Widget Function()? buildLoadingIndicator; + final Future Function(Iterable)? onRemoveFromAlbum; + final Widget? topWidget; + final bool stackEnabled; + final bool archiveEnabled; + final bool unarchive; + final bool deleteEnabled; + final bool favoriteEnabled; + final bool unfavorite; + final bool editEnabled; + + Widget buildDefaultLoadingIndicator() => + const Center(child: ImmichLoadingIndicator()); + + Widget buildEmptyIndicator() => + const Center(child: Text("No assets to show")); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final multiselectEnabled = ref.watch(multiselectProvider.notifier); + final selectionEnabledHook = useState(false); + final selectionAssetState = useState(const SelectionAssetState()); + + final selection = useState({}); + final currentUser = ref.watch(currentUserProvider); + final processing = useProcessingOverlay(); + + useEffect( + () { + selectionEnabledHook.addListener(() { + multiselectEnabled.state = selectionEnabledHook.value; + }); + + return () { + // This does not work in tests + if (kReleaseMode) { + selectionEnabledHook.dispose(); + } + }; + }, + [], + ); + + void selectionListener( + bool multiselect, + Set selectedAssets, + ) { + selectionEnabledHook.value = multiselect; + selection.value = selectedAssets; + selectionAssetState.value = + SelectionAssetState.fromSelection(selectedAssets); + } + + errorBuilder(String? msg) => msg != null && msg.isNotEmpty + ? () => ImmichToast.show( + context: context, + msg: msg, + gravity: ToastGravity.BOTTOM, + ) + : null; + + Iterable remoteOnly( + Iterable assets, { + void Function()? errorCallback, + }) { + final bool onlyRemote = assets.every((e) => e.isRemote); + if (!onlyRemote) { + if (errorCallback != null) errorCallback(); + return assets.where((a) => a.isRemote); + } + return assets; + } + + Iterable ownedOnly( + Iterable assets, { + void Function()? errorCallback, + }) { + if (currentUser == null) return []; + final userId = currentUser.isarId; + final bool onlyOwned = assets.every((e) => e.ownerId == userId); + if (!onlyOwned) { + if (errorCallback != null) errorCallback(); + return assets.where((a) => a.ownerId == userId); + } + return assets; + } + + Iterable ownedRemoteSelection({ + String? localErrorMessage, + String? ownerErrorMessage, + }) { + final assets = selection.value; + return remoteOnly( + ownedOnly(assets, errorCallback: errorBuilder(ownerErrorMessage)), + errorCallback: errorBuilder(localErrorMessage), + ); + } + + Iterable remoteSelection({String? errorMessage}) => remoteOnly( + selection.value, + errorCallback: errorBuilder(errorMessage), + ); + + void onShareAssets(bool shareLocal) { + processing.value = true; + if (shareLocal) { + handleShareAssets(ref, context, selection.value.toList()); + } else { + final ids = + remoteSelection(errorMessage: "home_page_share_err_local".tr()) + .map((e) => e.remoteId!); + context.autoPush(SharedLinkEditRoute(assetsList: ids.toList())); + } + processing.value = false; + selectionEnabledHook.value = false; + } + + void onFavoriteAssets() async { + processing.value = true; + try { + final remoteAssets = ownedRemoteSelection( + localErrorMessage: 'home_page_favorite_err_local'.tr(), + ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), + ); + if (remoteAssets.isNotEmpty) { + await handleFavoriteAssets(ref, context, remoteAssets.toList()); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + + void onArchiveAsset() async { + processing.value = true; + try { + final remoteAssets = ownedRemoteSelection( + localErrorMessage: 'home_page_archive_err_local'.tr(), + ownerErrorMessage: 'home_page_archive_err_partner'.tr(), + ); + await handleArchiveAssets(ref, context, remoteAssets.toList()); + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + + void onDelete() async { + processing.value = true; + try { + final trashEnabled = + ref.read(serverInfoProvider.select((v) => v.serverFeatures.trash)); + final toDelete = ownedOnly( + selection.value, + errorCallback: errorBuilder('home_page_delete_err_partner'.tr()), + ).toList(); + await ref + .read(assetProvider.notifier) + .deleteAssets(toDelete, force: !trashEnabled); + + final hasRemote = toDelete.any((a) => a.isRemote); + final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; + final trashOrRemoved = + !trashEnabled ? 'deleted permanently' : 'trashed'; + if (hasRemote) { + ImmichToast.show( + context: context, + msg: '${selection.value.length} $assetOrAssets $trashOrRemoved', + gravity: ToastGravity.BOTTOM, + ); + } + selectionEnabledHook.value = false; + } finally { + processing.value = false; + } + } + + void onUpload() { + processing.value = true; + selectionEnabledHook.value = false; + try { + ref.read(manualUploadProvider.notifier).uploadAssets( + context, + selection.value.where((a) => a.storage == AssetState.local), + ); + } finally { + processing.value = false; + } + } + + void onAddToAlbum(Album album) async { + processing.value = true; + try { + final Iterable assets = remoteSelection( + errorMessage: "home_page_add_to_album_err_local".tr(), + ); + if (assets.isEmpty) { + return; + } + final result = + await ref.read(albumServiceProvider).addAdditionalAssetToAlbum( + assets, + album, + ); + + if (result != null) { + if (result.alreadyInAlbum.isNotEmpty) { + ImmichToast.show( + context: context, + msg: "home_page_add_to_album_conflicts".tr( + namedArgs: { + "album": album.name, + "added": result.successfullyAdded.toString(), + "failed": result.alreadyInAlbum.length.toString(), + }, + ), + ); + } else { + ImmichToast.show( + context: context, + msg: "home_page_add_to_album_success".tr( + namedArgs: { + "album": album.name, + "added": result.successfullyAdded.toString(), + }, + ), + toastType: ToastType.success, + ); + } + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + + void onCreateNewAlbum() async { + processing.value = true; + try { + final Iterable assets = remoteSelection( + errorMessage: "home_page_add_to_album_err_local".tr(), + ); + if (assets.isEmpty) { + return; + } + final result = await ref + .read(albumServiceProvider) + .createAlbumWithGeneratedName(assets); + + if (result != null) { + ref.watch(albumProvider.notifier).getAllAlbums(); + ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); + selectionEnabledHook.value = false; + + context.autoPush(AlbumViewerRoute(albumId: result.id)); + } + } finally { + processing.value = false; + } + } + + void onStack() async { + try { + processing.value = true; + if (!selectionEnabledHook.value || selection.value.length < 2) { + return; + } + final parent = selection.value.elementAt(0); + selection.value.remove(parent); + await ref.read(assetStackServiceProvider).updateStack( + parent, + childrenToAdd: selection.value.toList(), + ); + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + + void onEditTime() async { + try { + final remoteAssets = ownedRemoteSelection( + localErrorMessage: 'home_page_favorite_err_local'.tr(), + ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), + ); + if (remoteAssets.isNotEmpty) { + handleEditDateTime(ref, context, remoteAssets.toList()); + } + } finally { + selectionEnabledHook.value = false; + } + } + + void onEditLocation() async { + try { + final remoteAssets = ownedRemoteSelection( + localErrorMessage: 'home_page_favorite_err_local'.tr(), + ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), + ); + if (remoteAssets.isNotEmpty) { + handleEditLocation(ref, context, remoteAssets.toList()); + } + } finally { + selectionEnabledHook.value = false; + } + } + + Future Function() wrapLongRunningFun(Future Function() fun) => + () async { + processing.value = true; + try { + final result = await fun(); + if (result.runtimeType != bool || result == true) { + selectionEnabledHook.value = false; + } + return result; + } finally { + processing.value = false; + } + }; + + return SafeArea( + top: true, + bottom: false, + child: Stack( + children: [ + ref.watch(renderListProvider).when( + data: (data) => data.isEmpty && + (buildLoadingIndicator != null || topWidget == null) + ? (buildLoadingIndicator ?? buildEmptyIndicator)() + : ImmichAssetGrid( + renderList: data, + listener: selectionListener, + selectionActive: selectionEnabledHook.value, + onRefresh: onRefresh == null + ? null + : wrapLongRunningFun(onRefresh!), + topWidget: topWidget, + showStack: stackEnabled, + ), + error: (error, _) => Center(child: Text(error.toString())), + loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator, + ), + if (selectionEnabledHook.value) + ControlBottomAppBar( + onShare: onShareAssets, + onFavorite: favoriteEnabled ? onFavoriteAssets : null, + onArchive: archiveEnabled ? onArchiveAsset : null, + onDelete: deleteEnabled ? onDelete : null, + onAddToAlbum: onAddToAlbum, + onCreateNewAlbum: onCreateNewAlbum, + onUpload: onUpload, + enabled: !processing.value, + selectionAssetState: selectionAssetState.value, + onStack: stackEnabled ? onStack : null, + onEditTime: editEnabled ? onEditTime : null, + onEditLocation: editEnabled ? onEditLocation : null, + unfavorite: unfavorite, + unarchive: unarchive, + onRemoveFromAlbum: onRemoveFromAlbum != null + ? wrapLongRunningFun( + () => onRemoveFromAlbum!(selection.value), + ) + : null, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index 47bf33e987..2cda733881 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -45,10 +45,11 @@ Future handleArchiveAssets( WidgetRef ref, BuildContext context, List selection, { - bool shouldArchive = true, + bool? shouldArchive, ToastGravity toastGravity = ToastGravity.BOTTOM, }) async { if (selection.isNotEmpty) { + shouldArchive ??= !selection.every((a) => a.isArchived); await ref .read(assetProvider.notifier) .toggleArchive(selection, shouldArchive); @@ -69,10 +70,11 @@ Future handleFavoriteAssets( WidgetRef ref, BuildContext context, List selection, { - bool shouldFavorite = true, + bool? shouldFavorite, ToastGravity toastGravity = ToastGravity.BOTTOM, }) async { if (selection.isNotEmpty) { + shouldFavorite ??= !selection.every((a) => a.isFavorite); await ref .watch(assetProvider.notifier) .toggleFavorite(selection, shouldFavorite);