Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
eb78130
feat(mobile): init of add quick action configurator and settings for …
idubnori Nov 5, 2025
74f90fe
fix: dcm analyze fails
idubnori Nov 5, 2025
0eae657
refactor: rename to QuickActionConfigurator
idubnori Nov 5, 2025
d4df41d
Merge remote-tracking branch 'origin/main' into feature/rearrange-but…
idubnori Nov 17, 2025
6c07915
feat: configurable AddActionButton
idubnori Nov 17, 2025
7f9ba91
feat(mobile): implement viewer kebab menu with about option
idubnori Nov 24, 2025
72f1818
Merge remote-tracking branch 'upstream/main' into feature/kebab-menu-2
idubnori Dec 4, 2025
c7c929b
feat: revert exisitng buttons, adjust label name
idubnori Dec 4, 2025
80c1771
Merge branch 'feature/kebab-menu-2' into feature/rearrange-buttons-2
idubnori Dec 4, 2025
be9e632
Revert "chore(mobile): add table schemas to swift (#23749)"
idubnori Nov 17, 2025
7f3386c
feat: add configurator button to viewer kebab menu
idubnori Dec 4, 2025
3700f99
revert: viewer_kebab
idubnori Dec 9, 2025
1e5c3d7
Merge branch 'main' into feature/rearrange-buttons-2
idubnori Dec 9, 2025
2d4e901
refactor: update viewer quick action order handling and refactor rela…
idubnori Dec 9, 2025
f874c12
fix: update JSON serialization for ActionButtonType and improve type …
idubnori Dec 9, 2025
7473b95
refactor: proper layer archtecture
idubnori Dec 9, 2025
17361d1
refactor: clean up
idubnori Dec 9, 2025
598e856
refactor: replace flutter_reorderable_grid_view with custom _Reordera…
idubnori Dec 10, 2025
1517385
refactor: enhance drag-and-drop functionality in _ReorderableGrid wit…
idubnori Dec 10, 2025
f91d5d7
fix: improve drag-and-drop handling in _ReorderableGrid with enhanced…
idubnori Dec 10, 2025
a84f4fc
refactor: remove debug print statements from _ReorderableGrid drag-an…
idubnori Dec 10, 2025
a604a0a
refactor: replace custom _ReorderableGrid with ReorderableDragDropGri…
idubnori Dec 10, 2025
c136b5f
Merge branch 'main' into feature/rearrange-buttons-2
idubnori Dec 10, 2025
1851349
refactor: remove redundant "open_bottom_sheet_info"
idubnori Dec 10, 2025
7c3f175
feat: add reorder buttons action button and integrate into viewer keb…
idubnori Dec 10, 2025
61f069e
Revert "Revert "chore(mobile): add table schemas to swift (#23749)""
idubnori Dec 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1667,6 +1667,8 @@
"purchase_settings_server_activated": "The server product key is managed by the admin",
"query_asset_id": "Query Asset ID",
"queue_status": "Queuing {count}/{total}",
"quick_actions_settings_description": "Drag to rearrange buttons. Up to {count} available buttons are displayed in order.",
"quick_actions_settings_title": "Button order settings",
"rating": "Star rating",
"rating_clear": "Clear rating",
"rating_count": "{count, plural, one {# star} other {# stars}}",
Expand Down Expand Up @@ -1727,6 +1729,7 @@
"removed_photo_from_memory": "Removed photo from memory",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
"reorder_buttons": "Reorder buttons",
"repair": "Repair",
"repair_no_results_message": "Untracked and missing files will show up here",
"replace_with_upload": "Replace with upload",
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/domain/models/store.model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ enum StoreKey<T> {

autoPlayVideo<bool>._(139),
albumGridView<bool>._(140),
viewerQuickActionOrder<String>._(141),

// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
Expand Down
85 changes: 85 additions & 0 deletions mobile/lib/domain/services/quick_action.service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';

class QuickActionService {
final ActionButtonOrderRepository _repository;

const QuickActionService(this._repository);

static final Set<ActionButtonType> _quickActionSet = Set<ActionButtonType>.unmodifiable(
ActionButtonBuilder.defaultQuickActionOrder,
);

List<ActionButtonType> get() {
return _repository.get();
}

Future<void> set(List<ActionButtonType> order) async {
final normalized = _normalizeQuickActionOrder(order);
await _repository.set(normalized);
}

Stream<List<ActionButtonType>> watch() {
return _repository.watch();
}

List<ActionButtonType> _normalizeQuickActionOrder(List<ActionButtonType> order) {
final ordered = <ActionButtonType>{};

for (final type in order) {
if (_quickActionSet.contains(type)) {
ordered.add(type);
}
}

ordered.addAll(ActionButtonBuilder.defaultQuickActionOrder);

return ordered.toList(growable: false);
}

List<ActionButtonType> buildQuickActionTypes(
ActionButtonContext context, {
List<ActionButtonType>? quickActionOrder,
int limit = ActionButtonBuilder.defaultQuickActionLimit,
}) {
final normalized = _normalizeQuickActionOrder(
quickActionOrder == null || quickActionOrder.isEmpty
? ActionButtonBuilder.defaultQuickActionOrder
: quickActionOrder,
);

final seen = <ActionButtonType>{};
final result = <ActionButtonType>[];

for (final type in normalized) {
if (!_quickActionSet.contains(type)) {
continue;
}

final resolved = _resolveQuickActionType(type, context);
if (!seen.add(resolved) || !resolved.shouldShow(context)) {
continue;
}

result.add(resolved);
if (result.length >= limit) {
break;
}
}

return result;
}

/// Resolve quick action type based on context (e.g., archive -> unarchive)
ActionButtonType _resolveQuickActionType(ActionButtonType type, ActionButtonContext context) {
if (type == ActionButtonType.archive && context.isArchived) {
return ActionButtonType.unarchive;
}

if (type == ActionButtonType.delete && context.asset.isLocalOnly) {
return ActionButtonType.deleteLocal;
}

return type;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'dart:convert';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';

class ActionButtonOrderRepository {
const ActionButtonOrderRepository();

static const storeKey = StoreKey.viewerQuickActionOrder;

List<ActionButtonType> get() {
final json = Store.tryGet(storeKey);
if (json == null || json.isEmpty) {
return ActionButtonBuilder.defaultQuickActionOrder;
}

final deserialized = _deserialize(json);
return deserialized.isEmpty ? ActionButtonBuilder.defaultQuickActionOrder : deserialized;
}

Future<void> set(List<ActionButtonType> order) async {
final json = _serialize(order);
await Store.put(storeKey, json);
}

Stream<List<ActionButtonType>> watch() {
return Store.watch(storeKey).map((json) {
if (json == null || json.isEmpty) {
return ActionButtonBuilder.defaultQuickActionOrder;
}
final deserialized = _deserialize(json);
return deserialized.isEmpty ? ActionButtonBuilder.defaultQuickActionOrder : deserialized;
});
}

String _serialize(List<ActionButtonType> order) {
return jsonEncode(order.map((type) => type.name).toList());
}

List<ActionButtonType> _deserialize(String json) {
try {
final list = jsonDecode(json) as List<dynamic>;
return list
.whereType<String>()
.map((name) {
try {
return ActionButtonType.values.byName(name);
} catch (e) {
return null;
}
})
.whereType<ActionButtonType>()
.toList();
} catch (e) {
return [];
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart';

class ReorderButtonsActionButton extends ConsumerWidget {
const ReorderButtonsActionButton({super.key, this.originalTheme});

final ThemeData? originalTheme;

@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
label: 'reorder_buttons'.tr(),
iconData: Icons.swap_vert,
iconColor: originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () async {
final viewerNotifier = ref.read(assetViewerProvider.notifier);
viewerNotifier.setBottomSheet(true);

await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
enableDrag: false,
builder: (sheetContext) => const FractionallySizedBox(heightFactor: 0.75, child: QuickActionConfigurator()),
).whenComplete(() {
viewerNotifier.setBottomSheet(false);
});
},
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart';
import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';

class ViewerBottomBar extends ConsumerWidget {
Expand All @@ -33,25 +34,53 @@ class ViewerBottomBar extends ConsumerWidget {
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final isInLockedView = ref.watch(inLockedViewProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final isTrashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final quickActionOrder = ref.watch(viewerQuickActionOrderProvider);

if (!showControls) {
opacity = 0;
}

final originalTheme = context.themeData;
final buttonContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnabled,
isStacked: asset is RemoteAsset && asset.stackId != null,
isInLockedView: isInLockedView,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
);

final quickActionService = ref.watch(quickActionServiceProvider);
final quickActionTypes = quickActionService.buildQuickActionTypes(
buttonContext,
quickActionOrder: quickActionOrder,
);

final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
Future<void> openConfigurator() async {
final viewerNotifier = ref.read(assetViewerProvider.notifier);

viewerNotifier.setBottomSheet(true);

await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
enableDrag: false,
builder: (sheetContext) => const FractionallySizedBox(heightFactor: 0.75, child: QuickActionConfigurator()),
).whenComplete(() {
viewerNotifier.setBottomSheet(false);
});
}

if (isOwner) ...[
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
];
final actions = quickActionTypes
.map((type) => type.buildButton(buttonContext))
.map((widget) => GestureDetector(onLongPress: openConfigurator, child: widget))
.toList(growable: false);

return IgnorePointer(
ignoring: opacity < 255,
Expand Down
Loading
Loading