mirror of https://github.com/immich-app/immich.git
Merge 61f069e410 into baad38f0e6
commit
d77b50850d
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue