diff --git a/i18n/en.json b/i18n/en.json index ce999793d4..6da205d85a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -32,6 +32,7 @@ "add_to_album_toggle": "Toggle selection for {album}", "add_to_albums": "Add to albums", "add_to_albums_count": "Add to albums ({count})", + "add_to_bottom_bar": "Add to", "add_to_shared_album": "Add to shared album", "add_upload_to_stack": "Add upload to stack", "add_url": "Add URL", @@ -430,6 +431,7 @@ "age_months": "Age {months, plural, one {# month} other {# months}}", "age_year_months": "Age 1 year, {months, plural, one {# month} other {# months}}", "age_years": "{years, plural, other {Age #}}", + "album": "Album", "album_added": "Album added", "album_added_notification_setting_description": "Receive an email notification when you are added to a shared album", "album_cover_updated": "Album cover updated", @@ -1385,6 +1387,7 @@ "more": "More", "move": "Move", "move_off_locked_folder": "Move out of locked folder", + "move_to": "Move to", "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart new file mode 100644 index 0000000000..9155d82753 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -0,0 +1,191 @@ +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/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; + +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; + +enum AddToMenuItem { album, archive, unarchive, lockedFolder } + +class AddActionButton extends ConsumerWidget { + const AddActionButton({super.key}); + + Future _showAddOptions(BuildContext context, WidgetRef ref) async { + final asset = ref.read(currentAssetNotifier); + if (asset == null) return; + + final user = ref.read(currentUserProvider); + final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; + final isInLockedView = ref.watch(inLockedViewProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + final hasRemote = asset is RemoteAsset; + final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived; + final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived; + final menuItemHeight = 30.0; + + final List> items = [ + PopupMenuItem( + enabled: false, + textStyle: context.textTheme.labelMedium, + height: 40, + child: Text("add_to_bottom_bar".tr()), + ), + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.album, + child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())), + ), + const PopupMenuDivider(), + PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())), + if (isOwner) ...[ + if (showArchive) + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.archive, + child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())), + ), + if (showUnarchive) + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.unarchive, + child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())), + ), + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.lockedFolder, + child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())), + ), + ], + ]; + + final AddToMenuItem? selected = await showMenu( + context: context, + color: context.themeData.scaffoldBackgroundColor, + position: _menuPosition(context), + items: items, + ); + + if (selected == null) { + return; + } + + switch (selected) { + case AddToMenuItem.album: + _openAlbumSelector(context, ref); + break; + case AddToMenuItem.archive: + await performArchiveAction(context, ref, source: ActionSource.viewer); + break; + case AddToMenuItem.unarchive: + await performUnArchiveAction(context, ref, source: ActionSource.viewer); + break; + case AddToMenuItem.lockedFolder: + await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer); + break; + } + } + + RelativeRect _menuPosition(BuildContext context) { + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox) { + return RelativeRect.fill; + } + + final size = renderObject.size; + final position = renderObject.localToGlobal(Offset.zero); + + return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy); + } + + void _openAlbumSelector(BuildContext context, WidgetRef ref) { + final currentAsset = ref.read(currentAssetNotifier); + if (currentAsset == null) { + ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); + return; + } + + final List slivers = [ + AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)), + ]; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) { + return BaseBottomSheet( + actions: const [], + slivers: slivers, + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.95, + expand: false, + backgroundColor: context.isDarkTheme ? Colors.black : Colors.white, + ); + }, + ); + } + + Future _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async { + final latest = ref.read(currentAssetNotifier); + + if (latest == null) { + ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); + return; + } + + final addedCount = await ref.read(remoteAlbumProvider.notifier).addAssets(album.id, [latest.remoteId!]); + + if (!context.mounted) { + return; + } + + if (addedCount == 0) { + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}), + ); + } else { + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ); + } + + if (!context.mounted) { + return; + } + await Navigator.of(context).maybePop(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + return Builder( + builder: (buttonContext) { + return BaseActionButton( + iconData: Icons.add, + label: "add_to_bottom_bar".tr(), + onPressed: () => _showAddOptions(buttonContext, ref), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart index d30ba07d0c..290a19f584 100644 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart @@ -10,33 +10,36 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +// used to allow performing archive action from different sources (without duplicating code) +Future performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { + if (!context.mounted) return; + + final result = await ref.read(actionProvider.notifier).archive(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } +} + class ArchiveActionButton extends ConsumerWidget { final ActionSource source; const ArchiveActionButton({super.key, required this.source}); - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).archive(source); - ref.read(multiSelectProvider.notifier).reset(); - - if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); - } - - final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); - - if (context.mounted) { - ImmichToast.show( - context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, - ); - } + Future _onTap(BuildContext context, WidgetRef ref) async { + await performArchiveAction(context, ref, source: source); } @override diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index 78b9e3cde6..ddc83cb383 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -10,36 +10,39 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +// Reusable helper: move to locked folder from any source (e.g called from menu) +Future performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { + if (!context.mounted) return; + + final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'move_to_lock_folder_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } +} + class MoveToLockFolderActionButton extends ConsumerWidget { final ActionSource source; const MoveToLockFolderActionButton({super.key, required this.source}); - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); - ref.read(multiSelectProvider.notifier).reset(); - - if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); - } - - final successMessage = 'move_to_lock_folder_action_prompt'.t( - context: context, - args: {'count': result.count.toString()}, - ); - - if (context.mounted) { - ImmichToast.show( - context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, - ); - } + Future _onTap(BuildContext context, WidgetRef ref) async { + await performMoveToLockFolderAction(context, ref, source: source); } @override diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart index b457a1b4ca..8b04a1b05d 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart @@ -1,3 +1,5 @@ +// dart +// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart` import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,30 +9,39 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; + +// used to allow performing unarchive action from different sources (without duplicating code) +Future performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { + if (!context.mounted) return; + + final result = await ref.read(actionProvider.notifier).unArchive(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } +} class UnArchiveActionButton extends ConsumerWidget { final ActionSource source; const UnArchiveActionButton({super.key, required this.source}); - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).unArchive(source); - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); - - if (context.mounted) { - ImmichToast.show( - context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, - ); - } + Future _onTap(BuildContext context, WidgetRef ref) async { + await performUnArchiveAction(context, ref, source: source); } @override diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 3111512823..14c03ad637 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -3,13 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; @@ -34,7 +33,6 @@ class ViewerBottomBar extends ConsumerWidget { int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final isInLockedView = ref.watch(inLockedViewProvider); - final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; if (!showControls) { opacity = 0; @@ -44,11 +42,9 @@ class ViewerBottomBar extends ConsumerWidget { const ShareActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.type == AssetType.image) const EditImageActionButton(), + if (asset.hasRemote) const AddActionButton(), + if (isOwner) ...[ - if (asset.hasRemote && isOwner && isArchived) - const UnArchiveActionButton(source: ActionSource.viewer) - else - const ArchiveActionButton(source: ActionSource.viewer), asset.isLocalOnly ? const DeleteLocalActionButton(source: ActionSource.viewer) : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),