diff --git a/mobile/lib/pages/library/partner/partner.page.dart b/mobile/lib/pages/library/partner/partner.page.dart index 7274b8a14e..d75c0734b2 100644 --- a/mobile/lib/pages/library/partner/partner.page.dart +++ b/mobile/lib/pages/library/partner/partner.page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/generated/translations.g.dart'; -import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/partner.action.dart'; import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; @@ -33,7 +33,7 @@ class PartnerPage extends ConsumerWidget { title: Text(context.t.partners), elevation: 0, centerTitle: false, - actions: const [ActionIconButtonWidget(action: PartnerAddAction())], + actions: const [PartnerAddAction(display: ActionDisplay.iconButton)], ), body: sharedByAsync.when( data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)), @@ -60,7 +60,7 @@ class _EmptyPartners extends StatelessWidget { ), const Align( alignment: .center, - child: ActionButtonWidget(action: PartnerAddAction()), + child: PartnerAddAction(), ), ], ), @@ -88,8 +88,10 @@ class PartnerSharedByList extends StatelessWidget { leading: PartnerUserAvatar(userId: partner.id, name: partner.name), title: Text(partner.name), subtitle: Text(partner.email), - trailing: ActionIconButtonWidget( - action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name), + trailing: PartnerRemoveAction( + sharedWithId: partner.id, + partnerName: partner.name, + display: ActionDisplay.iconButton, ), ); }, diff --git a/mobile/lib/presentation/actions/action.dart b/mobile/lib/presentation/actions/action.dart index 5ceb2f855d..0244e1d5e2 100644 --- a/mobile/lib/presentation/actions/action.dart +++ b/mobile/lib/presentation/actions/action.dart @@ -1,32 +1,17 @@ -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/domain/models/user.model.dart'; +import 'package:flutter/widgets.dart' hide Action; -class ActionScope { - final BuildContext context; - final WidgetRef ref; - final UserDto authUser; +enum ActionDisplay { button, iconButton, menuItem, columnButton } - const ActionScope({required this.context, required this.ref, required this.authUser}); -} - -abstract class BaseAction { - const BaseAction(); - - IconData get icon; - - String label(ActionScope scope); - - bool isVisible(ActionScope scope) => true; - - Future onAction(ActionScope scope); -} - -abstract class AssetAction extends BaseAction { - final Iterable assets; - - const AssetAction({required this.assets}); - - Iterable filter(ActionScope scope) => assets.whereType(); +class Action { + final IconData icon; + final bool isVisible; + final String label; + final Future Function() onAction; + + const Action({ + required this.icon, + this.isVisible = true, + required this.label, + required this.onAction, + }); } diff --git a/mobile/lib/presentation/actions/action.widget.dart b/mobile/lib/presentation/actions/action.widget.dart index 0f891abde6..812b5898c8 100644 --- a/mobile/lib/presentation/actions/action.widget.dart +++ b/mobile/lib/presentation/actions/action.widget.dart @@ -1,95 +1,30 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart' hide Action; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_ui/immich_ui.dart'; -class _ActionWidgetScope { - final String label; - final VoidCallback onAction; +abstract class ActionWidget extends ConsumerWidget { + final ActionDisplay display; - const _ActionWidgetScope({required this.label, required this.onAction}); -} + const ActionWidget({super.key, this.display = ActionDisplay.button}); -class _ActionWidget extends ConsumerWidget { - final BaseAction action; - final Widget Function(_ActionWidgetScope context) builder; - - const _ActionWidget({required this.action, required this.builder}); - - Future _onAction(ActionScope scope) async { - try { - await action.onAction(scope); - } catch (error, stackTrace) { - handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}'); - } - } + Action resolve(BuildContext context, WidgetRef ref); @override Widget build(BuildContext context, WidgetRef ref) { - final authUser = ref.watch(currentUserProvider); - if (authUser == null) { + final action = resolve(context, ref); + if (!action.isVisible) { return const SizedBox.shrink(); } - final scope = ActionScope(context: context, ref: ref, authUser: authUser); - if (!action.isVisible(scope)) { - return const SizedBox.shrink(); - } - - return builder(.new(label: action.label(scope), onAction: () => _onAction(scope))); + return switch (display) { + ActionDisplay.button => + ImmichTextButton(labelText: action.label, icon: action.icon, onPressed: action.onAction), + ActionDisplay.iconButton => ImmichIconButton(icon: action.icon, onPressed: action.onAction), + ActionDisplay.menuItem => + ImmichMenuItem(icon: action.icon, label: action.label, onPressed: action.onAction), + ActionDisplay.columnButton => + ImmichColumnButton(icon: action.icon, label: action.label, onPressed: action.onAction), + }; } } - -class ActionIconButtonWidget extends StatelessWidget { - final BaseAction action; - final ImmichVariant variant; - - const ActionIconButtonWidget({super.key, required this.action, this.variant = .ghost}); - - @override - Widget build(BuildContext context) => _ActionWidget( - action: action, - builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant), - ); -} - -class ActionButtonWidget extends StatelessWidget { - final BaseAction action; - final ImmichVariant variant; - - const ActionButtonWidget({super.key, required this.action, this.variant = .ghost}); - - @override - Widget build(BuildContext context) => _ActionWidget( - action: action, - builder: (ctx) => - ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant), - ); -} - -class ActionColumnButtonWidget extends StatelessWidget { - final BaseAction action; - - const ActionColumnButtonWidget({super.key, required this.action}); - - @override - Widget build(BuildContext context) => _ActionWidget( - action: action, - builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction), - ); -} - -class ActionMenuItemWidget extends StatelessWidget { - final BaseAction action; - - const ActionMenuItemWidget({super.key, required this.action}); - - @override - Widget build(BuildContext context) => _ActionWidget( - action: action, - builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction), - ); -} diff --git a/mobile/lib/presentation/actions/asset_action.dart b/mobile/lib/presentation/actions/asset_action.dart new file mode 100644 index 0000000000..29903803c3 --- /dev/null +++ b/mobile/lib/presentation/actions/asset_action.dart @@ -0,0 +1,33 @@ +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/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; + +typedef AssetActionSelection = ({Set assets, void Function() clearSelect}); + +final assetsActionProvider = Provider.family((ref, source) { + final assets = switch (source) { + ActionSource.timeline => ref.watch(multiSelectProvider.select((s) => s.selectedAssets)), + ActionSource.viewer => switch (ref.watch(assetViewerProvider.select((s) => s.currentAsset))) { + BaseAsset asset => {asset}, + null => const {}, + }, + }; + + return ( + assets: assets, + clearSelect: () { + if (source == ActionSource.timeline) { + ref.read(multiSelectProvider.notifier).reset(); + } + }, + ); +}); + +abstract class AssetAction extends ActionWidget { + final ActionSource source; + + const AssetAction({super.key, super.display, this.source = ActionSource.timeline}); +} diff --git a/mobile/lib/presentation/actions/asset_debug.action.dart b/mobile/lib/presentation/actions/asset_debug.action.dart index aec99fc90b..ab8e3015ac 100644 --- a/mobile/lib/presentation/actions/asset_debug.action.dart +++ b/mobile/lib/presentation/actions/asset_debug.action.dart @@ -1,27 +1,30 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:flutter/material.dart' hide Action; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/actions/asset_action.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -class AssetDebugAction extends AssetAction { - const AssetDebugAction({required super.assets}); +class AssetDebugAction extends AssetAction { + const AssetDebugAction({super.key, super.display, super.source}); @override - IconData get icon => Icons.help_outline_rounded; + Action resolve(BuildContext context, WidgetRef ref) { + final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source)); + final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting); - @override - String label(ActionScope scope) => scope.context.t.troubleshoot; - - @override - bool isVisible(ActionScope scope) => - assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting); - - @override - Future onAction(ActionScope scope) async => - unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first))); + return Action( + icon: Icons.help_outline_rounded, + isVisible: assets.length == 1 && advancedTroubleshooting, + label: context.t.troubleshoot, + onAction: () async { + unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first))); + clearSelect(); + }, + ); + } } diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart index a480e10a5f..ba3d7a8d84 100644 --- a/mobile/lib/presentation/actions/favorite.action.dart +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -1,38 +1,60 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Action; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/actions/asset_action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/utils/asset_filter.dart'; import 'package:immich_ui/immich_ui.dart'; -class FavoriteAction extends AssetAction { - final bool favorite; - - FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite); +class FavoriteAction extends AssetAction { + const FavoriteAction({super.key, super.display, super.source}); @override - IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded; + Action resolve(BuildContext context, WidgetRef ref) { + final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source)); + final userId = ref.read(currentUserProvider)?.id; + final targets = userId == null + ? const [] + : AssetFilter(assets).owned(userId).favorite(isFavorite: false).toList(growable: false); - @override - String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite; - - @override - Iterable filter(ActionScope scope) => - AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite); - - @override - bool isVisible(ActionScope scope) => filter(scope).isNotEmpty; - - @override - Future onAction(ActionScope scope) async { - final ActionScope(:ref) = scope; - final assets = filter(scope).map((asset) => asset.id).toList(growable: false); - - await ref.read(assetServiceProvider).updateFavorite(assets, favorite); - final message = favorite - ? StaticTranslations.instance.favorite_action_prompt(count: assets.length) - : StaticTranslations.instance.unfavorite_action_prompt(count: assets.length); - snackbar.success(message); + return Action( + icon: Icons.favorite_border_rounded, + isVisible: targets.isNotEmpty, + label: context.t.favorite, + onAction: () async { + final ids = targets.map((asset) => asset.id).toList(growable: false); + await ref.read(assetServiceProvider).updateFavorite(ids, true); + snackbar.success(StaticTranslations.instance.favorite_action_prompt(count: ids.length)); + clearSelect(); + }, + ); + } +} + +class UnfavoriteAction extends AssetAction { + const UnfavoriteAction({super.key, super.display, super.source}); + + @override + Action resolve(BuildContext context, WidgetRef ref) { + final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source)); + final userId = ref.read(currentUserProvider)?.id; + final targets = userId == null + ? const [] + : AssetFilter(assets).owned(userId).favorite(isFavorite: true).toList(growable: false); + + return Action( + icon: Icons.favorite_rounded, + isVisible: targets.isNotEmpty, + label: context.t.unfavorite, + onAction: () async { + final ids = targets.map((asset) => asset.id).toList(growable: false); + await ref.read(assetServiceProvider).updateFavorite(ids, false); + snackbar.success(StaticTranslations.instance.unfavorite_action_prompt(count: ids.length)); + clearSelect(); + }, + ); } } diff --git a/mobile/lib/presentation/actions/partner.action.dart b/mobile/lib/presentation/actions/partner.action.dart index 11fb69ee75..baec1a77c3 100644 --- a/mobile/lib/presentation/actions/partner.action.dart +++ b/mobile/lib/presentation/actions/partner.action.dart @@ -1,69 +1,76 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Action; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; -class PartnerAddAction extends BaseAction { - const PartnerAddAction(); +class PartnerAddAction extends ActionWidget { + const PartnerAddAction({super.key, super.display}); @override - IconData get icon => Icons.person_add_rounded; + Action resolve(BuildContext context, WidgetRef ref) { + return Action( + icon: Icons.person_add_rounded, + label: context.t.add_partner, + onAction: () async { + final authUser = ref.read(currentUserProvider); + if (authUser == null) { + return; + } - @override - String label(ActionScope scope) => scope.context.t.add_partner; + final selected = await showDialog(context: context, builder: (_) => const PartnerSelectionDialog()); + if (selected == null) { + return; + } - @override - Future onAction(ActionScope scope) async { - final ActionScope(:context, :ref, :authUser) = scope; - final selected = await showDialog(context: context, builder: (_) => const PartnerSelectionDialog()); - if (selected == null) { - return; - } - - await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id); + await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id); + }, + ); } } -class PartnerRemoveAction extends BaseAction { - const PartnerRemoveAction({required this.sharedWithId, required this.partnerName}); - +class PartnerRemoveAction extends ActionWidget { final String sharedWithId; final String partnerName; - @override - IconData get icon => Icons.person_remove_rounded; + const PartnerRemoveAction({super.key, super.display, required this.sharedWithId, required this.partnerName}); @override - String label(ActionScope scope) => scope.context.t.remove; + Action resolve(BuildContext context, WidgetRef ref) { + return Action( + icon: Icons.person_remove_rounded, + label: context.t.remove, + onAction: () async { + final authUser = ref.read(currentUserProvider); + if (authUser == null) { + return; + } - @override - Future onAction(ActionScope scope) async { - final ActionScope(:context, :ref, :authUser) = scope; + final confirmed = await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: context.t.stop_photo_sharing, + content: context.t.partner_page_stop_sharing_content(partner: partnerName), + ), + ); + if (confirmed != true) { + return; + } - final confirmed = await showDialog( - context: context, - builder: (_) => ConfirmDialog( - title: context.t.stop_photo_sharing, - content: context.t.partner_page_stop_sharing_content(partner: partnerName), - ), + await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId); + }, ); - if (confirmed != true) { - return; - } - - await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId); } } @visibleForTesting final candidatesStateProvider = StreamProvider.autoDispose>((ref) { final currentUser = ref.watch(currentUserProvider); - // TODO: Refactor with a route guard to avoid this check in every provider if (currentUser == null) { return const Stream.empty(); } diff --git a/mobile/lib/presentation/actions/timeline.action.dart b/mobile/lib/presentation/actions/timeline.action.dart deleted file mode 100644 index d8d367f674..0000000000 --- a/mobile/lib/presentation/actions/timeline.action.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:immich_mobile/presentation/actions/action.dart'; -import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; - -class TimelineAction extends BaseAction { - final BaseAction action; - - const TimelineAction({required this.action}); - - @override - IconData get icon => action.icon; - - @override - String label(ActionScope scope) => action.label(scope); - - @override - bool isVisible(ActionScope scope) => action.isVisible(scope); - - @override - Future onAction(ActionScope scope) async { - await action.onAction(scope); - scope.ref.read(multiSelectProvider.notifier).reset(); - } -} 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 5997e15bf0..92710ca5fe 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 @@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; 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/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; @@ -43,7 +44,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); final originalTheme = context.themeData; - final assetForAction = [asset]; final actions = [ if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), @@ -61,7 +61,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { }, ), - ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)), + const FavoriteAction(source: ActionSource.viewer, display: ActionDisplay.iconButton), + const UnfavoriteAction(source: ActionSource.viewer, display: ActionDisplay.iconButton), ImmichColorOverride(color: null, child: ViewerKebabMenu(originalTheme: originalTheme)), ]; diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 3c9c0c692e..b24aea2aff 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -3,9 +3,8 @@ 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/album/album.model.dart'; -import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; -import 'package:immich_mobile/presentation/actions/timeline.action.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'; @@ -76,9 +75,6 @@ class _ArchiveBottomSheetState extends ConsumerState { return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); } - final assets = multiselect.selectedAssets.toList(growable: false); - final actions = [FavoriteAction(assets: assets)]; - return BaseBottomSheet( controller: sheetController, initialChildSize: 0.25, @@ -89,7 +85,7 @@ class _ArchiveBottomSheetState extends ConsumerState { if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const UnArchiveActionButton(source: ActionSource.timeline), - ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), + const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index bcb9fc6fe3..44e8b5f3ed 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -4,9 +4,8 @@ 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/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; -import 'package:immich_mobile/presentation/actions/timeline.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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'; @@ -74,9 +73,6 @@ class FavoriteBottomSheet extends ConsumerWidget { ref.read(multiSelectProvider.notifier).reset(); } - final assets = multiselect.selectedAssets.toList(growable: false); - final actions = [FavoriteAction(assets: assets)]; - return BaseBottomSheet( initialChildSize: 0.4, maxChildSize: 0.7, @@ -85,7 +81,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), - ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), + const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton), const ArchiveActionButton(source: ActionSource.timeline), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), isTrashEnable diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 1949a79495..321aae8f9b 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -3,9 +3,8 @@ 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/album/album.model.dart'; -import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; -import 'package:immich_mobile/presentation/actions/timeline.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; @@ -83,9 +82,6 @@ class _GeneralBottomSheetState extends ConsumerState { return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); } - final assets = multiselect.selectedAssets.toList(growable: false); - final actions = [AssetDebugAction(assets: assets)]; - return BaseBottomSheet( controller: sheetController, initialChildSize: widget.minChildSize ?? 0.15, @@ -93,7 +89,7 @@ class _GeneralBottomSheetState extends ConsumerState { maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: [ - ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), + const AssetDebugAction(source: ActionSource.timeline, display: ActionDisplay.columnButton), const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), 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 a292c1899c..7b889648d3 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 @@ -3,9 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; -import 'package:immich_mobile/presentation/actions/timeline.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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'; @@ -85,9 +84,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); } - final assets = multiselect.selectedAssets.toList(growable: false); - final actions = [FavoriteAction(assets: assets)]; - return BaseBottomSheet( controller: sheetController, initialChildSize: 0.22, @@ -101,7 +97,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState if (ownsAlbum) ...[ const ArchiveActionButton(source: ActionSource.timeline), - ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), + const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton), ], const DownloadActionButton(source: ActionSource.timeline), if (ownsAlbum) ...[ diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 0e5a3123e7..5ad56663a9 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/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/action.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.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'; @@ -192,7 +192,7 @@ enum ActionButtonType { bool menuItem = false, ]) { return switch (this) { - ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])), + ActionButtonType.advancedInfo => AssetDebugAction(source: context.source, display: ActionDisplay.menuItem), ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.shareLink => ShareLinkActionButton( source: context.source, diff --git a/mobile/test/unit/presentation/actions/asset_debug_action_test.dart b/mobile/test/unit/presentation/actions/asset_debug_action_test.dart index 4e84c100cb..c03736aa6e 100644 --- a/mobile/test/unit/presentation/actions/asset_debug_action_test.dart +++ b/mobile/test/unit/presentation/actions/asset_debug_action_test.dart @@ -1,8 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; -import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_ui/immich_ui.dart'; import '../../factories/remote_asset_factory.dart'; @@ -20,11 +23,18 @@ void main() { context.dispose(); }); + List selection(Set assets) => [ + multiSelectProvider.overrideWith( + () => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})), + ), + ]; + group('AssetDebugAction', () { testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async { await tester.pumpTestWidget( context, - ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])), + const AssetDebugAction(display: ActionDisplay.iconButton), + overrides: selection({RemoteAssetFactory.create()}), ); expect(find.byType(ImmichIconButton), findsOneWidget); @@ -33,9 +43,8 @@ void main() { testWidgets('hidden for multiple assets', (tester) async { await tester.pumpTestWidget( context, - ActionIconButtonWidget( - action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]), - ), + const AssetDebugAction(display: ActionDisplay.iconButton), + overrides: selection({RemoteAssetFactory.create(), RemoteAssetFactory.create()}), ); expect(find.byType(ImmichIconButton), findsNothing); @@ -45,7 +54,8 @@ void main() { await StoreService.I.put(StoreKey.advancedTroubleshooting, false); await tester.pumpTestWidget( context, - ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])), + const AssetDebugAction(display: ActionDisplay.iconButton), + overrides: selection({RemoteAssetFactory.create()}), ); expect(find.byType(ImmichIconButton), findsNothing); diff --git a/mobile/test/unit/presentation/actions/favorite_action_test.dart b/mobile/test/unit/presentation/actions/favorite_action_test.dart index cd0fc19ea2..00580f5255 100644 --- a/mobile/test/unit/presentation/actions/favorite_action_test.dart +++ b/mobile/test/unit/presentation/actions/favorite_action_test.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:mocktail/mocktail.dart'; import '../../../domain/service.mock.dart'; @@ -24,28 +27,34 @@ void main() { RemoteAsset owned({bool isFavorite = false}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite); + List selection(Set assets) => [ + multiSelectProvider.overrideWith( + () => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})), + ), + ]; + group('FavoriteAction', () { testWidgets('favorites the eligible owned assets', (tester) async { final asset = owned(); - await tester.pumpTestAction(context, FavoriteAction(assets: [asset])); + await tester.pumpTestAction( + context, + const FavoriteAction(display: ActionDisplay.iconButton), + overrides: selection({asset}), + ); verify(() => assetService.updateFavorite([asset.id], true)).called(1); }); - testWidgets('unfavorite the eligible owned assets', (tester) async { - final asset = owned(isFavorite: true); - - await tester.pumpTestAction(context, FavoriteAction(assets: [asset])); - - verify(() => assetService.updateFavorite([asset.id], false)).called(1); - }); - testWidgets('ignores assets owned by someone else', (tester) async { final mine = owned(); final theirs = RemoteAssetFactory.create(); - await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs])); + await tester.pumpTestAction( + context, + const FavoriteAction(display: ActionDisplay.iconButton), + overrides: selection({mine, theirs}), + ); verify(() => assetService.updateFavorite([mine.id], true)).called(1); }); @@ -54,25 +63,51 @@ void main() { final first = owned(); final second = owned(); - await tester.pumpTestAction(context, FavoriteAction(assets: [first, second])); + await tester.pumpTestAction( + context, + const FavoriteAction(display: ActionDisplay.iconButton), + overrides: selection({first, second}), + ); verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1); }); - testWidgets('skips owned assets already in the target state', (tester) async { + testWidgets('skips owned assets already favorited', (tester) async { final stale = owned(); final alreadyFavorite = owned(isFavorite: true); - await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite])); + await tester.pumpTestAction( + context, + const FavoriteAction(display: ActionDisplay.iconButton), + overrides: selection({stale, alreadyFavorite}), + ); verify(() => assetService.updateFavorite([stale.id], true)).called(1); }); testWidgets('shows a confirmation snackbar on success', (tester) async { - await tester.pumpTestAction(context, FavoriteAction(assets: [owned()])); + await tester.pumpTestAction( + context, + const FavoriteAction(display: ActionDisplay.iconButton), + overrides: selection({owned()}), + ); await tester.pumpUntilFound(find.byType(SnackBar)); expect(find.byType(SnackBar), findsOneWidget); }); }); + + group('UnfavoriteAction', () { + testWidgets('unfavorites the eligible owned assets', (tester) async { + final asset = owned(isFavorite: true); + + await tester.pumpTestAction( + context, + const UnfavoriteAction(display: ActionDisplay.iconButton), + overrides: selection({asset}), + ); + + verify(() => assetService.updateFavorite([asset.id], false)).called(1); + }); + }); } diff --git a/mobile/test/unit/presentation/actions/partner_action_test.dart b/mobile/test/unit/presentation/actions/partner_action_test.dart index 6c09337fa2..a6f6d03451 100644 --- a/mobile/test/unit/presentation/actions/partner_action_test.dart +++ b/mobile/test/unit/presentation/actions/partner_action_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/partner.action.dart'; import 'package:mocktail/mocktail.dart'; @@ -31,7 +32,7 @@ void main() { testWidgets('creates a partner for the selected candidate', (tester) async { final candidate = UserFactory.create(); - await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate])); + await tester.pumpTestAction(context, const PartnerAddAction(display: ActionDisplay.iconButton), overrides: overrides(candidates: [candidate])); await tester.pumpUntilFound(find.text(candidate.name)); await tester.tap(find.text(candidate.name)); await tester.pumpAndSettle(); @@ -42,7 +43,7 @@ void main() { testWidgets('creates nothing when the selection dialog is dismissed', (tester) async { await tester.pumpTestAction( context, - const PartnerAddAction(), + const PartnerAddAction(display: ActionDisplay.iconButton), overrides: overrides(candidates: [UserFactory.create()]), ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting @@ -57,7 +58,7 @@ void main() { final partner = UserFactory.create(); await tester.pumpTestAction( context, - PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name), + PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, display: ActionDisplay.iconButton), overrides: overrides(), ); await tester.tap(find.byType(TextButton).last); // confirm @@ -70,7 +71,7 @@ void main() { final partner = UserFactory.create(); await tester.pumpTestAction( context, - PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name), + PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, display: ActionDisplay.iconButton), overrides: overrides(), ); await tester.tap(find.byType(TextButton).first); // cancel diff --git a/mobile/test/unit/presentation/actions/timeline_action_test.dart b/mobile/test/unit/presentation/actions/timeline_action_test.dart deleted file mode 100644 index 5661be72c0..0000000000 --- a/mobile/test/unit/presentation/actions/timeline_action_test.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/presentation/actions/action.dart'; -import 'package:immich_mobile/presentation/actions/action.widget.dart'; -import 'package:immich_mobile/presentation/actions/timeline.action.dart'; -import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; - -import '../../factories/remote_asset_factory.dart'; -import '../presentation_context.dart'; - -class _FakeAction extends BaseAction { - _FakeAction({this.visible = true, this.error}); - - final bool visible; - final Object? error; - - bool ran = false; - bool? selectionDuringOnAction; - - @override - IconData get icon => Icons.bolt; - - @override - String label(ActionScope scope) => 'fake'; - - @override - bool isVisible(ActionScope scope) => visible; - - @override - Future onAction(ActionScope scope) async { - ran = true; - selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled; - if (error != null) { - throw error!; - } - } -} - -void main() { - late PresentationContext context; - - setUp(() async { - context = await PresentationContext.create(); - }); - - tearDown(() { - context.dispose(); - }); - - List overrides() => [ - multiSelectProvider.overrideWith( - () => MultiSelectNotifier( - MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}), - ), - ), - ]; - - Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async { - late ActionScope scope; - late ProviderContainer container; - await tester.pumpTestWidget( - context, - Consumer( - builder: (innerContext, ref, _) { - scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser); - container = ProviderScope.containerOf(innerContext, listen: false); - return const SizedBox.shrink(); - }, - ), - overrides: overrides(), - ); - return (scope, container); - } - - group('TimelineAction', () { - testWidgets('runs the wrapped action and then clears the selection', (tester) async { - final inner = _FakeAction(); - final (scope, container) = await pumpScope(tester); - await TimelineAction(action: inner).onAction(scope); - - expect(inner.ran, isTrue); - expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before'); - expect(container.read(multiSelectProvider).isEnabled, isFalse); - }); - - testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async { - final error = Exception('boom'); - final inner = _FakeAction(error: error); - final (scope, container) = await pumpScope(tester); - - await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error))); - - expect(inner.ran, isTrue); - expect(container.read(multiSelectProvider).isEnabled, isTrue); - }); - - testWidgets('delegates visibility to the wrapped action', (tester) async { - await tester.pumpTestWidget( - context, - ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))), - ); - - expect(find.byType(ActionIconButtonWidget), findsOneWidget); - expect(find.byIcon(Icons.bolt), findsNothing); - }); - }); -} diff --git a/mobile/test/unit/presentation/partner_page_test.dart b/mobile/test/unit/presentation/partner_page_test.dart index 162094c720..f7183deac3 100644 --- a/mobile/test/unit/presentation/partner_page_test.dart +++ b/mobile/test/unit/presentation/partner_page_test.dart @@ -17,12 +17,10 @@ void main() { group('PartnerSharedByList', () { testWidgets('shows the empty-state add button when there are no partners', (tester) async { - final action = const PartnerAddAction(); - await tester.pumpTestWidget(context, const PartnerSharedByList(partners: [])); expect(find.byType(ListView), findsNothing); - expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget); + expect(find.widgetWithIcon(TextButton, Icons.person_add_rounded), findsOneWidget); }); testWidgets('renders a tile per partner with name and email', (tester) async { @@ -39,9 +37,8 @@ void main() { testWidgets('renders a remove action for each partner', (tester) async { final partner1 = PartnerFactory.create(inTimeline: true); final partner2 = PartnerFactory.create(); - final action = const PartnerRemoveAction(sharedWithId: '', partnerName: ''); await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2])); - expect(find.byIcon(action.icon), findsNWidgets(2)); + expect(find.byIcon(Icons.person_remove_rounded), findsNWidgets(2)); }); }); diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 585cb3707e..2ed37c9d27 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; -import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; @@ -98,10 +97,10 @@ extension PumpPresentationWidget on WidgetTester { Future pumpTestAction( PresentationContext context, - BaseAction action, { + ActionWidget action, { List overrides = const [], }) async { - await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides); + await pumpTestWidget(context, action, overrides: overrides); await tap(find.byType(ImmichIconButton)); await pump(); }