From eb7813047b82ae4d66bb03d1a305acbc55131934 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 5 Nov 2025 21:34:40 +0900 Subject: [PATCH 01/21] feat(mobile): init of add quick action configurator and settings for viewer actions --- i18n/en.json | 2 + mobile/lib/domain/models/store.model.dart | 1 + .../asset_viewer/bottom_bar.widget.dart | 68 ++++-- .../quick_action_configurator.dart | 225 ++++++++++++++++++ .../viewer_quick_action_order.provider.dart | 50 ++++ .../viewer_quick_action_order.provider.g.dart | 27 +++ mobile/lib/services/app_settings.service.dart | 17 ++ mobile/lib/utils/action_button.utils.dart | 142 +++++++++++ mobile/lib/utils/action_button_visuals.dart | 53 +++++ mobile/pubspec.lock | 8 + mobile/pubspec.yaml | 1 + .../test/utils/action_button_utils_test.dart | 101 ++++++++ 12 files changed, 674 insertions(+), 21 deletions(-) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart create mode 100644 mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart create mode 100644 mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart create mode 100644 mobile/lib/utils/action_button_visuals.dart diff --git a/i18n/en.json b/i18n/en.json index 30c8949aefd10..28e1250c8a45c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1619,6 +1619,8 @@ "purchase_settings_server_activated": "The server product key is managed by the admin", "query_asset_id": "Query Asset ID", "queue_status": "Queuing {count}/{total}", + "quick_actions_settings_description": "Drag to rearrange buttons. Up to {count} available buttons are displayed in order.", + "quick_actions_settings_title": "Button order settings", "rating": "Star rating", "rating_clear": "Clear rating", "rating_count": "{count, plural, one {# star} other {# stars}}", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index d8404db40939d..0650fc44f018c 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -71,6 +71,7 @@ enum StoreKey { readonlyModeEnabled._(138), autoPlayVideo._(139), + viewerQuickActionOrder._(140), // Experimental stuff photoManagerCustomFilter._(1000), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 3111512823b65..a750e92186042 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -2,19 +2,19 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.dart'; +import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; class ViewerBottomBar extends ConsumerWidget { @@ -35,25 +35,51 @@ class ViewerBottomBar extends ConsumerWidget { final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final isInLockedView = ref.watch(inLockedViewProvider); final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + final isTrashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); + final quickActionOrder = ref.watch(viewerQuickActionOrderProvider); if (!showControls) { opacity = 0; } - final actions = [ - const ShareActionButton(source: ActionSource.viewer), - if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - if (asset.type == AssetType.image) const EditImageActionButton(), - if (isOwner) ...[ - if (asset.hasRemote && isOwner && isArchived) - const UnArchiveActionButton(source: ActionSource.viewer) - else - const ArchiveActionButton(source: ActionSource.viewer), - asset.isLocalOnly - ? const DeleteLocalActionButton(source: ActionSource.viewer) - : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), - ], - ]; + final buttonContext = ActionButtonContext( + asset: asset, + isOwner: isOwner, + isArchived: isArchived, + isTrashEnabled: isTrashEnabled, + isStacked: asset is RemoteAsset && asset.stackId != null, + isInLockedView: isInLockedView, + currentAlbum: currentAlbum, + advancedTroubleshooting: advancedTroubleshooting, + source: ActionSource.viewer, + ); + + final quickActionTypes = ActionButtonBuilder.buildQuickActionTypes( + buttonContext, + quickActionOrder: quickActionOrder, + ); + + Future openConfigurator() async { + final viewerNotifier = ref.read(assetViewerProvider.notifier); + + viewerNotifier.setBottomSheet(true); + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + enableDrag: false, + builder: (sheetContext) => + const FractionallySizedBox(heightFactor: 0.75, child: ViewerQuickActionConfigurator()), + ).whenComplete(() { + viewerNotifier.setBottomSheet(false); + }); + } + + final actions = quickActionTypes + .map((type) => GestureDetector(onLongPress: openConfigurator, child: type.buildButton(buttonContext))) + .toList(growable: false); return IgnorePointer( ignoring: opacity < 255, diff --git a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart new file mode 100644 index 0000000000000..30ad9a237a1a9 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart @@ -0,0 +1,225 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; +import 'package:immich_mobile/utils/action_button_visuals.dart'; + +class ViewerQuickActionConfigurator extends ConsumerStatefulWidget { + const ViewerQuickActionConfigurator({super.key}); + + @override + ConsumerState createState() => _ViewerQuickActionConfiguratorState(); +} + +class _ViewerQuickActionConfiguratorState extends ConsumerState { + late List _order; + late final ScrollController _scrollController; + bool _hasLocalChanges = false; + + @override + void initState() { + super.initState(); + _order = List.from(ref.read(viewerQuickActionOrderProvider)); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onReorder(ReorderedListFunction reorder) { + setState(() { + _order = reorder(_order); + _hasLocalChanges = true; + }); + } + + void _resetToDefault() { + setState(() { + _order = List.from(ActionButtonBuilder.defaultQuickActionOrder); + _hasLocalChanges = true; + }); + } + + void _cancel() => Navigator.of(context).pop(); + + Future _save() async { + final normalized = ActionButtonBuilder.normalizeQuickActionOrder(_order); + + await ref.read(viewerQuickActionOrderProvider.notifier).setOrder(normalized); + _hasLocalChanges = false; + if (mounted) { + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const crossAxisCount = 4; + const crossAxisSpacing = 12.0; + const mainAxisSpacing = 12.0; + const tileHeight = 130.0; + final currentOrder = ref.watch(viewerQuickActionOrderProvider); + if (!_hasLocalChanges && !listEquals(_order, currentOrder)) { + _order = List.from(currentOrder); + } + final normalizedSelection = ActionButtonBuilder.normalizeQuickActionOrder(_order); + final hasChanges = !listEquals(currentOrder, normalizedSelection); + + return SafeArea( + child: Padding( + padding: EdgeInsets.only(left: 20, right: 20, bottom: MediaQuery.of(context).viewInsets.bottom + 20, top: 16), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text('quick_actions_settings_title'.tr(), style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'quick_actions_settings_description'.tr( + namedArgs: {'count': ActionButtonBuilder.defaultQuickActionLimit.toString()}, + ), + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final rows = (_order.length / crossAxisCount).ceil().clamp(1, 4); + final naturalHeight = rows * tileHeight + (rows - 1) * mainAxisSpacing; + final shouldScroll = naturalHeight > constraints.maxHeight; + final horizontalPadding = 8.0; // matches GridView padding + final tileWidth = + (constraints.maxWidth - horizontalPadding - (crossAxisSpacing * (crossAxisCount - 1))) / + crossAxisCount; + final childAspectRatio = tileWidth / tileHeight; + final gridController = shouldScroll ? _scrollController : null; + + return ReorderableBuilder( + onReorder: _onReorder, + enableLongPress: false, + scrollController: gridController, + children: [ + for (var i = 0; i < _order.length; i++) + _QuickActionTile(key: ValueKey(_order[i].name), index: i, type: _order[i]), + ], + builder: (children) => GridView.count( + controller: gridController, + crossAxisCount: crossAxisCount, + crossAxisSpacing: crossAxisSpacing, + mainAxisSpacing: mainAxisSpacing, + // padding: const EdgeInsets.fromLTRB(4, 0, 4, 12), + physics: shouldScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), + childAspectRatio: childAspectRatio, + children: children, + ), + ); + }, + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton(onPressed: _resetToDefault, child: const Text('reset').tr()), + Row( + children: [ + TextButton(onPressed: _cancel, child: const Text('cancel').tr()), + const SizedBox(width: 8), + FilledButton(onPressed: hasChanges ? _save : null, child: const Text('done').tr()), + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +class _QuickActionTile extends StatelessWidget { + final int index; + final ActionButtonType type; + + const _QuickActionTile({super.key, required this.index, required this.type}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final borderColor = theme.dividerColor; + final backgroundColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2); + final indicatorColor = theme.colorScheme.primary; + final accentColor = theme.colorScheme.onSurface.withValues(alpha: 0.7); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + color: backgroundColor, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: indicatorColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + '${index + 1}', + style: theme.textTheme.labelSmall?.copyWith(color: indicatorColor, fontWeight: FontWeight.bold), + ), + ), + ), + const Spacer(), + Icon(Icons.drag_indicator_rounded, size: 18, color: indicatorColor), + ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.topCenter, + child: Icon(type.iconData, size: 28, color: theme.colorScheme.onSurface), + ), + const SizedBox(height: 6), + Align( + alignment: Alignment.topCenter, + child: Text( + type.localizedLabel(context), + style: theme.textTheme.labelSmall?.copyWith(color: accentColor), + textAlign: TextAlign.center, + maxLines: 3, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart new file mode 100644 index 0000000000000..10d2a8835c8d3 --- /dev/null +++ b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'viewer_quick_action_order.provider.g.dart'; + +@Riverpod(keepAlive: true) +class ViewerQuickActionOrder extends _$ViewerQuickActionOrder { + StreamSubscription>? _subscription; + + @override + List build() { + final service = ref.watch(appSettingsServiceProvider); + final initial = ActionButtonBuilder.normalizeQuickActionOrder(service.getViewerQuickActionOrder()); + + _subscription ??= service.watchViewerQuickActionOrder().listen((order) { + state = ActionButtonBuilder.normalizeQuickActionOrder(order); + }); + + ref.onDispose(() { + _subscription?.cancel(); + _subscription = null; + }); + + return initial; + } + + Future setOrder(List order) async { + final normalized = ActionButtonBuilder.normalizeQuickActionOrder(order); + + if (listEquals(state, normalized)) { + return; + } + + final previous = state; + state = normalized; + + try { + await ref.read(appSettingsServiceProvider).setViewerQuickActionOrder(normalized); + } catch (error) { + state = previous; + rethrow; + } + } +} + +/// Mock class for testing +abstract class ViewerQuickActionOrderInternal extends _$ViewerQuickActionOrder {} diff --git a/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart new file mode 100644 index 0000000000000..c54e80a452803 --- /dev/null +++ b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'viewer_quick_action_order.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$viewerQuickActionOrderHash() => + r'd539bc6ba5fae4fa07a7c30c42d9f6aee1488f97'; + +/// See also [ViewerQuickActionOrder]. +@ProviderFor(ViewerQuickActionOrder) +final viewerQuickActionOrderProvider = + NotifierProvider>.internal( + ViewerQuickActionOrder.new, + name: r'viewerQuickActionOrderProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$viewerQuickActionOrderHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ViewerQuickActionOrder = Notifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 7149408e8a4aa..788562d50d31c 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; enum AppSettingsEnum { loadPreview(StoreKey.loadPreview, "loadPreview", true), @@ -71,4 +72,20 @@ class AppSettingsService { Future setSetting(AppSettingsEnum setting, T value) { return Store.put(setting.storeKey, value); } + + List getViewerQuickActionOrder() { + final stored = Store.get(StoreKey.viewerQuickActionOrder, ActionButtonBuilder.defaultQuickActionOrderStorageValue); + return ActionButtonBuilder.parseQuickActionOrder(stored); + } + + Stream> watchViewerQuickActionOrder() { + return Store.watch(StoreKey.viewerQuickActionOrder).map( + (value) => + ActionButtonBuilder.parseQuickActionOrder(value ?? ActionButtonBuilder.defaultQuickActionOrderStorageValue), + ); + } + + Future setViewerQuickActionOrder(List order) { + return Store.put(StoreKey.viewerQuickActionOrder, ActionButtonBuilder.encodeQuickActionOrder(order)); + } } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 42729becc9c6b..961e44d7fe4ad 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; @@ -47,6 +48,7 @@ class ActionButtonContext { enum ActionButtonType { advancedInfo, share, + edit, shareLink, similarPhotos, archive, @@ -67,6 +69,9 @@ enum ActionButtonType { return switch (this) { ActionButtonType.advancedInfo => context.advancedTroubleshooting, ActionButtonType.share => true, + ActionButtonType.edit => + !context.isInLockedView && // + context.asset.isImage, ActionButtonType.shareLink => !context.isInLockedView && // context.asset.hasRemote, @@ -135,6 +140,7 @@ enum ActionButtonType { return switch (this) { ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source), ActionButtonType.share => ShareActionButton(source: context.source), + ActionButtonType.edit => const EditImageActionButton(), ActionButtonType.shareLink => ShareLinkActionButton(source: context.source), ActionButtonType.archive => ArchiveActionButton(source: context.source), ActionButtonType.unarchive => UnArchiveActionButton(source: context.source), @@ -160,7 +166,143 @@ enum ActionButtonType { class ActionButtonBuilder { static const List _actionTypes = ActionButtonType.values; + static const int defaultQuickActionLimit = 4; + static const String quickActionStorageDelimiter = ','; + + static const List _defaultQuickActionSeed = [ + ActionButtonType.share, + ActionButtonType.upload, + ActionButtonType.edit, + ActionButtonType.archive, + ActionButtonType.delete, + ActionButtonType.removeFromAlbum, + ActionButtonType.likeActivity, + ]; + + static final Set _quickActionSet = Set.unmodifiable(_defaultQuickActionSeed); + + static final List defaultQuickActionOrder = List.unmodifiable( + _defaultQuickActionSeed, + ); + + static final String defaultQuickActionOrderStorageValue = defaultQuickActionOrder + .map((type) => type.name) + .join(quickActionStorageDelimiter); + + static List get quickActionOptions => defaultQuickActionOrder; + + static List parseQuickActionOrder(String? stored) { + final parsed = []; + + if (stored != null && stored.trim().isNotEmpty) { + for (final name in stored.split(quickActionStorageDelimiter)) { + final type = _typeByName(name.trim()); + if (type != null) { + parsed.add(type); + } + } + } + + return normalizeQuickActionOrder(parsed); + } + + static String encodeQuickActionOrder(List order) { + final unique = {}; + final buffer = []; + + for (final type in order) { + if (unique.add(type)) { + buffer.add(type.name); + } + } + + final result = buffer.join(quickActionStorageDelimiter); + return result; + } + + static List buildQuickActionTypes( + ActionButtonContext context, { + List? quickActionOrder, + int limit = defaultQuickActionLimit, + }) { + final normalized = normalizeQuickActionOrder( + quickActionOrder == null || quickActionOrder.isEmpty ? defaultQuickActionOrder : quickActionOrder, + ); + + final seen = {}; + final result = []; + + for (final type in normalized) { + if (!_quickActionSet.contains(type)) { + continue; + } + + final resolved = _resolveQuickActionType(type, context); + if (!seen.add(resolved) || !resolved.shouldShow(context)) { + continue; + } + + result.add(resolved); + if (result.length >= limit) { + break; + } + } + + return result; + } + + static List buildQuickActions( + ActionButtonContext context, { + List? quickActionOrder, + int limit = defaultQuickActionLimit, + }) { + final types = buildQuickActionTypes(context, quickActionOrder: quickActionOrder, limit: limit); + return types.map((type) => type.buildButton(context)).toList(); + } + + static ActionButtonType? _typeByName(String name) { + if (name.isEmpty) { + return null; + } + + for (final type in ActionButtonType.values) { + if (type.name == name) { + return type; + } + } + + return null; + } + static List build(ActionButtonContext context) { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } + + static List normalizeQuickActionOrder(List order) { + final ordered = {}; + + for (final type in order) { + if (_quickActionSet.contains(type)) { + ordered.add(type); + } + } + + ordered.addAll(_defaultQuickActionSeed); + + return ordered.toList(growable: false); + } + + static ActionButtonType _resolveQuickActionType(ActionButtonType type, ActionButtonContext context) { + if (type == ActionButtonType.archive && context.isArchived) { + return ActionButtonType.unarchive; + } + + if (type == ActionButtonType.delete && context.asset.isLocalOnly) { + return ActionButtonType.deleteLocal; + } + + return type; + } + + static bool isSupportedQuickAction(ActionButtonType type) => _quickActionSet.contains(type); } diff --git a/mobile/lib/utils/action_button_visuals.dart b/mobile/lib/utils/action_button_visuals.dart new file mode 100644 index 0000000000000..9d85174ac6279 --- /dev/null +++ b/mobile/lib/utils/action_button_visuals.dart @@ -0,0 +1,53 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; + +extension ActionButtonTypeVisuals on ActionButtonType { + IconData get iconData { + return switch (this) { + ActionButtonType.advancedInfo => Icons.help_outline_rounded, + ActionButtonType.share => Icons.share_rounded, + ActionButtonType.edit => Icons.tune, + ActionButtonType.shareLink => Icons.link_rounded, + ActionButtonType.similarPhotos => Icons.compare, + ActionButtonType.archive => Icons.archive_outlined, + ActionButtonType.unarchive => Icons.unarchive_outlined, + ActionButtonType.download => Icons.download, + ActionButtonType.trash => Icons.delete_outline_rounded, + ActionButtonType.deletePermanent => Icons.delete_forever, + ActionButtonType.delete => Icons.delete_sweep_outlined, + ActionButtonType.moveToLockFolder => Icons.lock_outline_rounded, + ActionButtonType.removeFromLockFolder => Icons.lock_open_rounded, + ActionButtonType.deleteLocal => Icons.no_cell_outlined, + ActionButtonType.upload => Icons.backup_outlined, + ActionButtonType.removeFromAlbum => Icons.remove_circle_outline, + ActionButtonType.unstack => Icons.layers_clear_outlined, + ActionButtonType.likeActivity => Icons.favorite_border, + }; + } + + String get _labelKey { + return switch (this) { + ActionButtonType.advancedInfo => 'troubleshoot', + ActionButtonType.share => 'share', + ActionButtonType.edit => 'edit', + ActionButtonType.shareLink => 'share_link', + ActionButtonType.similarPhotos => 'view_similar_photos', + ActionButtonType.archive => 'to_archive', + ActionButtonType.unarchive => 'unarchive', + ActionButtonType.download => 'download', + ActionButtonType.trash => 'control_bottom_app_bar_trash_from_immich', + ActionButtonType.deletePermanent => 'delete_permanently', + ActionButtonType.delete => 'delete', + ActionButtonType.moveToLockFolder => 'move_to_locked_folder', + ActionButtonType.removeFromLockFolder => 'remove_from_locked_folder', + ActionButtonType.deleteLocal => 'control_bottom_app_bar_delete_from_local', + ActionButtonType.upload => 'upload', + ActionButtonType.removeFromAlbum => 'remove_from_album', + ActionButtonType.unstack => 'unstack', + ActionButtonType.likeActivity => 'like', + }; + } + + String localizedLabel(BuildContext context) => _labelKey.tr(); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0b10384621cd7..da560c4064fd8 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -664,6 +664,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.27" + flutter_reorderable_grid_view: + dependency: "direct main" + description: + name: flutter_reorderable_grid_view + sha256: beb85f95325c83515d8953e8612dc70d287a69d1437c14262b7d738070133a87 + url: "https://pub.dev" + source: hosted + version: "5.5.2" flutter_riverpod: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b47038b7e510c..a48d17c7bb528 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: flutter_udid: ^4.0.0 flutter_web_auth_2: ^5.0.0-alpha.0 fluttertoast: ^8.2.12 + flutter_reorderable_grid_view: ^5.5.2 geolocator: ^14.0.2 home_widget: ^0.8.1 hooks_riverpod: ^2.6.1 diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index d93d59d3c7783..ed5691dafd2fc 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -3,6 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; LocalAsset createLocalAsset({ @@ -137,6 +140,56 @@ void main() { }); }); + group('edit button', () { + test('should show for images when not in locked view', () { + final context = ActionButtonContext( + asset: createRemoteAsset(type: AssetType.image), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.edit.shouldShow(context), isTrue); + }); + + test('should not show in locked view', () { + final context = ActionButtonContext( + asset: createRemoteAsset(type: AssetType.image), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.edit.shouldShow(context), isFalse); + }); + + test('should not show for non-image assets', () { + final context = ActionButtonContext( + asset: createRemoteAsset(type: AssetType.video), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.edit.shouldShow(context), isFalse); + }); + }); + group('shareLink button', () { test('should show when not in locked view and asset has remote', () { final remoteAsset = createRemoteAsset(); @@ -961,5 +1014,53 @@ void main() { expect(archivedWidgets, isNotEmpty); expect(nonArchivedWidgets, isNotEmpty); }); + + test('should encode and parse quick action order consistently', () { + final encoded = ActionButtonBuilder.encodeQuickActionOrder([ + ActionButtonType.edit, + ActionButtonType.share, + ActionButtonType.archive, + ]); + + final decoded = ActionButtonBuilder.parseQuickActionOrder(encoded); + + final expectedOrder = ActionButtonBuilder.normalizeQuickActionOrder([ + ActionButtonType.edit, + ActionButtonType.share, + ActionButtonType.archive, + ]); + + expect(decoded, expectedOrder); + }); + + test('should build quick actions honoring custom order', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + final quickActions = ActionButtonBuilder.buildQuickActions( + context, + quickActionOrder: const [ + ActionButtonType.archive, + ActionButtonType.share, + ActionButtonType.edit, + ActionButtonType.delete, + ], + ); + + expect(quickActions.length, ActionButtonBuilder.defaultQuickActionLimit); + expect(quickActions.first, isA()); + expect(quickActions[1], isA()); + expect(quickActions[2], isA()); + }); }); } From 74f90fe9441c9316bd9855df41f2f9298941d1a1 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 5 Nov 2025 21:47:14 +0900 Subject: [PATCH 02/21] fix: dcm analyze fails --- .../widgets/asset_viewer/quick_action_configurator.dart | 6 +++--- mobile/lib/utils/action_button_visuals.dart | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart index 30ad9a237a1a9..c1a90ab197240 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart @@ -83,7 +83,7 @@ class _ViewerQuickActionConfiguratorState extends ConsumerState _labelKey.tr(); + String localizedLabel(BuildContext _) => _labelKey.tr(); } From 0eae657611c34158fcc683f7042ac6531808f147 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 5 Nov 2025 23:14:14 +0900 Subject: [PATCH 03/21] refactor: rename to QuickActionConfigurator --- .../widgets/asset_viewer/bottom_bar.widget.dart | 5 ++--- ...gurator.dart => quick_action_configurator.widget.dart} | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) rename mobile/lib/presentation/widgets/asset_viewer/{quick_action_configurator.dart => quick_action_configurator.widget.dart} (96%) 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 a750e92186042..da897c6d57193 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -5,7 +5,7 @@ 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/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.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'; @@ -70,8 +70,7 @@ class ViewerBottomBar extends ConsumerWidget { context: context, isScrollControlled: true, enableDrag: false, - builder: (sheetContext) => - const FractionallySizedBox(heightFactor: 0.75, child: ViewerQuickActionConfigurator()), + builder: (sheetContext) => const FractionallySizedBox(heightFactor: 0.75, child: QuickActionConfigurator()), ).whenComplete(() { viewerNotifier.setBottomSheet(false); }); diff --git a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart similarity index 96% rename from mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart rename to mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart index c1a90ab197240..64fc915d113ff 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -7,14 +7,14 @@ import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/action_button_visuals.dart'; -class ViewerQuickActionConfigurator extends ConsumerStatefulWidget { - const ViewerQuickActionConfigurator({super.key}); +class QuickActionConfigurator extends ConsumerStatefulWidget { + const QuickActionConfigurator({super.key}); @override - ConsumerState createState() => _ViewerQuickActionConfiguratorState(); + ConsumerState createState() => _QuickActionConfiguratorState(); } -class _ViewerQuickActionConfiguratorState extends ConsumerState { +class _QuickActionConfiguratorState extends ConsumerState { late List _order; late final ScrollController _scrollController; bool _hasLocalChanges = false; From 6c07915f84e2ec796bf9c1436001f2a8c5fef6df Mon Sep 17 00:00:00 2001 From: idubnori Date: Mon, 17 Nov 2025 09:41:00 +0900 Subject: [PATCH 04/21] feat: configurable AddActionButton --- mobile/lib/utils/action_button.utils.dart | 5 +++++ mobile/lib/utils/action_button_visuals.dart | 2 ++ 2 files changed, 7 insertions(+) diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 961e44d7fe4ad..8d07e23cbf62b 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.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/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; @@ -49,6 +50,7 @@ enum ActionButtonType { advancedInfo, share, edit, + add, shareLink, similarPhotos, archive, @@ -72,6 +74,7 @@ enum ActionButtonType { ActionButtonType.edit => !context.isInLockedView && // context.asset.isImage, + ActionButtonType.add => context.asset.hasRemote, ActionButtonType.shareLink => !context.isInLockedView && // context.asset.hasRemote, @@ -141,6 +144,7 @@ enum ActionButtonType { 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), @@ -173,6 +177,7 @@ class ActionButtonBuilder { ActionButtonType.share, ActionButtonType.upload, ActionButtonType.edit, + ActionButtonType.add, ActionButtonType.archive, ActionButtonType.delete, ActionButtonType.removeFromAlbum, diff --git a/mobile/lib/utils/action_button_visuals.dart b/mobile/lib/utils/action_button_visuals.dart index 6861b5a242b91..d979c96f42c08 100644 --- a/mobile/lib/utils/action_button_visuals.dart +++ b/mobile/lib/utils/action_button_visuals.dart @@ -8,6 +8,7 @@ extension ActionButtonTypeVisuals on ActionButtonType { 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, @@ -31,6 +32,7 @@ extension ActionButtonTypeVisuals on ActionButtonType { 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', From 7f9ba91c8d488a4ad9c52ec651c6f40178147b85 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 25 Nov 2025 06:19:00 +0900 Subject: [PATCH 05/21] feat(mobile): implement viewer kebab menu with about option --- i18n/en.json | 1 + .../base_action_button.widget.dart | 23 +++++ .../asset_viewer/bottom_sheet.widget.dart | 29 +----- .../asset_viewer/top_app_bar.widget.dart | 19 +--- .../viewer_kebab_menu.widget.dart | 90 +++++++++++++++++++ mobile/lib/utils/action_button.utils.dart | 20 ++++- 6 files changed, 134 insertions(+), 48 deletions(-) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart diff --git a/i18n/en.json b/i18n/en.json index 210e05459dc87..951602ef861cf 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1496,6 +1496,7 @@ "online": "Online", "only_favorites": "Only favorites", "open": "Open", + "open_bottom_sheet_about": "About", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 5ec6c8bc542d8..b62398c03e39b 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -38,6 +38,29 @@ class BaseActionButton extends StatelessWidget { ); } + if (context.findAncestorWidgetOfExactType() != null) { + final theme = context.themeData; + final textStyle = theme.textTheme.bodyMedium; + final defaultTextColor = theme.colorScheme.onSurfaceVariant; + final effectiveStyle = (textStyle ?? theme.textTheme.bodyMedium)?.copyWith( + color: (textStyle?.color ?? defaultTextColor), + ); + final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant; + + return MenuItemButton( + style: MenuItemButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + visualDensity: const VisualDensity(vertical: -2.5), + ), + trailingIcon: Icon(iconData, size: 18, color: effectiveIconColor), + onPressed: onPressed, + child: Align( + alignment: Alignment.centerLeft, + child: Text(label, style: effectiveStyle), + ), + ); + } + return ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: MaterialButton( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 582a33136af97..9ced79db2536c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -8,7 +8,6 @@ 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/exif.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; @@ -20,14 +19,9 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.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/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/repositories/asset_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -46,29 +40,8 @@ class AssetDetailBottomSheet extends ConsumerWidget { return const SizedBox.shrink(); } - final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); - final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id; - final isInLockedView = ref.watch(inLockedViewProvider); - final currentAlbum = ref.watch(currentRemoteAlbumProvider); - final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; - final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); - - final buttonContext = ActionButtonContext( - asset: asset, - isOwner: isOwner, - isArchived: isArchived, - isTrashEnabled: isTrashEnable, - isInLockedView: isInLockedView, - isStacked: asset is RemoteAsset && asset.stackId != null, - currentAlbum: currentAlbum, - advancedTroubleshooting: advancedTroubleshooting, - source: ActionSource.viewer, - ); - - final actions = ActionButtonBuilder.build(buttonContext); - return BaseBottomSheet( - actions: actions, + actions: [], slivers: const [_AssetDetailBottomSheet()], controller: controller, initialChildSize: initialChildSize, diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index ab88dffab48cd..e5bab6635b534 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; @@ -89,12 +90,12 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { if (asset.hasRemote && isOwner && asset.isFavorite) const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true), if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true), - const _KebabMenu(), + const ViewerKebabMenu(), ]; final lockedViewActions = [ if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), - const _KebabMenu(), + const ViewerKebabMenu(), ]; return IgnorePointer( @@ -122,20 +123,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { Size get preferredSize => const Size.fromHeight(60.0); } -class _KebabMenu extends ConsumerWidget { - const _KebabMenu(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return IconButton( - onPressed: () { - EventStream.shared.emit(const ViewerOpenBottomSheetEvent()); - }, - icon: const Icon(Icons.more_vert_rounded), - ); - } -} - class _AppBarBackButton extends ConsumerWidget { const _AppBarBackButton(); diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart new file mode 100644 index 0000000000000..ea6fd7ed32dcf --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -0,0 +1,90 @@ +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/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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/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/presentation/widgets/asset_viewer/asset_viewer.state.dart'; + +class ViewerKebabMenu extends ConsumerWidget { + const ViewerKebabMenu({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + + final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); + final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id; + final isInLockedView = ref.watch(inLockedViewProvider); + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); + + final buttonContext = ActionButtonContext( + asset: asset, + isOwner: isOwner, + isArchived: isArchived, + isTrashEnabled: isTrashEnable, + isInLockedView: isInLockedView, + isStacked: asset is RemoteAsset && asset.stackId != null, + currentAlbum: currentAlbum, + advancedTroubleshooting: advancedTroubleshooting, + source: ActionSource.viewer, + ); + + final theme = context.themeData; + final menuChildren = [ + BaseActionButton( + label: 'open_bottom_sheet_about'.t(context: context), + iconData: Icons.info_outline, + onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), + ), + ]; + + final actions = ActionButtonBuilder.build( + buttonContext, + actionTypes: ActionButtonBuilder.kebabMenuActionTypes, + ).map((w) => w.build(context, ref)).expand((action) => [const Divider(height: 0), action]).toList(growable: false); + + if (actions.isNotEmpty) { + menuChildren.addAll(actions); + } + + return MenuAnchor( + style: MenuStyle( + backgroundColor: WidgetStatePropertyAll(theme.scaffoldBackgroundColor), + elevation: const WidgetStatePropertyAll(4), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 2)), + ), + menuChildren: menuChildren, + builder: (context, controller, child) { + return IconButton( + icon: const Icon(Icons.more_vert_rounded), + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + ); + }, + ); + } +} diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 42729becc9c6b..23790e2b25a51 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -131,7 +131,7 @@ enum ActionButtonType { }; } - Widget buildButton(ActionButtonContext context) { + ConsumerWidget buildButton(ActionButtonContext context) { return switch (this) { ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source), ActionButtonType.share => ShareActionButton(source: context.source), @@ -160,7 +160,19 @@ enum ActionButtonType { class ActionButtonBuilder { static const List _actionTypes = ActionButtonType.values; - static List build(ActionButtonContext context) { - return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); + static const _excludedActions = { + ActionButtonType.share, + ActionButtonType.archive, + ActionButtonType.delete, + ActionButtonType.moveToLockFolder, + }; + + static final List kebabMenuActionTypes = ActionButtonType.values + .where((type) => !_excludedActions.contains(type)) + .toList(); + + static List build(ActionButtonContext context, {List? actionTypes}) { + final types = actionTypes ?? _actionTypes; + return types.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } } From c7c929b3b589840357eacf855af7f0617881e22e Mon Sep 17 00:00:00 2001 From: idubnori Date: Thu, 4 Dec 2025 14:01:03 +0900 Subject: [PATCH 06/21] feat: revert exisitng buttons, adjust label name --- i18n/en.json | 2 +- .../asset_viewer/bottom_sheet.widget.dart | 29 ++++++++++++- .../viewer_kebab_menu.widget.dart | 43 ++----------------- mobile/lib/utils/action_button.utils.dart | 20 ++------- 4 files changed, 36 insertions(+), 58 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 6de7f270e4c57..280a3cee21ef3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1512,7 +1512,7 @@ "online": "Online", "only_favorites": "Only favorites", "open": "Open", - "open_bottom_sheet_about": "About", + "open_bottom_sheet_info": "Information", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index f0ba970b98979..80840d94b4dde 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -8,6 +8,7 @@ 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/exif.model.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -20,9 +21,14 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.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/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/repositories/asset_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -42,8 +48,29 @@ class AssetDetailBottomSheet extends ConsumerWidget { return const SizedBox.shrink(); } + final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); + final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id; + final isInLockedView = ref.watch(inLockedViewProvider); + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); + + final buttonContext = ActionButtonContext( + asset: asset, + isOwner: isOwner, + isArchived: isArchived, + isTrashEnabled: isTrashEnable, + isInLockedView: isInLockedView, + isStacked: asset is RemoteAsset && asset.stackId != null, + currentAlbum: currentAlbum, + advancedTroubleshooting: advancedTroubleshooting, + source: ActionSource.viewer, + ); + + final actions = ActionButtonBuilder.build(buttonContext); + return BaseBottomSheet( - actions: [], + actions: actions, slivers: const [_AssetDetailBottomSheet()], controller: controller, initialChildSize: initialChildSize, diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index ea6fd7ed32dcf..b228c0331b5b5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -1,20 +1,11 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.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/presentation/widgets/asset_viewer/asset_viewer.state.dart'; class ViewerKebabMenu extends ConsumerWidget { const ViewerKebabMenu({super.key}); @@ -26,43 +17,15 @@ class ViewerKebabMenu extends ConsumerWidget { return const SizedBox.shrink(); } - final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); - final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id; - final isInLockedView = ref.watch(inLockedViewProvider); - final currentAlbum = ref.watch(currentRemoteAlbumProvider); - final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; - final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); - - final buttonContext = ActionButtonContext( - asset: asset, - isOwner: isOwner, - isArchived: isArchived, - isTrashEnabled: isTrashEnable, - isInLockedView: isInLockedView, - isStacked: asset is RemoteAsset && asset.stackId != null, - currentAlbum: currentAlbum, - advancedTroubleshooting: advancedTroubleshooting, - source: ActionSource.viewer, - ); - final theme = context.themeData; final menuChildren = [ BaseActionButton( - label: 'open_bottom_sheet_about'.t(context: context), + label: 'open_bottom_sheet_info'.tr(), iconData: Icons.info_outline, onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), ), ]; - final actions = ActionButtonBuilder.build( - buttonContext, - actionTypes: ActionButtonBuilder.kebabMenuActionTypes, - ).map((w) => w.build(context, ref)).expand((action) => [const Divider(height: 0), action]).toList(growable: false); - - if (actions.isNotEmpty) { - menuChildren.addAll(actions); - } - return MenuAnchor( style: MenuStyle( backgroundColor: WidgetStatePropertyAll(theme.scaffoldBackgroundColor), diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 23790e2b25a51..42729becc9c6b 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -1,4 +1,4 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter/widgets.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'; @@ -131,7 +131,7 @@ enum ActionButtonType { }; } - ConsumerWidget buildButton(ActionButtonContext context) { + Widget buildButton(ActionButtonContext context) { return switch (this) { ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source), ActionButtonType.share => ShareActionButton(source: context.source), @@ -160,19 +160,7 @@ enum ActionButtonType { class ActionButtonBuilder { static const List _actionTypes = ActionButtonType.values; - static const _excludedActions = { - ActionButtonType.share, - ActionButtonType.archive, - ActionButtonType.delete, - ActionButtonType.moveToLockFolder, - }; - - static final List kebabMenuActionTypes = ActionButtonType.values - .where((type) => !_excludedActions.contains(type)) - .toList(); - - static List build(ActionButtonContext context, {List? actionTypes}) { - final types = actionTypes ?? _actionTypes; - return types.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); + static List build(ActionButtonContext context) { + return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } } From be9e632efb84d8b19aee659c13ca163a5169103e Mon Sep 17 00:00:00 2001 From: idubnori Date: Mon, 17 Nov 2025 16:31:57 +0900 Subject: [PATCH 07/21] Revert "chore(mobile): add table schemas to swift (#23749)" This reverts commit 9e2208b8dd4c327e5fca7c8d77f89f6904f2ec0e. --- mobile/ios/Runner.xcodeproj/project.pbxproj | 56 ----- .../xcshareddata/swiftpm/Package.resolved | 168 ------------- mobile/ios/Runner/Schemas/Constants.swift | 177 ------------- mobile/ios/Runner/Schemas/Store.swift | 146 ----------- mobile/ios/Runner/Schemas/Tables.swift | 237 ------------------ 5 files changed, 784 deletions(-) delete mode 100644 mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 mobile/ios/Runner/Schemas/Constants.swift delete mode 100644 mobile/ios/Runner/Schemas/Store.swift delete mode 100644 mobile/ios/Runner/Schemas/Tables.swift diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 599e7990f4746..ce9d1848cd4e1 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -32,9 +32,6 @@ FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; - FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; }; - FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; }; - FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -156,13 +153,6 @@ path = WidgetExtension; sourceTree = ""; }; - FEE084F22EC172080045228E /* Schemas */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - path = Schemas; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -170,9 +160,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - FEE084F82EC172460045228E /* SQLiteData in Frameworks */, - FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */, - FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */, D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -267,7 +254,6 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( - FEE084F22EC172080045228E /* Schemas */, B231F52D2E93A44A00BC45D1 /* Core */, B25D37792E72CA15008B6CA7 /* Connectivity */, B21E34A62E5AF9760031FDB9 /* Background */, @@ -355,7 +341,6 @@ fileSystemSynchronizedGroups = ( B231F52D2E93A44A00BC45D1 /* Core */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */, - FEE084F22EC172080045228E /* Schemas */, ); name = Runner; productName = Runner; @@ -434,10 +419,6 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; - packageReferences = ( - FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */, - FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */, - ); preferredProjectObjectVersion = 77; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -1220,43 +1201,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/sqlite-data"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.3.0; - }; - }; - FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-http-structured-headers.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.5.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - FEE084F72EC172460045228E /* SQLiteData */ = { - isa = XCSwiftPackageProductDependency; - package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */; - productName = SQLiteData; - }; - FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */ = { - isa = XCSwiftPackageProductDependency; - package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */; - productName = RawStructuredFieldValues; - }; - FEE084FC2EC1725A0045228E /* StructuredFieldValues */ = { - isa = XCSwiftPackageProductDependency; - package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */; - productName = StructuredFieldValues; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index ff8a53ff4ba93..0000000000000 --- a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,168 +0,0 @@ -{ - "originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49", - "pins" : [ - { - "identity" : "combine-schedulers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/combine-schedulers", - "state" : { - "revision" : "5928286acce13def418ec36d05a001a9641086f2", - "version" : "1.0.3" - } - }, - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift", - "state" : { - "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", - "version" : "7.8.0" - } - }, - { - "identity" : "sqlite-data", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/sqlite-data", - "state" : { - "revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", - "version" : "1.7.2" - } - }, - { - "identity" : "swift-clocks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-clocks", - "state" : { - "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", - "version" : "1.0.6" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" - } - }, - { - "identity" : "swift-dependencies", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies", - "state" : { - "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", - "version" : "1.10.0" - } - }, - { - "identity" : "swift-http-structured-headers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-structured-headers.git", - "state" : { - "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-identified-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections", - "state" : { - "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-perception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-perception", - "state" : { - "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", - "version" : "2.0.9" - } - }, - { - "identity" : "swift-sharing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-sharing", - "state" : { - "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", - "version" : "2.7.4" - } - }, - { - "identity" : "swift-snapshot-testing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing", - "state" : { - "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", - "version" : "1.18.7" - } - }, - { - "identity" : "swift-structured-queries", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-structured-queries", - "state" : { - "revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756", - "version" : "0.25.0" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" - } - }, - { - "identity" : "swift-tagged", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged", - "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", - "version" : "1.7.0" - } - } - ], - "version" : 3 -} diff --git a/mobile/ios/Runner/Schemas/Constants.swift b/mobile/ios/Runner/Schemas/Constants.swift deleted file mode 100644 index a4b0f701a1da3..0000000000000 --- a/mobile/ios/Runner/Schemas/Constants.swift +++ /dev/null @@ -1,177 +0,0 @@ -import SQLiteData - -struct Endpoint: Codable { - let url: URL - let status: Status - - enum Status: String, Codable { - case loading, valid, error, unknown - } -} - -enum StoreKey: Int, CaseIterable, QueryBindable { - // MARK: - Int - case _version = 0 - static let version = Typed(rawValue: ._version) - case _deviceIdHash = 3 - static let deviceIdHash = Typed(rawValue: ._deviceIdHash) - case _backupTriggerDelay = 8 - static let backupTriggerDelay = Typed(rawValue: ._backupTriggerDelay) - case _tilesPerRow = 103 - static let tilesPerRow = Typed(rawValue: ._tilesPerRow) - case _groupAssetsBy = 105 - static let groupAssetsBy = Typed(rawValue: ._groupAssetsBy) - case _uploadErrorNotificationGracePeriod = 106 - static let uploadErrorNotificationGracePeriod = Typed(rawValue: ._uploadErrorNotificationGracePeriod) - case _thumbnailCacheSize = 110 - static let thumbnailCacheSize = Typed(rawValue: ._thumbnailCacheSize) - case _imageCacheSize = 111 - static let imageCacheSize = Typed(rawValue: ._imageCacheSize) - case _albumThumbnailCacheSize = 112 - static let albumThumbnailCacheSize = Typed(rawValue: ._albumThumbnailCacheSize) - case _selectedAlbumSortOrder = 113 - static let selectedAlbumSortOrder = Typed(rawValue: ._selectedAlbumSortOrder) - case _logLevel = 115 - static let logLevel = Typed(rawValue: ._logLevel) - case _mapRelativeDate = 119 - static let mapRelativeDate = Typed(rawValue: ._mapRelativeDate) - case _mapThemeMode = 124 - static let mapThemeMode = Typed(rawValue: ._mapThemeMode) - - // MARK: - String - case _assetETag = 1 - static let assetETag = Typed(rawValue: ._assetETag) - case _currentUser = 2 - static let currentUser = Typed(rawValue: ._currentUser) - case _deviceId = 4 - static let deviceId = Typed(rawValue: ._deviceId) - case _accessToken = 11 - static let accessToken = Typed(rawValue: ._accessToken) - case _serverEndpoint = 12 - static let serverEndpoint = Typed(rawValue: ._serverEndpoint) - case _sslClientCertData = 15 - static let sslClientCertData = Typed(rawValue: ._sslClientCertData) - case _sslClientPasswd = 16 - static let sslClientPasswd = Typed(rawValue: ._sslClientPasswd) - case _themeMode = 102 - static let themeMode = Typed(rawValue: ._themeMode) - case _customHeaders = 127 - static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders) - case _primaryColor = 128 - static let primaryColor = Typed(rawValue: ._primaryColor) - case _preferredWifiName = 133 - static let preferredWifiName = Typed(rawValue: ._preferredWifiName) - - // MARK: - Endpoint - case _externalEndpointList = 135 - static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList) - - // MARK: - URL - case _localEndpoint = 134 - static let localEndpoint = Typed(rawValue: ._localEndpoint) - case _serverUrl = 10 - static let serverUrl = Typed(rawValue: ._serverUrl) - - // MARK: - Date - case _backupFailedSince = 5 - static let backupFailedSince = Typed(rawValue: ._backupFailedSince) - - // MARK: - Bool - case _backupRequireWifi = 6 - static let backupRequireWifi = Typed(rawValue: ._backupRequireWifi) - case _backupRequireCharging = 7 - static let backupRequireCharging = Typed(rawValue: ._backupRequireCharging) - case _autoBackup = 13 - static let autoBackup = Typed(rawValue: ._autoBackup) - case _backgroundBackup = 14 - static let backgroundBackup = Typed(rawValue: ._backgroundBackup) - case _loadPreview = 100 - static let loadPreview = Typed(rawValue: ._loadPreview) - case _loadOriginal = 101 - static let loadOriginal = Typed(rawValue: ._loadOriginal) - case _dynamicLayout = 104 - static let dynamicLayout = Typed(rawValue: ._dynamicLayout) - case _backgroundBackupTotalProgress = 107 - static let backgroundBackupTotalProgress = Typed(rawValue: ._backgroundBackupTotalProgress) - case _backgroundBackupSingleProgress = 108 - static let backgroundBackupSingleProgress = Typed(rawValue: ._backgroundBackupSingleProgress) - case _storageIndicator = 109 - static let storageIndicator = Typed(rawValue: ._storageIndicator) - case _advancedTroubleshooting = 114 - static let advancedTroubleshooting = Typed(rawValue: ._advancedTroubleshooting) - case _preferRemoteImage = 116 - static let preferRemoteImage = Typed(rawValue: ._preferRemoteImage) - case _loopVideo = 117 - static let loopVideo = Typed(rawValue: ._loopVideo) - case _mapShowFavoriteOnly = 118 - static let mapShowFavoriteOnly = Typed(rawValue: ._mapShowFavoriteOnly) - case _selfSignedCert = 120 - static let selfSignedCert = Typed(rawValue: ._selfSignedCert) - case _mapIncludeArchived = 121 - static let mapIncludeArchived = Typed(rawValue: ._mapIncludeArchived) - case _ignoreIcloudAssets = 122 - static let ignoreIcloudAssets = Typed(rawValue: ._ignoreIcloudAssets) - case _selectedAlbumSortReverse = 123 - static let selectedAlbumSortReverse = Typed(rawValue: ._selectedAlbumSortReverse) - case _mapwithPartners = 125 - static let mapwithPartners = Typed(rawValue: ._mapwithPartners) - case _enableHapticFeedback = 126 - static let enableHapticFeedback = Typed(rawValue: ._enableHapticFeedback) - case _dynamicTheme = 129 - static let dynamicTheme = Typed(rawValue: ._dynamicTheme) - case _colorfulInterface = 130 - static let colorfulInterface = Typed(rawValue: ._colorfulInterface) - case _syncAlbums = 131 - static let syncAlbums = Typed(rawValue: ._syncAlbums) - case _autoEndpointSwitching = 132 - static let autoEndpointSwitching = Typed(rawValue: ._autoEndpointSwitching) - case _loadOriginalVideo = 136 - static let loadOriginalVideo = Typed(rawValue: ._loadOriginalVideo) - case _manageLocalMediaAndroid = 137 - static let manageLocalMediaAndroid = Typed(rawValue: ._manageLocalMediaAndroid) - case _readonlyModeEnabled = 138 - static let readonlyModeEnabled = Typed(rawValue: ._readonlyModeEnabled) - case _autoPlayVideo = 139 - static let autoPlayVideo = Typed(rawValue: ._autoPlayVideo) - case _photoManagerCustomFilter = 1000 - static let photoManagerCustomFilter = Typed(rawValue: ._photoManagerCustomFilter) - case _betaPromptShown = 1001 - static let betaPromptShown = Typed(rawValue: ._betaPromptShown) - case _betaTimeline = 1002 - static let betaTimeline = Typed(rawValue: ._betaTimeline) - case _enableBackup = 1003 - static let enableBackup = Typed(rawValue: ._enableBackup) - case _useWifiForUploadVideos = 1004 - static let useWifiForUploadVideos = Typed(rawValue: ._useWifiForUploadVideos) - case _useWifiForUploadPhotos = 1005 - static let useWifiForUploadPhotos = Typed(rawValue: ._useWifiForUploadPhotos) - case _needBetaMigration = 1006 - static let needBetaMigration = Typed(rawValue: ._needBetaMigration) - case _shouldResetSync = 1007 - static let shouldResetSync = Typed(rawValue: ._shouldResetSync) - - struct Typed: RawRepresentable { - let rawValue: StoreKey - - @_transparent - init(rawValue value: StoreKey) { - self.rawValue = value - } - } -} - -enum BackupSelection: Int, QueryBindable { - case selected, none, excluded -} - -enum AvatarColor: Int, QueryBindable { - case primary, pink, red, yellow, blue, green, purple, orange, gray, amber -} - -enum AlbumUserRole: Int, QueryBindable { - case editor, viewer -} - -enum MemoryType: Int, QueryBindable { - case onThisDay -} diff --git a/mobile/ios/Runner/Schemas/Store.swift b/mobile/ios/Runner/Schemas/Store.swift deleted file mode 100644 index ee5280b6c0dd7..0000000000000 --- a/mobile/ios/Runner/Schemas/Store.swift +++ /dev/null @@ -1,146 +0,0 @@ -import SQLiteData - -enum StoreError: Error { - case invalidJSON(String) - case invalidURL(String) - case encodingFailed -} - -protocol StoreConvertible { - associatedtype StorageType - static func fromValue(_ value: StorageType) throws(StoreError) -> Self - static func toValue(_ value: Self) throws(StoreError) -> StorageType -} - -extension Int: StoreConvertible { - static func fromValue(_ value: Int) -> Int { value } - static func toValue(_ value: Int) -> Int { value } -} - -extension Bool: StoreConvertible { - static func fromValue(_ value: Int) -> Bool { value == 1 } - static func toValue(_ value: Bool) -> Int { value ? 1 : 0 } -} - -extension Date: StoreConvertible { - static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) } - static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) } -} - -extension String: StoreConvertible { - static func fromValue(_ value: String) -> String { value } - static func toValue(_ value: String) -> String { value } -} - -extension URL: StoreConvertible { - static func fromValue(_ value: String) throws(StoreError) -> URL { - guard let url = URL(string: value) else { - throw StoreError.invalidURL(value) - } - return url - } - static func toValue(_ value: URL) -> String { value.absoluteString } -} - -extension StoreConvertible where Self: Codable, StorageType == String { - static var jsonDecoder: JSONDecoder { JSONDecoder() } - static var jsonEncoder: JSONEncoder { JSONEncoder() } - - static func fromValue(_ value: String) throws(StoreError) -> Self { - do { - return try jsonDecoder.decode(Self.self, from: Data(value.utf8)) - } catch { - throw StoreError.invalidJSON(value) - } - } - - static func toValue(_ value: Self) throws(StoreError) -> String { - let encoded: Data - do { - encoded = try jsonEncoder.encode(value) - } catch { - throw StoreError.encodingFailed - } - - guard let string = String(data: encoded, encoding: .utf8) else { - throw StoreError.encodingFailed - } - return string - } -} - -extension Array: StoreConvertible where Element: Codable { - typealias StorageType = String -} - -extension Dictionary: StoreConvertible where Key == String, Value: Codable { - typealias StorageType = String -} - -class StoreRepository { - private let db: DatabasePool - - init(db: DatabasePool) { - self.db = db - } - - func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == Int { - let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) } - if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil { - return try T.fromValue(value) - } - return nil - } - - func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == String { - let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) } - if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil { - return try T.fromValue(value) - } - return nil - } - - func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == Int { - let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) } - if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil { - return try T.fromValue(value) - } - return nil - } - - func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == String { - let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) } - if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil { - return try T.fromValue(value) - } - return nil - } - - func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == Int { - let value = try T.toValue(value) - try db.write { conn in - try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn) - } - } - - func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == String { - let value = try T.toValue(value) - try db.write { conn in - try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn) - } - } - - func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == Int { - let value = try T.toValue(value) - try await db.write { conn in - try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn) - } - } - - func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == String { - let value = try T.toValue(value) - try await db.write { conn in - try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn) - } - } -} diff --git a/mobile/ios/Runner/Schemas/Tables.swift b/mobile/ios/Runner/Schemas/Tables.swift deleted file mode 100644 index c256b0d0edba5..0000000000000 --- a/mobile/ios/Runner/Schemas/Tables.swift +++ /dev/null @@ -1,237 +0,0 @@ -import GRDB -import SQLiteData - -@Table("asset_face_entity") -struct AssetFace { - let id: String - let assetId: String - let personId: String? - let imageWidth: Int - let imageHeight: Int - let boundingBoxX1: Int - let boundingBoxY1: Int - let boundingBoxX2: Int - let boundingBoxY2: Int - let sourceType: String -} - -@Table("auth_user_entity") -struct AuthUser { - let id: String - let name: String - let email: String - let isAdmin: Bool - let hasProfileImage: Bool - let profileChangedAt: Date - let avatarColor: AvatarColor - let quotaSizeInBytes: Int - let quotaUsageInBytes: Int - let pinCode: String? -} - -@Table("local_album_entity") -struct LocalAlbum { - let id: String - let backupSelection: BackupSelection - let linkedRemoteAlbumId: String? - let marker_: Bool? - let name: String - let isIosSharedAlbum: Bool - let updatedAt: Date -} - -@Table("local_album_asset_entity") -struct LocalAlbumAsset { - let id: ID - let marker_: String? - - @Selection - struct ID { - let assetId: String - let albumId: String - } -} - -@Table("local_asset_entity") -struct LocalAsset { - let id: String - let checksum: String? - let createdAt: Date - let durationInSeconds: Int? - let height: Int? - let isFavorite: Bool - let name: String - let orientation: String - let type: Int - let updatedAt: Date - let width: Int? -} - -@Table("memory_asset_entity") -struct MemoryAsset { - let id: ID - - @Selection - struct ID { - let assetId: String - let albumId: String - } -} - -@Table("memory_entity") -struct Memory { - let id: String - let createdAt: Date - let updatedAt: Date - let deletedAt: Date? - let ownerId: String - let type: MemoryType - let data: String - let isSaved: Bool - let memoryAt: Date - let seenAt: Date? - let showAt: Date? - let hideAt: Date? -} - -@Table("partner_entity") -struct Partner { - let id: ID - let inTimeline: Bool - - @Selection - struct ID { - let sharedById: String - let sharedWithId: String - } -} - -@Table("person_entity") -struct Person { - let id: String - let createdAt: Date - let updatedAt: Date - let ownerId: String - let name: String - let faceAssetId: String? - let isFavorite: Bool - let isHidden: Bool - let color: String? - let birthDate: Date? -} - -@Table("remote_album_entity") -struct RemoteAlbum { - let id: String - let createdAt: Date - let description: String? - let isActivityEnabled: Bool - let name: String - let order: Int - let ownerId: String - let thumbnailAssetId: String? - let updatedAt: Date -} - -@Table("remote_album_asset_entity") -struct RemoteAlbumAsset { - let id: ID - - @Selection - struct ID { - let assetId: String - let albumId: String - } -} - -@Table("remote_album_user_entity") -struct RemoteAlbumUser { - let id: ID - let role: AlbumUserRole - - @Selection - struct ID { - let albumId: String - let userId: String - } -} - -@Table("remote_asset_entity") -struct RemoteAsset { - let id: String - let checksum: String? - let deletedAt: Date? - let isFavorite: Int - let libraryId: String? - let livePhotoVideoId: String? - let localDateTime: Date? - let orientation: String - let ownerId: String - let stackId: String? - let visibility: Int -} - -@Table("remote_exif_entity") -struct RemoteExif { - @Column(primaryKey: true) - let assetId: String - let city: String? - let state: String? - let country: String? - let dateTimeOriginal: Date? - let description: String? - let height: Int? - let width: Int? - let exposureTime: String? - let fNumber: Double? - let fileSize: Int? - let focalLength: Double? - let latitude: Double? - let longitude: Double? - let iso: Int? - let make: String? - let model: String? - let lens: String? - let orientation: String? - let timeZone: String? - let rating: Int? - let projectionType: String? -} - -@Table("stack_entity") -struct Stack { - let id: String - let createdAt: Date - let updatedAt: Date - let ownerId: String - let primaryAssetId: String -} - -@Table("store_entity") -struct Store { - let id: StoreKey - let stringValue: String? - let intValue: Int? -} - -@Table("user_entity") -struct User { - let id: String - let name: String - let email: String - let hasProfileImage: Bool - let profileChangedAt: Date - let avatarColor: AvatarColor -} - -@Table("user_metadata_entity") -struct UserMetadata { - let id: ID - let value: Data - - @Selection - struct ID { - let userId: String - let key: Date - } -} From 7f3386c8d0385d9bdc614cf78f9a1dc5937a577a Mon Sep 17 00:00:00 2001 From: idubnori Date: Thu, 4 Dec 2025 23:47:16 +0900 Subject: [PATCH 08/21] feat: add configurator button to viewer kebab menu --- i18n/en.json | 1 + .../asset_viewer/top_app_bar.widget.dart | 20 +++++++++++++++++-- .../viewer_kebab_menu.widget.dart | 6 +++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 8ec590ec611fb..4b7984bbc6c38 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1727,6 +1727,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/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 918ed8a223a4a..96b42da6c80b0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; @@ -65,6 +66,21 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + 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); + }); + } + final actions = [ if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true), if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), @@ -90,12 +106,12 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { if (asset.hasRemote && isOwner && asset.isFavorite) const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true), if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true), - const ViewerKebabMenu(), + ViewerKebabMenu(onConfigureButtons: openConfigurator), ]; final lockedViewActions = [ if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), - const ViewerKebabMenu(), + ViewerKebabMenu(onConfigureButtons: openConfigurator), ]; return IgnorePointer( diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index b228c0331b5b5..b740df7f37705 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -8,7 +8,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; class ViewerKebabMenu extends ConsumerWidget { - const ViewerKebabMenu({super.key}); + final VoidCallback onConfigureButtons; + + const ViewerKebabMenu({super.key, required this.onConfigureButtons}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,6 +26,8 @@ class ViewerKebabMenu extends ConsumerWidget { iconData: Icons.info_outline, onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), ), + const Divider(height: 0), + BaseActionButton(label: 'reorder_buttons'.tr(), iconData: Icons.tune, onPressed: onConfigureButtons), ]; return MenuAnchor( From 3700f9980fbf839b139ccdc1fa678dc3a99c1206 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 9 Dec 2025 11:48:40 +0900 Subject: [PATCH 09/21] revert: viewer_kebab --- .../viewer_kebab_menu.widget.dart | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index b740df7f37705..4651b5eea8c9f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -8,9 +8,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; class ViewerKebabMenu extends ConsumerWidget { - final VoidCallback onConfigureButtons; - - const ViewerKebabMenu({super.key, required this.onConfigureButtons}); + const ViewerKebabMenu({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -19,37 +17,29 @@ class ViewerKebabMenu extends ConsumerWidget { return const SizedBox.shrink(); } - final theme = context.themeData; final menuChildren = [ BaseActionButton( - label: 'open_bottom_sheet_info'.tr(), + label: 'about'.tr(), iconData: Icons.info_outline, + menuItem: true, onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), ), - const Divider(height: 0), - BaseActionButton(label: 'reorder_buttons'.tr(), iconData: Icons.tune, onPressed: onConfigureButtons), ]; return MenuAnchor( + consumeOutsideTap: true, style: MenuStyle( - backgroundColor: WidgetStatePropertyAll(theme.scaffoldBackgroundColor), + backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor), elevation: const WidgetStatePropertyAll(4), shape: const WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), ), - padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 2)), ), menuChildren: menuChildren, builder: (context, controller, child) { return IconButton( icon: const Icon(Icons.more_vert_rounded), - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, + onPressed: () => controller.isOpen ? controller.close() : controller.open(), ); }, ); From 2d4e901c55e8740a036f71a78633cbc3e04685de Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 9 Dec 2025 14:56:57 +0900 Subject: [PATCH 10/21] refactor: update viewer quick action order handling and refactor related utilities --- mobile/lib/domain/models/store.model.dart | 3 +- .../repositories/store.repository.dart | 7 +++ .../viewer_quick_action_order.provider.dart | 9 ++- mobile/lib/services/app_settings.service.dart | 24 +++----- mobile/lib/utils/action_button.utils.dart | 58 ++----------------- .../test/utils/action_button_utils_test.dart | 18 ------ 6 files changed, 30 insertions(+), 89 deletions(-) diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index ae0112acb3501..c27b1f1ca833e 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; /// Key for each possible value in the `Store`. /// Defines the data type for each value @@ -72,7 +73,7 @@ enum StoreKey { autoPlayVideo._(139), albumGridView._(140), - viewerQuickActionOrder._(141), + viewerQuickActionOrder>._(141), // Experimental stuff photoManagerCustomFilter._(1000), diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index d4e34a02f5fda..0050eb9911bf5 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -5,6 +7,7 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:isar/isar.dart'; // Temporary interface until Isar is removed to make the service work with both Isar and Sqlite @@ -84,6 +87,7 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), const (UserDto) => entity.strValue == null ? null : await IsarUserRepository(_db).getByUserId(entity.strValue!), + const (List) => jsonDecode(entity.strValue ?? '[]') as T, _ => null, } as T?; @@ -95,6 +99,7 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi const (bool) => ((value as bool) ? 1 : 0, null), const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), const (UserDto) => (null, (await IsarUserRepository(_db).update(value as UserDto)).id), + const (List) => (null, jsonEncode(value)), _ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"), }; return StoreValue(key.id, intValue: intValue, strValue: strValue); @@ -174,6 +179,7 @@ class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepo const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), const (UserDto) => entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!), + const (List) => jsonDecode(entity.stringValue ?? '[]') as T, _ => null, } as T?; @@ -185,6 +191,7 @@ class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepo const (bool) => ((value as bool) ? 1 : 0, null), const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id), + const (List) => (null, jsonEncode(value)), _ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"), }; return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue)); diff --git a/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart index 10d2a8835c8d3..c9e48d63c6ded 100644 --- a/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart +++ b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -13,9 +14,11 @@ class ViewerQuickActionOrder extends _$ViewerQuickActionOrder { @override List build() { final service = ref.watch(appSettingsServiceProvider); - final initial = ActionButtonBuilder.normalizeQuickActionOrder(service.getViewerQuickActionOrder()); + final initial = ActionButtonBuilder.normalizeQuickActionOrder( + service.getSetting(AppSettingsEnum.viewerQuickActionOrder), + ); - _subscription ??= service.watchViewerQuickActionOrder().listen((order) { + _subscription ??= service.watchSetting(AppSettingsEnum.viewerQuickActionOrder).listen((order) { state = ActionButtonBuilder.normalizeQuickActionOrder(order); }); @@ -38,7 +41,7 @@ class ViewerQuickActionOrder extends _$ViewerQuickActionOrder { state = normalized; try { - await ref.read(appSettingsServiceProvider).setViewerQuickActionOrder(normalized); + await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.viewerQuickActionOrder, normalized); } catch (error) { state = previous; rethrow; diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 1c48d9e868823..dc2c78331f4f3 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -55,7 +55,12 @@ enum AppSettingsEnum { readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), albumGridView(StoreKey.albumGridView, "albumGridView", false), backupRequireCharging(StoreKey.backupRequireCharging, null, false), - backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30); + backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), + viewerQuickActionOrder>( + StoreKey.viewerQuickActionOrder, + null, + ActionButtonBuilder.defaultQuickActionSeed, + ); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); @@ -66,6 +71,7 @@ enum AppSettingsEnum { class AppSettingsService { const AppSettingsService(); + T getSetting(AppSettingsEnum setting) { return Store.get(setting.storeKey, setting.defaultValue); } @@ -74,19 +80,7 @@ class AppSettingsService { return Store.put(setting.storeKey, value); } - List getViewerQuickActionOrder() { - final stored = Store.get(StoreKey.viewerQuickActionOrder, ActionButtonBuilder.defaultQuickActionOrderStorageValue); - return ActionButtonBuilder.parseQuickActionOrder(stored); - } - - Stream> watchViewerQuickActionOrder() { - return Store.watch(StoreKey.viewerQuickActionOrder).map( - (value) => - ActionButtonBuilder.parseQuickActionOrder(value ?? ActionButtonBuilder.defaultQuickActionOrderStorageValue), - ); - } - - Future setViewerQuickActionOrder(List order) { - return Store.put(StoreKey.viewerQuickActionOrder, ActionButtonBuilder.encodeQuickActionOrder(order)); + Stream watchSetting(AppSettingsEnum setting) { + return Store.watch(setting.storeKey).map((value) => value ?? setting.defaultValue); } } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 8d07e23cbf62b..b10dc7542b3c8 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -67,6 +67,8 @@ enum ActionButtonType { unstack, likeActivity; + dynamic toJson() => name; + bool shouldShow(ActionButtonContext context) { return switch (this) { ActionButtonType.advancedInfo => context.advancedTroubleshooting, @@ -171,9 +173,8 @@ class ActionButtonBuilder { static const List _actionTypes = ActionButtonType.values; static const int defaultQuickActionLimit = 4; - static const String quickActionStorageDelimiter = ','; - static const List _defaultQuickActionSeed = [ + static const List defaultQuickActionSeed = [ ActionButtonType.share, ActionButtonType.upload, ActionButtonType.edit, @@ -184,47 +185,14 @@ class ActionButtonBuilder { ActionButtonType.likeActivity, ]; - static final Set _quickActionSet = Set.unmodifiable(_defaultQuickActionSeed); + static final Set _quickActionSet = Set.unmodifiable(defaultQuickActionSeed); static final List defaultQuickActionOrder = List.unmodifiable( - _defaultQuickActionSeed, + defaultQuickActionSeed, ); - static final String defaultQuickActionOrderStorageValue = defaultQuickActionOrder - .map((type) => type.name) - .join(quickActionStorageDelimiter); - static List get quickActionOptions => defaultQuickActionOrder; - static List parseQuickActionOrder(String? stored) { - final parsed = []; - - if (stored != null && stored.trim().isNotEmpty) { - for (final name in stored.split(quickActionStorageDelimiter)) { - final type = _typeByName(name.trim()); - if (type != null) { - parsed.add(type); - } - } - } - - return normalizeQuickActionOrder(parsed); - } - - static String encodeQuickActionOrder(List order) { - final unique = {}; - final buffer = []; - - for (final type in order) { - if (unique.add(type)) { - buffer.add(type.name); - } - } - - final result = buffer.join(quickActionStorageDelimiter); - return result; - } - static List buildQuickActionTypes( ActionButtonContext context, { List? quickActionOrder, @@ -265,20 +233,6 @@ class ActionButtonBuilder { return types.map((type) => type.buildButton(context)).toList(); } - static ActionButtonType? _typeByName(String name) { - if (name.isEmpty) { - return null; - } - - for (final type in ActionButtonType.values) { - if (type.name == name) { - return type; - } - } - - return null; - } - static List build(ActionButtonContext context) { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } @@ -292,7 +246,7 @@ class ActionButtonBuilder { } } - ordered.addAll(_defaultQuickActionSeed); + ordered.addAll(defaultQuickActionSeed); return ordered.toList(growable: false); } diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index ed5691dafd2fc..bfc572624ff81 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -1015,24 +1015,6 @@ void main() { expect(nonArchivedWidgets, isNotEmpty); }); - test('should encode and parse quick action order consistently', () { - final encoded = ActionButtonBuilder.encodeQuickActionOrder([ - ActionButtonType.edit, - ActionButtonType.share, - ActionButtonType.archive, - ]); - - final decoded = ActionButtonBuilder.parseQuickActionOrder(encoded); - - final expectedOrder = ActionButtonBuilder.normalizeQuickActionOrder([ - ActionButtonType.edit, - ActionButtonType.share, - ActionButtonType.archive, - ]); - - expect(decoded, expectedOrder); - }); - test('should build quick actions honoring custom order', () { final remoteAsset = createRemoteAsset(); final context = ActionButtonContext( From f874c12bee2c1f7d4a6ef02e909fd39e6f148839 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 9 Dec 2025 15:53:18 +0900 Subject: [PATCH 11/21] fix: update JSON serialization for ActionButtonType and improve type safety --- .../repositories/store.repository.dart | 13 ++++++++++--- mobile/lib/utils/action_button.utils.dart | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index 0050eb9911bf5..920863ecf8021 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -1,5 +1,4 @@ import 'dart:convert'; - import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -87,7 +86,11 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), const (UserDto) => entity.strValue == null ? null : await IsarUserRepository(_db).getByUserId(entity.strValue!), - const (List) => jsonDecode(entity.strValue ?? '[]') as T, + const (List) => + (jsonDecode(entity.strValue ?? '[]') as List) + .map((d) => ActionButtonType.values.byName(d)) + .toList() + as T, _ => null, } as T?; @@ -179,7 +182,11 @@ class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepo const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), const (UserDto) => entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!), - const (List) => jsonDecode(entity.stringValue ?? '[]') as T, + const (List) => + (jsonDecode(entity.stringValue ?? '[]') as List) + .map((d) => ActionButtonType.values.byName(d)) + .toList() + as T, _ => null, } as T?; diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index b10dc7542b3c8..40ae0e41574aa 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -67,7 +67,7 @@ enum ActionButtonType { unstack, likeActivity; - dynamic toJson() => name; + String toJson() => name; bool shouldShow(ActionButtonContext context) { return switch (this) { From 7473b959dcc2daa5b641c23946c24fe62ef5bce8 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 01:06:07 +0900 Subject: [PATCH 12/21] refactor: proper layer archtecture --- mobile/lib/domain/models/store.model.dart | 3 +- .../domain/services/quick_action.service.dart | 109 +++++++++++++ .../action_button_order.repository.dart | 76 +++++++++ .../repositories/store.repository.dart | 14 -- .../asset_viewer/bottom_bar.widget.dart | 10 +- .../quick_action_configurator.widget.dart | 13 +- .../viewer_quick_action_order.provider.dart | 56 ++++--- .../viewer_quick_action_order.provider.g.dart | 27 ---- mobile/lib/services/app_settings.service.dart | 7 +- mobile/lib/utils/action_button.utils.dart | 92 +---------- .../services/quick_action_service_test.dart | 150 ++++++++++++++++++ .../test/utils/action_button_utils_test.dart | 9 +- 12 files changed, 391 insertions(+), 175 deletions(-) create mode 100644 mobile/lib/domain/services/quick_action.service.dart create mode 100644 mobile/lib/infrastructure/repositories/action_button_order.repository.dart delete mode 100644 mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart create mode 100644 mobile/test/domain/services/quick_action_service_test.dart diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index c27b1f1ca833e..ae0112acb3501 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -1,5 +1,4 @@ import 'package:immich_mobile/domain/models/user.model.dart'; -import 'package:immich_mobile/utils/action_button.utils.dart'; /// Key for each possible value in the `Store`. /// Defines the data type for each value @@ -73,7 +72,7 @@ enum StoreKey { autoPlayVideo._(139), albumGridView._(140), - viewerQuickActionOrder>._(141), + 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..a98e994b264c9 --- /dev/null +++ b/mobile/lib/domain/services/quick_action.service.dart @@ -0,0 +1,109 @@ +import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; + +/// Service for managing quick action configurations. +/// Provides business logic for building quick action types based on context. +class QuickActionService { + final ActionButtonOrderRepository _repository; + + const QuickActionService(this._repository); + + // Constants + static const int defaultQuickActionLimit = 4; + + static const List defaultQuickActionSeed = [ + ActionButtonType.share, + ActionButtonType.upload, + ActionButtonType.edit, + ActionButtonType.add, + ActionButtonType.archive, + ActionButtonType.delete, + ActionButtonType.removeFromAlbum, + ActionButtonType.likeActivity, + ]; + + static final Set _quickActionSet = Set.unmodifiable(defaultQuickActionSeed); + + static final List defaultQuickActionOrder = List.unmodifiable( + defaultQuickActionSeed, + ); + + /// Get the list of available quick action options + // static List get quickActionOptions => defaultQuickActionOrder; + + /// Get the current quick action order + List get() { + return _repository.get(); + } + + /// Set the quick action order + Future set(List order) async { + final normalized = _normalizeQuickActionOrder(order); + await _repository.set(normalized); + } + + /// Watch for changes to quick action order + Stream> watch() { + return _repository.watch(); + } + + /// Normalize quick action order by filtering valid types and ensuring all defaults are included + List _normalizeQuickActionOrder(List order) { + final ordered = {}; + + for (final type in order) { + if (_quickActionSet.contains(type)) { + ordered.add(type); + } + } + + ordered.addAll(defaultQuickActionSeed); + + return ordered.toList(growable: false); + } + + /// Build a list of quick action types based on context and custom order + List buildQuickActionTypes( + ActionButtonContext context, { + List? quickActionOrder, + int limit = defaultQuickActionLimit, + }) { + final normalized = _normalizeQuickActionOrder( + quickActionOrder == null || quickActionOrder.isEmpty ? defaultQuickActionOrder : quickActionOrder, + ); + + final seen = {}; + final result = []; + + for (final type in normalized) { + if (!_quickActionSet.contains(type)) { + continue; + } + + final resolved = _resolveQuickActionType(type, context); + if (!seen.add(resolved) || !resolved.shouldShow(context)) { + continue; + } + + result.add(resolved); + if (result.length >= limit) { + break; + } + } + + return result; + } + + /// 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..2efaeb0a75950 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/action_button_order.repository.dart @@ -0,0 +1,76 @@ +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'; + +/// Repository for managing quick action button order persistence. +/// Handles serialization, deserialization, and storage operations. +class ActionButtonOrderRepository { + const ActionButtonOrderRepository(); + + /// Default order for quick actions + static const List defaultOrder = [ + ActionButtonType.share, + ActionButtonType.upload, + ActionButtonType.edit, + ActionButtonType.add, + ActionButtonType.archive, + ActionButtonType.delete, + ActionButtonType.removeFromAlbum, + ActionButtonType.likeActivity, + ]; + + /// Get the current quick action order from storage + List get() { + final json = Store.tryGet(StoreKey.viewerQuickActionOrder); + if (json == null || json.isEmpty) { + return defaultOrder; + } + + final deserialized = _deserialize(json); + return deserialized.isEmpty ? defaultOrder : deserialized; + } + + /// Save quick action order to storage + Future set(List order) async { + final json = _serialize(order); + await Store.put(StoreKey.viewerQuickActionOrder, json); + } + + /// Watch for changes to quick action order + Stream> watch() { + return Store.watch(StoreKey.viewerQuickActionOrder).map((json) { + if (json == null || json.isEmpty) { + return defaultOrder; + } + final deserialized = _deserialize(json); + return deserialized.isEmpty ? defaultOrder : deserialized; + }); + } + + /// Serialize a list of ActionButtonType to JSON string + String _serialize(List order) { + return jsonEncode(order.map((type) => type.name).toList()); + } + + /// Deserialize a JSON string to a list of ActionButtonType + 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/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index 920863ecf8021..d4e34a02f5fda 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -6,7 +5,6 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; -import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:isar/isar.dart'; // Temporary interface until Isar is removed to make the service work with both Isar and Sqlite @@ -86,11 +84,6 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), const (UserDto) => entity.strValue == null ? null : await IsarUserRepository(_db).getByUserId(entity.strValue!), - const (List) => - (jsonDecode(entity.strValue ?? '[]') as List) - .map((d) => ActionButtonType.values.byName(d)) - .toList() - as T, _ => null, } as T?; @@ -102,7 +95,6 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi const (bool) => ((value as bool) ? 1 : 0, null), const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), const (UserDto) => (null, (await IsarUserRepository(_db).update(value as UserDto)).id), - const (List) => (null, jsonEncode(value)), _ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"), }; return StoreValue(key.id, intValue: intValue, strValue: strValue); @@ -182,11 +174,6 @@ class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepo const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), const (UserDto) => entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!), - const (List) => - (jsonDecode(entity.stringValue ?? '[]') as List) - .map((d) => ActionButtonType.values.byName(d)) - .toList() - as T, _ => null, } as T?; @@ -198,7 +185,6 @@ class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepo const (bool) => ((value as bool) ? 1 : 0, null), const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id), - const (List) => (null, jsonEncode(value)), _ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"), }; return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue)); 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 da897c6d57193..832b8a0221963 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -56,7 +56,8 @@ class ViewerBottomBar extends ConsumerWidget { source: ActionSource.viewer, ); - final quickActionTypes = ActionButtonBuilder.buildQuickActionTypes( + final quickActionService = ref.watch(quickActionServiceProvider); + final quickActionTypes = quickActionService.buildQuickActionTypes( buttonContext, quickActionOrder: quickActionOrder, ); @@ -76,9 +77,10 @@ class ViewerBottomBar extends ConsumerWidget { }); } - final actions = quickActionTypes - .map((type) => GestureDetector(onLongPress: openConfigurator, child: type.buildButton(buttonContext))) - .toList(growable: false); + final actions = ActionButtonBuilder.buildQuickActions( + buttonContext, + quickActionTypes: quickActionTypes, + ).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 index 64fc915d113ff..9672edfc94e09 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/quick_action.service.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'; @@ -41,7 +42,7 @@ class _QuickActionConfiguratorState extends ConsumerState.from(ActionButtonBuilder.defaultQuickActionOrder); + _order = List.from(QuickActionService.defaultQuickActionOrder); _hasLocalChanges = true; }); } @@ -49,9 +50,7 @@ class _QuickActionConfiguratorState extends ConsumerState Navigator.of(context).pop(); Future _save() async { - final normalized = ActionButtonBuilder.normalizeQuickActionOrder(_order); - - await ref.read(viewerQuickActionOrderProvider.notifier).setOrder(normalized); + await ref.read(viewerQuickActionOrderProvider.notifier).setOrder(_order); _hasLocalChanges = false; if (mounted) { Navigator.of(context).pop(); @@ -69,8 +68,8 @@ class _QuickActionConfiguratorState extends ConsumerState.from(currentOrder); } - final normalizedSelection = ActionButtonBuilder.normalizeQuickActionOrder(_order); - final hasChanges = !listEquals(currentOrder, normalizedSelection); + + final hasChanges = !listEquals(currentOrder, _order); return SafeArea( child: Padding( @@ -91,7 +90,7 @@ class _QuickActionConfiguratorState extends ConsumerState( + (ref) => const ActionButtonOrderRepository(), +); -@Riverpod(keepAlive: true) -class ViewerQuickActionOrder extends _$ViewerQuickActionOrder { - StreamSubscription>? _subscription; +final quickActionServiceProvider = Provider( + (ref) => QuickActionService(ref.watch(actionButtonOrderRepositoryProvider)), +); - @override - List build() { - final service = ref.watch(appSettingsServiceProvider); - final initial = ActionButtonBuilder.normalizeQuickActionOrder( - service.getSetting(AppSettingsEnum.viewerQuickActionOrder), - ); - - _subscription ??= service.watchSetting(AppSettingsEnum.viewerQuickActionOrder).listen((order) { - state = ActionButtonBuilder.normalizeQuickActionOrder(order); - }); +final viewerQuickActionOrderProvider = StateNotifierProvider>( + (ref) => ViewerQuickActionOrderNotifier(ref.watch(quickActionServiceProvider)), +); + +class ViewerQuickActionOrderNotifier extends StateNotifier> { + final QuickActionService _service; + StreamSubscription>? _subscription; - ref.onDispose(() { - _subscription?.cancel(); - _subscription = null; + ViewerQuickActionOrderNotifier(this._service) : super(_service.get()) { + _subscription = _service.watch().listen((order) { + state = order; }); + } - return initial; + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); } Future setOrder(List order) async { - final normalized = ActionButtonBuilder.normalizeQuickActionOrder(order); - - if (listEquals(state, normalized)) { + if (listEquals(state, order)) { return; } final previous = state; - state = normalized; + state = order; try { - await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.viewerQuickActionOrder, normalized); + await _service.set(order); } catch (error) { state = previous; rethrow; } } } - -/// Mock class for testing -abstract class ViewerQuickActionOrderInternal extends _$ViewerQuickActionOrder {} diff --git a/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart b/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart deleted file mode 100644 index c54e80a452803..0000000000000 --- a/mobile/lib/providers/infrastructure/viewer_quick_action_order.provider.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'viewer_quick_action_order.provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$viewerQuickActionOrderHash() => - r'd539bc6ba5fae4fa07a7c30c42d9f6aee1488f97'; - -/// See also [ViewerQuickActionOrder]. -@ProviderFor(ViewerQuickActionOrder) -final viewerQuickActionOrderProvider = - NotifierProvider>.internal( - ViewerQuickActionOrder.new, - name: r'viewerQuickActionOrderProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$viewerQuickActionOrderHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ViewerQuickActionOrder = Notifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index dc2c78331f4f3..9216bc1254010 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -1,7 +1,6 @@ import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/utils/action_button.utils.dart'; enum AppSettingsEnum { loadPreview(StoreKey.loadPreview, "loadPreview", true), @@ -56,11 +55,7 @@ enum AppSettingsEnum { albumGridView(StoreKey.albumGridView, "albumGridView", false), backupRequireCharging(StoreKey.backupRequireCharging, null, false), backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), - viewerQuickActionOrder>( - StoreKey.viewerQuickActionOrder, - null, - ActionButtonBuilder.defaultQuickActionSeed, - ); + viewerQuickActionOrder(StoreKey.viewerQuickActionOrder, null, ''); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 40ae0e41574aa..a57484735ea86 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -169,99 +169,23 @@ enum ActionButtonType { } } +/// Builder class for creating action button widgets. +/// This class provides simple factory methods for building action button widgets +/// from ActionButtonContext. Business logic for quick actions is handled by QuickActionService. class ActionButtonBuilder { static const List _actionTypes = ActionButtonType.values; - static const int defaultQuickActionLimit = 4; - - static const List defaultQuickActionSeed = [ - ActionButtonType.share, - ActionButtonType.upload, - ActionButtonType.edit, - ActionButtonType.add, - ActionButtonType.archive, - ActionButtonType.delete, - ActionButtonType.removeFromAlbum, - ActionButtonType.likeActivity, - ]; - - static final Set _quickActionSet = Set.unmodifiable(defaultQuickActionSeed); - - static final List defaultQuickActionOrder = List.unmodifiable( - defaultQuickActionSeed, - ); - - static List get quickActionOptions => defaultQuickActionOrder; - - static List buildQuickActionTypes( - ActionButtonContext context, { - List? quickActionOrder, - int limit = defaultQuickActionLimit, - }) { - final normalized = normalizeQuickActionOrder( - quickActionOrder == null || quickActionOrder.isEmpty ? defaultQuickActionOrder : quickActionOrder, - ); - - final seen = {}; - final result = []; - - for (final type in normalized) { - if (!_quickActionSet.contains(type)) { - continue; - } - - final resolved = _resolveQuickActionType(type, context); - if (!seen.add(resolved) || !resolved.shouldShow(context)) { - continue; - } - - result.add(resolved); - if (result.length >= limit) { - break; - } - } - - return result; - } - + /// Build a list of quick action widgets based on context and custom order. + /// Uses QuickActionService for business logic. static List buildQuickActions( ActionButtonContext context, { - List? quickActionOrder, - int limit = defaultQuickActionLimit, + required List quickActionTypes, }) { - final types = buildQuickActionTypes(context, quickActionOrder: quickActionOrder, limit: limit); - return types.map((type) => type.buildButton(context)).toList(); + return quickActionTypes.map((type) => type.buildButton(context)).toList(); } + /// Build all available action button widgets for the given context. static List build(ActionButtonContext context) { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } - - static List normalizeQuickActionOrder(List order) { - final ordered = {}; - - for (final type in order) { - if (_quickActionSet.contains(type)) { - ordered.add(type); - } - } - - ordered.addAll(defaultQuickActionSeed); - - return ordered.toList(growable: false); - } - - static ActionButtonType _resolveQuickActionType(ActionButtonType type, ActionButtonContext context) { - if (type == ActionButtonType.archive && context.isArchived) { - return ActionButtonType.unarchive; - } - - if (type == ActionButtonType.delete && context.asset.isLocalOnly) { - return ActionButtonType.deleteLocal; - } - - return type; - } - - static bool isSupportedQuickAction(ActionButtonType type) => _quickActionSet.contains(type); } diff --git a/mobile/test/domain/services/quick_action_service_test.dart b/mobile/test/domain/services/quick_action_service_test.dart new file mode 100644 index 0000000000000..35d43c06a04c3 --- /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(QuickActionService.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: QuickActionService.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 bfc572624ff81..c03ca2910b2f7 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -3,6 +3,8 @@ 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'; @@ -1029,7 +1031,8 @@ void main() { source: ActionSource.viewer, ); - final quickActions = ActionButtonBuilder.buildQuickActions( + final quickActionService = const QuickActionService(ActionButtonOrderRepository()); + final quickActionTypes = quickActionService.buildQuickActionTypes( context, quickActionOrder: const [ ActionButtonType.archive, @@ -1039,7 +1042,9 @@ void main() { ], ); - expect(quickActions.length, ActionButtonBuilder.defaultQuickActionLimit); + final quickActions = ActionButtonBuilder.buildQuickActions(context, quickActionTypes: quickActionTypes); + + expect(quickActions.length, QuickActionService.defaultQuickActionLimit); expect(quickActions.first, isA()); expect(quickActions[1], isA()); expect(quickActions[2], isA()); From 17361d189cf2dee45f6abe65e10670e01817498f Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 01:28:31 +0900 Subject: [PATCH 13/21] refactor: clean up --- .../domain/services/quick_action.service.dart | 38 ++++--------------- .../action_button_order.repository.dart | 34 ++++------------- .../asset_viewer/bottom_bar.widget.dart | 8 ++-- .../quick_action_configurator.widget.dart | 5 +-- mobile/lib/services/app_settings.service.dart | 7 +--- mobile/lib/utils/action_button.utils.dart | 24 ++++++------ .../services/quick_action_service_test.dart | 4 +- .../test/utils/action_button_utils_test.dart | 4 +- 8 files changed, 38 insertions(+), 86 deletions(-) diff --git a/mobile/lib/domain/services/quick_action.service.dart b/mobile/lib/domain/services/quick_action.service.dart index a98e994b264c9..d15a67fd47c75 100644 --- a/mobile/lib/domain/services/quick_action.service.dart +++ b/mobile/lib/domain/services/quick_action.service.dart @@ -1,53 +1,28 @@ import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; -/// Service for managing quick action configurations. -/// Provides business logic for building quick action types based on context. class QuickActionService { final ActionButtonOrderRepository _repository; const QuickActionService(this._repository); - // Constants - static const int defaultQuickActionLimit = 4; - - static const List defaultQuickActionSeed = [ - ActionButtonType.share, - ActionButtonType.upload, - ActionButtonType.edit, - ActionButtonType.add, - ActionButtonType.archive, - ActionButtonType.delete, - ActionButtonType.removeFromAlbum, - ActionButtonType.likeActivity, - ]; - - static final Set _quickActionSet = Set.unmodifiable(defaultQuickActionSeed); - - static final List defaultQuickActionOrder = List.unmodifiable( - defaultQuickActionSeed, + static final Set _quickActionSet = Set.unmodifiable( + ActionButtonBuilder.defaultQuickActionOrder, ); - /// Get the list of available quick action options - // static List get quickActionOptions => defaultQuickActionOrder; - - /// Get the current quick action order List get() { return _repository.get(); } - /// Set the quick action order Future set(List order) async { final normalized = _normalizeQuickActionOrder(order); await _repository.set(normalized); } - /// Watch for changes to quick action order Stream> watch() { return _repository.watch(); } - /// Normalize quick action order by filtering valid types and ensuring all defaults are included List _normalizeQuickActionOrder(List order) { final ordered = {}; @@ -57,19 +32,20 @@ class QuickActionService { } } - ordered.addAll(defaultQuickActionSeed); + ordered.addAll(ActionButtonBuilder.defaultQuickActionOrder); return ordered.toList(growable: false); } - /// Build a list of quick action types based on context and custom order List buildQuickActionTypes( ActionButtonContext context, { List? quickActionOrder, - int limit = defaultQuickActionLimit, + int limit = ActionButtonBuilder.defaultQuickActionLimit, }) { final normalized = _normalizeQuickActionOrder( - quickActionOrder == null || quickActionOrder.isEmpty ? defaultQuickActionOrder : quickActionOrder, + quickActionOrder == null || quickActionOrder.isEmpty + ? ActionButtonBuilder.defaultQuickActionOrder + : quickActionOrder, ); final seen = {}; diff --git a/mobile/lib/infrastructure/repositories/action_button_order.repository.dart b/mobile/lib/infrastructure/repositories/action_button_order.repository.dart index 2efaeb0a75950..552ee5bac85f7 100644 --- a/mobile/lib/infrastructure/repositories/action_button_order.repository.dart +++ b/mobile/lib/infrastructure/repositories/action_button_order.repository.dart @@ -1,60 +1,42 @@ 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'; -/// Repository for managing quick action button order persistence. -/// Handles serialization, deserialization, and storage operations. class ActionButtonOrderRepository { const ActionButtonOrderRepository(); - /// Default order for quick actions - static const List defaultOrder = [ - ActionButtonType.share, - ActionButtonType.upload, - ActionButtonType.edit, - ActionButtonType.add, - ActionButtonType.archive, - ActionButtonType.delete, - ActionButtonType.removeFromAlbum, - ActionButtonType.likeActivity, - ]; + static const storeKey = StoreKey.viewerQuickActionOrder; - /// Get the current quick action order from storage List get() { - final json = Store.tryGet(StoreKey.viewerQuickActionOrder); + final json = Store.tryGet(storeKey); if (json == null || json.isEmpty) { - return defaultOrder; + return ActionButtonBuilder.defaultQuickActionOrder; } final deserialized = _deserialize(json); - return deserialized.isEmpty ? defaultOrder : deserialized; + return deserialized.isEmpty ? ActionButtonBuilder.defaultQuickActionOrder : deserialized; } - /// Save quick action order to storage Future set(List order) async { final json = _serialize(order); - await Store.put(StoreKey.viewerQuickActionOrder, json); + await Store.put(storeKey, json); } - /// Watch for changes to quick action order Stream> watch() { - return Store.watch(StoreKey.viewerQuickActionOrder).map((json) { + return Store.watch(storeKey).map((json) { if (json == null || json.isEmpty) { - return defaultOrder; + return ActionButtonBuilder.defaultQuickActionOrder; } final deserialized = _deserialize(json); - return deserialized.isEmpty ? defaultOrder : deserialized; + return deserialized.isEmpty ? ActionButtonBuilder.defaultQuickActionOrder : deserialized; }); } - /// Serialize a list of ActionButtonType to JSON string String _serialize(List order) { return jsonEncode(order.map((type) => type.name).toList()); } - /// Deserialize a JSON string to a list of ActionButtonType List _deserialize(String json) { try { final list = jsonDecode(json) as List; 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 832b8a0221963..725a382d4220d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -77,10 +77,10 @@ class ViewerBottomBar extends ConsumerWidget { }); } - final actions = ActionButtonBuilder.buildQuickActions( - buttonContext, - quickActionTypes: quickActionTypes, - ).map((widget) => GestureDetector(onLongPress: openConfigurator, child: widget)).toList(growable: false); + 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 index 9672edfc94e09..eb6b3f15769ff 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -3,7 +3,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/services/quick_action.service.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'; @@ -42,7 +41,7 @@ class _QuickActionConfiguratorState extends ConsumerState.from(QuickActionService.defaultQuickActionOrder); + _order = List.from(ActionButtonBuilder.defaultQuickActionOrder); _hasLocalChanges = true; }); } @@ -90,7 +89,7 @@ class _QuickActionConfiguratorState extends ConsumerState { readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), albumGridView(StoreKey.albumGridView, "albumGridView", false), backupRequireCharging(StoreKey.backupRequireCharging, null, false), - backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), - viewerQuickActionOrder(StoreKey.viewerQuickActionOrder, null, ''); + backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); @@ -74,8 +73,4 @@ class AppSettingsService { Future setSetting(AppSettingsEnum setting, T value) { return Store.put(setting.storeKey, value); } - - Stream watchSetting(AppSettingsEnum setting) { - return Store.watch(setting.storeKey).map((value) => value ?? setting.defaultValue); - } } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index a57484735ea86..0761cea4627f4 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -169,22 +169,22 @@ enum ActionButtonType { } } -/// Builder class for creating action button widgets. -/// This class provides simple factory methods for building action button widgets -/// from ActionButtonContext. Business logic for quick actions is handled by QuickActionService. class ActionButtonBuilder { static const List _actionTypes = ActionButtonType.values; - /// Build a list of quick action widgets based on context and custom order. - /// Uses QuickActionService for business logic. - static List buildQuickActions( - ActionButtonContext context, { - required List quickActionTypes, - }) { - return quickActionTypes.map((type) => type.buildButton(context)).toList(); - } + 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, + ]; - /// Build all available action button widgets for the given context. static List build(ActionButtonContext context) { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } diff --git a/mobile/test/domain/services/quick_action_service_test.dart b/mobile/test/domain/services/quick_action_service_test.dart index 35d43c06a04c3..be236e831f806 100644 --- a/mobile/test/domain/services/quick_action_service_test.dart +++ b/mobile/test/domain/services/quick_action_service_test.dart @@ -46,7 +46,7 @@ void main() { final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder); - expect(types.length, lessThanOrEqualTo(QuickActionService.defaultQuickActionLimit)); + expect(types.length, lessThanOrEqualTo(ActionButtonBuilder.defaultQuickActionLimit)); expect(types.first, ActionButtonType.archive); expect(types[1], ActionButtonType.share); }); @@ -140,7 +140,7 @@ void main() { final types = service.buildQuickActionTypes( context, - quickActionOrder: QuickActionService.defaultQuickActionOrder, + quickActionOrder: ActionButtonBuilder.defaultQuickActionOrder, limit: 2, ); diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index c03ca2910b2f7..a7652e7cd99e9 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -1042,9 +1042,9 @@ void main() { ], ); - final quickActions = ActionButtonBuilder.buildQuickActions(context, quickActionTypes: quickActionTypes); + final quickActions = quickActionTypes.map((type) => type.buildButton(context)).toList(); - expect(quickActions.length, QuickActionService.defaultQuickActionLimit); + expect(quickActions.length, ActionButtonBuilder.defaultQuickActionLimit); expect(quickActions.first, isA()); expect(quickActions[1], isA()); expect(quickActions[2], isA()); From 598e856322d0be1e2b7e066605dde29d363e3b0b Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 09:09:15 +0900 Subject: [PATCH 14/21] refactor: replace flutter_reorderable_grid_view with custom _ReorderableGrid implementation --- .../quick_action_configurator.widget.dart | 137 +++++++++++++++--- mobile/pubspec.lock | 8 - mobile/pubspec.yaml | 1 - 3 files changed, 115 insertions(+), 31 deletions(-) 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 index eb6b3f15769ff..66c73746f149b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -1,7 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; @@ -32,9 +31,10 @@ class _QuickActionConfiguratorState extends ConsumerState reorder) { + void _onReorder(int oldIndex, int newIndex) { setState(() { - _order = reorder(_order); + final item = _order.removeAt(oldIndex); + _order.insert(newIndex, item); _hasLocalChanges = true; }); } @@ -101,31 +101,22 @@ class _QuickActionConfiguratorState extends ConsumerState constraints.maxHeight; - final horizontalPadding = 8.0; // matches GridView padding + final horizontalPadding = 8.0; final tileWidth = (constraints.maxWidth - horizontalPadding - (crossAxisSpacing * (crossAxisCount - 1))) / crossAxisCount; final childAspectRatio = tileWidth / tileHeight; final gridController = shouldScroll ? _scrollController : null; - return ReorderableBuilder( - onReorder: _onReorder, - enableLongPress: false, + return _ReorderableGrid( scrollController: gridController, - children: [ - for (var i = 0; i < _order.length; i++) - _QuickActionTile(key: ValueKey(_order[i].name), index: i, type: _order[i]), - ], - builder: (children) => GridView.count( - controller: gridController, - crossAxisCount: crossAxisCount, - crossAxisSpacing: crossAxisSpacing, - mainAxisSpacing: mainAxisSpacing, - // padding: const EdgeInsets.fromLTRB(4, 0, 4, 12), - physics: shouldScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), - childAspectRatio: childAspectRatio, - children: children, - ), + items: _order, + onReorder: _onReorder, + crossAxisCount: crossAxisCount, + crossAxisSpacing: crossAxisSpacing, + mainAxisSpacing: mainAxisSpacing, + childAspectRatio: childAspectRatio, + shouldScroll: shouldScroll, ); }, ), @@ -151,11 +142,113 @@ class _QuickActionConfiguratorState extends ConsumerState items; + final Function(int oldIndex, int newIndex) onReorder; + final int crossAxisCount; + final double crossAxisSpacing; + final double mainAxisSpacing; + final double childAspectRatio; + final bool shouldScroll; + + const _ReorderableGrid({ + required this.scrollController, + required this.items, + required this.onReorder, + required this.crossAxisCount, + required this.crossAxisSpacing, + required this.mainAxisSpacing, + required this.childAspectRatio, + required this.shouldScroll, + }); + + @override + State<_ReorderableGrid> createState() => _ReorderableGridState(); +} + +class _ReorderableGridState extends State<_ReorderableGrid> { + int? _draggingIndex; + int? _hoveringIndex; + + @override + Widget build(BuildContext context) { + return GridView.builder( + controller: widget.scrollController, + physics: widget.shouldScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + crossAxisSpacing: widget.crossAxisSpacing, + mainAxisSpacing: widget.mainAxisSpacing, + childAspectRatio: widget.childAspectRatio, + ), + itemCount: widget.items.length, + itemBuilder: (context, index) { + final item = widget.items[index]; + final isDragging = _draggingIndex == index; + final isHovering = _hoveringIndex == index; + + return DragTarget( + onWillAcceptWithDetails: (details) { + if (details.data != index) { + setState(() => _hoveringIndex = index); + } + return details.data != index; + }, + onLeave: (_) { + setState(() => _hoveringIndex = null); + }, + onAcceptWithDetails: (details) { + final oldIndex = details.data; + if (oldIndex != index) { + widget.onReorder(oldIndex, index); + } + setState(() { + _hoveringIndex = null; + _draggingIndex = null; + }); + }, + builder: (context, candidateData, rejectedData) { + return LongPressDraggable( + data: index, + feedback: Material( + color: Colors.transparent, + child: Opacity( + opacity: 0.8, + child: Transform.scale( + scale: 1.1, + child: _QuickActionTile(index: index, type: item), + ), + ), + ), + childWhenDragging: Opacity( + opacity: 0.3, + child: _QuickActionTile(index: index, type: item), + ), + onDragStarted: () { + setState(() => _draggingIndex = index); + }, + onDragEnd: (_) { + setState(() => _draggingIndex = null); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + transform: isHovering && !isDragging ? Matrix4.translationValues(0, -4, 0) : Matrix4.identity(), + child: _QuickActionTile(index: index, type: item), + ), + ); + }, + ); + }, + ); + } +} + class _QuickActionTile extends StatelessWidget { final int index; final ActionButtonType type; - const _QuickActionTile({super.key, required this.index, required this.type}); + const _QuickActionTile({required this.index, required this.type}); @override Widget build(BuildContext context) { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 735031846f58f..6a067f509f517 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -665,14 +665,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.27" - flutter_reorderable_grid_view: - dependency: "direct main" - description: - name: flutter_reorderable_grid_view - sha256: beb85f95325c83515d8953e8612dc70d287a69d1437c14262b7d738070133a87 - url: "https://pub.dev" - source: hosted - version: "5.5.2" flutter_riverpod: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2c2816e548e6e..a49a012031c56 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -38,7 +38,6 @@ dependencies: flutter_udid: ^4.0.0 flutter_web_auth_2: ^5.0.0-alpha.0 fluttertoast: ^8.2.12 - flutter_reorderable_grid_view: ^5.5.2 geolocator: ^14.0.2 home_widget: ^0.8.1 hooks_riverpod: ^2.6.1 From 151738570455224824f905a727a7f1337dbfe0cf Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 09:46:19 +0900 Subject: [PATCH 15/21] refactor: enhance drag-and-drop functionality in _ReorderableGrid with visual feedback --- .../quick_action_configurator.widget.dart | 232 +++++++++++++----- 1 file changed, 170 insertions(+), 62 deletions(-) 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 index 66c73746f149b..814c706df0dd3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -169,77 +169,185 @@ class _ReorderableGrid extends StatefulWidget { class _ReorderableGridState extends State<_ReorderableGrid> { int? _draggingIndex; - int? _hoveringIndex; + late List _itemOrder; + + @override + void initState() { + super.initState(); + _itemOrder = List.generate(widget.items.length, (index) => index); + } + + @override + void didUpdateWidget(_ReorderableGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.items.length != widget.items.length) { + _itemOrder = List.generate(widget.items.length, (index) => index); + } + } + + void _updateHover(int draggedIndex, int targetIndex) { + if (draggedIndex == targetIndex || _draggingIndex == null) return; + + setState(() { + // Temporarily reorder for visual feedback + final newOrder = List.from(_itemOrder); + final draggedOrderIndex = newOrder.indexOf(draggedIndex); + final targetOrderIndex = newOrder.indexOf(targetIndex); + + newOrder.removeAt(draggedOrderIndex); + newOrder.insert(targetOrderIndex, draggedIndex); + _itemOrder = newOrder; + }); + } @override Widget build(BuildContext context) { - return GridView.builder( - controller: widget.scrollController, - physics: widget.shouldScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: widget.crossAxisCount, - crossAxisSpacing: widget.crossAxisSpacing, - mainAxisSpacing: widget.mainAxisSpacing, - childAspectRatio: widget.childAspectRatio, - ), - itemCount: widget.items.length, - itemBuilder: (context, index) { - final item = widget.items[index]; - final isDragging = _draggingIndex == index; - final isHovering = _hoveringIndex == index; - - return DragTarget( - onWillAcceptWithDetails: (details) { - if (details.data != index) { - setState(() => _hoveringIndex = index); - } - return details.data != index; - }, - onLeave: (_) { - setState(() => _hoveringIndex = null); - }, - onAcceptWithDetails: (details) { - final oldIndex = details.data; - if (oldIndex != index) { - widget.onReorder(oldIndex, index); - } - setState(() { - _hoveringIndex = null; - _draggingIndex = null; - }); - }, - builder: (context, candidateData, rejectedData) { - return LongPressDraggable( - data: index, - feedback: Material( - color: Colors.transparent, + 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.items.length, (index) { + final visualIndex = _itemOrder.indexOf(index); + final item = widget.items[index]; + final isDragging = _draggingIndex == index; + + // Calculate position + 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, + item: item, + isDragging: isDragging, + tileWidth: tileWidth, + tileHeight: tileHeight, + left: left, + top: top, + onDragStarted: () { + setState(() => _draggingIndex = index); + }, + onDragUpdate: (draggedIndex, targetIndex) { + _updateHover(draggedIndex, targetIndex); + }, + onDragEnd: (draggedIndex, targetIndex) { + if (draggedIndex != targetIndex) { + final oldVisualIndex = _itemOrder.indexOf(draggedIndex); + final newVisualIndex = _itemOrder.indexOf(targetIndex); + widget.onReorder(oldVisualIndex, newVisualIndex); + } + setState(() { + _draggingIndex = null; + _itemOrder = List.generate(widget.items.length, (i) => i); + }); + }, + onDragCanceled: () { + setState(() { + _draggingIndex = null; + _itemOrder = List.generate(widget.items.length, (i) => i); + }); + }, + ); + }), + ), + ), + ); + }, + ); + } +} + +class _AnimatedGridItem extends StatelessWidget { + final int index; + final ActionButtonType item; + final bool isDragging; + final double tileWidth; + final double tileHeight; + final double left; + final double top; + final VoidCallback onDragStarted; + final Function(int draggedIndex, int targetIndex) onDragUpdate; + final Function(int draggedIndex, int targetIndex) onDragEnd; + final VoidCallback onDragCanceled; + + const _AnimatedGridItem({ + super.key, + required this.index, + required this.item, + required this.isDragging, + required this.tileWidth, + required this.tileHeight, + required this.left, + required this.top, + required this.onDragStarted, + required this.onDragUpdate, + required this.onDragEnd, + required this.onDragCanceled, + }); + + @override + Widget build(BuildContext context) { + return AnimatedPositioned( + duration: const Duration(milliseconds: 200), + 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; + }, + onAcceptWithDetails: (details) { + onDragEnd(details.data, index); + }, + builder: (context, candidateData, rejectedData) { + Widget child = _QuickActionTile(index: index, type: item); + + if (isDragging) { + child = Opacity(opacity: 0.0, child: child); + } + + return Draggable( + data: index, + feedback: Material( + color: Colors.transparent, + child: SizedBox( + width: tileWidth, + height: tileHeight, child: Opacity( - opacity: 0.8, + opacity: 0.9, child: Transform.scale( - scale: 1.1, + scale: 1.05, child: _QuickActionTile(index: index, type: item), ), ), ), - childWhenDragging: Opacity( - opacity: 0.3, - child: _QuickActionTile(index: index, type: item), - ), - onDragStarted: () { - setState(() => _draggingIndex = index); - }, - onDragEnd: (_) { - setState(() => _draggingIndex = null); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - transform: isHovering && !isDragging ? Matrix4.translationValues(0, -4, 0) : Matrix4.identity(), - child: _QuickActionTile(index: index, type: item), - ), - ); - }, - ); - }, + ), + childWhenDragging: const SizedBox.shrink(), + onDragStarted: onDragStarted, + onDragEnd: (_) => onDragCanceled(), + onDraggableCanceled: (_, __) => onDragCanceled(), + child: child, + ); + }, + ), ); } } From f91d5d7da8bff8dcdd0f085f7d590a2cbba9ad52 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 13:41:04 +0900 Subject: [PATCH 16/21] fix: improve drag-and-drop handling in _ReorderableGrid with enhanced visual feedback and snap animation --- .../quick_action_configurator.widget.dart | 114 +++++++++++++----- 1 file changed, 87 insertions(+), 27 deletions(-) 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 index 814c706df0dd3..841a55286de7b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -170,6 +170,8 @@ class _ReorderableGrid extends StatefulWidget { class _ReorderableGridState extends State<_ReorderableGrid> { int? _draggingIndex; late List _itemOrder; + int? _lastHoveredIndex; + bool _snapNow = false; @override void initState() { @@ -189,6 +191,7 @@ class _ReorderableGridState extends State<_ReorderableGrid> { if (draggedIndex == targetIndex || _draggingIndex == null) return; setState(() { + _lastHoveredIndex = targetIndex; // Temporarily reorder for visual feedback final newOrder = List.from(_itemOrder); final draggedOrderIndex = newOrder.indexOf(draggedIndex); @@ -197,6 +200,65 @@ class _ReorderableGridState extends State<_ReorderableGrid> { newOrder.removeAt(draggedOrderIndex); newOrder.insert(targetOrderIndex, draggedIndex); _itemOrder = newOrder; + + // ignore: avoid_print + print('[D&D] Hover: dragged=$draggedIndex -> target=$targetIndex, visualOrder=$_itemOrder'); + }); + } + + void _handleDragEnd(int draggedIndex, int? targetIndex) { + // ignore: avoid_print + print('[D&D] DragEnd called: draggedIndex=$draggedIndex, targetIndex=$targetIndex, visualOrder=$_itemOrder'); + + // Use targetIndex if available, otherwise check if visual position changed + final effectiveTargetIndex = + targetIndex ?? + (() { + final currentVisualIndex = _itemOrder.indexOf(draggedIndex); + // If visual position changed from original, use the item at current visual position + if (currentVisualIndex != draggedIndex) { + return _itemOrder[currentVisualIndex]; + } + return null; + })(); + + // ignore: avoid_print + print('[D&D] Effective target: $effectiveTargetIndex'); + + if (effectiveTargetIndex != null && draggedIndex != effectiveTargetIndex) { + // Find the visual positions in _itemOrder + final oldVisualPosition = _itemOrder.indexOf(draggedIndex); + final newVisualPosition = _itemOrder.indexOf(effectiveTargetIndex); + // ignore: avoid_print + print('[D&D] Visual positions: old=$oldVisualPosition, new=$newVisualPosition'); + // Pass the actual indices (draggedIndex is old, effectiveTargetIndex is new) + // But we need to pass the position in the visual order + widget.onReorder(draggedIndex, effectiveTargetIndex); + // ignore: avoid_print + print('[D&D] Called onReorder: oldIndex=$draggedIndex, newIndex=$effectiveTargetIndex'); + } else { + // ignore: avoid_print + print('[D&D] Skipping onReorder: no valid target or same position'); + } + + // Trigger snap animation for all items + _armSnapNow(); + + setState(() { + _draggingIndex = null; + _lastHoveredIndex = null; + _itemOrder = List.generate(widget.items.length, (i) => i); + }); + } + + void _armSnapNow() { + // ignore: avoid_print + print('[D&D] Snap animation triggered for all items'); + // 直後のレイアウト更新でだけ duration を 0 にする + setState(() => _snapNow = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _snapNow = false); }); } @@ -233,32 +295,24 @@ class _ReorderableGridState extends State<_ReorderableGrid> { index: index, item: item, isDragging: isDragging, + snapNow: _snapNow, tileWidth: tileWidth, tileHeight: tileHeight, left: left, top: top, onDragStarted: () { - setState(() => _draggingIndex = index); + // ignore: avoid_print + print('[D&D] DragStarted: index=$index'); + setState(() { + _draggingIndex = index; + _lastHoveredIndex = index; + }); }, onDragUpdate: (draggedIndex, targetIndex) { _updateHover(draggedIndex, targetIndex); }, - onDragEnd: (draggedIndex, targetIndex) { - if (draggedIndex != targetIndex) { - final oldVisualIndex = _itemOrder.indexOf(draggedIndex); - final newVisualIndex = _itemOrder.indexOf(targetIndex); - widget.onReorder(oldVisualIndex, newVisualIndex); - } - setState(() { - _draggingIndex = null; - _itemOrder = List.generate(widget.items.length, (i) => i); - }); - }, - onDragCanceled: () { - setState(() { - _draggingIndex = null; - _itemOrder = List.generate(widget.items.length, (i) => i); - }); + onDragCompleted: (draggedIndex) { + _handleDragEnd(draggedIndex, _lastHoveredIndex); }, ); }), @@ -274,34 +328,37 @@ class _AnimatedGridItem extends StatelessWidget { final int index; final ActionButtonType item; final bool isDragging; + final bool snapNow; final double tileWidth; final double tileHeight; final double left; final double top; final VoidCallback onDragStarted; final Function(int draggedIndex, int targetIndex) onDragUpdate; - final Function(int draggedIndex, int targetIndex) onDragEnd; - final VoidCallback onDragCanceled; + final Function(int draggedIndex) onDragCompleted; const _AnimatedGridItem({ super.key, required this.index, required this.item, required this.isDragging, + required this.snapNow, required this.tileWidth, required this.tileHeight, required this.left, required this.top, required this.onDragStarted, required this.onDragUpdate, - required this.onDragEnd, - required this.onDragCanceled, + required this.onDragCompleted, }); @override Widget build(BuildContext context) { + // ドロップ直後は全アイテムが 0ms でスナップ + final Duration animDuration = snapNow ? Duration.zero : const Duration(milliseconds: 150); + return AnimatedPositioned( - duration: const Duration(milliseconds: 200), + duration: animDuration, curve: Curves.easeInOut, left: left, top: top, @@ -314,9 +371,6 @@ class _AnimatedGridItem extends StatelessWidget { } return details.data != index; }, - onAcceptWithDetails: (details) { - onDragEnd(details.data, index); - }, builder: (context, candidateData, rejectedData) { Widget child = _QuickActionTile(index: index, type: item); @@ -342,8 +396,14 @@ class _AnimatedGridItem extends StatelessWidget { ), childWhenDragging: const SizedBox.shrink(), onDragStarted: onDragStarted, - onDragEnd: (_) => onDragCanceled(), - onDraggableCanceled: (_, __) => onDragCanceled(), + onDragCompleted: () { + // DragTargetに受け入れられた場合 + onDragCompleted(index); + }, + onDraggableCanceled: (_, __) { + // DragTarget外にドロップした場合 + onDragCompleted(index); + }, child: child, ); }, From a84f4fc1bd787b8a56964fe3f0d47529326fb1e7 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 13:47:24 +0900 Subject: [PATCH 17/21] refactor: remove debug print statements from _ReorderableGrid drag-and-drop handling --- .../quick_action_configurator.widget.dart | 29 ------------------- 1 file changed, 29 deletions(-) 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 index 841a55286de7b..b24ced3579e18 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -200,16 +200,10 @@ class _ReorderableGridState extends State<_ReorderableGrid> { newOrder.removeAt(draggedOrderIndex); newOrder.insert(targetOrderIndex, draggedIndex); _itemOrder = newOrder; - - // ignore: avoid_print - print('[D&D] Hover: dragged=$draggedIndex -> target=$targetIndex, visualOrder=$_itemOrder'); }); } void _handleDragEnd(int draggedIndex, int? targetIndex) { - // ignore: avoid_print - print('[D&D] DragEnd called: draggedIndex=$draggedIndex, targetIndex=$targetIndex, visualOrder=$_itemOrder'); - // Use targetIndex if available, otherwise check if visual position changed final effectiveTargetIndex = targetIndex ?? @@ -222,23 +216,8 @@ class _ReorderableGridState extends State<_ReorderableGrid> { return null; })(); - // ignore: avoid_print - print('[D&D] Effective target: $effectiveTargetIndex'); - if (effectiveTargetIndex != null && draggedIndex != effectiveTargetIndex) { - // Find the visual positions in _itemOrder - final oldVisualPosition = _itemOrder.indexOf(draggedIndex); - final newVisualPosition = _itemOrder.indexOf(effectiveTargetIndex); - // ignore: avoid_print - print('[D&D] Visual positions: old=$oldVisualPosition, new=$newVisualPosition'); - // Pass the actual indices (draggedIndex is old, effectiveTargetIndex is new) - // But we need to pass the position in the visual order widget.onReorder(draggedIndex, effectiveTargetIndex); - // ignore: avoid_print - print('[D&D] Called onReorder: oldIndex=$draggedIndex, newIndex=$effectiveTargetIndex'); - } else { - // ignore: avoid_print - print('[D&D] Skipping onReorder: no valid target or same position'); } // Trigger snap animation for all items @@ -252,9 +231,6 @@ class _ReorderableGridState extends State<_ReorderableGrid> { } void _armSnapNow() { - // ignore: avoid_print - print('[D&D] Snap animation triggered for all items'); - // 直後のレイアウト更新でだけ duration を 0 にする setState(() => _snapNow = true); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -301,8 +277,6 @@ class _ReorderableGridState extends State<_ReorderableGrid> { left: left, top: top, onDragStarted: () { - // ignore: avoid_print - print('[D&D] DragStarted: index=$index'); setState(() { _draggingIndex = index; _lastHoveredIndex = index; @@ -354,7 +328,6 @@ class _AnimatedGridItem extends StatelessWidget { @override Widget build(BuildContext context) { - // ドロップ直後は全アイテムが 0ms でスナップ final Duration animDuration = snapNow ? Duration.zero : const Duration(milliseconds: 150); return AnimatedPositioned( @@ -397,11 +370,9 @@ class _AnimatedGridItem extends StatelessWidget { childWhenDragging: const SizedBox.shrink(), onDragStarted: onDragStarted, onDragCompleted: () { - // DragTargetに受け入れられた場合 onDragCompleted(index); }, onDraggableCanceled: (_, __) { - // DragTarget外にドロップした場合 onDragCompleted(index); }, child: child, From a604a0ad6d825dfc18abb8e56745b07baebfafcf Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 14:00:36 +0900 Subject: [PATCH 18/21] refactor: replace custom _ReorderableGrid with ReorderableDragDropGrid for improved drag-and-drop functionality --- .../quick_action_configurator.widget.dart | 250 +-------------- .../common/reorderable_drag_drop_grid.dart | 288 ++++++++++++++++++ 2 files changed, 295 insertions(+), 243 deletions(-) create mode 100644 mobile/lib/widgets/common/reorderable_drag_drop_grid.dart 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 index b24ced3579e18..5cd8d81d4e1c3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -5,6 +5,7 @@ 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}); @@ -108,9 +109,13 @@ class _QuickActionConfiguratorState extends ConsumerState items; - final Function(int oldIndex, int newIndex) onReorder; - final int crossAxisCount; - final double crossAxisSpacing; - final double mainAxisSpacing; - final double childAspectRatio; - final bool shouldScroll; - - const _ReorderableGrid({ - required this.scrollController, - required this.items, - required this.onReorder, - required this.crossAxisCount, - required this.crossAxisSpacing, - required this.mainAxisSpacing, - required this.childAspectRatio, - required this.shouldScroll, - }); - - @override - State<_ReorderableGrid> createState() => _ReorderableGridState(); -} - -class _ReorderableGridState extends State<_ReorderableGrid> { - int? _draggingIndex; - late List _itemOrder; - int? _lastHoveredIndex; - bool _snapNow = false; - - @override - void initState() { - super.initState(); - _itemOrder = List.generate(widget.items.length, (index) => index); - } - - @override - void didUpdateWidget(_ReorderableGrid oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.items.length != widget.items.length) { - _itemOrder = List.generate(widget.items.length, (index) => index); - } - } - - void _updateHover(int draggedIndex, int targetIndex) { - if (draggedIndex == targetIndex || _draggingIndex == null) return; - - setState(() { - _lastHoveredIndex = targetIndex; - // Temporarily reorder for visual feedback - 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) { - // Use targetIndex if available, otherwise check if visual position changed - final effectiveTargetIndex = - targetIndex ?? - (() { - final currentVisualIndex = _itemOrder.indexOf(draggedIndex); - // If visual position changed from original, use the item at current visual position - if (currentVisualIndex != draggedIndex) { - return _itemOrder[currentVisualIndex]; - } - return null; - })(); - - if (effectiveTargetIndex != null && draggedIndex != effectiveTargetIndex) { - widget.onReorder(draggedIndex, effectiveTargetIndex); - } - - // Trigger snap animation for all items - _armSnapNow(); - - setState(() { - _draggingIndex = null; - _lastHoveredIndex = null; - _itemOrder = List.generate(widget.items.length, (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.items.length, (index) { - final visualIndex = _itemOrder.indexOf(index); - final item = widget.items[index]; - final isDragging = _draggingIndex == index; - - // Calculate position - 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, - item: item, - isDragging: isDragging, - snapNow: _snapNow, - tileWidth: tileWidth, - tileHeight: tileHeight, - left: left, - top: top, - onDragStarted: () { - setState(() { - _draggingIndex = index; - _lastHoveredIndex = index; - }); - }, - onDragUpdate: (draggedIndex, targetIndex) { - _updateHover(draggedIndex, targetIndex); - }, - onDragCompleted: (draggedIndex) { - _handleDragEnd(draggedIndex, _lastHoveredIndex); - }, - ); - }), - ), - ), - ); - }, - ); - } -} - -class _AnimatedGridItem extends StatelessWidget { - final int index; - final ActionButtonType item; - final bool isDragging; - final bool snapNow; - final double tileWidth; - final double tileHeight; - final double left; - final double top; - final VoidCallback onDragStarted; - final Function(int draggedIndex, int targetIndex) onDragUpdate; - final Function(int draggedIndex) onDragCompleted; - - const _AnimatedGridItem({ - super.key, - required this.index, - required this.item, - required this.isDragging, - required this.snapNow, - required this.tileWidth, - required this.tileHeight, - required this.left, - required this.top, - required this.onDragStarted, - required this.onDragUpdate, - required this.onDragCompleted, - }); - - @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 child = _QuickActionTile(index: index, type: item); - - if (isDragging) { - child = Opacity(opacity: 0.0, child: child); - } - - return Draggable( - data: index, - feedback: Material( - color: Colors.transparent, - child: SizedBox( - width: tileWidth, - height: tileHeight, - child: Opacity( - opacity: 0.9, - child: Transform.scale( - scale: 1.05, - child: _QuickActionTile(index: index, type: item), - ), - ), - ), - ), - childWhenDragging: const SizedBox.shrink(), - onDragStarted: onDragStarted, - onDragCompleted: () { - onDragCompleted(index); - }, - onDraggableCanceled: (_, __) { - onDragCompleted(index); - }, - child: child, - ); - }, - ), - ); - } -} - class _QuickActionTile extends StatelessWidget { final int index; final ActionButtonType type; 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, + ); + }, + ), + ); + } +} From 185134953d431ad2a48d1c09c886ad3df0331daa Mon Sep 17 00:00:00 2001 From: idubnori Date: Thu, 11 Dec 2025 00:49:39 +0900 Subject: [PATCH 19/21] refactor: remove redundant "open_bottom_sheet_info" --- i18n/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/i18n/en.json b/i18n/en.json index f97ce113f0bf1..e3787f61bb88b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1515,7 +1515,6 @@ "online": "Online", "only_favorites": "Only favorites", "open": "Open", - "open_bottom_sheet_info": "Information", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", From 7c3f1753f13a1b31e45362207c7b3c24f26012c2 Mon Sep 17 00:00:00 2001 From: idubnori Date: Thu, 11 Dec 2025 01:45:45 +0900 Subject: [PATCH 20/21] feat: add reorder buttons action button and integrate into viewer kebab menu --- .../reorder_buttons_action_button.widget.dart | 35 +++++++++++++++++++ .../asset_viewer/top_app_bar.widget.dart | 16 --------- mobile/lib/utils/action_button.utils.dart | 5 +++ 3 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/reorder_buttons_action_button.widget.dart 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/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index e794a96b70485..193cf6022052e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; @@ -50,21 +49,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final originalTheme = context.themeData; - 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); - }); - } - final actions = [ if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), if (album != null && album.isActivityEnabled && album.isShared) diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 4d565103af35f..161c6961b15e7 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -30,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 { @@ -219,6 +220,7 @@ class ViewerKebabMenuButtonContext { enum ViewerKebabMenuButtonType { openInfo, viewInTimeline, + reorderButtons, cast, download; @@ -228,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, }; @@ -244,6 +247,7 @@ enum ViewerKebabMenuButtonType { context.isOwner, ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote, ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly, + ViewerKebabMenuButtonType.reorderButtons => true, }; } @@ -270,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), }; } } From 61f069e410788dc10c8e12eeeff8132723fa5cf6 Mon Sep 17 00:00:00 2001 From: idubnori Date: Thu, 11 Dec 2025 01:48:11 +0900 Subject: [PATCH 21/21] Revert "Revert "chore(mobile): add table schemas to swift (#23749)"" This reverts commit be9e632efb84d8b19aee659c13ca163a5169103e. --- mobile/ios/Runner.xcodeproj/project.pbxproj | 56 +++++ .../xcshareddata/swiftpm/Package.resolved | 168 +++++++++++++ mobile/ios/Runner/Schemas/Constants.swift | 177 +++++++++++++ mobile/ios/Runner/Schemas/Store.swift | 146 +++++++++++ mobile/ios/Runner/Schemas/Tables.swift | 237 ++++++++++++++++++ 5 files changed, 784 insertions(+) create mode 100644 mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 mobile/ios/Runner/Schemas/Constants.swift create mode 100644 mobile/ios/Runner/Schemas/Store.swift create mode 100644 mobile/ios/Runner/Schemas/Tables.swift diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index ce9d1848cd4e1..599e7990f4746 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -32,6 +32,9 @@ FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; + FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; }; + FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; }; + FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -153,6 +156,13 @@ path = WidgetExtension; sourceTree = ""; }; + FEE084F22EC172080045228E /* Schemas */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = Schemas; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -160,6 +170,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FEE084F82EC172460045228E /* SQLiteData in Frameworks */, + FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */, + FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */, D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -254,6 +267,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + FEE084F22EC172080045228E /* Schemas */, B231F52D2E93A44A00BC45D1 /* Core */, B25D37792E72CA15008B6CA7 /* Connectivity */, B21E34A62E5AF9760031FDB9 /* Background */, @@ -341,6 +355,7 @@ fileSystemSynchronizedGroups = ( B231F52D2E93A44A00BC45D1 /* Core */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */, + FEE084F22EC172080045228E /* Schemas */, ); name = Runner; productName = Runner; @@ -419,6 +434,10 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */, + FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -1201,6 +1220,43 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/sqlite-data"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.3.0; + }; + }; + FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-http-structured-headers.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + FEE084F72EC172460045228E /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */; + productName = SQLiteData; + }; + FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */ = { + isa = XCSwiftPackageProductDependency; + package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */; + productName = RawStructuredFieldValues; + }; + FEE084FC2EC1725A0045228E /* StructuredFieldValues */ = { + isa = XCSwiftPackageProductDependency; + package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */; + productName = StructuredFieldValues; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000000..ff8a53ff4ba93 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,168 @@ +{ + "originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49", + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", + "version" : "7.8.0" + } + }, + { + "identity" : "sqlite-data", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/sqlite-data", + "state" : { + "revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", + "version" : "1.7.2" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", + "version" : "1.10.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", + "version" : "2.0.9" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", + "version" : "2.7.4" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", + "version" : "1.18.7" + } + }, + { + "identity" : "swift-structured-queries", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-structured-queries", + "state" : { + "revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756", + "version" : "0.25.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" + } + } + ], + "version" : 3 +} diff --git a/mobile/ios/Runner/Schemas/Constants.swift b/mobile/ios/Runner/Schemas/Constants.swift new file mode 100644 index 0000000000000..a4b0f701a1da3 --- /dev/null +++ b/mobile/ios/Runner/Schemas/Constants.swift @@ -0,0 +1,177 @@ +import SQLiteData + +struct Endpoint: Codable { + let url: URL + let status: Status + + enum Status: String, Codable { + case loading, valid, error, unknown + } +} + +enum StoreKey: Int, CaseIterable, QueryBindable { + // MARK: - Int + case _version = 0 + static let version = Typed(rawValue: ._version) + case _deviceIdHash = 3 + static let deviceIdHash = Typed(rawValue: ._deviceIdHash) + case _backupTriggerDelay = 8 + static let backupTriggerDelay = Typed(rawValue: ._backupTriggerDelay) + case _tilesPerRow = 103 + static let tilesPerRow = Typed(rawValue: ._tilesPerRow) + case _groupAssetsBy = 105 + static let groupAssetsBy = Typed(rawValue: ._groupAssetsBy) + case _uploadErrorNotificationGracePeriod = 106 + static let uploadErrorNotificationGracePeriod = Typed(rawValue: ._uploadErrorNotificationGracePeriod) + case _thumbnailCacheSize = 110 + static let thumbnailCacheSize = Typed(rawValue: ._thumbnailCacheSize) + case _imageCacheSize = 111 + static let imageCacheSize = Typed(rawValue: ._imageCacheSize) + case _albumThumbnailCacheSize = 112 + static let albumThumbnailCacheSize = Typed(rawValue: ._albumThumbnailCacheSize) + case _selectedAlbumSortOrder = 113 + static let selectedAlbumSortOrder = Typed(rawValue: ._selectedAlbumSortOrder) + case _logLevel = 115 + static let logLevel = Typed(rawValue: ._logLevel) + case _mapRelativeDate = 119 + static let mapRelativeDate = Typed(rawValue: ._mapRelativeDate) + case _mapThemeMode = 124 + static let mapThemeMode = Typed(rawValue: ._mapThemeMode) + + // MARK: - String + case _assetETag = 1 + static let assetETag = Typed(rawValue: ._assetETag) + case _currentUser = 2 + static let currentUser = Typed(rawValue: ._currentUser) + case _deviceId = 4 + static let deviceId = Typed(rawValue: ._deviceId) + case _accessToken = 11 + static let accessToken = Typed(rawValue: ._accessToken) + case _serverEndpoint = 12 + static let serverEndpoint = Typed(rawValue: ._serverEndpoint) + case _sslClientCertData = 15 + static let sslClientCertData = Typed(rawValue: ._sslClientCertData) + case _sslClientPasswd = 16 + static let sslClientPasswd = Typed(rawValue: ._sslClientPasswd) + case _themeMode = 102 + static let themeMode = Typed(rawValue: ._themeMode) + case _customHeaders = 127 + static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders) + case _primaryColor = 128 + static let primaryColor = Typed(rawValue: ._primaryColor) + case _preferredWifiName = 133 + static let preferredWifiName = Typed(rawValue: ._preferredWifiName) + + // MARK: - Endpoint + case _externalEndpointList = 135 + static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList) + + // MARK: - URL + case _localEndpoint = 134 + static let localEndpoint = Typed(rawValue: ._localEndpoint) + case _serverUrl = 10 + static let serverUrl = Typed(rawValue: ._serverUrl) + + // MARK: - Date + case _backupFailedSince = 5 + static let backupFailedSince = Typed(rawValue: ._backupFailedSince) + + // MARK: - Bool + case _backupRequireWifi = 6 + static let backupRequireWifi = Typed(rawValue: ._backupRequireWifi) + case _backupRequireCharging = 7 + static let backupRequireCharging = Typed(rawValue: ._backupRequireCharging) + case _autoBackup = 13 + static let autoBackup = Typed(rawValue: ._autoBackup) + case _backgroundBackup = 14 + static let backgroundBackup = Typed(rawValue: ._backgroundBackup) + case _loadPreview = 100 + static let loadPreview = Typed(rawValue: ._loadPreview) + case _loadOriginal = 101 + static let loadOriginal = Typed(rawValue: ._loadOriginal) + case _dynamicLayout = 104 + static let dynamicLayout = Typed(rawValue: ._dynamicLayout) + case _backgroundBackupTotalProgress = 107 + static let backgroundBackupTotalProgress = Typed(rawValue: ._backgroundBackupTotalProgress) + case _backgroundBackupSingleProgress = 108 + static let backgroundBackupSingleProgress = Typed(rawValue: ._backgroundBackupSingleProgress) + case _storageIndicator = 109 + static let storageIndicator = Typed(rawValue: ._storageIndicator) + case _advancedTroubleshooting = 114 + static let advancedTroubleshooting = Typed(rawValue: ._advancedTroubleshooting) + case _preferRemoteImage = 116 + static let preferRemoteImage = Typed(rawValue: ._preferRemoteImage) + case _loopVideo = 117 + static let loopVideo = Typed(rawValue: ._loopVideo) + case _mapShowFavoriteOnly = 118 + static let mapShowFavoriteOnly = Typed(rawValue: ._mapShowFavoriteOnly) + case _selfSignedCert = 120 + static let selfSignedCert = Typed(rawValue: ._selfSignedCert) + case _mapIncludeArchived = 121 + static let mapIncludeArchived = Typed(rawValue: ._mapIncludeArchived) + case _ignoreIcloudAssets = 122 + static let ignoreIcloudAssets = Typed(rawValue: ._ignoreIcloudAssets) + case _selectedAlbumSortReverse = 123 + static let selectedAlbumSortReverse = Typed(rawValue: ._selectedAlbumSortReverse) + case _mapwithPartners = 125 + static let mapwithPartners = Typed(rawValue: ._mapwithPartners) + case _enableHapticFeedback = 126 + static let enableHapticFeedback = Typed(rawValue: ._enableHapticFeedback) + case _dynamicTheme = 129 + static let dynamicTheme = Typed(rawValue: ._dynamicTheme) + case _colorfulInterface = 130 + static let colorfulInterface = Typed(rawValue: ._colorfulInterface) + case _syncAlbums = 131 + static let syncAlbums = Typed(rawValue: ._syncAlbums) + case _autoEndpointSwitching = 132 + static let autoEndpointSwitching = Typed(rawValue: ._autoEndpointSwitching) + case _loadOriginalVideo = 136 + static let loadOriginalVideo = Typed(rawValue: ._loadOriginalVideo) + case _manageLocalMediaAndroid = 137 + static let manageLocalMediaAndroid = Typed(rawValue: ._manageLocalMediaAndroid) + case _readonlyModeEnabled = 138 + static let readonlyModeEnabled = Typed(rawValue: ._readonlyModeEnabled) + case _autoPlayVideo = 139 + static let autoPlayVideo = Typed(rawValue: ._autoPlayVideo) + case _photoManagerCustomFilter = 1000 + static let photoManagerCustomFilter = Typed(rawValue: ._photoManagerCustomFilter) + case _betaPromptShown = 1001 + static let betaPromptShown = Typed(rawValue: ._betaPromptShown) + case _betaTimeline = 1002 + static let betaTimeline = Typed(rawValue: ._betaTimeline) + case _enableBackup = 1003 + static let enableBackup = Typed(rawValue: ._enableBackup) + case _useWifiForUploadVideos = 1004 + static let useWifiForUploadVideos = Typed(rawValue: ._useWifiForUploadVideos) + case _useWifiForUploadPhotos = 1005 + static let useWifiForUploadPhotos = Typed(rawValue: ._useWifiForUploadPhotos) + case _needBetaMigration = 1006 + static let needBetaMigration = Typed(rawValue: ._needBetaMigration) + case _shouldResetSync = 1007 + static let shouldResetSync = Typed(rawValue: ._shouldResetSync) + + struct Typed: RawRepresentable { + let rawValue: StoreKey + + @_transparent + init(rawValue value: StoreKey) { + self.rawValue = value + } + } +} + +enum BackupSelection: Int, QueryBindable { + case selected, none, excluded +} + +enum AvatarColor: Int, QueryBindable { + case primary, pink, red, yellow, blue, green, purple, orange, gray, amber +} + +enum AlbumUserRole: Int, QueryBindable { + case editor, viewer +} + +enum MemoryType: Int, QueryBindable { + case onThisDay +} diff --git a/mobile/ios/Runner/Schemas/Store.swift b/mobile/ios/Runner/Schemas/Store.swift new file mode 100644 index 0000000000000..ee5280b6c0dd7 --- /dev/null +++ b/mobile/ios/Runner/Schemas/Store.swift @@ -0,0 +1,146 @@ +import SQLiteData + +enum StoreError: Error { + case invalidJSON(String) + case invalidURL(String) + case encodingFailed +} + +protocol StoreConvertible { + associatedtype StorageType + static func fromValue(_ value: StorageType) throws(StoreError) -> Self + static func toValue(_ value: Self) throws(StoreError) -> StorageType +} + +extension Int: StoreConvertible { + static func fromValue(_ value: Int) -> Int { value } + static func toValue(_ value: Int) -> Int { value } +} + +extension Bool: StoreConvertible { + static func fromValue(_ value: Int) -> Bool { value == 1 } + static func toValue(_ value: Bool) -> Int { value ? 1 : 0 } +} + +extension Date: StoreConvertible { + static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) } + static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) } +} + +extension String: StoreConvertible { + static func fromValue(_ value: String) -> String { value } + static func toValue(_ value: String) -> String { value } +} + +extension URL: StoreConvertible { + static func fromValue(_ value: String) throws(StoreError) -> URL { + guard let url = URL(string: value) else { + throw StoreError.invalidURL(value) + } + return url + } + static func toValue(_ value: URL) -> String { value.absoluteString } +} + +extension StoreConvertible where Self: Codable, StorageType == String { + static var jsonDecoder: JSONDecoder { JSONDecoder() } + static var jsonEncoder: JSONEncoder { JSONEncoder() } + + static func fromValue(_ value: String) throws(StoreError) -> Self { + do { + return try jsonDecoder.decode(Self.self, from: Data(value.utf8)) + } catch { + throw StoreError.invalidJSON(value) + } + } + + static func toValue(_ value: Self) throws(StoreError) -> String { + let encoded: Data + do { + encoded = try jsonEncoder.encode(value) + } catch { + throw StoreError.encodingFailed + } + + guard let string = String(data: encoded, encoding: .utf8) else { + throw StoreError.encodingFailed + } + return string + } +} + +extension Array: StoreConvertible where Element: Codable { + typealias StorageType = String +} + +extension Dictionary: StoreConvertible where Key == String, Value: Codable { + typealias StorageType = String +} + +class StoreRepository { + private let db: DatabasePool + + init(db: DatabasePool) { + self.db = db + } + + func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == Int { + let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) } + if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == String { + let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) } + if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == Int { + let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) } + if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == String { + let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) } + if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == Int { + let value = try T.toValue(value) + try db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn) + } + } + + func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == String { + let value = try T.toValue(value) + try db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn) + } + } + + func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == Int { + let value = try T.toValue(value) + try await db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn) + } + } + + func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == String { + let value = try T.toValue(value) + try await db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn) + } + } +} diff --git a/mobile/ios/Runner/Schemas/Tables.swift b/mobile/ios/Runner/Schemas/Tables.swift new file mode 100644 index 0000000000000..c256b0d0edba5 --- /dev/null +++ b/mobile/ios/Runner/Schemas/Tables.swift @@ -0,0 +1,237 @@ +import GRDB +import SQLiteData + +@Table("asset_face_entity") +struct AssetFace { + let id: String + let assetId: String + let personId: String? + let imageWidth: Int + let imageHeight: Int + let boundingBoxX1: Int + let boundingBoxY1: Int + let boundingBoxX2: Int + let boundingBoxY2: Int + let sourceType: String +} + +@Table("auth_user_entity") +struct AuthUser { + let id: String + let name: String + let email: String + let isAdmin: Bool + let hasProfileImage: Bool + let profileChangedAt: Date + let avatarColor: AvatarColor + let quotaSizeInBytes: Int + let quotaUsageInBytes: Int + let pinCode: String? +} + +@Table("local_album_entity") +struct LocalAlbum { + let id: String + let backupSelection: BackupSelection + let linkedRemoteAlbumId: String? + let marker_: Bool? + let name: String + let isIosSharedAlbum: Bool + let updatedAt: Date +} + +@Table("local_album_asset_entity") +struct LocalAlbumAsset { + let id: ID + let marker_: String? + + @Selection + struct ID { + let assetId: String + let albumId: String + } +} + +@Table("local_asset_entity") +struct LocalAsset { + let id: String + let checksum: String? + let createdAt: Date + let durationInSeconds: Int? + let height: Int? + let isFavorite: Bool + let name: String + let orientation: String + let type: Int + let updatedAt: Date + let width: Int? +} + +@Table("memory_asset_entity") +struct MemoryAsset { + let id: ID + + @Selection + struct ID { + let assetId: String + let albumId: String + } +} + +@Table("memory_entity") +struct Memory { + let id: String + let createdAt: Date + let updatedAt: Date + let deletedAt: Date? + let ownerId: String + let type: MemoryType + let data: String + let isSaved: Bool + let memoryAt: Date + let seenAt: Date? + let showAt: Date? + let hideAt: Date? +} + +@Table("partner_entity") +struct Partner { + let id: ID + let inTimeline: Bool + + @Selection + struct ID { + let sharedById: String + let sharedWithId: String + } +} + +@Table("person_entity") +struct Person { + let id: String + let createdAt: Date + let updatedAt: Date + let ownerId: String + let name: String + let faceAssetId: String? + let isFavorite: Bool + let isHidden: Bool + let color: String? + let birthDate: Date? +} + +@Table("remote_album_entity") +struct RemoteAlbum { + let id: String + let createdAt: Date + let description: String? + let isActivityEnabled: Bool + let name: String + let order: Int + let ownerId: String + let thumbnailAssetId: String? + let updatedAt: Date +} + +@Table("remote_album_asset_entity") +struct RemoteAlbumAsset { + let id: ID + + @Selection + struct ID { + let assetId: String + let albumId: String + } +} + +@Table("remote_album_user_entity") +struct RemoteAlbumUser { + let id: ID + let role: AlbumUserRole + + @Selection + struct ID { + let albumId: String + let userId: String + } +} + +@Table("remote_asset_entity") +struct RemoteAsset { + let id: String + let checksum: String? + let deletedAt: Date? + let isFavorite: Int + let libraryId: String? + let livePhotoVideoId: String? + let localDateTime: Date? + let orientation: String + let ownerId: String + let stackId: String? + let visibility: Int +} + +@Table("remote_exif_entity") +struct RemoteExif { + @Column(primaryKey: true) + let assetId: String + let city: String? + let state: String? + let country: String? + let dateTimeOriginal: Date? + let description: String? + let height: Int? + let width: Int? + let exposureTime: String? + let fNumber: Double? + let fileSize: Int? + let focalLength: Double? + let latitude: Double? + let longitude: Double? + let iso: Int? + let make: String? + let model: String? + let lens: String? + let orientation: String? + let timeZone: String? + let rating: Int? + let projectionType: String? +} + +@Table("stack_entity") +struct Stack { + let id: String + let createdAt: Date + let updatedAt: Date + let ownerId: String + let primaryAssetId: String +} + +@Table("store_entity") +struct Store { + let id: StoreKey + let stringValue: String? + let intValue: Int? +} + +@Table("user_entity") +struct User { + let id: String + let name: String + let email: String + let hasProfileImage: Bool + let profileChangedAt: Date + let avatarColor: AvatarColor +} + +@Table("user_metadata_entity") +struct UserMetadata { + let id: ID + let value: Data + + @Selection + struct ID { + let userId: String + let key: Date + } +}