From e6893adf2f7cf20f04f0a2f1e8a5560ff40e8b74 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 9 Jun 2026 09:12:13 +0200 Subject: [PATCH] feat(mobile): consolidate asset viewer action buttons --------- Co-authored-by: idubnori --- mobile/lib/constants/enums.dart | 2 + .../edit_image_action_button.widget.dart | 7 +- .../open_activity_action_button.widget.dart | 34 ++ .../asset_viewer/bottom_bar.widget.dart | 60 +-- .../viewer_kebab_menu.widget.dart | 2 + .../viewer_top_app_bar.widget.dart | 15 - mobile/lib/utils/action_button.utils.dart | 104 ++++- .../action_button_utils_test.dart | 435 ++++++++++++++++++ 8 files changed, 591 insertions(+), 68 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/open_activity_action_button.widget.dart diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 72479416a8..6b73eab05f 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -15,6 +15,8 @@ enum ActionSource { timeline, viewer } enum ShareAssetType { original, preview } +enum ButtonPosition { bottomBar, kebabMenu } + enum CleanupStep { selectDate, scan, delete } enum AssetKeepType { none, photosOnly, videosOnly } diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 564b02d884..21269951b5 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -15,7 +15,10 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class EditImageActionButton extends ConsumerWidget { - const EditImageActionButton({super.key}); + final bool iconOnly; + final bool menuItem; + + const EditImageActionButton({super.key, this.iconOnly = false, this.menuItem = false}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -54,6 +57,8 @@ class EditImageActionButton extends ConsumerWidget { iconData: Icons.tune, label: "edit".t(context: context), onPressed: onPress, + iconOnly: iconOnly, + menuItem: menuItem, ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/open_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_activity_action_button.widget.dart new file mode 100644 index 0000000000..ad0b99b1ab --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/open_activity_action_button.widget.dart @@ -0,0 +1,34 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.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/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class OpenActivityActionButton extends ConsumerWidget { + const OpenActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false}); + + final bool iconOnly; + final bool menuItem; + + void _onTap(BuildContext context, WidgetRef ref) { + final album = ref.read(currentRemoteAlbumProvider); + final asset = ref.read(assetViewerProvider).currentAsset; + if (album == null || asset == null) return; + context.router.push( + DriftActivitiesRoute(album: album, assetId: asset is RemoteAsset ? asset.id : null, assetName: asset.name), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) => BaseActionButton( + iconData: Icons.chat_outlined, + label: "activity".t(context: context), + onPressed: () => _onTap(context, ref), + iconOnly: iconOnly, + menuItem: menuItem, + ); +} 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 01a48e7e97..a2eee673d8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -2,24 +2,18 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/add_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/edit_image_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/restore_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/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.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/semver.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; class ViewerBottomBar extends ConsumerWidget { @@ -37,35 +31,31 @@ class ViewerBottomBar extends ConsumerWidget { final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); - final serverInfo = ref.watch(serverInfoProvider); - final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; + final album = ref.watch(currentRemoteAlbumProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); + final timelineOrigin = ref.read(timelineServiceProvider).origin; + final isTrashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); + final serverVersion = ref.watch(serverInfoProvider.select((state) => state.serverVersion)); final originalTheme = context.themeData; - final actions = [ - if (isInTrash && isOwner && asset.hasRemote) - const RestoreActionButton(source: ActionSource.viewer) - else - const ShareActionButton(source: ActionSource.viewer), + final buttonContext = ActionButtonContext( + asset: asset, + isOwner: isOwner, + isArchived: isArchived, + isTrashEnabled: isTrashEnabled, + isStacked: asset is RemoteAsset && asset.stackId != null, + isInLockedView: isInLockedView, + currentAlbum: album, + advancedTroubleshooting: advancedTroubleshooting, + source: ActionSource.viewer, + timelineOrigin: timelineOrigin, + originalTheme: originalTheme, + serverVersion: serverVersion, + ); - if (!isInLockedView) ...[ - if (!isInTrash) ...[ - if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - // edit sync was added in 2.6.0 - if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) - const EditImageActionButton(), - if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), - ], - if (isOwner) ...[ - if (asset.isLocalOnly) - const DeleteLocalActionButton(source: ActionSource.viewer) - else if (asset.isTrashed) - const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true) - else - const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), - ], - ], - ]; + final actions = ActionButtonBuilder.buildViewerBottomBar(buttonContext, context); return AnimatedSwitcher( duration: Durations.short4, diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index da0abad1dd..ccff494d76 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -31,6 +31,7 @@ class ViewerKebabMenu extends ConsumerWidget { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final timelineOrigin = ref.read(timelineServiceProvider).origin; final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); + final serverVersion = ref.watch(serverInfoProvider.select((state) => state.serverVersion)); final isInLockedView = ref.watch(inLockedViewProvider); final currentAlbum = ref.watch(currentRemoteAlbumProvider); final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; @@ -48,6 +49,7 @@ class ViewerKebabMenu extends ConsumerWidget { source: ActionSource.viewer, isCasting: isCasting, timelineOrigin: timelineOrigin, + serverVersion: serverVersion, ); final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context); diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 8d51b8cd2e..9cb255ef25 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -14,7 +14,6 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provid import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_ui/immich_ui.dart'; @@ -47,20 +46,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final actions = [ if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), - if (album != null && album.isActivityEnabled && album.isShared) - IconButton( - icon: const Icon(Icons.chat_outlined), - onPressed: () { - context.router.push( - DriftActivitiesRoute( - album: album, - assetId: asset is RemoteAsset ? asset.id : null, - assetName: asset.name, - ), - ); - }, - ), - ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)), ViewerKebabMenu(originalTheme: originalTheme), diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 0e5a3123e7..1efaa189b8 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; +import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; @@ -16,8 +17,11 @@ 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/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/add_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/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/open_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_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'; @@ -47,6 +51,8 @@ class ActionButtonContext { final bool isCasting; final TimelineOrigin timelineOrigin; final int selectedCount; + final ThemeData? originalTheme; + final SemVer serverVersion; const ActionButtonContext({ required this.asset, @@ -61,13 +67,17 @@ class ActionButtonContext { this.isCasting = false, this.timelineOrigin = TimelineOrigin.main, this.selectedCount = 1, + this.originalTheme, + this.serverVersion = const SemVer(major: 0, minor: 0, patch: 0), }); } enum ActionButtonType { openInfo, + openActivity, likeActivity, share, + editImage, shareLink, cast, setAlbumCover, @@ -89,8 +99,16 @@ enum ActionButtonType { deleteLocal, deletePermanent, delete, + addTo, advancedInfo; + bool _isInActivityAlbum(ActionButtonContext context) { + return !context.isInLockedView && + context.currentAlbum != null && + context.currentAlbum!.isActivityEnabled && + context.currentAlbum!.isShared; + } + bool shouldShow(ActionButtonContext context) { return switch (this) { ActionButtonType.advancedInfo => context.advancedTroubleshooting, @@ -159,11 +177,7 @@ enum ActionButtonType { !context.isInLockedView && // context.isStacked, ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, - ActionButtonType.likeActivity => - !context.isInLockedView && - context.currentAlbum != null && - context.currentAlbum!.isActivityEnabled && - context.currentAlbum!.isShared, + ActionButtonType.likeActivity => _isInActivityAlbum(context), ActionButtonType.similarPhotos => !context.isInLockedView && // context.asset is RemoteAsset, @@ -181,10 +195,25 @@ enum ActionButtonType { context.timelineOrigin != TimelineOrigin.localAlbum && context.isOwner, ActionButtonType.cast => context.isCasting || context.asset.hasRemote, + ActionButtonType.editImage => + !context.isInLockedView && + context.asset.isEditable && + context.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0), + ActionButtonType.addTo => + !context.isInLockedView && // + context.asset.hasRemote, + ActionButtonType.openActivity => _isInActivityAlbum(context), ActionButtonType.slideshow => true, }; } + bool showsAt(ButtonPosition position, ActionButtonContext context) => switch (this) { + ActionButtonType.openActivity || ActionButtonType.likeActivity => + position != ButtonPosition.bottomBar || context.timelineOrigin != TimelineOrigin.trash, + ActionButtonType.editImage => position != ButtonPosition.bottomBar || context.currentAlbum?.isShared != true, + _ => true, + }; + Widget buildButton( ActionButtonContext context, [ BuildContext? buildContext, @@ -217,8 +246,14 @@ enum ActionButtonType { source: context.source, iconOnly: iconOnly, menuItem: menuItem, + useShortLabel: false, + ), + ActionButtonType.delete => DeleteActionButton( + source: context.source, + iconOnly: iconOnly, + menuItem: menuItem, + showConfirmation: true, ), - ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton( source: context.source, iconOnly: iconOnly, @@ -284,6 +319,9 @@ enum ActionButtonType { }, ), ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.editImage => EditImageActionButton(iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.addTo => AddActionButton(originalTheme: context.originalTheme), + ActionButtonType.openActivity => OpenActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), }; } @@ -315,34 +353,62 @@ enum ActionButtonType { class ActionButtonBuilder { static const List _actionTypes = ActionButtonType.values; static const List defaultViewerKebabMenuOrder = _actionTypes; - static const Set defaultViewerBottomBarButtons = { + static const List _defaultViewerBottomBarOrder = [ ActionButtonType.share, - ActionButtonType.moveToLockFolder, ActionButtonType.upload, - ActionButtonType.delete, - ActionButtonType.archive, - ActionButtonType.unarchive, + ActionButtonType.editImage, + ActionButtonType.addTo, + ActionButtonType.openActivity, + ActionButtonType.likeActivity, + ActionButtonType.removeFromLockFolder, ActionButtonType.restoreTrash, ActionButtonType.deletePermanent, - }; + ActionButtonType.delete, + ActionButtonType.deleteLocal, + ]; static List build(ActionButtonContext context) { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } - static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) { - final visibleButtons = defaultViewerKebabMenuOrder - .where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context)) + static List getViewerBottomBarTypes(ActionButtonContext context) { + return _defaultViewerBottomBarOrder + .where((type) => type.shouldShow(context) && type.showsAt(ButtonPosition.bottomBar, context)) + .take(4) .toList(); + } - if (visibleButtons.isEmpty) { + static List getViewerKebabMenuTypes(ActionButtonContext context) { + final inBottomBar = getViewerBottomBarTypes(context).toSet(); + return defaultViewerKebabMenuOrder + .where( + (type) => + !inBottomBar.contains(type) && + type.shouldShow(context) && + type.showsAt(ButtonPosition.kebabMenu, context), + ) + .toList(); + } + + static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) { + return getViewerKebabMenuTypes(context).toKebabMenuWidgets(context, buildContext); + } + + static List buildViewerBottomBar(ActionButtonContext context, BuildContext buildContext) { + return getViewerBottomBarTypes(context).toBottomBarWidgets(context, buildContext); + } +} + +extension ActionButtonTypeListExtension on List { + List toKebabMenuWidgets(ActionButtonContext context, BuildContext buildContext) { + if (isEmpty) { return []; } final List result = []; int? lastGroup; - for (final type in visibleButtons) { + for (final type in this) { if (lastGroup != null && type.kebabMenuGroup != lastGroup) { result.add(const Divider(height: 1)); } @@ -352,4 +418,8 @@ class ActionButtonBuilder { return result; } + + List toBottomBarWidgets(ActionButtonContext context, BuildContext buildContext) { + return map((type) => type.buildButton(context, buildContext, false, false)).toList(); + } } diff --git a/mobile/test/utils_legacy/action_button_utils_test.dart b/mobile/test/utils_legacy/action_button_utils_test.dart index 0a6020762a..6524f57784 100644 --- a/mobile/test/utils_legacy/action_button_utils_test.dart +++ b/mobile/test/utils_legacy/action_button_utils_test.dart @@ -5,6 +5,7 @@ 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/services/timeline.service.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; +import 'package:immich_mobile/utils/semver.dart'; LocalAsset createLocalAsset({ String? remoteId, @@ -1238,4 +1239,438 @@ void main() { expect(nonArchivedWidgets, isNotEmpty); }); }); + + group('ActionButtonBuilder.getViewerBottomBarTypes', () { + test('should return correct button types for shared album with activity', () { + 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, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + const expectedTypes = [ + ActionButtonType.share, + ActionButtonType.addTo, + ActionButtonType.openActivity, + ActionButtonType.likeActivity, + ]; + + final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context); + final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context); + + expect(bottomBarTypes, expectedTypes); + expect(bottomBarTypes.any(kebabTypes.contains), isFalse); + }); + + test('should return correct button types for local only asset', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + serverVersion: const SemVer(major: 2, minor: 6, patch: 0), + ); + + const expectedTypes = [ActionButtonType.share, ActionButtonType.upload, ActionButtonType.deleteLocal]; + + final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context); + final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context); + + expect(bottomBarTypes, expectedTypes); + expect(bottomBarTypes.any(kebabTypes.contains), isFalse); + }); + + test('should return correct button types for locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: false, + isInLockedView: true, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + const expectedTypes = [ + ActionButtonType.share, + ActionButtonType.removeFromLockFolder, + ActionButtonType.deletePermanent, + ]; + + final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context); + final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context); + + expect(bottomBarTypes, expectedTypes); + expect(bottomBarTypes.any(kebabTypes.contains), isFalse); + }); + + test('should return correct button types for remote only asset', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + serverVersion: SemVer(major: 2, minor: 6, patch: 0), + ); + + const expectedTypes = [ + ActionButtonType.share, + ActionButtonType.editImage, + ActionButtonType.addTo, + ActionButtonType.delete, + ]; + + final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context); + final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context); + + expect(bottomBarTypes, expectedTypes); + expect(bottomBarTypes.any(kebabTypes.contains), isFalse); + }); + + test('trashed asset (no album) keeps restore and permanent delete in bottom bar', () { + final remoteAsset = createRemoteAsset(deletedAt: DateTime.now()); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + timelineOrigin: TimelineOrigin.trash, + ); + + const expectedTypes = [ + ActionButtonType.share, + ActionButtonType.addTo, + ActionButtonType.restoreTrash, + ActionButtonType.deletePermanent, + ]; + + expect(ActionButtonBuilder.getViewerBottomBarTypes(context), expectedTypes); + }); + + test('trashed asset in shared activity album still surfaces restore and permanent delete', () { + final remoteAsset = createRemoteAsset(deletedAt: DateTime.now()); + final album = createRemoteAlbum(isActivityEnabled: true, isShared: true); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + timelineOrigin: TimelineOrigin.trash, + ); + + final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context); + + expect(bottomBarTypes, contains(ActionButtonType.restoreTrash)); + expect(bottomBarTypes, contains(ActionButtonType.deletePermanent)); + expect(bottomBarTypes, isNot(contains(ActionButtonType.openActivity))); + expect(bottomBarTypes, isNot(contains(ActionButtonType.likeActivity))); + }); + + test('5th bottom-bar candidate overflows into the kebab', () { + 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, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + serverVersion: const SemVer(major: 2, minor: 6, patch: 0), + ); + + final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context); + final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context); + + expect(bottomBarTypes.length, 4); + expect(bottomBarTypes, isNot(contains(ActionButtonType.delete))); + expect(kebabTypes, contains(ActionButtonType.delete)); + }); + }); + + group('ActionButtonType.shouldShow editImage button', () { + final editableRemote = createRemoteAsset(); + + test('should show for editable remote asset on a recent server', () { + final context = ActionButtonContext( + asset: editableRemote, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + serverVersion: const SemVer(major: 2, minor: 6, patch: 0), + ); + + expect(ActionButtonType.editImage.shouldShow(context), isTrue); + }); + + test('should not show when local-only (LocalAsset is not editable)', () { + final context = ActionButtonContext( + asset: createLocalAsset(), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + serverVersion: const SemVer(major: 2, minor: 6, patch: 0), + ); + + expect(ActionButtonType.editImage.shouldShow(context), isFalse); + }); + + test('should not show when server version is below 2.6', () { + final context = ActionButtonContext( + asset: editableRemote, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + serverVersion: const SemVer(major: 2, minor: 5, patch: 9), + ); + + expect(ActionButtonType.editImage.shouldShow(context), isFalse); + }); + + test('should not show in locked view', () { + final context = ActionButtonContext( + asset: editableRemote, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + serverVersion: const SemVer(major: 2, minor: 6, patch: 0), + ); + + expect(ActionButtonType.editImage.shouldShow(context), isFalse); + }); + }); + + group('ActionButtonType.shouldShow addTo button', () { + test('should show when not locked and asset has remote', () { + final context = ActionButtonContext( + asset: createRemoteAsset(), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + expect(ActionButtonType.addTo.shouldShow(context), isTrue); + }); + + test('should not show in locked view', () { + final context = ActionButtonContext( + asset: createRemoteAsset(), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + expect(ActionButtonType.addTo.shouldShow(context), isFalse); + }); + + test('should not show for local-only asset', () { + final context = ActionButtonContext( + asset: createLocalAsset(), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + expect(ActionButtonType.addTo.shouldShow(context), isFalse); + }); + }); + + group('ActionButtonType.shouldShow openActivity button', () { + final asset = createRemoteAsset(); + + test('should show in a shared activity-enabled album', () { + final context = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: true), + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + expect(ActionButtonType.openActivity.shouldShow(context), isTrue); + }); + + test('should not show when no album', () { + final context = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + expect(ActionButtonType.openActivity.shouldShow(context), isFalse); + }); + + test('should not show when album not shared', () { + final context = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: false), + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + expect(ActionButtonType.openActivity.shouldShow(context), isFalse); + }); + + test('should not show when activity disabled', () { + final context = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: createRemoteAlbum(isActivityEnabled: false, isShared: true), + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + expect(ActionButtonType.openActivity.shouldShow(context), isFalse); + }); + + test('should not show in locked view', () { + final context = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: true), + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + ); + + expect(ActionButtonType.openActivity.shouldShow(context), isFalse); + }); + }); + + group('ActionButtonType.showsAt', () { + ActionButtonContext ctx({RemoteAlbum? album, TimelineOrigin origin = TimelineOrigin.main}) => ActionButtonContext( + asset: createRemoteAsset(), + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + timelineOrigin: origin, + ); + + group('editImage', () { + test('hidden from bottom bar when album is shared', () { + final context = ctx(album: createRemoteAlbum(isShared: true)); + expect(ActionButtonType.editImage.showsAt(ButtonPosition.bottomBar, context), isFalse); + }); + + test('shown in bottom bar when album is not shared', () { + final context = ctx(album: createRemoteAlbum(isShared: false)); + expect(ActionButtonType.editImage.showsAt(ButtonPosition.bottomBar, context), isTrue); + }); + + test('shown in bottom bar when there is no album', () { + expect(ActionButtonType.editImage.showsAt(ButtonPosition.bottomBar, ctx()), isTrue); + }); + }); + + group('openActivity / likeActivity', () { + test('hidden from bottom bar when viewing from trash', () { + final context = ctx(origin: TimelineOrigin.trash); + expect(ActionButtonType.openActivity.showsAt(ButtonPosition.bottomBar, context), isFalse); + expect(ActionButtonType.likeActivity.showsAt(ButtonPosition.bottomBar, context), isFalse); + }); + + test('shown in bottom bar outside trash', () { + expect(ActionButtonType.openActivity.showsAt(ButtonPosition.bottomBar, ctx()), isTrue); + expect(ActionButtonType.likeActivity.showsAt(ButtonPosition.bottomBar, ctx()), isTrue); + }); + }); + }); }