refactor: mobile album actions

This commit is contained in:
shenlong-tanwen
2026-07-27 15:10:59 +05:30
parent 010a185abe
commit 23c0f008f3
11 changed files with 253 additions and 208 deletions
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
final _stateProvider = Provider.family.autoDispose<List<String>?, ActionSource>((ref, source) {
final AssetsActionState(:assets) = ref.watch(assetsActionProvider(source));
final assetIds = assets.remote().map((asset) => asset.id).toList(growable: false);
return assetIds.isEmpty ? null : assetIds;
});
class RemoveFromAlbumAction extends AssetActionBuilder {
final String albumId;
const RemoveFromAlbumAction({required super.source, required this.albumId});
@override
ActionData? build(BuildContext context, WidgetRef ref) {
final assetIds = ref.watch(_stateProvider(source));
if (assetIds == null) {
return null;
}
return .new(
icon: Icons.remove_circle_outline,
label: context.t.remove_from_album,
onAction: () => _remove(context, ref, assetIds),
);
}
Future<void> _remove(BuildContext context, WidgetRef ref, List<String> assetIds) async {
final albumService = ref.read(remoteAlbumServiceProvider);
final toast = ref.read(toastRepositoryProvider);
final selection = ref.read(assetsActionProvider(source).notifier);
try {
final count = await albumService.removeAssets(albumId: albumId, assetIds: assetIds);
if (!context.mounted) {
return;
}
toast.success(context.t.remove_from_album_action_prompt(count: count));
selection.clearSelect();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to remove the assets from the album");
}
}
}
@@ -0,0 +1,49 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
final _stateProvider = Provider.family.autoDispose<String?, ActionSource>((ref, source) {
final AssetsActionState(:assets) = ref.watch(assetsActionProvider(source));
return assets.remote().map((asset) => asset.id).singleOrNull;
});
class SetAlbumCoverAction extends AssetActionBuilder {
final String albumId;
const SetAlbumCoverAction({required super.source, required this.albumId});
@override
ActionData? build(BuildContext context, WidgetRef ref) {
final assetId = ref.watch(_stateProvider(source));
if (assetId == null) {
return null;
}
return .new(
icon: Icons.image_outlined,
label: context.t.set_as_album_cover,
onAction: () => _setCover(context, ref, assetId),
);
}
Future<void> _setCover(BuildContext context, WidgetRef ref, String assetId) async {
final message = context.t.album_cover_updated;
final albumService = ref.read(remoteAlbumServiceProvider);
final toast = ref.read(toastRepositoryProvider);
final selection = ref.read(assetsActionProvider(source).notifier);
try {
await albumService.updateAlbum(albumId, thumbnailAssetId: assetId);
toast.success(message);
selection.clearSelect();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to update the album cover");
}
}
}
@@ -1,65 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoveFromAlbumActionButton extends ConsumerWidget {
final String albumId;
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromAlbumActionButton({
super.key,
required this.albumId,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_album_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.remove_circle_outline,
label: "remove_from_album".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
@@ -1,56 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class SetAlbumCoverActionButton extends ConsumerWidget {
final String albumId;
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const SetAlbumCoverActionButton({
super.key,
required this.albumId,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).setAlbumCover(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'album_cover_updated'.t(context: context);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.image_outlined,
label: 'set_as_album_cover'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
@@ -4,18 +4,18 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -104,9 +104,14 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
],
],
const ActionColumnButton(action: CleanupLocalAction(source: .timeline)),
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
if (ownsAlbum && multiselect.selectedAssets.length == 1)
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),
if (ownsAlbum) ...[
ActionColumnButton(
action: RemoveFromAlbumAction(source: .timeline, albumId: widget.album.id),
),
ActionColumnButton(
action: SetAlbumCoverAction(source: .timeline, albumId: widget.album.id),
),
],
],
slivers: ownsAlbum
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand)]
@@ -169,33 +169,6 @@ class ActionNotifier extends Notifier<void> {
);
}
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
final ids = _getRemoteIdsForSource(source);
try {
final removedCount = await _service.removeFromAlbum(ids, albumId);
return ActionResult(count: removedCount, success: true);
} catch (error, stack) {
_logger.severe('Failed to remove assets from album', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> setAlbumCover(ActionSource source, String albumId) async {
final assets = _getAssets(source);
final asset = assets.first;
if (asset is! RemoteAsset) {
return const ActionResult(count: 1, success: false, error: 'Asset must be remote');
}
try {
await _service.setAlbumCover(albumId, asset.id);
return const ActionResult(count: 1, success: true);
} catch (error, stack) {
_logger.severe('Failed to set album cover', error, stack);
return ActionResult(count: 1, success: false, error: error.toString());
}
}
Future<ActionResult> updateDescription(ActionSource source, String description) async {
final ids = _getRemoteIdsForSource(source);
if (ids.length != 1) {
-25
View File
@@ -4,21 +4,16 @@ 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/domain/services/tag.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
final actionServiceProvider = Provider<ActionService>(
(ref) => ActionService(
ref.watch(assetApiRepositoryProvider),
ref.watch(remoteAssetRepositoryProvider),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
),
@@ -27,16 +22,12 @@ final actionServiceProvider = Provider<ActionService>(
class ActionService {
final AssetApiRepository _assetApiRepository;
final RemoteAssetRepository _remoteAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
const ActionService(
this._assetApiRepository,
this._remoteAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
this._downloadRepository,
this._tagService,
);
@@ -53,14 +44,6 @@ class ActionService {
return count;
}
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
if (result.removed.isNotEmpty) {
await _remoteAlbumRepository.removeAssets(albumId, result.removed);
}
return result.removed.length;
}
Future<bool> updateDescription(String assetId, String description) async {
// update remote first, then local to ensure consistency
await _assetApiRepository.updateDescription(assetId, description);
@@ -100,12 +83,4 @@ class ActionService {
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
return _downloadRepository.downloadAllAssets(assets);
}
Future<bool> setAlbumCover(String albumId, String assetId) async {
final owner = await _remoteAlbumRepository.getOwner(albumId);
final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, owner, thumbnailAssetId: assetId);
await _remoteAlbumRepository.update(updatedAlbum);
return true;
}
}
+6 -12
View File
@@ -14,7 +14,9 @@ import 'package:immich_mobile/presentation/actions/cast.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/open_in_browser.action.dart';
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
import 'package:immich_mobile/presentation/actions/set_profile_picture.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
@@ -24,8 +26,6 @@ import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_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/like_activity_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/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -185,17 +185,11 @@ enum ActionButtonType {
ActionButtonType.removeFromLockFolder => ActionMenuItem(action: LockAction(source: context.source)),
ActionButtonType.deleteLocal => ActionMenuItem(action: CleanupLocalAction(source: context.source)),
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
albumId: context.currentAlbum!.id,
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.removeFromAlbum => ActionMenuItem(
action: RemoveFromAlbumAction(source: context.source, albumId: context.currentAlbum!.id),
),
ActionButtonType.setAlbumCover => SetAlbumCoverActionButton(
albumId: context.currentAlbum!.id,
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.setAlbumCover => ActionMenuItem(
action: SetAlbumCoverAction(source: context.source, albumId: context.currentAlbum!.id),
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => ActionMenuItem(action: StackAction(source: context.source)),
+1 -12
View File
@@ -17,8 +17,6 @@ void main() {
late MockAssetApiRepository assetApiRepository;
late MockRemoteAssetRepository remoteAssetRepository;
late MockDriftAlbumApiRepository albumApiRepository;
late MockRemoteAlbumRepository remoteAlbumRepository;
late MockDownloadRepository downloadRepository;
late MockTagService tagService;
@@ -41,19 +39,10 @@ void main() {
setUp(() {
assetApiRepository = MockAssetApiRepository();
remoteAssetRepository = MockRemoteAssetRepository();
albumApiRepository = MockDriftAlbumApiRepository();
remoteAlbumRepository = MockRemoteAlbumRepository();
downloadRepository = MockDownloadRepository();
tagService = MockTagService();
sut = ActionService(
assetApiRepository,
remoteAssetRepository,
albumApiRepository,
remoteAlbumRepository,
downloadRepository,
tagService,
);
sut = ActionService(assetApiRepository, remoteAssetRepository, downloadRepository, tagService);
});
tearDown(() async {
@@ -0,0 +1,127 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.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.widget.dart';
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../../service.mocks.dart';
import '../../factories/local_asset_factory.dart';
import '../../factories/remote_album_factory.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
const albumId = 'album-1';
late PresentationContext context;
late MockRemoteAlbumService albumService;
setUp(() async {
context = await PresentationContext.create();
albumService = context.service.album.service;
});
tearDown(() {
context.dispose();
});
List<Override> withMockToast() => [toastRepositoryProvider.overrideWithValue(context.repository.toast)];
group('RemoveFromAlbumAction', () {
Future<void> pumpRemove(WidgetTester tester, Set<BaseAsset> selection) => tester.pumpTestAction(
context,
const RemoveFromAlbumAction(source: .timeline, albumId: albumId),
overrides: [...context.selected(selection), ...withMockToast()],
);
testWidgets('removes every selected remote asset from the album', (tester) async {
final first = RemoteAssetFactory.create();
final second = RemoteAssetFactory.create();
await pumpRemove(tester, {first, second});
await tester.pumpAndSettle();
verify(() => albumService.removeAssets(albumId: albumId, assetIds: [first.id, second.id])).called(1);
});
testWidgets('reports the count the server actually removed', (tester) async {
when(
() => albumService.removeAssets(
albumId: any(named: 'albumId'),
assetIds: any(named: 'assetIds'),
),
).thenAnswer((_) async => 1);
await pumpRemove(tester, {RemoteAssetFactory.create(), RemoteAssetFactory.create()});
await tester.pumpAndSettle();
final message = verify(() => context.repository.toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.remove_from_album_action_prompt(count: 1));
});
testWidgets('is hidden for a local-only asset, which is not in the album', (tester) async {
await tester.pumpTestWidget(
context,
const ActionIconButton(
action: RemoveFromAlbumAction(source: .timeline, albumId: albumId),
),
overrides: context.selected({LocalAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
});
});
group('SetAlbumCoverAction', () {
testWidgets('sets the single selected asset as the cover', (tester) async {
final asset = RemoteAssetFactory.create();
when(
() => albumService.updateAlbum(any(), thumbnailAssetId: any(named: 'thumbnailAssetId')),
).thenAnswer((_) async => RemoteAlbumFactory.create(id: albumId));
await tester.pumpTestAction(
context,
const SetAlbumCoverAction(source: .timeline, albumId: albumId),
overrides: [
...context.selected({asset}),
...withMockToast(),
],
);
await tester.pumpAndSettle();
verify(() => albumService.updateAlbum(albumId, thumbnailAssetId: asset.id)).called(1);
final message = verify(() => context.repository.toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.album_cover_updated);
});
testWidgets('is hidden for more than one asset, since a cover is a single image', (tester) async {
await tester.pumpTestWidget(
context,
const ActionIconButton(
action: SetAlbumCoverAction(source: .timeline, albumId: albumId),
),
overrides: context.selected({RemoteAssetFactory.create(), RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('is hidden for a local-only asset, which has no remote id to point at', (tester) async {
await tester.pumpTestWidget(
context,
const ActionIconButton(
action: SetAlbumCoverAction(source: .timeline, albumId: albumId),
),
overrides: context.selected({LocalAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
});
});
}
@@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
@@ -51,6 +52,7 @@ class PresentationContext {
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
cleanupServiceProvider.overrideWithValue(service.cleanup.service),
remoteAlbumServiceProvider.overrideWithValue(service.album.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
gCastServiceProvider.overrideWithValue(service.cast),
serverInfoServiceProvider.overrideWithValue(service.serverInfo),