feat(mobile): Allow users to set album cover from mobile app (#25515)

* set album cover from asset

* add to correct kebab group

* add to album selection

* add to legacy control bottom bar

* add tests

* format

* analyze

* Revert "add to legacy control bottom bar"

This reverts commit 9d68e12a08.

* remove unnecessary event emission

* lint

* fix tests

* fix: button order and remove unncessary check

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Timon
2026-02-22 06:53:39 +01:00
committed by GitHub
parent f0e2fced57
commit 3ce0654cab
6 changed files with 221 additions and 1 deletions

View File

@@ -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,
);
}
}

View File

@@ -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/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_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_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_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/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_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<RemoteAlbumBottomSheet>
], ],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline), if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (ownsAlbum) RemoveFromAlbumActionButton(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),
], ],
slivers: ownsAlbum slivers: ownsAlbum
? [ ? [

View File

@@ -343,6 +343,22 @@ class ActionNotifier extends Notifier<void> {
} }
} }
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 { Future<ActionResult> updateDescription(ActionSource source, String description) async {
final ids = _getRemoteIdsForSource(source); final ids = _getRemoteIdsForSource(source);
if (ids.length != 1) { if (ids.length != 1) {

View File

@@ -240,6 +240,12 @@ class ActionService {
return _downloadRepository.downloadAllAssets(assets); return _downloadRepository.downloadAllAssets(assets);
} }
Future<bool> setAlbumCover(String albumId, String assetId) async {
final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, thumbnailAssetId: assetId);
await _remoteAlbumRepository.update(updatedAlbum);
return true;
}
Future<int> _deleteLocalAssets(List<String> localIds) async { Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds); final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) { if (deletedIds.isEmpty) {

View File

@@ -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/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_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/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_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/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_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 bool isCasting;
final TimelineOrigin timelineOrigin; final TimelineOrigin timelineOrigin;
final ThemeData? originalTheme; final ThemeData? originalTheme;
final int selectedCount;
const ActionButtonContext({ const ActionButtonContext({
required this.asset, required this.asset,
@@ -56,6 +58,7 @@ class ActionButtonContext {
this.isCasting = false, this.isCasting = false,
this.timelineOrigin = TimelineOrigin.main, this.timelineOrigin = TimelineOrigin.main,
this.originalTheme, this.originalTheme,
this.selectedCount = 1,
}); });
} }
@@ -65,6 +68,7 @@ enum ActionButtonType {
share, share,
shareLink, shareLink,
cast, cast,
setAlbumCover,
similarPhotos, similarPhotos,
viewInTimeline, viewInTimeline,
download, download,
@@ -134,6 +138,11 @@ enum ActionButtonType {
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
context.currentAlbum != null, context.currentAlbum != null,
ActionButtonType.setAlbumCover =>
context.isOwner && //
!context.isInLockedView && //
context.currentAlbum != null && //
context.selectedCount == 1,
ActionButtonType.unstack => ActionButtonType.unstack =>
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
@@ -213,6 +222,12 @@ enum ActionButtonType {
iconOnly: iconOnly, iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
), ),
ActionButtonType.setAlbumCover => SetAlbumCoverActionButton(
albumId: context.currentAlbum!.id,
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.similarPhotos => SimilarPhotosActionButton( ActionButtonType.similarPhotos => SimilarPhotosActionButton(
@@ -251,7 +266,7 @@ enum ActionButtonType {
int get kebabMenuGroup => switch (this) { int get kebabMenuGroup => switch (this) {
// 0: info // 0: info
ActionButtonType.openInfo => 0, ActionButtonType.openInfo => 0,
// 10: move,remove, and delete // 10: move, remove, and delete
ActionButtonType.trash => 10, ActionButtonType.trash => 10,
ActionButtonType.deletePermanent => 10, ActionButtonType.deletePermanent => 10,
ActionButtonType.removeFromLockFolder => 10, ActionButtonType.removeFromLockFolder => 10,

View File

@@ -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', () { group('likeActivity button', () {
test('should show when not locked, has album, activity enabled, and shared', () { test('should show when not locked, has album, activity enabled, and shared', () {
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true); final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
@@ -846,6 +955,21 @@ void main() {
); );
final widget = buttonType.buildButton(contextWithAlbum); final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>()); expect(widget, isA<Widget>());
} 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<Widget>());
} else if (buttonType == ActionButtonType.unstack) { } else if (buttonType == ActionButtonType.unstack) {
final album = createRemoteAlbum(); final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext( final contextWithAlbum = ActionButtonContext(