diff --git a/i18n/en.json b/i18n/en.json index 5903d7850e3bb..e3787f61bb88b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1667,6 +1667,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}}", @@ -1727,6 +1729,7 @@ "removed_photo_from_memory": "Removed photo from memory", "removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}", "rename": "Rename", + "reorder_buttons": "Reorder buttons", "repair": "Repair", "repair_no_results_message": "Untracked and missing files will show up here", "replace_with_upload": "Replace with upload", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index a18644cd2a6c9..ae0112acb3501 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -72,6 +72,7 @@ enum StoreKey { autoPlayVideo._(139), albumGridView._(140), + viewerQuickActionOrder._(141), // Experimental stuff photoManagerCustomFilter._(1000), diff --git a/mobile/lib/domain/services/quick_action.service.dart b/mobile/lib/domain/services/quick_action.service.dart new file mode 100644 index 0000000000000..d15a67fd47c75 --- /dev/null +++ b/mobile/lib/domain/services/quick_action.service.dart @@ -0,0 +1,85 @@ +import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; + +class QuickActionService { + final ActionButtonOrderRepository _repository; + + const QuickActionService(this._repository); + + static final Set _quickActionSet = Set.unmodifiable( + ActionButtonBuilder.defaultQuickActionOrder, + ); + + List get() { + return _repository.get(); + } + + Future set(List order) async { + final normalized = _normalizeQuickActionOrder(order); + await _repository.set(normalized); + } + + Stream> watch() { + return _repository.watch(); + } + + List _normalizeQuickActionOrder(List order) { + final ordered = {}; + + for (final type in order) { + if (_quickActionSet.contains(type)) { + ordered.add(type); + } + } + + ordered.addAll(ActionButtonBuilder.defaultQuickActionOrder); + + return ordered.toList(growable: false); + } + + List buildQuickActionTypes( + ActionButtonContext context, { + List? quickActionOrder, + int limit = ActionButtonBuilder.defaultQuickActionLimit, + }) { + final normalized = _normalizeQuickActionOrder( + quickActionOrder == null || quickActionOrder.isEmpty + ? ActionButtonBuilder.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; + } + + /// Resolve quick action type based on context (e.g., archive -> unarchive) + 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; + } +} diff --git a/mobile/lib/infrastructure/repositories/action_button_order.repository.dart b/mobile/lib/infrastructure/repositories/action_button_order.repository.dart new file mode 100644 index 0000000000000..552ee5bac85f7 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/action_button_order.repository.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +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'; + +class ActionButtonOrderRepository { + const ActionButtonOrderRepository(); + + static const storeKey = StoreKey.viewerQuickActionOrder; + + List get() { + final json = Store.tryGet(storeKey); + if (json == null || json.isEmpty) { + return ActionButtonBuilder.defaultQuickActionOrder; + } + + final deserialized = _deserialize(json); + return deserialized.isEmpty ? ActionButtonBuilder.defaultQuickActionOrder : deserialized; + } + + Future set(List order) async { + final json = _serialize(order); + await Store.put(storeKey, json); + } + + Stream> watch() { + return Store.watch(storeKey).map((json) { + if (json == null || json.isEmpty) { + return ActionButtonBuilder.defaultQuickActionOrder; + } + final deserialized = _deserialize(json); + return deserialized.isEmpty ? ActionButtonBuilder.defaultQuickActionOrder : deserialized; + }); + } + + String _serialize(List order) { + return jsonEncode(order.map((type) => type.name).toList()); + } + + List _deserialize(String json) { + try { + final list = jsonDecode(json) as List; + return list + .whereType() + .map((name) { + try { + return ActionButtonType.values.byName(name); + } catch (e) { + return null; + } + }) + .whereType() + .toList(); + } catch (e) { + return []; + } + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/reorder_buttons_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/reorder_buttons_action_button.widget.dart new file mode 100644 index 0000000000000..b7855ae3e1f10 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/reorder_buttons_action_button.widget.dart @@ -0,0 +1,35 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_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.widget.dart'; + +class ReorderButtonsActionButton extends ConsumerWidget { + const ReorderButtonsActionButton({super.key, this.originalTheme}); + + final ThemeData? originalTheme; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + label: 'reorder_buttons'.tr(), + iconData: Icons.swap_vert, + iconColor: originalTheme?.iconTheme.color, + menuItem: true, + onPressed: () 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: QuickActionConfigurator()), + ).whenComplete(() { + viewerNotifier.setBottomSheet(false); + }); + }, + ); + } +} 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 67bbc4c83a656..725a382d4220d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -2,18 +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/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/upload_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.widget.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 { @@ -33,25 +34,53 @@ class ViewerBottomBar extends ConsumerWidget { int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final isInLockedView = ref.watch(inLockedViewProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + 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 originalTheme = context.themeData; + 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 quickActionService = ref.watch(quickActionServiceProvider); + final quickActionTypes = quickActionService.buildQuickActionTypes( + buttonContext, + quickActionOrder: quickActionOrder, + ); - final actions = [ - const ShareActionButton(source: ActionSource.viewer), - if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - if (asset.type == AssetType.image) const EditImageActionButton(), - if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), + 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: QuickActionConfigurator()), + ).whenComplete(() { + viewerNotifier.setBottomSheet(false); + }); + } - if (isOwner) ...[ - asset.isLocalOnly - ? const DeleteLocalActionButton(source: ActionSource.viewer) - : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), - ], - ]; + final actions = quickActionTypes + .map((type) => type.buildButton(buttonContext)) + .map((widget) => GestureDetector(onLongPress: openConfigurator, child: widget)) + .toList(growable: false); return IgnorePointer( ignoring: opacity < 255, diff --git a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart new file mode 100644 index 0000000000000..5cd8d81d4e1c3 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -0,0 +1,219 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.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'; +import 'package:immich_mobile/widgets/common/reorderable_drag_drop_grid.dart'; + +class QuickActionConfigurator extends ConsumerStatefulWidget { + const QuickActionConfigurator({super.key}); + + @override + ConsumerState createState() => _QuickActionConfiguratorState(); +} + +class _QuickActionConfiguratorState 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(int oldIndex, int newIndex) { + setState(() { + final item = _order.removeAt(oldIndex); + _order.insert(newIndex, item); + _hasLocalChanges = true; + }); + } + + void _resetToDefault() { + setState(() { + _order = List.from(ActionButtonBuilder.defaultQuickActionOrder); + _hasLocalChanges = true; + }); + } + + void _cancel() => Navigator.of(context).pop(); + + Future _save() async { + await ref.read(viewerQuickActionOrderProvider.notifier).setOrder(_order); + _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 hasChanges = !listEquals(currentOrder, _order); + + 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: const BorderRadius.all(Radius.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; + final tileWidth = + (constraints.maxWidth - horizontalPadding - (crossAxisSpacing * (crossAxisCount - 1))) / + crossAxisCount; + final childAspectRatio = tileWidth / tileHeight; + final gridController = shouldScroll ? _scrollController : null; + + return ReorderableDragDropGrid( + scrollController: gridController, + itemCount: _order.length, + itemBuilder: (context, index) { + final type = _order[index]; + return _QuickActionTile(index: index, type: type); + }, + onReorder: _onReorder, + crossAxisCount: crossAxisCount, + crossAxisSpacing: crossAxisSpacing, + mainAxisSpacing: mainAxisSpacing, + childAspectRatio: childAspectRatio, + shouldScroll: shouldScroll, + ); + }, + ), + ), + 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({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: const BorderRadius.all(Radius.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: const BorderRadius.all(Radius.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 0000000000000..a76793ae417af --- /dev/null +++ b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/quick_action.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; + +final actionButtonOrderRepositoryProvider = Provider( + (ref) => const ActionButtonOrderRepository(), +); + +final quickActionServiceProvider = Provider( + (ref) => QuickActionService(ref.watch(actionButtonOrderRepositoryProvider)), +); + +final viewerQuickActionOrderProvider = StateNotifierProvider>( + (ref) => ViewerQuickActionOrderNotifier(ref.watch(quickActionServiceProvider)), +); + +class ViewerQuickActionOrderNotifier extends StateNotifier> { + final QuickActionService _service; + StreamSubscription>? _subscription; + + ViewerQuickActionOrderNotifier(this._service) : super(_service.get()) { + _subscription = _service.watch().listen((order) { + state = order; + }); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + Future setOrder(List order) async { + if (listEquals(state, order)) { + return; + } + + final previous = state; + state = order; + + try { + await _service.set(order); + } catch (error) { + state = previous; + rethrow; + } + } +} diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index fc08193d11977..7dabd91d51d4d 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -65,6 +65,7 @@ enum AppSettingsEnum { class AppSettingsService { const AppSettingsService(); + T getSetting(AppSettingsEnum setting) { return Store.get(setting.storeKey, setting.defaultValue); } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 917ddbebca8ee..161c6961b15e7 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.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'; @@ -17,6 +18,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'; @@ -28,6 +30,7 @@ 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/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/reorder_buttons_action_button.widget.dart'; import 'package:immich_mobile/routing/router.dart'; class ActionButtonContext { @@ -57,6 +60,8 @@ class ActionButtonContext { enum ActionButtonType { advancedInfo, share, + edit, + add, shareLink, similarPhotos, archive, @@ -73,10 +78,16 @@ enum ActionButtonType { unstack, likeActivity; + String toJson() => name; + bool shouldShow(ActionButtonContext context) { return switch (this) { ActionButtonType.advancedInfo => context.advancedTroubleshooting, ActionButtonType.share => true, + ActionButtonType.edit => + !context.isInLockedView && // + context.asset.isImage, + ActionButtonType.add => context.asset.hasRemote, ActionButtonType.shareLink => !context.isInLockedView && // context.asset.hasRemote, @@ -145,6 +156,8 @@ enum ActionButtonType { return switch (this) { ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source), ActionButtonType.share => ShareActionButton(source: context.source), + ActionButtonType.edit => const EditImageActionButton(), + ActionButtonType.add => const AddActionButton(), ActionButtonType.shareLink => ShareLinkActionButton(source: context.source), ActionButtonType.archive => ArchiveActionButton(source: context.source), ActionButtonType.unarchive => UnArchiveActionButton(source: context.source), @@ -170,6 +183,19 @@ enum ActionButtonType { class ActionButtonBuilder { static const List _actionTypes = ActionButtonType.values; + static const int defaultQuickActionLimit = 4; + + static const List defaultQuickActionOrder = [ + ActionButtonType.share, + ActionButtonType.upload, + ActionButtonType.edit, + ActionButtonType.add, + ActionButtonType.archive, + ActionButtonType.delete, + ActionButtonType.removeFromAlbum, + ActionButtonType.likeActivity, + ]; + static List build(ActionButtonContext context) { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } @@ -194,6 +220,7 @@ class ViewerKebabMenuButtonContext { enum ViewerKebabMenuButtonType { openInfo, viewInTimeline, + reorderButtons, cast, download; @@ -203,6 +230,7 @@ enum ViewerKebabMenuButtonType { int get group => switch (this) { ViewerKebabMenuButtonType.openInfo => 0, ViewerKebabMenuButtonType.viewInTimeline => 1, + ViewerKebabMenuButtonType.reorderButtons => 1, ViewerKebabMenuButtonType.cast => 1, ViewerKebabMenuButtonType.download => 1, }; @@ -219,6 +247,7 @@ enum ViewerKebabMenuButtonType { context.isOwner, ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote, ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly, + ViewerKebabMenuButtonType.reorderButtons => true, }; } @@ -245,6 +274,7 @@ enum ViewerKebabMenuButtonType { ), ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true), ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true), + ViewerKebabMenuButtonType.reorderButtons => ReorderButtonsActionButton(originalTheme: context.originalTheme), }; } } diff --git a/mobile/lib/utils/action_button_visuals.dart b/mobile/lib/utils/action_button_visuals.dart new file mode 100644 index 0000000000000..d979c96f42c08 --- /dev/null +++ b/mobile/lib/utils/action_button_visuals.dart @@ -0,0 +1,55 @@ +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.add => Icons.add, + 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.add => 'add_to_bottom_bar', + 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 _) => _labelKey.tr(); +} diff --git a/mobile/lib/widgets/common/reorderable_drag_drop_grid.dart b/mobile/lib/widgets/common/reorderable_drag_drop_grid.dart new file mode 100644 index 0000000000000..b6bc9a65b9150 --- /dev/null +++ b/mobile/lib/widgets/common/reorderable_drag_drop_grid.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; + +/// A callback that is called when items are reordered. +/// [oldIndex] is the original index of the item being moved. +/// [newIndex] is the target index where the item should be moved to. +typedef ReorderCallback = void Function(int oldIndex, int newIndex); + +/// A callback that is called during drag to update hover state. +/// [draggedIndex] is the index of the item being dragged. +/// [targetIndex] is the index of the item being hovered over. +typedef DragUpdateCallback = void Function(int draggedIndex, int targetIndex); + +/// A reorderable grid that supports drag and drop reordering with smooth animations. +/// +/// This widget provides a drag-and-drop interface for reordering items in a grid layout. +/// Items can be dragged to new positions, and the grid will animate smoothly to reflect +/// the new order. +/// +/// Features: +/// - Smooth animations during drag and drop +/// - Instant snap animation on drop completion +/// - Visual feedback during dragging +/// - Customizable grid layout parameters +class ReorderableDragDropGrid extends StatefulWidget { + /// Controller for scrolling the grid. + final ScrollController? scrollController; + + /// The number of items to display. + final int itemCount; + + /// Builder function to create each grid item. + final Widget Function(BuildContext context, int index) itemBuilder; + + /// Callback when items are reordered. + final ReorderCallback onReorder; + + /// Number of columns in the grid. + final int crossAxisCount; + + /// Horizontal spacing between grid items. + final double crossAxisSpacing; + + /// Vertical spacing between grid items. + final double mainAxisSpacing; + + /// The ratio of width to height for each grid item. + final double childAspectRatio; + + /// Whether the grid should be scrollable. + final bool shouldScroll; + + /// Scale factor for the dragged item feedback widget. + final double feedbackScaleFactor; + + /// Opacity for the dragged item feedback widget. + final double feedbackOpacity; + + const ReorderableDragDropGrid({ + super.key, + this.scrollController, + required this.itemCount, + required this.itemBuilder, + required this.onReorder, + required this.crossAxisCount, + required this.crossAxisSpacing, + required this.mainAxisSpacing, + required this.childAspectRatio, + this.shouldScroll = true, + this.feedbackScaleFactor = 1.05, + this.feedbackOpacity = 0.9, + }); + + @override + State createState() => _ReorderableDragDropGridState(); +} + +class _ReorderableDragDropGridState extends State { + int? _draggingIndex; + late List _itemOrder; + int? _lastHoveredIndex; + bool _snapNow = false; + + @override + void initState() { + super.initState(); + _itemOrder = List.generate(widget.itemCount, (index) => index); + } + + @override + void didUpdateWidget(ReorderableDragDropGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.itemCount != widget.itemCount) { + _itemOrder = List.generate(widget.itemCount, (index) => index); + } + } + + void _updateHover(int draggedIndex, int targetIndex) { + if (draggedIndex == targetIndex || _draggingIndex == null) return; + + setState(() { + _lastHoveredIndex = targetIndex; + final newOrder = List.from(_itemOrder); + final draggedOrderIndex = newOrder.indexOf(draggedIndex); + final targetOrderIndex = newOrder.indexOf(targetIndex); + + newOrder.removeAt(draggedOrderIndex); + newOrder.insert(targetOrderIndex, draggedIndex); + _itemOrder = newOrder; + }); + } + + void _handleDragEnd(int draggedIndex, int? targetIndex) { + final effectiveTargetIndex = + targetIndex ?? + (() { + final currentVisualIndex = _itemOrder.indexOf(draggedIndex); + if (currentVisualIndex != draggedIndex) { + return _itemOrder[currentVisualIndex]; + } + return null; + })(); + + if (effectiveTargetIndex != null && draggedIndex != effectiveTargetIndex) { + widget.onReorder(draggedIndex, effectiveTargetIndex); + } + + _armSnapNow(); + + setState(() { + _draggingIndex = null; + _lastHoveredIndex = null; + _itemOrder = List.generate(widget.itemCount, (i) => i); + }); + } + + void _armSnapNow() { + setState(() => _snapNow = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _snapNow = false); + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final tileWidth = + (constraints.maxWidth - (widget.crossAxisSpacing * (widget.crossAxisCount - 1))) / widget.crossAxisCount; + final tileHeight = tileWidth / widget.childAspectRatio; + final rows = (_itemOrder.length / widget.crossAxisCount).ceil(); + final totalHeight = rows * tileHeight + (rows - 1) * widget.mainAxisSpacing; + + return SingleChildScrollView( + controller: widget.scrollController, + physics: widget.shouldScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), + child: SizedBox( + width: constraints.maxWidth, + height: totalHeight, + child: Stack( + children: List.generate(widget.itemCount, (index) { + final visualIndex = _itemOrder.indexOf(index); + final isDragging = _draggingIndex == index; + + final row = visualIndex ~/ widget.crossAxisCount; + final col = visualIndex % widget.crossAxisCount; + final left = col * (tileWidth + widget.crossAxisSpacing); + final top = row * (tileHeight + widget.mainAxisSpacing); + + return _AnimatedGridItem( + key: ValueKey(index), + index: index, + isDragging: isDragging, + snapNow: _snapNow, + tileWidth: tileWidth, + tileHeight: tileHeight, + left: left, + top: top, + feedbackScaleFactor: widget.feedbackScaleFactor, + feedbackOpacity: widget.feedbackOpacity, + onDragStarted: () { + setState(() { + _draggingIndex = index; + _lastHoveredIndex = index; + }); + }, + onDragUpdate: (draggedIndex, targetIndex) { + _updateHover(draggedIndex, targetIndex); + }, + onDragCompleted: (draggedIndex) { + _handleDragEnd(draggedIndex, _lastHoveredIndex); + }, + child: widget.itemBuilder(context, index), + ); + }), + ), + ), + ); + }, + ); + } +} + +class _AnimatedGridItem extends StatelessWidget { + final int index; + final bool isDragging; + final bool snapNow; + final double tileWidth; + final double tileHeight; + final double left; + final double top; + final double feedbackScaleFactor; + final double feedbackOpacity; + final VoidCallback onDragStarted; + final DragUpdateCallback onDragUpdate; + final Function(int draggedIndex) onDragCompleted; + final Widget child; + + const _AnimatedGridItem({ + super.key, + required this.index, + required this.isDragging, + required this.snapNow, + required this.tileWidth, + required this.tileHeight, + required this.left, + required this.top, + required this.feedbackScaleFactor, + required this.feedbackOpacity, + required this.onDragStarted, + required this.onDragUpdate, + required this.onDragCompleted, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final Duration animDuration = snapNow ? Duration.zero : const Duration(milliseconds: 150); + + return AnimatedPositioned( + duration: animDuration, + curve: Curves.easeInOut, + left: left, + top: top, + width: tileWidth, + height: tileHeight, + child: DragTarget( + onWillAcceptWithDetails: (details) { + if (details.data != index) { + onDragUpdate(details.data, index); + } + return details.data != index; + }, + builder: (context, candidateData, rejectedData) { + Widget displayChild = child; + + if (isDragging) { + displayChild = Opacity(opacity: 0.0, child: child); + } + + return Draggable( + data: index, + feedback: Material( + color: Colors.transparent, + child: SizedBox( + width: tileWidth, + height: tileHeight, + child: Opacity( + opacity: feedbackOpacity, + child: Transform.scale(scale: feedbackScaleFactor, child: child), + ), + ), + ), + childWhenDragging: const SizedBox.shrink(), + onDragStarted: onDragStarted, + onDragCompleted: () { + onDragCompleted(index); + }, + onDraggableCanceled: (_, __) { + onDragCompleted(index); + }, + child: displayChild, + ); + }, + ), + ); + } +} diff --git a/mobile/test/domain/services/quick_action_service_test.dart b/mobile/test/domain/services/quick_action_service_test.dart new file mode 100644 index 0000000000000..be236e831f806 --- /dev/null +++ b/mobile/test/domain/services/quick_action_service_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/quick_action.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; + +void main() { + group('QuickActionService', () { + late QuickActionService service; + + setUp(() { + // Use repository with default behavior for testing + service = const QuickActionService(ActionButtonOrderRepository()); + }); + + test('buildQuickActionTypes should respect custom order', () { + final remoteAsset = RemoteAsset( + id: 'test-id', + name: 'test.jpg', + checksum: 'checksum', + type: AssetType.image, + ownerId: 'owner-id', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + final customOrder = [ + ActionButtonType.archive, + ActionButtonType.share, + ActionButtonType.edit, + ActionButtonType.delete, + ]; + + final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder); + + expect(types.length, lessThanOrEqualTo(ActionButtonBuilder.defaultQuickActionLimit)); + expect(types.first, ActionButtonType.archive); + expect(types[1], ActionButtonType.share); + }); + + test('buildQuickActionTypes should resolve archive to unarchive when archived', () { + final remoteAsset = RemoteAsset( + id: 'test-id', + name: 'test.jpg', + checksum: 'checksum', + type: AssetType.image, + ownerId: 'owner-id', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: true, // archived + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + final customOrder = [ActionButtonType.archive]; + + final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder); + + expect(types.contains(ActionButtonType.unarchive), isTrue); + expect(types.contains(ActionButtonType.archive), isFalse); + }); + + test('buildQuickActionTypes should filter types that shouldShow returns false', () { + final localAsset = LocalAsset( + id: 'local-id', + name: 'test.jpg', + checksum: 'checksum', + type: AssetType.image, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + final customOrder = [ + ActionButtonType.archive, // should not show for local-only asset + ActionButtonType.share, + ]; + + final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder); + + expect(types.contains(ActionButtonType.archive), isFalse); + expect(types.contains(ActionButtonType.share), isTrue); + }); + + test('buildQuickActionTypes should respect limit', () { + final remoteAsset = RemoteAsset( + id: 'test-id', + name: 'test.jpg', + checksum: 'checksum', + type: AssetType.image, + ownerId: 'owner-id', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + final types = service.buildQuickActionTypes( + context, + quickActionOrder: ActionButtonBuilder.defaultQuickActionOrder, + limit: 2, + ); + + expect(types.length, 2); + }); + }); +} diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index d93d59d3c7783..a7652e7cd99e9 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -3,6 +3,11 @@ 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/domain/services/quick_action.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.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 +142,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 +1016,38 @@ void main() { expect(archivedWidgets, isNotEmpty); expect(nonArchivedWidgets, isNotEmpty); }); + + 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 quickActionService = const QuickActionService(ActionButtonOrderRepository()); + final quickActionTypes = quickActionService.buildQuickActionTypes( + context, + quickActionOrder: const [ + ActionButtonType.archive, + ActionButtonType.share, + ActionButtonType.edit, + ActionButtonType.delete, + ], + ); + + final quickActions = quickActionTypes.map((type) => type.buildButton(context)).toList(); + + expect(quickActions.length, ActionButtonBuilder.defaultQuickActionLimit); + expect(quickActions.first, isA()); + expect(quickActions[1], isA()); + expect(quickActions[2], isA()); + }); }); }