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 index 054f058739..b7108b8d58 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -24,6 +24,67 @@ enum AddToMenuItem { album, archive, unarchive, lockedFolder } class AddActionButton extends ConsumerStatefulWidget { const AddActionButton({super.key}); + static 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, + ); + }, + ); + } + + static 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 ConsumerState createState() => _AddActionButtonState(); } @@ -32,7 +93,7 @@ class _AddActionButtonState extends ConsumerState { void _handleMenuSelection(AddToMenuItem selected) { switch (selected) { case AddToMenuItem.album: - _openAlbumSelector(); + AddActionButton.openAlbumSelector(context, ref); break; case AddToMenuItem.archive: performArchiveAction(context, ref, source: ActionSource.viewer); @@ -100,65 +161,6 @@ class _AddActionButtonState extends ConsumerState { ]; } - void _openAlbumSelector() { - 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(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(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) { final asset = ref.watch(currentAssetNotifier); diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart index 8b82e5c839..886bd6277c 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -20,7 +20,7 @@ class DeleteActionButton extends ConsumerWidget { final bool showConfirmation; const DeleteActionButton({super.key, required this.source, this.showConfirmation = false}); - void _onTap(BuildContext context, WidgetRef ref) async { + static void deleteAsset(BuildContext context, WidgetRef ref, bool showConfirmation, ActionSource source) async { if (!context.mounted) { return; } @@ -74,7 +74,7 @@ class DeleteActionButton extends ConsumerWidget { maxWidth: 110.0, iconData: Icons.delete_sweep_outlined, label: "delete".t(context: context), - onPressed: () => _onTap(context, ref), + onPressed: () => deleteAsset(context, ref, showConfirmation, source), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart index a5129b643a..c9e17b859a 100644 --- a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart @@ -14,7 +14,12 @@ class DownloadActionButton extends ConsumerWidget { final bool menuItem; const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); - void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async { + static void onDownload( + BuildContext context, + WidgetRef ref, + ActionSource source, + BackgroundSyncManager backgroundSyncManager, + ) async { if (!context.mounted) { return; } @@ -41,7 +46,7 @@ class DownloadActionButton extends ConsumerWidget { label: "download".t(context: context), iconOnly: iconOnly, menuItem: menuItem, - onPressed: () => _onTap(context, ref, backgroundManager), + onPressed: () => onDownload(context, ref, source, backgroundManager), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart index ba2491365d..6ded7d8f76 100644 --- a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart @@ -15,7 +15,7 @@ class FavoriteActionButton extends ConsumerWidget { const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); - void _onTap(BuildContext context, WidgetRef ref) async { + static void favoriteAsset(BuildContext context, WidgetRef ref, ActionSource source) async { if (!context.mounted) { return; } @@ -47,7 +47,7 @@ class FavoriteActionButton extends ConsumerWidget { label: "favorite".t(context: context), iconOnly: iconOnly, menuItem: menuItem, - onPressed: () => _onTap(context, ref), + onPressed: () => favoriteAsset(context, ref, source), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 70eb6699aa..8769358cbb 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.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/domain/models/events.model.dart'; @@ -13,6 +14,9 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/add_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/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; @@ -27,11 +31,14 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart' import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; @@ -622,6 +629,70 @@ class _AssetViewerState extends ConsumerState { ref.read(currentAssetNotifier.notifier).dispose(); } + KeyEventResult handleKeyEvent(KeyDownEvent event) { + final asset = ref.watch(currentAssetNotifier); + final user = ref.watch(currentUserProvider); + final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; + // Arrow Left + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + final prevIndex = (pageController.page?.round() ?? 0) - 1; + if (prevIndex >= 0) { + pageController.animateToPage(prevIndex, duration: Durations.short4, curve: Curves.ease); + } + return KeyEventResult.handled; + } + + // Arrow Right + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + final nextIndex = (pageController.page?.round() ?? 0) + 1; + if (nextIndex < totalAssets) { + pageController.animateToPage(nextIndex, duration: Durations.short4, curve: Curves.ease); + } + return KeyEventResult.handled; + } + + // Key F - Favorite / Unfavorite + if (event.logicalKey == LogicalKeyboardKey.keyF) { + if (asset == null || !asset.hasRemote || !isOwner) return KeyEventResult.ignored; + if (asset.isFavorite) { + ref.read(actionProvider.notifier).unFavorite(ActionSource.viewer); + return KeyEventResult.handled; + } else { + ref.read(actionProvider.notifier).favorite(ActionSource.viewer); + return KeyEventResult.handled; + } + } + + // Shift + D - Download + if (event.logicalKey == LogicalKeyboardKey.keyD && + (HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight))) { + if (asset == null || !asset.isRemoteOnly) return KeyEventResult.ignored; + final backgroundManager = ref.watch(backgroundSyncProvider); + DownloadActionButton.onDownload(context, ref, ActionSource.viewer, backgroundManager); + return KeyEventResult.handled; + } + + // Key L - Add to Album + if (event.logicalKey == LogicalKeyboardKey.keyL) { + AddActionButton.openAlbumSelector(context, ref); + return KeyEventResult.handled; + } + + // Delete Key - Delete Asset + if (event.logicalKey == LogicalKeyboardKey.delete) { + if (asset == null || !asset.hasRemote || !isOwner) return KeyEventResult.ignored; + if (asset.isLocalOnly) { + ref.read(actionProvider.notifier).deleteLocal(ActionSource.viewer, context); + } else { + DeleteActionButton.deleteAsset(context, ref, true, ActionSource.viewer); + } + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { // Rebuild the widget when the asset viewer state changes @@ -658,46 +729,55 @@ class _AssetViewerState extends ConsumerState { // TODO: Add a custom scrum builder once the fix lands on stable return PopScope( onPopInvokedWithResult: _onPop, - child: Scaffold( - backgroundColor: backgroundColor, - appBar: const ViewerTopAppBar(), - extendBody: true, - extendBodyBehindAppBar: true, - floatingActionButton: IgnorePointer( - ignoring: !showingControls, - child: AnimatedOpacity( - opacity: showingControls ? 1.0 : 0.0, - duration: Durations.short2, - child: const DownloadStatusFloatingButton(), - ), - ), - body: Stack( - children: [ - PhotoViewGallery.builder( - gaplessPlayback: true, - loadingBuilder: _placeholderBuilder, - pageController: pageController, - scrollPhysics: CurrentPlatform.isIOS - ? const FastScrollPhysics() // Use bouncing physics for iOS - : const FastClampingScrollPhysics(), // Use heavy physics for Android - itemCount: totalAssets, - onPageChanged: _onPageChanged, - onPageBuild: _onPageBuild, - scaleStateChangedCallback: _onScaleStateChanged, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, + child: Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + return handleKeyEvent(event); + } + return KeyEventResult.ignored; + }, + child: Scaffold( + backgroundColor: backgroundColor, + appBar: const ViewerTopAppBar(), + extendBody: true, + extendBodyBehindAppBar: true, + floatingActionButton: IgnorePointer( + ignoring: !showingControls, + child: AnimatedOpacity( + opacity: showingControls ? 1.0 : 0.0, + duration: Durations.short2, + child: const DownloadStatusFloatingButton(), ), - ], - ), - bottomNavigationBar: showingBottomSheet - ? const SizedBox.shrink() - : const Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [AssetStackRow(), ViewerBottomBar()], + ), + body: Stack( + children: [ + PhotoViewGallery.builder( + gaplessPlayback: true, + loadingBuilder: _placeholderBuilder, + pageController: pageController, + scrollPhysics: CurrentPlatform.isIOS + ? const FastScrollPhysics() // Use bouncing physics for iOS + : const FastClampingScrollPhysics(), // Use heavy physics for Android + itemCount: totalAssets, + onPageChanged: _onPageChanged, + onPageBuild: _onPageBuild, + scaleStateChangedCallback: _onScaleStateChanged, + builder: _assetBuilder, + backgroundDecoration: BoxDecoration(color: backgroundColor), + enablePanAlways: true, ), + ], + ), + bottomNavigationBar: showingBottomSheet + ? const SizedBox.shrink() + : const Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [AssetStackRow(), ViewerBottomBar()], + ), + ), ), ); }