feat: add 'Move to album' feature to web and mobile

pull/28753/head
Tanishq Sangwan 2026-06-02 00:37:36 +05:30
parent 7eabac6702
commit 806578a079
8 changed files with 355 additions and 2 deletions

View File

@ -38,6 +38,7 @@
"add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})",
"add_to_bottom_bar": "Add to",
"move_to_album": "Move to album",
"add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
@ -600,6 +601,7 @@
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
"assets_moved_to_album": "Moved {count, plural, one {# asset} other {# assets}} to {album}",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
"assets_count": "{count, plural, one {# asset} other {# assets}}",
@ -1072,6 +1074,8 @@
"cant_search_people": "Can't search people",
"cant_search_places": "Can't search places",
"error_adding_assets_to_album": "Error adding assets to album",
"cannot_move_to_same_album": "Cannot move assets to the same album",
"error_moving_assets": "Error moving assets",
"error_adding_users_to_album": "Error adding users to album",
"error_deleting_shared_user": "Error deleting shared user",
"error_downloading": "Error downloading {filename}",

View File

@ -0,0 +1,178 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
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/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.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/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';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:auto_route/auto_route.dart';
class MoveToAlbumActionButton extends ConsumerWidget {
final String albumId;
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const MoveToAlbumActionButton({
super.key,
required this.albumId,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) {
Navigator.of(context).pop();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) {
return BaseBottomSheet(
actions: const [],
slivers: [
MoveToAlbumHeader(albumId: albumId),
AlbumSelector(
onAlbumSelected: (targetAlbum) => _moveAssetsToAlbum(context, ref, targetAlbum),
),
],
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.95,
expand: false,
backgroundColor: context.isDarkTheme ? Colors.black : Colors.white,
);
},
);
}
Future<void> _moveAssetsToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum targetAlbum) async {
if (targetAlbum.id == albumId) {
ImmichToast.show(
context: context,
msg: 'errors.cannot_move_to_same_album'.t(context: context),
toastType: ToastType.error,
);
return;
}
if (context.mounted) {
Navigator.of(context).pop();
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).moveToAlbum(source, albumId, targetAlbum);
final successMessage = 'assets_moved_to_album'.t(
context: context,
args: {
'count': result.count.toString(),
'album': targetAlbum.name,
},
);
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.drive_file_move_outlined,
label: "move_to_album".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
class MoveToAlbumHeader extends ConsumerWidget {
final String albumId;
const MoveToAlbumHeader({super.key, required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> onCreateAlbum() async {
final selectedAssets = ref.read(multiSelectProvider).selectedAssets;
if (selectedAssets.isEmpty) {
return;
}
final newAlbum = await ref
.read(remoteAlbumProvider.notifier)
.createAlbumWithAssets(title: "Untitled Album", assets: selectedAssets);
if (newAlbum == null) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());
return;
}
try {
final ids = selectedAssets.whereType<RemoteAsset>().map((e) => e.id).toList(growable: false);
if (ids.isNotEmpty) {
await ref.read(actionServiceProvider).removeFromAlbum(ids, albumId);
}
} catch (e) {
// Ignored
}
ref.read(multiSelectProvider.notifier).reset();
if (context.mounted) {
Navigator.of(context).pop();
unawaited(context.pushRoute(RemoteAlbumRoute(album: newAlbum)));
}
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("move_to_album", style: context.textTheme.titleSmall).tr(),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onCreateAlbum,
icon: Icon(Icons.add, color: context.primaryColor),
label: Text(
"common_create_new_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
],
),
),
);
}
}

View File

@ -11,6 +11,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_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/move_to_album_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/share_action_button.widget.dart';
@ -111,6 +112,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
],
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (ownsAlbum) MoveToAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
if (ownsAlbum && multiselect.selectedAssets.length == 1)
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),

View File

@ -422,6 +422,66 @@ class ActionNotifier extends Notifier<void> {
);
}
Future<ActionResult> moveToAlbum(ActionSource source, String sourceAlbumId, RemoteAlbum targetAlbum) async {
final selected = _getAssets(source).toList(growable: false);
if (selected.isEmpty) {
return const ActionResult(count: 0, success: true);
}
if (sourceAlbumId == targetAlbum.id) {
return const ActionResult(count: 0, success: false, error: 'Cannot move assets to the same album');
}
final candidates = RemoteAlbumService.categorizeCandidates(selected);
final remoteIds = candidates.remoteAssetIds;
final localAssets = candidates.localAssetsToUpload;
final albumNotifier = ref.read(remoteAlbumProvider.notifier);
int addedRemote = 0;
if (remoteIds.isNotEmpty) {
try {
addedRemote = await albumNotifier.addAssets(targetAlbum.id, remoteIds);
} catch (error, stack) {
_logger.severe('Failed to add assets to album ${targetAlbum.id}', error, stack);
return ActionResult(count: 0, success: false, error: error.toString());
}
}
final remoteIdsToRemove = selected.whereType<RemoteAsset>().toIds().toList(growable: false);
if (source == ActionSource.timeline) {
ref.read(multiSelectProvider.notifier).reset();
}
int uploadedAndLinked = 0;
if (localAssets.isNotEmpty) {
final uploadResult = await upload(
source,
assets: localAssets,
onAssetUploaded: (asset, remoteId) async {
await albumNotifier.linkUploadedAssetToAlbum(targetAlbum.id, asset, remoteId);
},
);
uploadedAndLinked = uploadResult.count;
}
int removedCount = 0;
if (remoteIdsToRemove.isNotEmpty) {
try {
removedCount = await _service.removeFromAlbum(remoteIdsToRemove, sourceAlbumId);
} catch (error, stack) {
_logger.severe('Failed to remove assets from source album during move', error, stack);
return ActionResult(
count: addedRemote + uploadedAndLinked,
success: false,
error: error.toString(),
);
}
}
return ActionResult(count: removedCount, success: true);
}
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
final ids = _getRemoteIdsForSource(source);
try {

View File

@ -0,0 +1,77 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { addAssetsToAlbums } from '$lib/services/album.service';
import { handleError } from '$lib/utils/handle-error';
import { getAlbumInfo, removeAssetFromAlbum, type AlbumResponseDto } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { mdiFolderMove } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
album: AlbumResponseDto;
onMove: ((assetIds: string[]) => void) | undefined;
assetIds?: string[];
menuItem?: boolean;
}
let { album = $bindable(), onMove, assetIds, menuItem = false }: Props = $props();
const moveToAlbum = async () => {
const ids = assetIds ?? assetMultiSelectManager.assets.map(({ id }) => id) ?? [];
if (ids.length === 0) {
return;
}
modalManager.show(AlbumPickerModal, {
title: $t('move_to_album'),
onClose: async (targetAlbums) => {
if (!targetAlbums || targetAlbums.length === 0) {
return;
}
// Filter out the current album from targets to prevent moving to itself
const targetAlbumIds = targetAlbums.map((a) => a.id).filter((id) => id !== album.id);
if (targetAlbumIds.length === 0) {
toastManager.warning($t('errors.cannot_move_to_same_album'));
return;
}
try {
// 1. Add assets to target album(s) without triggering 'added to album' toast
const addSuccess = await addAssetsToAlbums(targetAlbumIds, ids, { notify: false });
if (!addSuccess) {
return;
}
// 2. Remove assets from current source album
await removeAssetFromAlbum({
id: album.id,
bulkIdsDto: { ids },
});
// 3. Update current album info/state
album = await getAlbumInfo({ id: album.id });
// 4. Notify parent to update timeline assets
onMove?.(ids);
// 5. Show toast notification
const targetAlbumNames = targetAlbums.map((a) => a.albumName || $t('unnamed_album')).join(', ');
toastManager.primary(
$t('assets_moved_to_album', { values: { count: ids.length, album: targetAlbumNames } })
);
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.error_moving_assets'));
}
},
});
};
</script>
{#if menuItem}
<MenuOption text={$t('move_to_album')} icon={mdiFolderMove} onClick={moveToAlbum} />
{/if}

View File

@ -0,0 +1,29 @@
import '@testing-library/jest-dom';
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
import { init, register, waitLocale } from 'svelte-i18n';
import { albumFactory } from '@test-data/factories/album-factory';
import MoveToAlbumAction from '../MoveToAlbumAction.svelte';
describe('MoveToAlbumAction component', () => {
let sut: RenderResult<typeof MoveToAlbumAction>;
const album = albumFactory.build({ id: 'source-album-id', albumName: 'Source Album' });
beforeAll(async () => {
await init({ fallbackLocale: 'en-US' });
register('en-US', () => import('$i18n/en.json'));
await waitLocale('en-US');
});
it('renders a menu option with the label "Move to album"', () => {
sut = render(MoveToAlbumAction, {
album,
onMove: vi.fn(),
menuItem: true,
assetIds: ['asset-1'],
});
const button = sut.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent('Move to album');
});
});

View File

@ -23,9 +23,10 @@
type Props = {
onClose: (albums?: AlbumResponseDto[]) => void;
title?: string;
};
let { onClose }: Props = $props();
let { onClose, title }: Props = $props();
onMount(async () => {
albums = await getAllAlbums({});
@ -147,7 +148,7 @@
};
</script>
<Modal title={$t('add_to_album')} {onClose} size="small">
<Modal title={title ?? $t('add_to_album')} {onClose} size="small">
<ModalBody>
<div class="mb-2 flex max-h-100 flex-col">
{#if loading}

View File

@ -19,6 +19,7 @@
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import MoveToAlbum from '$lib/components/timeline/actions/MoveToAlbumAction.svelte';
import RemoveFromAlbum from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
@ -490,6 +491,7 @@
{/if}
{#if isOwned || assetMultiSelectManager.isAllUserOwned}
<MoveToAlbum menuItem bind:album onMove={handleRemoveAssets} />
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
{/if}
{#if assetMultiSelectManager.isAllUserOwned}