Compare commits

...

1 Commits

Author SHA1 Message Date
Yaros
539f9d64e8 feat(mobile): keyboard shortcuts in asset viewer 2025-12-08 19:49:14 +01:00
5 changed files with 191 additions and 104 deletions

View File

@@ -24,6 +24,67 @@ enum AddToMenuItem { album, archive, unarchive, lockedFolder }
class AddActionButton extends ConsumerStatefulWidget { class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key}); 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<Widget> 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<void> 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 @override
ConsumerState<AddActionButton> createState() => _AddActionButtonState(); ConsumerState<AddActionButton> createState() => _AddActionButtonState();
} }
@@ -32,7 +93,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
void _handleMenuSelection(AddToMenuItem selected) { void _handleMenuSelection(AddToMenuItem selected) {
switch (selected) { switch (selected) {
case AddToMenuItem.album: case AddToMenuItem.album:
_openAlbumSelector(); AddActionButton.openAlbumSelector(context, ref);
break; break;
case AddToMenuItem.archive: case AddToMenuItem.archive:
performArchiveAction(context, ref, source: ActionSource.viewer); performArchiveAction(context, ref, source: ActionSource.viewer);
@@ -100,65 +161,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
]; ];
} }
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<Widget> 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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(currentAssetNotifier);

View File

@@ -20,7 +20,7 @@ class DeleteActionButton extends ConsumerWidget {
final bool showConfirmation; final bool showConfirmation;
const DeleteActionButton({super.key, required this.source, this.showConfirmation = false}); 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) { if (!context.mounted) {
return; return;
} }
@@ -74,7 +74,7 @@ class DeleteActionButton extends ConsumerWidget {
maxWidth: 110.0, maxWidth: 110.0,
iconData: Icons.delete_sweep_outlined, iconData: Icons.delete_sweep_outlined,
label: "delete".t(context: context), label: "delete".t(context: context),
onPressed: () => _onTap(context, ref), onPressed: () => deleteAsset(context, ref, showConfirmation, source),
); );
} }
} }

View File

@@ -14,7 +14,12 @@ class DownloadActionButton extends ConsumerWidget {
final bool menuItem; final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); 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) { if (!context.mounted) {
return; return;
} }
@@ -41,7 +46,7 @@ class DownloadActionButton extends ConsumerWidget {
label: "download".t(context: context), label: "download".t(context: context),
iconOnly: iconOnly, iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager), onPressed: () => onDownload(context, ref, source, backgroundManager),
); );
} }
} }

View File

@@ -15,7 +15,7 @@ class FavoriteActionButton extends ConsumerWidget {
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); 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) { if (!context.mounted) {
return; return;
} }
@@ -47,7 +47,7 @@ class FavoriteActionButton extends ConsumerWidget {
label: "favorite".t(context: context), label: "favorite".t(context: context),
iconOnly: iconOnly, iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => favoriteAsset(context, ref, source),
); );
} }
} }

View File

@@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_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/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/activities_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.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/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_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/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/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.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/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
@@ -622,6 +629,70 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ref.read(currentAssetNotifier.notifier).dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Rebuild the widget when the asset viewer state changes // Rebuild the widget when the asset viewer state changes
@@ -658,46 +729,55 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// TODO: Add a custom scrum builder once the fix lands on stable // TODO: Add a custom scrum builder once the fix lands on stable
return PopScope( return PopScope(
onPopInvokedWithResult: _onPop, onPopInvokedWithResult: _onPop,
child: Scaffold( child: Focus(
backgroundColor: backgroundColor, autofocus: true,
appBar: const ViewerTopAppBar(), onKeyEvent: (node, event) {
extendBody: true, if (event is KeyDownEvent) {
extendBodyBehindAppBar: true, return handleKeyEvent(event);
floatingActionButton: IgnorePointer( }
ignoring: !showingControls, return KeyEventResult.ignored;
child: AnimatedOpacity( },
opacity: showingControls ? 1.0 : 0.0, child: Scaffold(
duration: Durations.short2, backgroundColor: backgroundColor,
child: const DownloadStatusFloatingButton(), appBar: const ViewerTopAppBar(),
), extendBody: true,
), extendBodyBehindAppBar: true,
body: Stack( floatingActionButton: IgnorePointer(
children: [ ignoring: !showingControls,
PhotoViewGallery.builder( child: AnimatedOpacity(
gaplessPlayback: true, opacity: showingControls ? 1.0 : 0.0,
loadingBuilder: _placeholderBuilder, duration: Durations.short2,
pageController: pageController, child: const DownloadStatusFloatingButton(),
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,
), ),
], ),
), body: Stack(
bottomNavigationBar: showingBottomSheet children: [
? const SizedBox.shrink() PhotoViewGallery.builder(
: const Column( gaplessPlayback: true,
mainAxisSize: MainAxisSize.min, loadingBuilder: _placeholderBuilder,
mainAxisAlignment: MainAxisAlignment.end, pageController: pageController,
crossAxisAlignment: CrossAxisAlignment.stretch, scrollPhysics: CurrentPlatform.isIOS
children: [AssetStackRow(), ViewerBottomBar()], ? 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()],
),
),
), ),
); );
} }