feat(mobile): init of add quick action configurator and settings for viewer actions

feature/rearrange-buttons-2
idubnori 2025-11-05 21:34:40 +07:00
parent 79d0e3e1ed
commit eb7813047b
12 changed files with 674 additions and 21 deletions

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

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

@ -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 = <Widget>[
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<void> openConfigurator() async {
final viewerNotifier = ref.read(assetViewerProvider.notifier);
viewerNotifier.setBottomSheet(true);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
enableDrag: false,
builder: (sheetContext) =>
const FractionallySizedBox(heightFactor: 0.75, child: 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,

@ -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<ViewerQuickActionConfigurator> createState() => _ViewerQuickActionConfiguratorState();
}
class _ViewerQuickActionConfiguratorState extends ConsumerState<ViewerQuickActionConfigurator> {
late List<ActionButtonType> _order;
late final ScrollController _scrollController;
bool _hasLocalChanges = false;
@override
void initState() {
super.initState();
_order = List<ActionButtonType>.from(ref.read(viewerQuickActionOrderProvider));
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onReorder(ReorderedListFunction<ActionButtonType> reorder) {
setState(() {
_order = reorder(_order);
_hasLocalChanges = true;
});
}
void _resetToDefault() {
setState(() {
_order = List<ActionButtonType>.from(ActionButtonBuilder.defaultQuickActionOrder);
_hasLocalChanges = true;
});
}
void _cancel() => Navigator.of(context).pop();
Future<void> _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<ActionButtonType>.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<ActionButtonType>(
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,
),
),
],
),
),
);
}
}

@ -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<List<ActionButtonType>>? _subscription;
@override
List<ActionButtonType> 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<void> setOrder(List<ActionButtonType> 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 {}

@ -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<ViewerQuickActionOrder, List<ActionButtonType>>.internal(
ViewerQuickActionOrder.new,
name: r'viewerQuickActionOrderProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$viewerQuickActionOrderHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ViewerQuickActionOrder = Notifier<List<ActionButtonType>>;
// 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

@ -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<T> {
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
@ -71,4 +72,20 @@ class AppSettingsService {
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
return Store.put(setting.storeKey, value);
}
List<ActionButtonType> getViewerQuickActionOrder() {
final stored = Store.get(StoreKey.viewerQuickActionOrder, ActionButtonBuilder.defaultQuickActionOrderStorageValue);
return ActionButtonBuilder.parseQuickActionOrder(stored);
}
Stream<List<ActionButtonType>> watchViewerQuickActionOrder() {
return Store.watch(StoreKey.viewerQuickActionOrder).map(
(value) =>
ActionButtonBuilder.parseQuickActionOrder(value ?? ActionButtonBuilder.defaultQuickActionOrderStorageValue),
);
}
Future<void> setViewerQuickActionOrder(List<ActionButtonType> order) {
return Store.put(StoreKey.viewerQuickActionOrder, ActionButtonBuilder.encodeQuickActionOrder(order));
}
}

@ -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<ActionButtonType> _actionTypes = ActionButtonType.values;
static const int defaultQuickActionLimit = 4;
static const String quickActionStorageDelimiter = ',';
static const List<ActionButtonType> _defaultQuickActionSeed = [
ActionButtonType.share,
ActionButtonType.upload,
ActionButtonType.edit,
ActionButtonType.archive,
ActionButtonType.delete,
ActionButtonType.removeFromAlbum,
ActionButtonType.likeActivity,
];
static final Set<ActionButtonType> _quickActionSet = Set<ActionButtonType>.unmodifiable(_defaultQuickActionSeed);
static final List<ActionButtonType> defaultQuickActionOrder = List<ActionButtonType>.unmodifiable(
_defaultQuickActionSeed,
);
static final String defaultQuickActionOrderStorageValue = defaultQuickActionOrder
.map((type) => type.name)
.join(quickActionStorageDelimiter);
static List<ActionButtonType> get quickActionOptions => defaultQuickActionOrder;
static List<ActionButtonType> parseQuickActionOrder(String? stored) {
final parsed = <ActionButtonType>[];
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<ActionButtonType> order) {
final unique = <ActionButtonType>{};
final buffer = <String>[];
for (final type in order) {
if (unique.add(type)) {
buffer.add(type.name);
}
}
final result = buffer.join(quickActionStorageDelimiter);
return result;
}
static List<ActionButtonType> buildQuickActionTypes(
ActionButtonContext context, {
List<ActionButtonType>? quickActionOrder,
int limit = defaultQuickActionLimit,
}) {
final normalized = normalizeQuickActionOrder(
quickActionOrder == null || quickActionOrder.isEmpty ? defaultQuickActionOrder : quickActionOrder,
);
final seen = <ActionButtonType>{};
final result = <ActionButtonType>[];
for (final type in normalized) {
if (!_quickActionSet.contains(type)) {
continue;
}
final resolved = _resolveQuickActionType(type, context);
if (!seen.add(resolved) || !resolved.shouldShow(context)) {
continue;
}
result.add(resolved);
if (result.length >= limit) {
break;
}
}
return result;
}
static List<Widget> buildQuickActions(
ActionButtonContext context, {
List<ActionButtonType>? 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<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
static List<ActionButtonType> normalizeQuickActionOrder(List<ActionButtonType> order) {
final ordered = <ActionButtonType>{};
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);
}

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

@ -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:

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

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