idubnori 2025-12-11 10:06:45 +07:00 committed by GitHub
commit d77b50850d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1111 additions and 18 deletions

@ -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",

@ -72,6 +72,7 @@ enum StoreKey<T> {
autoPlayVideo<bool>._(139),
albumGridView<bool>._(140),
viewerQuickActionOrder<String>._(141),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),

@ -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<ActionButtonType> _quickActionSet = Set<ActionButtonType>.unmodifiable(
ActionButtonBuilder.defaultQuickActionOrder,
);
List<ActionButtonType> get() {
return _repository.get();
}
Future<void> set(List<ActionButtonType> order) async {
final normalized = _normalizeQuickActionOrder(order);
await _repository.set(normalized);
}
Stream<List<ActionButtonType>> watch() {
return _repository.watch();
}
List<ActionButtonType> _normalizeQuickActionOrder(List<ActionButtonType> order) {
final ordered = <ActionButtonType>{};
for (final type in order) {
if (_quickActionSet.contains(type)) {
ordered.add(type);
}
}
ordered.addAll(ActionButtonBuilder.defaultQuickActionOrder);
return ordered.toList(growable: false);
}
List<ActionButtonType> buildQuickActionTypes(
ActionButtonContext context, {
List<ActionButtonType>? quickActionOrder,
int limit = ActionButtonBuilder.defaultQuickActionLimit,
}) {
final normalized = _normalizeQuickActionOrder(
quickActionOrder == null || quickActionOrder.isEmpty
? ActionButtonBuilder.defaultQuickActionOrder
: quickActionOrder,
);
final seen = <ActionButtonType>{};
final result = <ActionButtonType>[];
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;
}
}

@ -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<ActionButtonType> 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<void> set(List<ActionButtonType> order) async {
final json = _serialize(order);
await Store.put(storeKey, json);
}
Stream<List<ActionButtonType>> 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<ActionButtonType> order) {
return jsonEncode(order.map((type) => type.name).toList());
}
List<ActionButtonType> _deserialize(String json) {
try {
final list = jsonDecode(json) as List<dynamic>;
return list
.whereType<String>()
.map((name) {
try {
return ActionButtonType.values.byName(name);
} catch (e) {
return null;
}
})
.whereType<ActionButtonType>()
.toList();
} catch (e) {
return [];
}
}
}

@ -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<void>(
context: context,
isScrollControlled: true,
enableDrag: false,
builder: (sheetContext) => const FractionallySizedBox(heightFactor: 0.75, child: QuickActionConfigurator()),
).whenComplete(() {
viewerNotifier.setBottomSheet(false);
});
},
);
}
}

@ -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 = <Widget>[
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<void> openConfigurator() async {
final viewerNotifier = ref.read(assetViewerProvider.notifier);
viewerNotifier.setBottomSheet(true);
await showModalBottomSheet<void>(
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,

@ -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<QuickActionConfigurator> createState() => _QuickActionConfiguratorState();
}
class _QuickActionConfiguratorState extends ConsumerState<QuickActionConfigurator> {
late List<ActionButtonType> _order;
late final ScrollController _scrollController;
bool _hasLocalChanges = false;
@override
void initState() {
super.initState();
_order = List<ActionButtonType>.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<ActionButtonType>.from(ActionButtonBuilder.defaultQuickActionOrder);
_hasLocalChanges = true;
});
}
void _cancel() => Navigator.of(context).pop();
Future<void> _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<ActionButtonType>.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,
),
),
],
),
),
);
}
}

@ -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<ActionButtonOrderRepository>(
(ref) => const ActionButtonOrderRepository(),
);
final quickActionServiceProvider = Provider<QuickActionService>(
(ref) => QuickActionService(ref.watch(actionButtonOrderRepositoryProvider)),
);
final viewerQuickActionOrderProvider = StateNotifierProvider<ViewerQuickActionOrderNotifier, List<ActionButtonType>>(
(ref) => ViewerQuickActionOrderNotifier(ref.watch(quickActionServiceProvider)),
);
class ViewerQuickActionOrderNotifier extends StateNotifier<List<ActionButtonType>> {
final QuickActionService _service;
StreamSubscription<List<ActionButtonType>>? _subscription;
ViewerQuickActionOrderNotifier(this._service) : super(_service.get()) {
_subscription = _service.watch().listen((order) {
state = order;
});
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
Future<void> setOrder(List<ActionButtonType> order) async {
if (listEquals(state, order)) {
return;
}
final previous = state;
state = order;
try {
await _service.set(order);
} catch (error) {
state = previous;
rethrow;
}
}
}

@ -65,6 +65,7 @@ enum AppSettingsEnum<T> {
class AppSettingsService {
const AppSettingsService();
T getSetting<T>(AppSettingsEnum<T> setting) {
return Store.get(setting.storeKey, setting.defaultValue);
}

@ -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<ActionButtonType> _actionTypes = ActionButtonType.values;
static const int defaultQuickActionLimit = 4;
static const List<ActionButtonType> defaultQuickActionOrder = [
ActionButtonType.share,
ActionButtonType.upload,
ActionButtonType.edit,
ActionButtonType.add,
ActionButtonType.archive,
ActionButtonType.delete,
ActionButtonType.removeFromAlbum,
ActionButtonType.likeActivity,
];
static List<Widget> 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),
};
}
}

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

@ -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<ReorderableDragDropGrid> createState() => _ReorderableDragDropGridState();
}
class _ReorderableDragDropGridState extends State<ReorderableDragDropGrid> {
int? _draggingIndex;
late List<int> _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<int>.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<int>(
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<int>(
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,
);
},
),
);
}
}

@ -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);
});
});
}

@ -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<ArchiveActionButton>());
expect(quickActions[1], isA<ShareActionButton>());
expect(quickActions[2], isA<EditImageActionButton>());
});
});
}