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
20 changed files with 375 additions and 424 deletions

View File

@@ -652,7 +652,6 @@
"backup_options_page_title": "Backup options", "backup_options_page_title": "Backup options",
"backup_setting_subtitle": "Manage background and foreground upload settings", "backup_setting_subtitle": "Manage background and foreground upload settings",
"backup_settings_subtitle": "Manage upload settings", "backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward", "backward": "Backward",
"biometric_auth_enabled": "Biometric authentication enabled", "biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication", "biometric_locked_out": "You are locked out of biometric authentication",
@@ -719,7 +718,6 @@
"check_corrupt_asset_backup_button": "Perform check", "check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_logs": "Check Logs", "check_logs": "Check Logs",
"checksum": "Checksum",
"choose_matching_people_to_merge": "Choose matching people to merge", "choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City", "city": "City",
"clear": "Clear", "clear": "Clear",
@@ -1168,7 +1166,6 @@
"header_settings_header_name_input": "Header name", "header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value", "header_settings_header_value_input": "Header value",
"headers_settings_tile_title": "Custom proxy headers", "headers_settings_tile_title": "Custom proxy headers",
"height": "Height",
"hi_user": "Hi {name} ({email})", "hi_user": "Hi {name} ({email})",
"hide_all_people": "Hide all people", "hide_all_people": "Hide all people",
"hide_gallery": "Hide gallery", "hide_gallery": "Hide gallery",
@@ -1291,7 +1288,6 @@
"local": "Local", "local": "Local",
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server", "local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
"local_assets": "Local Assets", "local_assets": "Local Assets",
"local_id": "Local ID",
"local_media_summary": "Local Media Summary", "local_media_summary": "Local Media Summary",
"local_network": "Local network", "local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
@@ -2222,7 +2218,6 @@
"week": "Week", "week": "Week",
"welcome": "Welcome", "welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich", "welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name", "wifi_name": "Wi-Fi Name",
"workflow": "Workflow", "workflow": "Workflow",
"wrong_pin_code": "Wrong PIN code", "wrong_pin_code": "Wrong PIN code",

View File

@@ -98,7 +98,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
), ),
), ),
Text( Text(
"backup_upload_details_page_more_details".t(context: context), 'Tap for more details',
style: context.textTheme.bodySmall?.copyWith( style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6), color: context.colorScheme.onSurface.withValues(alpha: 0.6),
), ),
@@ -239,20 +239,14 @@ class FileDetailDialog extends ConsumerWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
if (asset != null) ...[ if (asset != null) ...[
_buildInfoSection(context, [ _buildInfoSection(context, [
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)), _buildInfoRow(context, "Filename", path.basename(uploadStatus.filename)),
_buildInfoRow(context, "local_id".t(context: context), asset.id), _buildInfoRow(context, "Local ID", asset.id),
_buildInfoRow( _buildInfoRow(context, "File Size", formatHumanReadableBytes(uploadStatus.fileSize, 2)),
context, if (asset.width != null) _buildInfoRow(context, "Width", "${asset.width}px"),
"file_size".t(context: context), if (asset.height != null) _buildInfoRow(context, "Height", "${asset.height}px"),
formatHumanReadableBytes(uploadStatus.fileSize, 2), _buildInfoRow(context, "Created At", asset.createdAt.toString()),
), _buildInfoRow(context, "Updated At", asset.updatedAt.toString()),
if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"), if (asset.checksum != null) _buildInfoRow(context, "Checksum", asset.checksum!),
if (asset.height != null)
_buildInfoRow(context, "height".t(context: context), "${asset.height}px"),
_buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()),
_buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()),
if (asset.checksum != null)
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
]), ]),
], ],
], ],

View File

@@ -22,9 +22,68 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
enum AddToMenuItem { album, archive, unarchive, lockedFolder } enum AddToMenuItem { album, archive, unarchive, lockedFolder }
class AddActionButton extends ConsumerStatefulWidget { class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key, this.originalTheme}); const AddActionButton({super.key});
final ThemeData? originalTheme; 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();
@@ -34,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);
@@ -73,7 +132,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
), ),
if (isOwner) ...[ if (isOwner) ...[
const Divider(), const PopupMenuDivider(),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium), child: Text("move_to".tr(), style: context.textTheme.labelMedium),
@@ -102,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);
@@ -168,27 +168,16 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final themeData = widget.originalTheme ?? context.themeData;
return MenuAnchor( return MenuAnchor(
consumeOutsideTap: true, consumeOutsideTap: true,
style: MenuStyle( style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor), backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4), elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll( shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
), ),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
), ),
menuChildren: widget.originalTheme != null menuChildren: _buildMenuChildren(),
? [
Theme(
data: widget.originalTheme!,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _buildMenuChildren()),
),
]
: _buildMenuChildren(),
builder: (context, controller, child) { builder: (context, controller, child) {
return BaseActionButton( return BaseActionButton(
iconData: Icons.add, iconData: Icons.add,

View File

@@ -1,8 +1,7 @@
import 'package:flutter/material.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/extensions/build_context_extensions.dart';
class BaseActionButton extends ConsumerWidget { class BaseActionButton extends StatelessWidget {
const BaseActionButton({ const BaseActionButton({
super.key, super.key,
required this.label, required this.label,
@@ -31,7 +30,7 @@ class BaseActionButton extends ConsumerWidget {
final void Function()? onLongPressed; final void Function()? onLongPressed;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context); final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0; final iconSize = iconTheme.size ?? 24.0;
@@ -47,13 +46,14 @@ class BaseActionButton extends ConsumerWidget {
if (menuItem) { if (menuItem) {
final theme = context.themeData; final theme = context.themeData;
final effectiveStyle = theme.textTheme.labelLarge;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant; final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton( return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)), style: MenuItemButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)),
leadingIcon: Icon(iconData, color: effectiveIconColor), leadingIcon: Icon(iconData, color: effectiveIconColor, size: 20),
onPressed: onPressed, onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)), child: Text(label, style: effectiveStyle),
); );
} }

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastActionButton extends ConsumerWidget { class CastActionButton extends ConsumerWidget {
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false}); const CastActionButton({super.key, this.iconOnly = true, this.menuItem = false});
final bool iconOnly; final bool iconOnly;
final bool menuItem; final bool menuItem;

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,7 +5,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
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';
class MotionPhotoActionButton extends ConsumerWidget { class MotionPhotoActionButton extends ConsumerWidget {
const MotionPhotoActionButton({super.key, this.iconOnly = false, this.menuItem = false}); const MotionPhotoActionButton({super.key, this.iconOnly = true, this.menuItem = false});
final bool iconOnly; final bool iconOnly;
final bool menuItem; final bool menuItem;

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()],
),
),
), ),
); );
} }

View File

@@ -38,13 +38,11 @@ class ViewerBottomBar extends ConsumerWidget {
opacity = 0; opacity = 0;
} }
final originalTheme = context.themeData;
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(), if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), if (asset.hasRemote) const AddActionButton(),
if (isOwner) ...[ if (isOwner) ...[
asset.isLocalOnly asset.isLocalOnly

View File

@@ -4,19 +4,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.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';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; 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/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_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/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/cast.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/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key}); const ViewerTopAppBar({super.key});
@@ -35,6 +42,15 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final showViewInTimelineButton =
timelineOrigin != TimelineOrigin.main &&
timelineOrigin != TimelineOrigin.deepLink &&
timelineOrigin != TimelineOrigin.trash &&
timelineOrigin != TimelineOrigin.archive &&
timelineOrigin != TimelineOrigin.localAlbum &&
isOwner;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -47,10 +63,11 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
opacity = 0; opacity = 0;
} }
final originalTheme = context.themeData; final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final actions = <Widget>[ final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, iconOnly: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared) if (album != null && album.isActivityEnabled && album.isShared)
IconButton( IconButton(
icon: const Icon(Icons.chat_outlined), icon: const Icon(Icons.chat_outlined),
@@ -58,16 +75,28 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
}, },
), ),
if (showViewInTimelineButton)
IconButton(
onPressed: () async {
await context.maybePop();
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
},
icon: const Icon(Icons.image_search),
tooltip: 'view_in_timeline'.t(context: context),
),
if (asset.hasRemote && isOwner && !asset.isFavorite) if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true), const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite) if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true), const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme), const ViewerKebabMenu(),
]; ];
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)]; final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
const ViewerKebabMenu(),
];
return IgnorePointer( return IgnorePointer(
ignoring: opacity < 255, ignoring: opacity < 255,

View File

@@ -1,17 +1,14 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/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/providers/cast.provider.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
class ViewerKebabMenu extends ConsumerWidget { class ViewerKebabMenu extends ConsumerWidget {
const ViewerKebabMenu({super.key, this.originalTheme}); const ViewerKebabMenu({super.key});
final ThemeData? originalTheme;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -20,42 +17,25 @@ class ViewerKebabMenu extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final user = ref.watch(currentUserProvider); final menuChildren = <Widget>[
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; BaseActionButton(
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); label: 'about'.tr(),
final timelineOrigin = ref.read(timelineServiceProvider).origin; iconData: Icons.info_outline,
menuItem: true,
final kebabContext = ViewerKebabMenuButtonContext( onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
asset: asset, ),
isOwner: isOwner, ];
isCasting: isCasting,
timelineOrigin: timelineOrigin,
originalTheme: originalTheme,
);
final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref);
return MenuAnchor( return MenuAnchor(
consumeOutsideTap: true, consumeOutsideTap: true,
style: MenuStyle( style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor), backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4), elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll( shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
), ),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
), ),
menuChildren: [ menuChildren: menuChildren,
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) { builder: (context, controller, child) {
return IconButton( return IconButton(
icon: const Icon(Icons.more_vert_rounded), icon: const Icon(Icons.more_vert_rounded),

View File

@@ -324,11 +324,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10; final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0; const scrubberBottomPadding = 100.0;
const bottomSheetOpenModifier = 120.0; final bottomPadding = context.padding.bottom + (widget.appBar == null ? 0 : scrubberBottomPadding);
final bottomPadding =
context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final grid = CustomScrollView( final grid = CustomScrollView(
primary: true, primary: true,
@@ -351,7 +347,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false, addRepaintBoundaries: false,
), ),
), ),
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)), const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
], ],
); );

View File

@@ -1,18 +1,9 @@
import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_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_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/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -28,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_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/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_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/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class ActionButtonContext { class ActionButtonContext {
final BaseAsset asset; final BaseAsset asset;
@@ -174,98 +164,3 @@ class ActionButtonBuilder {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
} }
} }
class ViewerKebabMenuButtonContext {
final BaseAsset asset;
final bool isOwner;
final bool isCasting;
final TimelineOrigin timelineOrigin;
final ThemeData? originalTheme;
const ViewerKebabMenuButtonContext({
required this.asset,
required this.isOwner,
required this.isCasting,
required this.timelineOrigin,
this.originalTheme,
});
}
enum ViewerKebabMenuButtonType {
openInfo,
viewInTimeline,
cast,
download;
/// Defines which group each button belongs to.
/// Buttons in the same group will be displayed together,
/// with dividers separating different groups.
int get group => switch (this) {
ViewerKebabMenuButtonType.openInfo => 0,
ViewerKebabMenuButtonType.viewInTimeline => 1,
ViewerKebabMenuButtonType.cast => 1,
ViewerKebabMenuButtonType.download => 1,
};
bool shouldShow(ViewerKebabMenuButtonContext context) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => true,
ViewerKebabMenuButtonType.viewInTimeline =>
context.timelineOrigin != TimelineOrigin.main &&
context.timelineOrigin != TimelineOrigin.deepLink &&
context.timelineOrigin != TimelineOrigin.trash &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote,
ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly,
};
}
ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => BaseActionButton(
label: 'info'.tr(),
iconData: Icons.info_outline,
iconColor: context.originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
),
ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton(
label: 'view_in_timeline'.t(context: buildContext),
iconData: Icons.image_search,
iconColor: context.originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () async {
await buildContext.maybePop();
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
),
ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true),
ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
};
}
}
class ViewerKebabMenuButtonBuilder {
static List<Widget> build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = ViewerKebabMenuButtonType.values.where((type) => type.shouldShow(context)).toList();
if (visibleButtons.isEmpty) return [];
final List<Widget> result = [];
int? lastGroup;
for (final type in visibleButtons) {
if (lastGroup != null && type.group != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext).build(buildContext, ref));
lastGroup = type.group;
}
return result;
}
}

View File

@@ -2,27 +2,26 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
class EntityCountTile extends StatelessWidget { class EntitiyCountTile extends StatelessWidget {
final int count; final int count;
final String label; final String label;
final IconData icon; final IconData icon;
const EntityCountTile({super.key, required this.count, required this.label, required this.icon}); const EntitiyCountTile({super.key, required this.count, required this.label, required this.icon});
String zeroPadding(int number, int targetWidth) { String zeroPadding(int number, int targetWidth) {
final numStr = number.toString(); final numStr = number.toString();
return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : ""; return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : "";
} }
int calculateMaxDigits(double availableWidth) {
const double charWidth = 11.0;
return (availableWidth / charWidth).floor().clamp(1, 8);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final availableWidth = (screenWidth - 32 - 8) / 2;
const double charWidth = 11.0;
final maxDigits = ((availableWidth - 32) / charWidth).floor().clamp(1, 8);
return Container( return Container(
height: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow, color: context.colorScheme.surfaceContainerLow,
@@ -30,6 +29,7 @@ class EntityCountTile extends StatelessWidget {
border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)), border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Icon and Label // Icon and Label
@@ -38,30 +38,33 @@ class EntityCountTile extends StatelessWidget {
children: [ children: [
Icon(icon, color: context.primaryColor), Icon(icon, color: context.primaryColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Text(
child: Text( label,
label, style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
), ),
], ],
), ),
const SizedBox(height: 12),
// Number // Number
const Spacer(), LayoutBuilder(
RichText( builder: (context, constraints) {
text: TextSpan( final maxDigits = calculateMaxDigits(constraints.maxWidth);
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600), return RichText(
children: [ text: TextSpan(
TextSpan( style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
text: zeroPadding(count, maxDigits), children: [
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)), TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
),
TextSpan(
text: count.toString(),
style: TextStyle(color: context.primaryColor),
),
],
), ),
TextSpan( );
text: count.toString(), },
style: TextStyle(color: context.primaryColor),
),
],
),
), ),
], ],
), ),

View File

@@ -282,87 +282,76 @@ class _SyncStatsCounts extends ConsumerWidget {
_SectionHeaderText(text: "assets".t(context: context)), _SectionHeaderText(text: "assets".t(context: context)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
// 1. Wrap in IntrinsicHeight child: Flex(
child: IntrinsicHeight( direction: Axis.horizontal,
child: Flex( mainAxisAlignment: MainAxisAlignment.spaceBetween,
direction: Axis.horizontal, spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
// 2. Stretch children vertically to fill the IntrinsicHeight Expanded(
crossAxisAlignment: CrossAxisAlignment.stretch, child: EntitiyCountTile(
spacing: 8.0, label: "local".t(context: context),
children: [ count: localAssetCount,
Expanded( icon: Icons.smartphone,
child: EntityCountTile(
label: "local".t(context: context),
count: localAssetCount,
icon: Icons.smartphone,
),
), ),
Expanded( ),
child: EntityCountTile( Expanded(
label: "remote".t(context: context), child: EntitiyCountTile(
count: remoteAssetCount, label: "remote".t(context: context),
icon: Icons.cloud, count: remoteAssetCount,
), icon: Icons.cloud,
), ),
], ),
), ],
), ),
), ),
_SectionHeaderText(text: "albums".t(context: context)), _SectionHeaderText(text: "albums".t(context: context)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight( child: Flex(
child: Flex( direction: Axis.horizontal,
direction: Axis.horizontal, mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 8.0,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added children: [
spacing: 8.0, Expanded(
children: [ child: EntitiyCountTile(
Expanded( label: "local".t(context: context),
child: EntityCountTile( count: localAlbumCount,
label: "local".t(context: context), icon: Icons.smartphone,
count: localAlbumCount,
icon: Icons.smartphone,
),
), ),
Expanded( ),
child: EntityCountTile( Expanded(
label: "remote".t(context: context), child: EntitiyCountTile(
count: remoteAlbumCount, label: "remote".t(context: context),
icon: Icons.cloud, count: remoteAlbumCount,
), icon: Icons.cloud,
), ),
], ),
), ],
), ),
), ),
_SectionHeaderText(text: "other".t(context: context)), _SectionHeaderText(text: "other".t(context: context)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight( child: Flex(
child: Flex( direction: Axis.horizontal,
direction: Axis.horizontal, mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 8.0,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added children: [
spacing: 8.0, Expanded(
children: [ child: EntitiyCountTile(
Expanded( label: "memories".t(context: context),
child: EntityCountTile( count: memoryCount,
label: "memories".t(context: context), icon: Icons.calendar_today,
count: memoryCount,
icon: Icons.calendar_today,
),
), ),
Expanded( ),
child: EntityCountTile( Expanded(
label: "hashed_assets".t(context: context), child: EntitiyCountTile(
count: localHashedCount, label: "hashed_assets".t(context: context),
icon: Icons.tag, count: localHashedCount,
), icon: Icons.tag,
), ),
], ),
), ],
), ),
), ),
// To be removed once the experimental feature is stable // To be removed once the experimental feature is stable
@@ -375,29 +364,26 @@ class _SyncStatsCounts extends ConsumerWidget {
return counts.when( return counts.when(
data: (c) => Padding( data: (c) => Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight( child: Flex(
child: Flex( direction: Axis.horizontal,
direction: Axis.horizontal, mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 8.0,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added children: [
spacing: 8.0, Expanded(
children: [ child: EntitiyCountTile(
Expanded( label: "local".t(context: context),
child: EntityCountTile( count: c.total,
label: "local".t(context: context), icon: Icons.delete_outline,
count: c.total,
icon: Icons.delete_outline,
),
), ),
Expanded( ),
child: EntityCountTile( Expanded(
label: "hashed_assets".t(context: context), child: EntitiyCountTile(
count: c.hashed, label: "hashed_assets".t(context: context),
icon: Icons.tag, count: c.hashed,
), icon: Icons.tag,
), ),
], ),
), ],
), ),
), ),
loading: () => const CircularProgressIndicator(), loading: () => const CircularProgressIndicator(),

10
pnpm-lock.yaml generated
View File

@@ -717,8 +717,8 @@ importers:
specifier: file:../open-api/typescript-sdk specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk version: link:../open-api/typescript-sdk
'@immich/ui': '@immich/ui':
specifier: ^0.50.1 specifier: ^0.50.0
version: 0.50.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2) version: 0.50.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
'@mapbox/mapbox-gl-rtl-text': '@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3 specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3) version: 0.2.3(mapbox-gl@1.13.3)
@@ -2989,8 +2989,8 @@ packages:
peerDependencies: peerDependencies:
svelte: ^5.0.0 svelte: ^5.0.0
'@immich/ui@0.50.1': '@immich/ui@0.50.0':
resolution: {integrity: sha512-fNlQGh75ZFa/UZAgJaYk9/ItHOXHNNzN4CunjCmE7WocVVkUZbUxopN9Ku3F5GULSqD/zJ5gNO6PQAZ1ZoSaaQ==} resolution: {integrity: sha512-7AW9SRZTAgal8xlkUAxm7o4+pSG7HcKb+Bh9JpWLaDRRdGyPCZMmsNa9CjZglOQ7wkAD07tQ9u4+zezBLe0dlQ==}
peerDependencies: peerDependencies:
svelte: ^5.0.0 svelte: ^5.0.0
@@ -14701,7 +14701,7 @@ snapshots:
dependencies: dependencies:
svelte: 5.45.2 svelte: 5.45.2
'@immich/ui@0.50.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)': '@immich/ui@0.50.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)':
dependencies: dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2) '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2)
'@internationalized/date': 3.10.0 '@internationalized/date': 3.10.0

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz'; import geotz from 'geo-tz';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { mimeTypes } from 'src/utils/mime-types';
interface ExifDuration { interface ExifDuration {
Value: number; Value: number;
@@ -81,7 +82,6 @@ export class MetadataRepository {
inferTimezoneFromDatestamps: true, inferTimezoneFromDatestamps: true,
inferTimezoneFromTimeStamp: true, inferTimezoneFromTimeStamp: true,
useMWG: true, useMWG: true,
taskTimeoutMillis: 1 * 60 * 1000,
numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize'], numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize'],
/* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */
geoTz: (lat, lon) => geotz.find(lat, lon)[0], geoTz: (lat, lon) => geotz.find(lat, lon)[0],
@@ -104,7 +104,8 @@ export class MetadataRepository {
} }
readTags(path: string): Promise<ImmichTags> { readTags(path: string): Promise<ImmichTags> {
return this.exiftool.read(path).catch((error) => { const args = mimeTypes.isVideo(path) ? ['-ee'] : [];
return this.exiftool.read(path, args).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`); this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
return {}; return {};
}) as Promise<ImmichTags>; }) as Promise<ImmichTags>;

View File

@@ -28,7 +28,7 @@
"@formatjs/icu-messageformat-parser": "^2.9.8", "@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/justified-layout-wasm": "^0.4.3", "@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.50.1", "@immich/ui": "^0.50.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0", "@photo-sphere-viewer/core": "^5.14.0",