migrate actions

This commit is contained in:
timonrieger 2026-07-07 15:34:12 +02:00
parent 64b83031e2
commit 19ae3e93ae
No known key found for this signature in database
9 changed files with 366 additions and 112 deletions

View file

@ -17,9 +17,14 @@ class AssetDebugAction extends AssetAction<BaseAsset> {
@override
String label(ActionScope scope) => scope.context.t.troubleshoot;
static bool canShow({required int assetCount, required bool advancedTroubleshooting}) =>
assetCount == 1 && advancedTroubleshooting;
@override
bool isVisible(ActionScope scope) =>
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
bool isVisible(ActionScope scope) => canShow(
assetCount: assets.length,
advancedTroubleshooting: scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting),
);
@override
Future<void> onAction(ActionScope scope) async =>

View file

@ -0,0 +1,67 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/semver.dart';
class EditImageAction extends AssetAction<BaseAsset> {
const EditImageAction({required super.assets});
@override
IconData get icon => Icons.tune;
@override
String label(ActionScope scope) => scope.context.t.edit;
static bool canShow({required BaseAsset asset, required bool isInLockedView, required SemVer serverVersion}) =>
!isInLockedView &&
asset.isEditable &&
// edit sync was added in 2.6.0
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
@override
bool isVisible(ActionScope scope) =>
assets.length == 1 &&
canShow(
asset: assets.first,
isInLockedView: scope.ref.watch(inLockedViewProvider),
serverVersion: scope.ref.watch(serverInfoProvider.select((state) => state.serverVersion)),
);
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:context, :ref) = scope;
final asset = assets.firstOrNull;
if (asset == null || asset.remoteId == null) {
return;
}
final imageProvider = getFullImageProvider(asset, edited: false);
final image = Image(image: imageProvider);
final (edits, exifInfo) = await (
ref.read(remoteAssetRepositoryProvider).getAssetEdits(asset.remoteId!),
ref.read(remoteAssetRepositoryProvider).getExif(asset.remoteId!),
).wait;
if (exifInfo == null) {
return;
}
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
await context.pushRoute(
DriftEditImageRoute(
image: image,
applyEdits: (edits) => ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits),
),
);
}
}

View file

@ -0,0 +1,45 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class OpenActivityAction extends AssetAction<BaseAsset> {
const OpenActivityAction({required super.assets});
@override
IconData get icon => Icons.chat_outlined;
@override
String label(ActionScope scope) => scope.context.t.activity;
static bool canShow({required bool isInLockedView, required RemoteAlbum? album}) =>
!isInLockedView && album != null && album.isActivityEnabled && album.isShared;
@override
bool isVisible(ActionScope scope) => canShow(
isInLockedView: scope.ref.watch(inLockedViewProvider),
album: scope.ref.watch(currentRemoteAlbumProvider),
);
@override
Future<void> onAction(ActionScope scope) async {
final album = scope.ref.read(currentRemoteAlbumProvider);
final asset = assets.firstOrNull;
if (album == null || asset == null) {
return;
}
unawaited(
scope.context.pushRoute(
DriftActivitiesRoute(album: album, assetId: asset is RemoteAsset ? asset.id : null, assetName: asset.name),
),
);
}
}

View file

@ -1,64 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.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_edit.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget {
final bool iconOnly;
final bool menuItem;
const EditImageActionButton({super.key, this.iconOnly = false, this.menuItem = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
Future<void> editImage(List<AssetEdit> edits) async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
}
Future<void> onPress() async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
final imageProvider = getFullImageProvider(currentAsset, edited: false);
final image = Image(image: imageProvider);
final (edits, exifInfo) = await (
ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!),
ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!),
).wait;
if (exifInfo == null) {
return;
}
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage));
}
return BaseActionButton(
iconData: Icons.tune,
label: "edit".t(context: context),
onPressed: onPress,
iconOnly: iconOnly,
menuItem: menuItem,
);
}
}

View file

@ -1,36 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class OpenActivityActionButton extends ConsumerWidget {
const OpenActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
void _onTap(BuildContext context, WidgetRef ref) {
final album = ref.read(currentRemoteAlbumProvider);
final asset = ref.read(assetViewerProvider).currentAsset;
if (album == null || asset == null) {
return;
}
context.router.push(
DriftActivitiesRoute(album: album, assetId: asset is RemoteAsset ? asset.id : null, assetName: asset.name),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) => BaseActionButton(
iconData: Icons.chat_outlined,
label: "activity".t(context: context),
onPressed: () => _onTap(context, ref),
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View file

@ -9,6 +9,8 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/edit_image.action.dart';
import 'package:immich_mobile/presentation/actions/open_activity.action.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
@ -18,10 +20,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_a
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/add_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/open_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
@ -111,7 +111,10 @@ enum ActionButtonType {
bool shouldShow(ActionButtonContext context) {
return switch (this) {
ActionButtonType.advancedInfo => context.advancedTroubleshooting,
ActionButtonType.advancedInfo => AssetDebugAction.canShow(
assetCount: context.selectedCount,
advancedTroubleshooting: context.advancedTroubleshooting,
),
ActionButtonType.share => true,
ActionButtonType.shareLink =>
!context.isInLockedView && //
@ -195,14 +198,18 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
ActionButtonType.editImage =>
!context.isInLockedView &&
context.asset.isEditable &&
context.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0),
ActionButtonType.editImage => EditImageAction.canShow(
asset: context.asset,
isInLockedView: context.isInLockedView,
serverVersion: context.serverVersion,
),
ActionButtonType.addTo =>
!context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.openActivity => _isInActivityAlbum(context),
ActionButtonType.openActivity => OpenActivityAction.canShow(
isInLockedView: context.isInLockedView,
album: context.currentAlbum,
),
ActionButtonType.slideshow => true,
};
}
@ -319,9 +326,15 @@ enum ActionButtonType {
},
),
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.editImage => EditImageActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.editImage when menuItem => ActionMenuItemWidget(
action: EditImageAction(assets: [context.asset]),
),
ActionButtonType.editImage => ActionColumnButtonWidget(action: EditImageAction(assets: [context.asset])),
ActionButtonType.addTo => AddActionButton(originalTheme: context.originalTheme),
ActionButtonType.openActivity => OpenActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.openActivity when menuItem => ActionMenuItemWidget(
action: OpenActivityAction(assets: [context.asset]),
),
ActionButtonType.openActivity => ActionColumnButtonWidget(action: OpenActivityAction(assets: [context.asset])),
};
}

View file

@ -0,0 +1,25 @@
import 'package:immich_mobile/domain/models/album/album.model.dart';
import '../../utils.dart';
class RemoteAlbumFactory {
const RemoteAlbumFactory();
static RemoteAlbum create({String? id, String? ownerId, bool isActivityEnabled = false, bool isShared = false}) {
id = TestUtils.uuid(id);
return RemoteAlbum(
id: id,
name: 'album_$id',
ownerId: TestUtils.uuid(ownerId),
description: '',
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isActivityEnabled: isActivityEnabled,
order: AlbumAssetOrder.desc,
assetCount: 1,
ownerName: 'owner',
isShared: isShared,
);
}
}

View file

@ -0,0 +1,113 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/edit_image.action.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../factories/local_asset_factory.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
class _MockServerInfoService extends Mock implements ServerInfoService {}
class _StubServerInfoNotifier extends ServerInfoNotifier {
_StubServerInfoNotifier(ServerVersion version) : super(_MockServerInfoService()) {
state = state.copyWith(serverVersion: version);
}
}
void main() {
late PresentationContext context;
const editCapableVersion = ServerVersion(major: 2, minor: 6, patch: 0);
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
Future<void> pumpAction(
WidgetTester tester,
EditImageAction action, {
ServerVersion serverVersion = editCapableVersion,
bool isInLockedView = false,
}) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: action),
overrides: [
serverInfoProvider.overrideWith((ref) => _StubServerInfoNotifier(serverVersion)),
inLockedViewProvider.overrideWith((ref) => isInLockedView),
],
);
}
group('EditImageAction', () {
testWidgets('visible for an editable remote asset on a capable server', (tester) async {
await pumpAction(tester, EditImageAction(assets: [RemoteAssetFactory.create()]));
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('hidden for a local-only asset', (tester) async {
await pumpAction(tester, EditImageAction(assets: [LocalAssetFactory.create()]));
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden when the server is older than 2.6.0', (tester) async {
await pumpAction(
tester,
EditImageAction(assets: [RemoteAssetFactory.create()]),
serverVersion: const ServerVersion(major: 2, minor: 5, patch: 9),
);
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden in the locked view', (tester) async {
await pumpAction(tester, EditImageAction(assets: [RemoteAssetFactory.create()]), isInLockedView: true);
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden for multiple assets', (tester) async {
await pumpAction(tester, EditImageAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]));
expect(find.byType(ImmichIconButton), findsNothing);
});
});
group('EditImageAction.canShow', () {
test('requires an editable asset, no locked view, and server >= 2.6.0', () {
final asset = RemoteAssetFactory.create();
expect(EditImageAction.canShow(asset: asset, isInLockedView: false, serverVersion: editCapableVersion), isTrue);
expect(EditImageAction.canShow(asset: asset, isInLockedView: true, serverVersion: editCapableVersion), isFalse);
expect(
EditImageAction.canShow(
asset: asset,
isInLockedView: false,
serverVersion: const SemVer(major: 2, minor: 5, patch: 9),
),
isFalse,
);
expect(
EditImageAction.canShow(
asset: LocalAssetFactory.create(),
isInLockedView: false,
serverVersion: editCapableVersion,
),
isFalse,
);
});
});
}

View file

@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/open_activity.action.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import '../../factories/remote_album_factory.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
class _StubAlbumNotifier extends CurrentAlbumNotifier {
_StubAlbumNotifier(this.album);
final RemoteAlbum? album;
@override
RemoteAlbum? build() => album;
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
Future<void> pumpAction(
WidgetTester tester, {
RemoteAlbum? album,
bool isInLockedView = false,
List<Override> overrides = const [],
}) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: OpenActivityAction(assets: [RemoteAssetFactory.create()])),
overrides: [
currentRemoteAlbumProvider.overrideWith(() => _StubAlbumNotifier(album)),
inLockedViewProvider.overrideWith((ref) => isInLockedView),
...overrides,
],
);
}
group('OpenActivityAction', () {
testWidgets('visible in a shared album with activity enabled', (tester) async {
await pumpAction(tester, album: RemoteAlbumFactory.create(isShared: true, isActivityEnabled: true));
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('hidden when there is no current album', (tester) async {
await pumpAction(tester, album: null);
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden when the album is not shared', (tester) async {
await pumpAction(tester, album: RemoteAlbumFactory.create(isShared: false, isActivityEnabled: true));
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden when activity is disabled', (tester) async {
await pumpAction(tester, album: RemoteAlbumFactory.create(isShared: true, isActivityEnabled: false));
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden in the locked view', (tester) async {
await pumpAction(
tester,
album: RemoteAlbumFactory.create(isShared: true, isActivityEnabled: true),
isInLockedView: true,
);
expect(find.byType(ImmichIconButton), findsNothing);
});
});
}