From ec01db5c8b6304c5772d168bb7e753874e967c71 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 18 Aug 2025 10:20:08 -0500 Subject: [PATCH] refactor: bottom sheet action button (#20964) * fix: incorrect archive action shown in asset viewer' * Refactor * use enums syntax and add tests --- .../like_activity_action_button.widget.dart | 1 + .../asset_viewer/bottom_bar.widget.dart | 15 +- .../asset_viewer/bottom_sheet.widget.dart | 49 +- .../base_bottom_sheet.widget.dart | 6 +- mobile/lib/utils/action_button.utils.dart | 160 ++++ .../test/utils/action_button_utils_test.dart | 717 ++++++++++++++++++ 6 files changed, 906 insertions(+), 42 deletions(-) create mode 100644 mobile/lib/utils/action_button.utils.dart create mode 100644 mobile/test/utils/action_button_utils_test.dart diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index 4fec4e24db..d143f600ce 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -44,6 +44,7 @@ class LikeActivityActionButton extends ConsumerWidget { ); return BaseActionButton( + maxWidth: 60, iconData: liked != null ? Icons.favorite : Icons.favorite_border, label: "like".t(context: context), onPressed: () => onTap(liked), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index bb7b06113c..732afee7f9 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_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/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; @@ -31,6 +32,7 @@ class ViewerBottomBar extends ConsumerWidget { int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final isInLockedView = ref.watch(inLockedViewProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; if (!showControls) { opacity = 0; @@ -40,10 +42,15 @@ class ViewerBottomBar extends ConsumerWidget { const ShareActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.type == AssetType.image) const EditImageActionButton(), - if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer), - asset.isLocalOnly - ? const DeleteLocalActionButton(source: ActionSource.viewer) - : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), + if (isOwner) ...[ + if (asset.hasRemote && isOwner && isArchived) + const UnArchiveActionButton(source: ActionSource.viewer) + else + const ArchiveActionButton(source: ActionSource.viewer), + asset.isLocalOnly + ? const DeleteLocalActionButton(source: ActionSource.viewer) + : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), + ], ]; return IgnorePointer( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index f531a29b2d..ccf6e0285e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -6,18 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.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/archive_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_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/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/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/trash_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; @@ -26,6 +14,8 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -45,34 +35,25 @@ class AssetDetailBottomSheet extends ConsumerWidget { } final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); + final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id; final isInLockedView = ref.watch(inLockedViewProvider); final currentAlbum = ref.watch(currentRemoteAlbumProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; - final actions = [ - const ShareActionButton(source: ActionSource.viewer), - if (currentAlbum != null && currentAlbum.isActivityEnabled && currentAlbum.isShared) - const LikeActivityActionButton(), - if (asset.hasRemote) ...[ - const ShareLinkActionButton(source: ActionSource.viewer), - const ArchiveActionButton(source: ActionSource.viewer), - if (!asset.hasLocal) const DownloadActionButton(source: ActionSource.viewer), - isTrashEnable - ? const TrashActionButton(source: ActionSource.viewer) - : const DeletePermanentActionButton(source: ActionSource.viewer), - const DeleteActionButton(source: ActionSource.viewer), - const MoveToLockFolderActionButton(source: ActionSource.viewer), - ], - if (asset.storage == AssetState.local) ...[ - const DeleteLocalActionButton(source: ActionSource.viewer), - const UploadActionButton(source: ActionSource.timeline), - ], - if (currentAlbum != null) RemoveFromAlbumActionButton(albumId: currentAlbum.id, source: ActionSource.viewer), - ]; + final buttonContext = ActionButtonContext( + asset: asset, + isOwner: isOwner, + isArchived: isArchived, + isTrashEnabled: isTrashEnable, + isInLockedView: isInLockedView, + currentAlbum: currentAlbum, + source: ActionSource.viewer, + ); - final lockedViewActions = []; + final actions = ActionButtonBuilder.build(buttonContext); return BaseBottomSheet( - actions: isInLockedView ? lockedViewActions : actions, + actions: actions, slivers: const [_AssetDetailBottomSheet()], controller: controller, initialChildSize: initialChildSize, diff --git a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart index acbf2ad74f..0549bceb9c 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart @@ -69,10 +69,8 @@ class _BaseDraggableScrollableSheetState extends ConsumerState shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent, builder: (BuildContext context, ScrollController scrollController) { return Card( - color: widget.backgroundColor ?? context.colorScheme.surface, - borderOnForeground: false, - clipBehavior: Clip.antiAlias, - elevation: 6.0, + color: widget.backgroundColor ?? context.colorScheme.surfaceContainer, + elevation: 3.0, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))), margin: const EdgeInsets.symmetric(horizontal: 0), child: CustomScrollView( diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart new file mode 100644 index 0000000000..10facea9a2 --- /dev/null +++ b/mobile/lib/utils/action_button.utils.dart @@ -0,0 +1,160 @@ +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_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/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/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/trash_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +class ActionButtonContext { + final BaseAsset asset; + final bool isOwner; + final bool isArchived; + final bool isTrashEnabled; + final bool isInLockedView; + final RemoteAlbum? currentAlbum; + final ActionSource source; + + const ActionButtonContext({ + required this.asset, + required this.isOwner, + required this.isArchived, + required this.isTrashEnabled, + required this.isInLockedView, + required this.currentAlbum, + required this.source, + }); +} + +enum ActionButtonType { + share, + shareLink, + archive, + unarchive, + download, + trash, + deletePermanent, + delete, + moveToLockFolder, + removeFromLockFolder, + deleteLocal, + upload, + removeFromAlbum, + likeActivity; + + bool shouldShow(ActionButtonContext context) { + return switch (this) { + ActionButtonType.share => true, + ActionButtonType.shareLink => + !context.isInLockedView && // + context.asset.hasRemote, + ActionButtonType.archive => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + !context.isArchived, + ActionButtonType.unarchive => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + context.isArchived, + ActionButtonType.download => + !context.isInLockedView && // + context.asset.hasRemote && // + !context.asset.hasLocal, + ActionButtonType.trash => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + context.isTrashEnabled, + ActionButtonType.deletePermanent => + context.isOwner && // + context.asset.hasRemote && // + !context.isTrashEnabled || + context.isInLockedView, + ActionButtonType.delete => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote, + ActionButtonType.moveToLockFolder => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote, + ActionButtonType.removeFromLockFolder => + context.isOwner && // + context.isInLockedView && // + context.asset.hasRemote, + ActionButtonType.deleteLocal => + !context.isInLockedView && // + context.asset.storage == AssetState.local, + ActionButtonType.upload => + !context.isInLockedView && // + context.asset.storage == AssetState.local, + ActionButtonType.removeFromAlbum => + context.isOwner && // + !context.isInLockedView && // + context.currentAlbum != null, + ActionButtonType.likeActivity => + !context.isInLockedView && + context.currentAlbum != null && + context.currentAlbum!.isActivityEnabled && + context.currentAlbum!.isShared, + }; + } + + Widget buildButton(ActionButtonContext context) { + return switch (this) { + ActionButtonType.share => ShareActionButton(source: context.source), + ActionButtonType.shareLink => ShareLinkActionButton(source: context.source), + ActionButtonType.archive => ArchiveActionButton(source: context.source), + ActionButtonType.unarchive => UnArchiveActionButton(source: context.source), + ActionButtonType.download => DownloadActionButton(source: context.source), + ActionButtonType.trash => TrashActionButton(source: context.source), + ActionButtonType.deletePermanent => DeletePermanentActionButton(source: context.source), + ActionButtonType.delete => DeleteActionButton(source: context.source), + ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(source: context.source), + ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(source: context.source), + ActionButtonType.deleteLocal => DeleteLocalActionButton(source: context.source), + ActionButtonType.upload => UploadActionButton(source: context.source), + ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton( + albumId: context.currentAlbum!.id, + source: context.source, + ), + ActionButtonType.likeActivity => const LikeActivityActionButton(), + }; + } +} + +class ActionButtonBuilder { + static const List _actionTypes = [ + ActionButtonType.share, + ActionButtonType.shareLink, + ActionButtonType.likeActivity, + ActionButtonType.archive, + ActionButtonType.unarchive, + ActionButtonType.download, + ActionButtonType.trash, + ActionButtonType.deletePermanent, + ActionButtonType.delete, + ActionButtonType.moveToLockFolder, + ActionButtonType.removeFromLockFolder, + ActionButtonType.deleteLocal, + ActionButtonType.upload, + ActionButtonType.removeFromAlbum, + ]; + + static List build(ActionButtonContext context) { + return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); + } +} diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart new file mode 100644 index 0000000000..3cb77c0b33 --- /dev/null +++ b/mobile/test/utils/action_button_utils_test.dart @@ -0,0 +1,717 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.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/utils/action_button.utils.dart'; + +LocalAsset createLocalAsset({ + String? remoteId, + String name = 'test.jpg', + String? checksum = 'test-checksum', + AssetType type = AssetType.image, + DateTime? createdAt, + DateTime? updatedAt, + bool isFavorite = false, +}) { + return LocalAsset( + id: 'local-id', + remoteId: remoteId, + name: name, + checksum: checksum, + type: type, + createdAt: createdAt ?? DateTime.now(), + updatedAt: updatedAt ?? DateTime.now(), + isFavorite: isFavorite, + ); +} + +RemoteAsset createRemoteAsset({ + String? localId, + String name = 'test.jpg', + String checksum = 'test-checksum', + AssetType type = AssetType.image, + DateTime? createdAt, + DateTime? updatedAt, + bool isFavorite = false, +}) { + return RemoteAsset( + id: 'remote-id', + localId: localId, + name: name, + checksum: checksum, + type: type, + ownerId: 'owner-id', + createdAt: createdAt ?? DateTime.now(), + updatedAt: updatedAt ?? DateTime.now(), + isFavorite: isFavorite, + ); +} + +RemoteAlbum createRemoteAlbum({ + String id = 'test-album-id', + String name = 'Test Album', + bool isActivityEnabled = false, + bool isShared = false, +}) { + return RemoteAlbum( + id: id, + name: name, + ownerId: 'owner-id', + description: 'Test Description', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + isActivityEnabled: isActivityEnabled, + isShared: isShared, + order: AlbumAssetOrder.asc, + assetCount: 0, + ownerName: 'Test Owner', + ); +} + +void main() { + group('ActionButtonContext', () { + test('should create context with all required parameters', () { + final asset = createLocalAsset(); + + final context = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(context.asset, isA()); + expect(context.isOwner, isTrue); + expect(context.isArchived, isFalse); + expect(context.isTrashEnabled, isTrue); + expect(context.isInLockedView, isFalse); + expect(context.currentAlbum, isNull); + expect(context.source, ActionSource.timeline); + }); + }); + + group('ActionButtonType.shouldShow', () { + late BaseAsset mergedAsset; + + setUp(() { + mergedAsset = createLocalAsset(remoteId: 'remote-id'); + }); + + group('share button', () { + test('should show when not in locked view', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.share.shouldShow(context), isTrue); + }); + + test('should show when in locked view', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.share.shouldShow(context), isTrue); + }); + }); + + group('shareLink button', () { + test('should show when not in locked view and asset has remote', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.shareLink.shouldShow(context), isTrue); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.shareLink.shouldShow(context), isFalse); + }); + + test('should not show when asset has no remote', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.shareLink.shouldShow(context), isFalse); + }); + }); + + group('archive button', () { + test('should show when owner, not locked, has remote, and not archived', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isTrue); + }); + + test('should not show when not owner', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: false, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isFalse); + }); + + test('should not show when asset has no remote', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isFalse); + }); + + test('should not show when already archived', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: true, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isFalse); + }); + }); + + group('unarchive button', () { + test('should show when owner, not locked, has remote, and is archived', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: true, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.unarchive.shouldShow(context), isTrue); + }); + + test('should not show when not archived', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.unarchive.shouldShow(context), isFalse); + }); + + test('should not show when not owner', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: false, + isArchived: true, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.unarchive.shouldShow(context), isFalse); + }); + }); + + group('download button', () { + test('should show when not locked, has remote, and no local copy', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.download.shouldShow(context), isTrue); + }); + + test('should not show when has local copy', () { + final mergedAsset = createLocalAsset(remoteId: 'remote-id'); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.download.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.download.shouldShow(context), isFalse); + }); + }); + + group('trash button', () { + test('should show when owner, not locked, has remote, and trash enabled', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.trash.shouldShow(context), isTrue); + }); + + test('should not show when trash disabled', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: false, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.trash.shouldShow(context), isFalse); + }); + }); + + group('deletePermanent button', () { + test('should show when owner, not locked, has remote, and trash disabled', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: false, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue); + }); + + test('should not show when trash enabled', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse); + }); + }); + + group('delete button', () { + test('should show when owner, not locked, and has remote', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.delete.shouldShow(context), isTrue); + }); + }); + + group('moveToLockFolder button', () { + test('should show when owner, not locked, and has remote', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue); + }); + }); + + group('deleteLocal button', () { + test('should show when not locked and asset is local only', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue); + }); + + test('should not show when asset is not local only', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse); + }); + }); + + group('upload button', () { + test('should show when not locked and asset is local only', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.upload.shouldShow(context), isTrue); + }); + }); + + group('removeFromAlbum button', () { + test('should show when owner, not locked, and has current album', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue); + }); + + test('should not show when no current album', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.removeFromAlbum.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); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.likeActivity.shouldShow(context), isTrue); + }); + + test('should not show when activity not enabled', () { + final album = createRemoteAlbum(isActivityEnabled: false, isShared: true); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); + }); + + test('should not show when album not shared', () { + final album = createRemoteAlbum(isActivityEnabled: true, isShared: false); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); + }); + + test('should not show when no album', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); + }); + }); + }); + + group('ActionButtonType.buildButton', () { + late BaseAsset asset; + late ActionButtonContext context; + + setUp(() { + asset = createLocalAsset(remoteId: 'remote-id'); + context = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + }); + + test('should build correct widget for each button type', () { + for (final buttonType in ActionButtonType.values) { + if (buttonType == ActionButtonType.removeFromAlbum) { + final album = createRemoteAlbum(); + final contextWithAlbum = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + final widget = buttonType.buildButton(contextWithAlbum); + expect(widget, isA()); + } else { + final widget = buttonType.buildButton(context); + expect(widget, isA()); + } + } + }); + }); + + group('ActionButtonBuilder', () { + test('should return buttons that should show', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + final widgets = ActionButtonBuilder.build(context); + + expect(widgets, isNotEmpty); + expect(widgets.length, greaterThan(0)); + }); + + test('should include album-specific buttons when album is present', () { + final remoteAsset = createRemoteAsset(); + final album = createRemoteAlbum(isActivityEnabled: true, isShared: true); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + final widgets = ActionButtonBuilder.build(context); + + expect(widgets, isNotEmpty); + }); + + test('should only include local buttons for local assets', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + final widgets = ActionButtonBuilder.build(context); + + expect(widgets, isNotEmpty); + }); + + test('should respect archived state', () { + final remoteAsset = createRemoteAsset(); + + final archivedContext = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: true, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + final archivedWidgets = ActionButtonBuilder.build(archivedContext); + + final nonArchivedContext = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + final nonArchivedWidgets = ActionButtonBuilder.build(nonArchivedContext); + + expect(archivedWidgets, isNotEmpty); + expect(nonArchivedWidgets, isNotEmpty); + }); + }); +}