diff --git a/mobile/lib/presentation/actions/share.action.dart b/mobile/lib/presentation/actions/share.action.dart new file mode 100644 index 0000000000..b2e2a2bd6d --- /dev/null +++ b/mobile/lib/presentation/actions/share.action.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +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/extensions/platform_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; + +final _stateProvider = Provider.family.autoDispose?, ActionSource>((ref, source) { + final assets = ref.watch(assetsActionProvider(source)); + final shareable = assets.toList(growable: false); + return shareable.isEmpty ? null : shareable; +}); + +class ShareAction extends AssetActionBuilder { + const ShareAction({required super.source}); + + @override + ActionItem? create(BuildContext context, WidgetRef ref) { + final assets = ref.watch(_stateProvider(source)); + if (assets == null) { + return null; + } + + return .new( + icon: CurrentPlatform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, + label: context.t.share, + onAction: () => _share(context, ref, assets, ref.read(appConfigProvider).share.fileType), + onSecondaryAction: () => _promptQualityAndShare(context, ref, assets), + ); + } + + Future _promptQualityAndShare(BuildContext context, WidgetRef ref, List assets) async { + // Only show preview option when at least one of the assets is not a video + final showPreview = assets.any((asset) => !asset.isVideo); + + final fileType = await showDialog( + context: context, + builder: (_) => _ShareFileTypeDialog(showPreview: showPreview), + useRootNavigator: false, + ); + if (fileType == null || !context.mounted) { + return; + } + + await _share(context, ref, assets, fileType); + } + + Future _share(BuildContext context, WidgetRef ref, List assets, ShareAssetType fileType) async { + final cancelCompleter = Completer(); + final progress = ValueNotifier(null); + final mediaRepository = ref.read(assetMediaRepositoryProvider); + final toastService = ref.read(toastServiceProvider); + final errorMessage = context.t.scaffold_body_error_occurred; + + await showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (dialogContext) { + void finish({required bool failed}) { + if (cancelCompleter.isCompleted || !dialogContext.mounted) { + return; + } + if (failed) { + toastService.error(errorMessage); + } + dialogContext.pop(); + } + + unawaited( + mediaRepository + .shareAssets( + assets, + context, + fileType: fileType, + cancelCompleter: cancelCompleter, + onAssetDownloadProgress: (value) => progress.value = value, + ) + .then((count) => finish(failed: count == 0), onError: (_) => finish(failed: true)), + ); + + return _SharePreparingDialog(progress: progress); + }, + ).then((_) { + if (!cancelCompleter.isCompleted) { + cancelCompleter.complete(); + } + progress.dispose(); + }); + } +} + +class _SharePreparingDialog extends StatelessWidget { + final ValueNotifier progress; + + const _SharePreparingDialog({required this.progress}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: Column( + mainAxisSize: .min, + children: [ + Container(margin: const .only(bottom: 12), child: Text(context.t.share_dialog_preparing)), + SizedBox( + width: 240, + child: ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, _) { + final percent = value == null ? null : (value * 100).clamp(0, 100); + return Column( + mainAxisSize: .min, + children: [ + LinearProgressIndicator(value: value, minHeight: 8.0), + if (percent != null) + Container(margin: const .only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')), + ], + ); + }, + ), + ), + ], + ), + ); + } +} + +class _ShareFileTypeDialog extends StatelessWidget { + final bool showPreview; + + const _ShareFileTypeDialog({this.showPreview = true}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(context.t.select_quality), + contentPadding: const .symmetric(vertical: 8), + content: Column( + mainAxisSize: .min, + children: [ + ListTile( + leading: const Icon(Icons.high_quality_rounded), + title: Text(context.t.share_original), + onTap: () => context.pop(ShareAssetType.original), + ), + if (showPreview) + ListTile( + leading: const Icon(Icons.photo_size_select_large_rounded), + title: Text(context.t.share_preview), + onTap: () => context.pop(ShareAssetType.preview), + ), + ], + ), + actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))], + ); + } +} diff --git a/mobile/lib/presentation/actions/share_link.action.dart b/mobile/lib/presentation/actions/share_link.action.dart new file mode 100644 index 0000000000..1966dd8811 --- /dev/null +++ b/mobile/lib/presentation/actions/share_link.action.dart @@ -0,0 +1,33 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/routing/router.dart'; + +final _stateProvider = Provider.family.autoDispose?, ActionSource>((ref, source) { + final assets = ref.watch(assetsActionProvider(source)); + final remoteIds = assets.remote().map((asset) => asset.id).toList(growable: false); + return remoteIds.isEmpty ? null : remoteIds; +}); + +class ShareLinkAction extends AssetActionBuilder { + const ShareLinkAction({required super.source}); + + @override + ActionItem? create(BuildContext context, WidgetRef ref) { + final remoteIds = ref.watch(_stateProvider(source)); + if (remoteIds == null) { + return null; + } + + return .new( + icon: Icons.link_rounded, + label: context.t.share_link, + onAction: () async => unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds))), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart deleted file mode 100644 index eef87f299d..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/generated/translations.g.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/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; -import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; - -class _SharePreparingDialog extends StatelessWidget { - final ValueNotifier progress; - - const _SharePreparingDialog({required this.progress}); - - @override - Widget build(BuildContext context) { - return AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()), - SizedBox( - width: 240, - child: ValueListenableBuilder( - valueListenable: progress, - builder: (context, value, _) { - final percent = value == null ? null : (value * 100).clamp(0, 100); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - LinearProgressIndicator(value: value, minHeight: 8.0), - if (percent != null) - Container(margin: const EdgeInsets.only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')), - ], - ); - }, - ), - ), - ], - ), - ); - } -} - -class _ShareFileTypeDialog extends StatelessWidget { - final bool showPreview; - - const _ShareFileTypeDialog({this.showPreview = true}); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(context.t.select_quality), - contentPadding: const EdgeInsets.symmetric(vertical: 8), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.high_quality_rounded), - title: Text(context.t.share_original), - onTap: () => context.pop(ShareAssetType.original), - ), - if (showPreview) - ListTile( - leading: const Icon(Icons.photo_size_select_large_rounded), - title: Text(context.t.share_preview), - onTap: () => context.pop(ShareAssetType.preview), - ), - ], - ), - actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))], - ); - } -} - -class ShareActionButton extends ConsumerWidget { - final ActionSource source; - final bool iconOnly; - final bool menuItem; - - const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); - - Set _getSelectedAssets(WidgetRef ref) { - return switch (source) { - ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, - ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) { - BaseAsset asset => {asset}, - null => const {}, - }, - }; - } - - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final fileType = ref.read(appConfigProvider).share.fileType; - await _share(context, ref, fileType); - } - - void _onLongPress(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - // only show preview option when at least one of the assets is not a video - // we cant share previews of videos - final assets = _getSelectedAssets(ref); - final showPreview = assets.isEmpty || assets.any((asset) => !asset.isVideo); - - final fileType = await showDialog( - context: context, - builder: (_) => _ShareFileTypeDialog(showPreview: showPreview), - useRootNavigator: false, - ); - - if (fileType == null || !context.mounted) { - return; - } - - await _share(context, ref, fileType); - } - - Future _share(BuildContext context, WidgetRef ref, ShareAssetType fileType) async { - final cancelCompleter = Completer(); - final progress = ValueNotifier(null); - final preparingDialog = _SharePreparingDialog(progress: progress); - await showDialog( - context: context, - builder: (BuildContext buildContext) { - ref - .read(actionProvider.notifier) - .shareAssets( - source, - context, - fileType: fileType, - cancelCompleter: cancelCompleter, - onAssetDownloadProgress: (value) => progress.value = value, - ) - .then((ActionResult result) { - if (cancelCompleter.isCompleted || !context.mounted) { - return; - } - - if (!result.success) { - ImmichToast.show( - context: context, - msg: context.t.scaffold_body_error_occurred, - gravity: ToastGravity.BOTTOM, - toastType: ToastType.error, - ); - } - - buildContext.pop(); - }); - - return preparingDialog; - }, - barrierDismissible: false, - useRootNavigator: false, - ).then((_) { - if (!cancelCompleter.isCompleted) { - cancelCompleter.complete(); - } - progress.dispose(); - }); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, - label: context.t.share, - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: () => _onTap(context, ref), - onLongPressed: () => _onLongPress(context, ref), - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/share_link_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/share_link_action_button.widget.dart deleted file mode 100644 index b8dc69f515..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/share_link_action_button.widget.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; - -class ShareLinkActionButton extends ConsumerWidget { - final ActionSource source; - final bool iconOnly; - final bool menuItem; - - const ShareLinkActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); - - _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - await ref.read(actionProvider.notifier).shareLink(source, context); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - iconData: Icons.link_rounded, - label: "share_link".t(context: context), - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: () => _onTap(context, ref), - ); - } -} 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 e86088cb2d..c1f6ec8b47 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -7,8 +7,8 @@ import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/edit_asset.action.dart'; import 'package:immich_mobile/presentation/actions/restore.action.dart'; +import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_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'; @@ -45,7 +45,7 @@ class ViewerBottomBar extends ConsumerWidget { final actions = [ ..._actionColumnButtons(context, ref, const [RestoreAction(source: .viewer)]), - const ShareActionButton(source: .viewer), + ..._actionColumnButtons(context, ref, const [ShareAction(source: .viewer)]), if (!isInLockedView) ...[ if (!isInTrash) ...[ 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 ca410b5908..f92ed3b2df 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 @@ -4,6 +4,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/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/share.action.dart'; +import 'package:immich_mobile/presentation/actions/share_link.action.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; @@ -12,8 +14,6 @@ import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/stack.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_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/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; @@ -76,9 +76,9 @@ class _ArchiveBottomSheetState extends ConsumerState { maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ShareAction(source: .timeline)), if (multiselect.hasRemote) ...[ - const ShareLinkActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ShareLinkAction(source: .timeline)), const ActionColumnButton(action: ArchiveAction(source: .timeline)), const ActionColumnButton(action: FavoriteAction(source: .timeline)), if (multiselect.onlyRemote) const DownloadActionButton(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 e347ef7817..1d54941c03 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 @@ -5,6 +5,8 @@ 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/share.action.dart'; +import 'package:immich_mobile/presentation/actions/share_link.action.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; @@ -13,8 +15,6 @@ import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/stack.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_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/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; @@ -73,9 +73,9 @@ class FavoriteBottomSheet extends ConsumerWidget { maxChildSize: 0.7, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ShareAction(source: .timeline)), if (multiselect.hasRemote) ...[ - const ShareLinkActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ShareLinkAction(source: .timeline)), const ActionColumnButton(action: FavoriteAction(source: .timeline)), const ActionColumnButton(action: ArchiveAction(source: .timeline)), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), 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 f6202541ba..e163e50182 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 @@ -4,6 +4,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/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/share.action.dart'; +import 'package:immich_mobile/presentation/actions/share_link.action.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; @@ -13,8 +15,6 @@ import 'package:immich_mobile/presentation/actions/lock.action.dart'; import 'package:immich_mobile/presentation/actions/stack.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_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/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/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; @@ -83,9 +83,9 @@ class _GeneralBottomSheetState extends ConsumerState { shouldCloseOnMinExtent: false, actions: [ const ActionColumnButton(action: FavoriteAction(source: .timeline)), - const ShareActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ShareAction(source: .timeline)), if (multiselect.hasRemote) ...[ - const ShareLinkActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ShareLinkAction(source: .timeline)), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), const ActionColumnButton(action: FavoriteAction(source: .timeline)), const ActionColumnButton(action: ArchiveAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart index dc49f22cfc..e4e464b9c0 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart @@ -4,8 +4,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/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.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/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; @@ -66,7 +66,7 @@ class _LocalAlbumBottomSheetState extends ConsumerState { maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: const [ - ShareActionButton(source: ActionSource.timeline), + ActionColumnButton(action: ShareAction(source: .timeline)), ActionColumnButton(action: DeleteAction(source: .timeline)), ActionColumnButton(action: CleanupLocalAction(source: .timeline)), UploadActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart index e0074c7866..9fdbab3524 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/lock.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; class LockedFolderBottomSheet extends ConsumerWidget { @@ -18,7 +18,7 @@ class LockedFolderBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - ShareActionButton(source: ActionSource.timeline), + ActionColumnButton(action: ShareAction(source: .timeline)), DownloadActionButton(source: ActionSource.timeline), ActionColumnButton(action: DeleteAction(source: .timeline)), ActionColumnButton(action: LockAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart index c49c805e3d..e8b8f26350 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart @@ -4,8 +4,9 @@ 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/widgets/action_buttons/download_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -64,7 +65,7 @@ class _PartnerDetailBottomSheetState extends ConsumerState maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ShareAction(source: .timeline)), if (multiselect.hasRemote) ...[ - const ShareLinkActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ShareLinkAction(source: .timeline)), if (ownsAlbum) ...[ const ActionColumnButton(action: ArchiveAction(source: .timeline)), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 488280bc38..22118cddf3 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -82,17 +82,6 @@ class ActionNotifier extends Notifier { return ActionResult(count: assets.length, success: true); } - Future shareLink(ActionSource source, BuildContext context) async { - final ids = _getRemoteIdsForSource(source); - try { - await _service.shareLink(ids, context); - return ActionResult(count: ids.length, success: true); - } catch (error, stack) { - _logger.severe('Failed to create shared link for assets', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } - Future emptyTrash(String userId) async { try { final count = await _service.emptyTrash(userId); @@ -239,30 +228,6 @@ class ActionNotifier extends Notifier { } } - Future shareAssets( - ActionSource source, - BuildContext context, { - ShareAssetType fileType = ShareAssetType.original, - Completer? cancelCompleter, - void Function(double progress)? onAssetDownloadProgress, - }) async { - final ids = _getAssets(source).toList(growable: false); - - try { - final count = await _service.shareAssets( - ids, - context, - fileType: fileType, - cancelCompleter: cancelCompleter, - onAssetDownloadProgress: onAssetDownloadProgress, - ); - return ActionResult(count: count, success: count > 0 || ids.isEmpty); - } catch (error, stack) { - _logger.severe('Failed to share assets', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } - Future downloadAll(ActionSource source) async { final assets = _getAssets(source).whereType().toList(growable: false); try { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 47883b212e..4954ebb977 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -1,9 +1,7 @@ import 'dart:async'; -import 'package:auto_route/auto_route.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/domain/services/tag.service.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; @@ -11,10 +9,8 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; -import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/tag_picker.dart'; final actionServiceProvider = Provider( @@ -23,7 +19,6 @@ final actionServiceProvider = Provider( ref.watch(remoteAssetRepositoryProvider), ref.watch(driftAlbumApiRepositoryProvider), ref.watch(remoteAlbumRepository), - ref.watch(assetMediaRepositoryProvider), ref.watch(downloadRepositoryProvider), ref.watch(tagServiceProvider), ), @@ -34,7 +29,6 @@ class ActionService { final RemoteAssetRepository _remoteAssetRepository; final DriftAlbumApiRepository _albumApiRepository; final DriftRemoteAlbumRepository _remoteAlbumRepository; - final AssetMediaRepository _assetMediaRepository; final DownloadRepository _downloadRepository; final TagService _tagService; @@ -43,15 +37,10 @@ class ActionService { this._remoteAssetRepository, this._albumApiRepository, this._remoteAlbumRepository, - this._assetMediaRepository, this._downloadRepository, this._tagService, ); - Future shareLink(List remoteIds, BuildContext context) async { - unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds))); - } - Future emptyTrash(String userId) async { final count = await _assetApiRepository.emptyTrash(); await _remoteAssetRepository.emptyTrash(userId); @@ -108,22 +97,6 @@ class ActionService { return _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList()); } - Future shareAssets( - List assets, - BuildContext context, { - ShareAssetType fileType = ShareAssetType.original, - Completer? cancelCompleter, - void Function(double progress)? onAssetDownloadProgress, - }) { - return _assetMediaRepository.shareAssets( - assets, - context, - fileType: fileType, - cancelCompleter: cancelCompleter, - onAssetDownloadProgress: onAssetDownloadProgress, - ); - } - Future> downloadAll(List assets) { return _downloadRepository.downloadAllAssets(assets); } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 41015fb064..60ffa84066 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -16,6 +16,8 @@ import 'package:immich_mobile/presentation/actions/lock.action.dart'; import 'package:immich_mobile/presentation/actions/open_in_browser.action.dart'; import 'package:immich_mobile/presentation/actions/restore.action.dart'; import 'package:immich_mobile/presentation/actions/set_profile_picture.action.dart'; +import 'package:immich_mobile/presentation/actions/share.action.dart'; +import 'package:immich_mobile/presentation/actions/share_link.action.dart'; import 'package:immich_mobile/presentation/actions/similar_photos.action.dart'; import 'package:immich_mobile/presentation/actions/slideshow.action.dart'; import 'package:immich_mobile/presentation/actions/stack.action.dart'; @@ -24,8 +26,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_actio import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -173,12 +173,8 @@ enum ActionButtonType { ]) { return switch (this) { ActionButtonType.advancedInfo => ActionMenuItem(action: AssetDebugAction(source: context.source)), - ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), - ActionButtonType.shareLink => ShareLinkActionButton( - source: context.source, - iconOnly: iconOnly, - menuItem: menuItem, - ), + ActionButtonType.share => ActionMenuItem(action: ShareAction(source: context.source)), + ActionButtonType.shareLink => ActionMenuItem(action: ShareLinkAction(source: context.source)), ActionButtonType.slideshow => const ActionMenuItem(action: SlideshowAction()), ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItem(action: ArchiveAction(source: context.source)), diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index 0a1fbf08bc..2ab2389056 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -19,7 +19,6 @@ void main() { late MockRemoteAssetRepository remoteAssetRepository; late MockDriftAlbumApiRepository albumApiRepository; late MockRemoteAlbumRepository remoteAlbumRepository; - late MockAssetMediaRepository assetMediaRepository; late MockDownloadRepository downloadRepository; late MockTagService tagService; @@ -44,7 +43,6 @@ void main() { remoteAssetRepository = MockRemoteAssetRepository(); albumApiRepository = MockDriftAlbumApiRepository(); remoteAlbumRepository = MockRemoteAlbumRepository(); - assetMediaRepository = MockAssetMediaRepository(); downloadRepository = MockDownloadRepository(); tagService = MockTagService(); @@ -53,7 +51,6 @@ void main() { remoteAssetRepository, albumApiRepository, remoteAlbumRepository, - assetMediaRepository, downloadRepository, tagService, ); @@ -88,5 +85,4 @@ void main() { verify(() => remoteAssetRepository.updateRating(assetId, null)).called(1); }); }); - } diff --git a/mobile/test/unit/presentation/action_buttons/share_action_button_test.dart b/mobile/test/unit/presentation/action_buttons/share_action_button_test.dart deleted file mode 100644 index 2f4aa3b8c9..0000000000 --- a/mobile/test/unit/presentation/action_buttons/share_action_button_test.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:async'; - -import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/settings_key.dart'; -import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; - -import '../../factories/remote_asset_factory.dart'; -import '../presentation_context.dart'; - -class _RecordingActionNotifier extends ActionNotifier { - final List sharedFileTypes = []; - - @override - void build() {} - - @override - Future shareAssets( - ActionSource source, - BuildContext context, { - ShareAssetType fileType = ShareAssetType.original, - Completer? cancelCompleter, - void Function(double progress)? onAssetDownloadProgress, - }) async { - sharedFileTypes.add(fileType); - return const ActionResult(count: 1, success: true); - } -} - -class _FakeAssetViewerNotifier extends AssetViewerStateNotifier { - final BaseAsset asset; - - _FakeAssetViewerNotifier(this.asset); - - @override - AssetViewerState build() => AssetViewerState(currentAsset: asset); -} - -void main() { - late PresentationContext context; - late _RecordingActionNotifier actionNotifier; - - setUpAll(() async { - final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await SettingsRepository.ensureInitialized(db); - }); - - setUp(() async { - context = await PresentationContext.create(); - actionNotifier = _RecordingActionNotifier(); - await SettingsRepository.instance.clear([SettingsKey.shareFileType]); - }); - - tearDown(() { - context.dispose(); - }); - - Future pumpShareButton(WidgetTester tester) async { - final asset = RemoteAssetFactory.create(ownerId: context.currentUser.id); - await tester.pumpTestWidget( - context, - const ShareActionButton(source: ActionSource.viewer), - overrides: [ - actionProvider.overrideWith(() => actionNotifier), - assetViewerProvider.overrideWith(() => _FakeAssetViewerNotifier(asset)), - ], - ); - } - - Future longPressAndPickPreview(WidgetTester tester) async { - await tester.longPress(find.byType(BaseActionButton)); - await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.photo_size_select_large_rounded)); - await tester.pumpAndSettle(); - } - - group('ShareActionButton', () { - testWidgets('single press shares with the configured default quality', (tester) async { - await pumpShareButton(tester); - - await tester.tap(find.byType(BaseActionButton)); - await tester.pumpAndSettle(); - - expect(actionNotifier.sharedFileTypes, [ShareAssetType.original]); - }); - - testWidgets('long press shares with the quality picked in the dialog', (tester) async { - await pumpShareButton(tester); - - await longPressAndPickPreview(tester); - - expect(actionNotifier.sharedFileTypes, [ShareAssetType.preview]); - }); - - testWidgets('quality picked on long press is a one-time choice and does not change the default', (tester) async { - await pumpShareButton(tester); - - await longPressAndPickPreview(tester); - expect(actionNotifier.sharedFileTypes, [ShareAssetType.preview]); - - await tester.tap(find.byType(BaseActionButton)); - await tester.pumpAndSettle(); - - expect(actionNotifier.sharedFileTypes, [ShareAssetType.preview, ShareAssetType.original]); - expect(SettingsRepository.instance.appConfig.share.fileType, ShareAssetType.original); - }); - }); -} diff --git a/mobile/test/unit/presentation/actions/share_action_test.dart b/mobile/test/unit/presentation/actions/share_action_test.dart new file mode 100644 index 0000000000..a56a0f5276 --- /dev/null +++ b/mobile/test/unit/presentation/actions/share_action_test.dart @@ -0,0 +1,159 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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/models/settings_key.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/share.action.dart'; +import 'package:immich_mobile/presentation/actions/share_link.action.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../factories/local_asset_factory.dart'; +import '../../factories/remote_asset_factory.dart'; +import '../presentation_context.dart'; + +void main() { + late PresentationContext context; + + setUpAll(() async { + final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await SettingsRepository.ensureInitialized(db); + }); + + setUp(() async { + context = await PresentationContext.create(); + await SettingsRepository.instance.clear([SettingsKey.shareFileType]); + }); + + tearDown(() { + context.dispose(); + }); + + late BuildContext actionContext; + late WidgetRef actionRef; + + Future pumpShare(WidgetTester tester, {Set? selection}) => tester.pumpTestWidget( + context, + Consumer( + builder: (widgetContext, ref, _) { + actionContext = widgetContext; + actionRef = ref; + return const ActionIconButton(action: ShareAction(source: .timeline)); + }, + ), + overrides: [ + ...context.selected(selection ?? {RemoteAssetFactory.create(ownerId: context.currentUser.id)}), + ], + ); + + // TODO: Replace with button tap once long press support in ui is merged + Future invokeSecondaryAction() async { + final resolved = const ShareAction(source: .timeline).create(actionContext, actionRef); + await resolved!.onSecondaryAction!(); + } + + List sharedFileTypes() => verify( + () => context.repository.assetMedia.api.shareAssets( + any(), + any(), + fileType: captureAny(named: 'fileType'), + cancelCompleter: any(named: 'cancelCompleter'), + onAssetDownloadProgress: any(named: 'onAssetDownloadProgress'), + ), + ).captured.cast(); + + Future settle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const .new(milliseconds: 300)); + } + + Future pickPreviewQuality(WidgetTester tester) async { + final shared = invokeSecondaryAction(); + await settle(tester); + await tester.tap(find.byIcon(Icons.photo_size_select_large_rounded)); + await settle(tester); + await shared; + } + + group('ShareAction', () { + testWidgets('single press shares with the configured default quality', (tester) async { + await pumpShare(tester); + + await tester.tap(find.byType(ImmichIconButton)); + await settle(tester); + + expect(sharedFileTypes(), [ShareAssetType.original]); + }); + + testWidgets('the secondary action shares with the quality picked in the dialog', (tester) async { + await pumpShare(tester); + + await pickPreviewQuality(tester); + + expect(sharedFileTypes(), [ShareAssetType.preview]); + }); + + testWidgets('quality picked there is a one-time choice and does not change the default', (tester) async { + await pumpShare(tester); + + await pickPreviewQuality(tester); + await tester.tap(find.byType(ImmichIconButton)); + await settle(tester); + + expect(sharedFileTypes(), [ShareAssetType.preview, ShareAssetType.original]); + expect(SettingsRepository.instance.appConfig.share.fileType, ShareAssetType.original); + }); + + testWidgets('offers no preview option for a video, which has none to share', (tester) async { + await pumpShare( + tester, + selection: {RemoteAssetFactory.create(ownerId: context.currentUser.id, type: .video)}, + ); + + final shared = invokeSecondaryAction(); + await settle(tester); + + expect(find.byIcon(Icons.high_quality_rounded), findsOneWidget); + expect(find.byIcon(Icons.photo_size_select_large_rounded), findsNothing); + + await tester.tap(find.byIcon(Icons.high_quality_rounded)); + await settle(tester); + await shared; + expect(find.byIcon(Icons.photo_size_select_large_rounded), findsNothing); + }); + + testWidgets('is hidden when nothing is selected', (tester) async { + await pumpShare(tester, selection: const {}); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); + + group('ShareLinkAction', () { + testWidgets('offers a link for a remote asset', (tester) async { + await tester.pumpTestWidget( + context, + const ActionIconButton(action: ShareLinkAction(source: .timeline)), + overrides: context.selected({RemoteAssetFactory.create()}), + ); + + expect(find.byType(ImmichIconButton), findsOneWidget); + }); + + testWidgets('is hidden for a local-only asset, which has nothing to link to', (tester) async { + await tester.pumpTestWidget( + context, + const ActionIconButton(action: ShareLinkAction(source: .timeline)), + overrides: context.selected({LocalAssetFactory.create()}), + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); +} diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index ba12183652..bfd8e877d8 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; import 'package:immich_mobile/services/gcast.service.dart'; import 'package:immich_mobile/services/server_info.service.dart'; @@ -55,6 +56,7 @@ class PresentationContext { serverInfoServiceProvider.overrideWithValue(service.serverInfo), inLockedViewProvider.overrideWithValue(false), remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo), + assetMediaRepositoryProvider.overrideWithValue(repository.assetMedia.api), ]; List selected(Set assets) => [