diff --git a/i18n/en.json b/i18n/en.json index 30c8949aef..28e1250c8a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1619,6 +1619,8 @@ "purchase_settings_server_activated": "The server product key is managed by the admin", "query_asset_id": "Query Asset ID", "queue_status": "Queuing {count}/{total}", + "quick_actions_settings_description": "Drag to rearrange buttons. Up to {count} available buttons are displayed in order.", + "quick_actions_settings_title": "Button order settings", "rating": "Star rating", "rating_clear": "Clear rating", "rating_count": "{count, plural, one {# star} other {# stars}}", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index d8404db409..0650fc44f0 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -71,6 +71,7 @@ enum StoreKey { readonlyModeEnabled._(138), autoPlayVideo._(139), + viewerQuickActionOrder._(140), // Experimental stuff photoManagerCustomFilter._(1000), 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..a750e92186 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -2,19 +2,19 @@ import 'package:flutter/material.dart'; 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/domain/models/setting.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/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.dart'; +import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.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/setting.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; class ViewerBottomBar extends ConsumerWidget { @@ -35,25 +35,51 @@ class ViewerBottomBar extends ConsumerWidget { final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final isInLockedView = ref.watch(inLockedViewProvider); final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + final isTrashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); + final quickActionOrder = ref.watch(viewerQuickActionOrderProvider); if (!showControls) { opacity = 0; } - final actions = [ - const ShareActionButton(source: ActionSource.viewer), - if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - if (asset.type == AssetType.image) const EditImageActionButton(), - 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), - ], - ]; + final buttonContext = ActionButtonContext( + asset: asset, + isOwner: isOwner, + isArchived: isArchived, + isTrashEnabled: isTrashEnabled, + isStacked: asset is RemoteAsset && asset.stackId != null, + isInLockedView: isInLockedView, + currentAlbum: currentAlbum, + advancedTroubleshooting: advancedTroubleshooting, + source: ActionSource.viewer, + ); + + final quickActionTypes = ActionButtonBuilder.buildQuickActionTypes( + buttonContext, + quickActionOrder: quickActionOrder, + ); + + Future openConfigurator() async { + final viewerNotifier = ref.read(assetViewerProvider.notifier); + + viewerNotifier.setBottomSheet(true); + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + enableDrag: false, + builder: (sheetContext) => + const FractionallySizedBox(heightFactor: 0.75, child: ViewerQuickActionConfigurator()), + ).whenComplete(() { + viewerNotifier.setBottomSheet(false); + }); + } + + final actions = quickActionTypes + .map((type) => GestureDetector(onLongPress: openConfigurator, child: type.buildButton(buttonContext))) + .toList(growable: false); return IgnorePointer( ignoring: opacity < 255, diff --git a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart new file mode 100644 index 0000000000..30ad9a237a --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart @@ -0,0 +1,225 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; +import 'package:immich_mobile/utils/action_button_visuals.dart'; + +class ViewerQuickActionConfigurator extends ConsumerStatefulWidget { + const ViewerQuickActionConfigurator({super.key}); + + @override + ConsumerState createState() => _ViewerQuickActionConfiguratorState(); +} + +class _ViewerQuickActionConfiguratorState extends ConsumerState { + late List _order; + late final ScrollController _scrollController; + bool _hasLocalChanges = false; + + @override + void initState() { + super.initState(); + _order = List.from(ref.read(viewerQuickActionOrderProvider)); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onReorder(ReorderedListFunction reorder) { + setState(() { + _order = reorder(_order); + _hasLocalChanges = true; + }); + } + + void _resetToDefault() { + setState(() { + _order = List.from(ActionButtonBuilder.defaultQuickActionOrder); + _hasLocalChanges = true; + }); + } + + void _cancel() => Navigator.of(context).pop(); + + Future _save() async { + final normalized = ActionButtonBuilder.normalizeQuickActionOrder(_order); + + await ref.read(viewerQuickActionOrderProvider.notifier).setOrder(normalized); + _hasLocalChanges = false; + if (mounted) { + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const crossAxisCount = 4; + const crossAxisSpacing = 12.0; + const mainAxisSpacing = 12.0; + const tileHeight = 130.0; + final currentOrder = ref.watch(viewerQuickActionOrderProvider); + if (!_hasLocalChanges && !listEquals(_order, currentOrder)) { + _order = List.from(currentOrder); + } + final normalizedSelection = ActionButtonBuilder.normalizeQuickActionOrder(_order); + final hasChanges = !listEquals(currentOrder, normalizedSelection); + + return SafeArea( + child: Padding( + padding: EdgeInsets.only(left: 20, right: 20, bottom: MediaQuery.of(context).viewInsets.bottom + 20, top: 16), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text('quick_actions_settings_title'.tr(), style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'quick_actions_settings_description'.tr( + namedArgs: {'count': ActionButtonBuilder.defaultQuickActionLimit.toString()}, + ), + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final rows = (_order.length / crossAxisCount).ceil().clamp(1, 4); + final naturalHeight = rows * tileHeight + (rows - 1) * mainAxisSpacing; + final shouldScroll = naturalHeight > constraints.maxHeight; + final horizontalPadding = 8.0; // matches GridView padding + final tileWidth = + (constraints.maxWidth - horizontalPadding - (crossAxisSpacing * (crossAxisCount - 1))) / + crossAxisCount; + final childAspectRatio = tileWidth / tileHeight; + final gridController = shouldScroll ? _scrollController : null; + + return ReorderableBuilder( + onReorder: _onReorder, + enableLongPress: false, + scrollController: gridController, + children: [ + for (var i = 0; i < _order.length; i++) + _QuickActionTile(key: ValueKey(_order[i].name), index: i, type: _order[i]), + ], + builder: (children) => GridView.count( + controller: gridController, + crossAxisCount: crossAxisCount, + crossAxisSpacing: crossAxisSpacing, + mainAxisSpacing: mainAxisSpacing, + // padding: const EdgeInsets.fromLTRB(4, 0, 4, 12), + physics: shouldScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), + childAspectRatio: childAspectRatio, + children: children, + ), + ); + }, + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton(onPressed: _resetToDefault, child: const Text('reset').tr()), + Row( + children: [ + TextButton(onPressed: _cancel, child: const Text('cancel').tr()), + const SizedBox(width: 8), + FilledButton(onPressed: hasChanges ? _save : null, child: const Text('done').tr()), + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +class _QuickActionTile extends StatelessWidget { + final int index; + final ActionButtonType type; + + const _QuickActionTile({super.key, required this.index, required this.type}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final borderColor = theme.dividerColor; + final backgroundColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2); + final indicatorColor = theme.colorScheme.primary; + final accentColor = theme.colorScheme.onSurface.withValues(alpha: 0.7); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + color: backgroundColor, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: indicatorColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + '${index + 1}', + style: theme.textTheme.labelSmall?.copyWith(color: indicatorColor, fontWeight: FontWeight.bold), + ), + ), + ), + const Spacer(), + Icon(Icons.drag_indicator_rounded, size: 18, color: indicatorColor), + ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.topCenter, + child: Icon(type.iconData, size: 28, color: theme.colorScheme.onSurface), + ), + const SizedBox(height: 6), + Align( + alignment: Alignment.topCenter, + child: Text( + type.localizedLabel(context), + style: theme.textTheme.labelSmall?.copyWith(color: accentColor), + textAlign: TextAlign.center, + maxLines: 3, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart new file mode 100644 index 0000000000..10d2a8835c --- /dev/null +++ b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'viewer_quick_action_order.provider.g.dart'; + +@Riverpod(keepAlive: true) +class ViewerQuickActionOrder extends _$ViewerQuickActionOrder { + StreamSubscription>? _subscription; + + @override + List build() { + final service = ref.watch(appSettingsServiceProvider); + final initial = ActionButtonBuilder.normalizeQuickActionOrder(service.getViewerQuickActionOrder()); + + _subscription ??= service.watchViewerQuickActionOrder().listen((order) { + state = ActionButtonBuilder.normalizeQuickActionOrder(order); + }); + + ref.onDispose(() { + _subscription?.cancel(); + _subscription = null; + }); + + return initial; + } + + Future setOrder(List order) async { + final normalized = ActionButtonBuilder.normalizeQuickActionOrder(order); + + if (listEquals(state, normalized)) { + return; + } + + final previous = state; + state = normalized; + + try { + await ref.read(appSettingsServiceProvider).setViewerQuickActionOrder(normalized); + } catch (error) { + state = previous; + rethrow; + } + } +} + +/// Mock class for testing +abstract class ViewerQuickActionOrderInternal extends _$ViewerQuickActionOrder {} diff --git a/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart new file mode 100644 index 0000000000..c54e80a452 --- /dev/null +++ b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'viewer_quick_action_order.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$viewerQuickActionOrderHash() => + r'd539bc6ba5fae4fa07a7c30c42d9f6aee1488f97'; + +/// See also [ViewerQuickActionOrder]. +@ProviderFor(ViewerQuickActionOrder) +final viewerQuickActionOrderProvider = + NotifierProvider>.internal( + ViewerQuickActionOrder.new, + name: r'viewerQuickActionOrderProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$viewerQuickActionOrderHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ViewerQuickActionOrder = Notifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 7149408e8a..788562d50d 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; enum AppSettingsEnum { loadPreview(StoreKey.loadPreview, "loadPreview", true), @@ -71,4 +72,20 @@ class AppSettingsService { Future setSetting(AppSettingsEnum setting, T value) { return Store.put(setting.storeKey, value); } + + List getViewerQuickActionOrder() { + final stored = Store.get(StoreKey.viewerQuickActionOrder, ActionButtonBuilder.defaultQuickActionOrderStorageValue); + return ActionButtonBuilder.parseQuickActionOrder(stored); + } + + Stream> watchViewerQuickActionOrder() { + return Store.watch(StoreKey.viewerQuickActionOrder).map( + (value) => + ActionButtonBuilder.parseQuickActionOrder(value ?? ActionButtonBuilder.defaultQuickActionOrderStorageValue), + ); + } + + Future setViewerQuickActionOrder(List order) { + return Store.put(StoreKey.viewerQuickActionOrder, ActionButtonBuilder.encodeQuickActionOrder(order)); + } } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 42729becc9..961e44d7fe 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_ 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/download_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/like_activity_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/action_buttons/remove_from_album_action_button.widget.dart'; @@ -47,6 +48,7 @@ class ActionButtonContext { enum ActionButtonType { advancedInfo, share, + edit, shareLink, similarPhotos, archive, @@ -67,6 +69,9 @@ enum ActionButtonType { return switch (this) { ActionButtonType.advancedInfo => context.advancedTroubleshooting, ActionButtonType.share => true, + ActionButtonType.edit => + !context.isInLockedView && // + context.asset.isImage, ActionButtonType.shareLink => !context.isInLockedView && // context.asset.hasRemote, @@ -135,6 +140,7 @@ enum ActionButtonType { return switch (this) { ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source), ActionButtonType.share => ShareActionButton(source: context.source), + ActionButtonType.edit => const EditImageActionButton(), ActionButtonType.shareLink => ShareLinkActionButton(source: context.source), ActionButtonType.archive => ArchiveActionButton(source: context.source), ActionButtonType.unarchive => UnArchiveActionButton(source: context.source), @@ -160,7 +166,143 @@ enum ActionButtonType { class ActionButtonBuilder { static const List _actionTypes = ActionButtonType.values; + static const int defaultQuickActionLimit = 4; + static const String quickActionStorageDelimiter = ','; + + static const List _defaultQuickActionSeed = [ + ActionButtonType.share, + ActionButtonType.upload, + ActionButtonType.edit, + ActionButtonType.archive, + ActionButtonType.delete, + ActionButtonType.removeFromAlbum, + ActionButtonType.likeActivity, + ]; + + static final Set _quickActionSet = Set.unmodifiable(_defaultQuickActionSeed); + + static final List defaultQuickActionOrder = List.unmodifiable( + _defaultQuickActionSeed, + ); + + static final String defaultQuickActionOrderStorageValue = defaultQuickActionOrder + .map((type) => type.name) + .join(quickActionStorageDelimiter); + + static List get quickActionOptions => defaultQuickActionOrder; + + static List parseQuickActionOrder(String? stored) { + final parsed = []; + + if (stored != null && stored.trim().isNotEmpty) { + for (final name in stored.split(quickActionStorageDelimiter)) { + final type = _typeByName(name.trim()); + if (type != null) { + parsed.add(type); + } + } + } + + return normalizeQuickActionOrder(parsed); + } + + static String encodeQuickActionOrder(List order) { + final unique = {}; + final buffer = []; + + for (final type in order) { + if (unique.add(type)) { + buffer.add(type.name); + } + } + + final result = buffer.join(quickActionStorageDelimiter); + return result; + } + + static List buildQuickActionTypes( + ActionButtonContext context, { + List? quickActionOrder, + int limit = defaultQuickActionLimit, + }) { + final normalized = normalizeQuickActionOrder( + quickActionOrder == null || quickActionOrder.isEmpty ? defaultQuickActionOrder : quickActionOrder, + ); + + final seen = {}; + final result = []; + + for (final type in normalized) { + if (!_quickActionSet.contains(type)) { + continue; + } + + final resolved = _resolveQuickActionType(type, context); + if (!seen.add(resolved) || !resolved.shouldShow(context)) { + continue; + } + + result.add(resolved); + if (result.length >= limit) { + break; + } + } + + return result; + } + + static List buildQuickActions( + ActionButtonContext context, { + List? quickActionOrder, + int limit = defaultQuickActionLimit, + }) { + final types = buildQuickActionTypes(context, quickActionOrder: quickActionOrder, limit: limit); + return types.map((type) => type.buildButton(context)).toList(); + } + + static ActionButtonType? _typeByName(String name) { + if (name.isEmpty) { + return null; + } + + for (final type in ActionButtonType.values) { + if (type.name == name) { + return type; + } + } + + return null; + } + static List build(ActionButtonContext context) { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } + + static List normalizeQuickActionOrder(List order) { + final ordered = {}; + + for (final type in order) { + if (_quickActionSet.contains(type)) { + ordered.add(type); + } + } + + ordered.addAll(_defaultQuickActionSeed); + + return ordered.toList(growable: false); + } + + static ActionButtonType _resolveQuickActionType(ActionButtonType type, ActionButtonContext context) { + if (type == ActionButtonType.archive && context.isArchived) { + return ActionButtonType.unarchive; + } + + if (type == ActionButtonType.delete && context.asset.isLocalOnly) { + return ActionButtonType.deleteLocal; + } + + return type; + } + + static bool isSupportedQuickAction(ActionButtonType type) => _quickActionSet.contains(type); } diff --git a/mobile/lib/utils/action_button_visuals.dart b/mobile/lib/utils/action_button_visuals.dart new file mode 100644 index 0000000000..9d85174ac6 --- /dev/null +++ b/mobile/lib/utils/action_button_visuals.dart @@ -0,0 +1,53 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; + +extension ActionButtonTypeVisuals on ActionButtonType { + IconData get iconData { + return switch (this) { + ActionButtonType.advancedInfo => Icons.help_outline_rounded, + ActionButtonType.share => Icons.share_rounded, + ActionButtonType.edit => Icons.tune, + ActionButtonType.shareLink => Icons.link_rounded, + ActionButtonType.similarPhotos => Icons.compare, + ActionButtonType.archive => Icons.archive_outlined, + ActionButtonType.unarchive => Icons.unarchive_outlined, + ActionButtonType.download => Icons.download, + ActionButtonType.trash => Icons.delete_outline_rounded, + ActionButtonType.deletePermanent => Icons.delete_forever, + ActionButtonType.delete => Icons.delete_sweep_outlined, + ActionButtonType.moveToLockFolder => Icons.lock_outline_rounded, + ActionButtonType.removeFromLockFolder => Icons.lock_open_rounded, + ActionButtonType.deleteLocal => Icons.no_cell_outlined, + ActionButtonType.upload => Icons.backup_outlined, + ActionButtonType.removeFromAlbum => Icons.remove_circle_outline, + ActionButtonType.unstack => Icons.layers_clear_outlined, + ActionButtonType.likeActivity => Icons.favorite_border, + }; + } + + String get _labelKey { + return switch (this) { + ActionButtonType.advancedInfo => 'troubleshoot', + ActionButtonType.share => 'share', + ActionButtonType.edit => 'edit', + ActionButtonType.shareLink => 'share_link', + ActionButtonType.similarPhotos => 'view_similar_photos', + ActionButtonType.archive => 'to_archive', + ActionButtonType.unarchive => 'unarchive', + ActionButtonType.download => 'download', + ActionButtonType.trash => 'control_bottom_app_bar_trash_from_immich', + ActionButtonType.deletePermanent => 'delete_permanently', + ActionButtonType.delete => 'delete', + ActionButtonType.moveToLockFolder => 'move_to_locked_folder', + ActionButtonType.removeFromLockFolder => 'remove_from_locked_folder', + ActionButtonType.deleteLocal => 'control_bottom_app_bar_delete_from_local', + ActionButtonType.upload => 'upload', + ActionButtonType.removeFromAlbum => 'remove_from_album', + ActionButtonType.unstack => 'unstack', + ActionButtonType.likeActivity => 'like', + }; + } + + String localizedLabel(BuildContext context) => _labelKey.tr(); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0b10384621..da560c4064 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -664,6 +664,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.27" + flutter_reorderable_grid_view: + dependency: "direct main" + description: + name: flutter_reorderable_grid_view + sha256: beb85f95325c83515d8953e8612dc70d287a69d1437c14262b7d738070133a87 + url: "https://pub.dev" + source: hosted + version: "5.5.2" flutter_riverpod: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b47038b7e5..a48d17c7bb 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: flutter_udid: ^4.0.0 flutter_web_auth_2: ^5.0.0-alpha.0 fluttertoast: ^8.2.12 + flutter_reorderable_grid_view: ^5.5.2 geolocator: ^14.0.2 home_widget: ^0.8.1 hooks_riverpod: ^2.6.1 diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index d93d59d3c7..ed5691dafd 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -3,6 +3,9 @@ import 'package:flutter_test/flutter_test.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/presentation/widgets/action_buttons/archive_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/utils/action_button.utils.dart'; LocalAsset createLocalAsset({ @@ -137,6 +140,56 @@ void main() { }); }); + group('edit button', () { + test('should show for images when not in locked view', () { + final context = ActionButtonContext( + asset: createRemoteAsset(type: AssetType.image), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.edit.shouldShow(context), isTrue); + }); + + test('should not show in locked view', () { + final context = ActionButtonContext( + asset: createRemoteAsset(type: AssetType.image), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.edit.shouldShow(context), isFalse); + }); + + test('should not show for non-image assets', () { + final context = ActionButtonContext( + asset: createRemoteAsset(type: AssetType.video), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.edit.shouldShow(context), isFalse); + }); + }); + group('shareLink button', () { test('should show when not in locked view and asset has remote', () { final remoteAsset = createRemoteAsset(); @@ -961,5 +1014,53 @@ void main() { expect(archivedWidgets, isNotEmpty); expect(nonArchivedWidgets, isNotEmpty); }); + + test('should encode and parse quick action order consistently', () { + final encoded = ActionButtonBuilder.encodeQuickActionOrder([ + ActionButtonType.edit, + ActionButtonType.share, + ActionButtonType.archive, + ]); + + final decoded = ActionButtonBuilder.parseQuickActionOrder(encoded); + + final expectedOrder = ActionButtonBuilder.normalizeQuickActionOrder([ + ActionButtonType.edit, + ActionButtonType.share, + ActionButtonType.archive, + ]); + + expect(decoded, expectedOrder); + }); + + test('should build quick actions honoring custom order', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + final quickActions = ActionButtonBuilder.buildQuickActions( + context, + quickActionOrder: const [ + ActionButtonType.archive, + ActionButtonType.share, + ActionButtonType.edit, + ActionButtonType.delete, + ], + ); + + expect(quickActions.length, ActionButtonBuilder.defaultQuickActionLimit); + expect(quickActions.first, isA()); + expect(quickActions[1], isA()); + expect(quickActions[2], isA()); + }); }); }