diff --git a/mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart b/mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart new file mode 100644 index 0000000000..1d704aafe8 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart @@ -0,0 +1,56 @@ +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, + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 2f2a2e0a4e..6848a07bb8 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_ 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/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'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; @@ -113,6 +114,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState ], if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.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), ], slivers: ownsAlbum ? [ diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index f6d05277ab..c06bcabf26 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -343,6 +343,22 @@ class ActionNotifier extends Notifier { } } + Future 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 updateDescription(ActionSource source, String description) async { final ids = _getRemoteIdsForSource(source); if (ids.length != 1) { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 3d3ef1494c..c435bf9d79 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -240,6 +240,12 @@ class ActionService { return _downloadRepository.downloadAllAssets(assets); } + Future setAlbumCover(String albumId, String assetId) async { + final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, thumbnailAssetId: assetId); + await _remoteAlbumRepository.update(updatedAlbum); + return true; + } + Future _deleteLocalAssets(List localIds) async { final deletedIds = await _assetMediaRepository.deleteAll(localIds); if (deletedIds.isEmpty) { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index dccb765760..78df9b3d8a 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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'; +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'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; @@ -42,6 +43,7 @@ class ActionButtonContext { final bool isCasting; final TimelineOrigin timelineOrigin; final ThemeData? originalTheme; + final int selectedCount; const ActionButtonContext({ required this.asset, @@ -56,6 +58,7 @@ class ActionButtonContext { this.isCasting = false, this.timelineOrigin = TimelineOrigin.main, this.originalTheme, + this.selectedCount = 1, }); } @@ -65,6 +68,7 @@ enum ActionButtonType { share, shareLink, cast, + setAlbumCover, similarPhotos, viewInTimeline, download, @@ -134,6 +138,11 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.currentAlbum != null, + ActionButtonType.setAlbumCover => + context.isOwner && // + !context.isInLockedView && // + context.currentAlbum != null && // + context.selectedCount == 1, ActionButtonType.unstack => context.isOwner && // !context.isInLockedView && // @@ -213,6 +222,12 @@ enum ActionButtonType { iconOnly: iconOnly, menuItem: menuItem, ), + ActionButtonType.setAlbumCover => SetAlbumCoverActionButton( + albumId: context.currentAlbum!.id, + source: context.source, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.similarPhotos => SimilarPhotosActionButton( @@ -251,7 +266,7 @@ enum ActionButtonType { int get kebabMenuGroup => switch (this) { // 0: info ActionButtonType.openInfo => 0, - // 10: move,remove, and delete + // 10: move, remove, and delete ActionButtonType.trash => 10, ActionButtonType.deletePermanent => 10, ActionButtonType.removeFromLockFolder => 10, diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index 4152155d24..a713a4063c 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -637,6 +637,115 @@ void main() { }); }); + group('setAlbumCover button', () { + test('should show when owner, not locked, has album, and selectedCount is 1', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue); + }); + + test('should not show when not owner', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: false, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when no current album', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when selectedCount is not 1', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 0, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when selectedCount is greater than 1', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 2, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + }); + group('likeActivity button', () { test('should show when not locked, has album, activity enabled, and shared', () { final album = createRemoteAlbum(isActivityEnabled: true, isShared: true); @@ -846,6 +955,21 @@ void main() { ); final widget = buttonType.buildButton(contextWithAlbum); expect(widget, isA()); + } else if (buttonType == ActionButtonType.setAlbumCover) { + final album = createRemoteAlbum(); + final contextWithAlbum = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + final widget = buttonType.buildButton(contextWithAlbum); + expect(widget, isA()); } else if (buttonType == ActionButtonType.unstack) { final album = createRemoteAlbum(); final contextWithAlbum = ActionButtonContext(