feat: add 'Move to album' feature to web and mobile
parent
7eabac6702
commit
806578a079
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue