refactor: mobile share actions

This commit is contained in:
shenlong-tanwen
2026-07-30 02:55:59 +05:30
parent 45ac9754d0
commit 10e24f19f5
19 changed files with 387 additions and 438 deletions
@@ -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<List<BaseAsset>?, 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<void> _promptQualityAndShare(BuildContext context, WidgetRef ref, List<BaseAsset> 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<ShareAssetType>(
context: context,
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
useRootNavigator: false,
);
if (fileType == null || !context.mounted) {
return;
}
await _share(context, ref, assets, fileType);
}
Future<void> _share(BuildContext context, WidgetRef ref, List<BaseAsset> assets, ShareAssetType fileType) async {
final cancelCompleter = Completer<void>();
final progress = ValueNotifier<double?>(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<void>((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<double?> 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<double?>(
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))],
);
}
}
@@ -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<List<String>?, 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))),
);
}
}
@@ -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<double?> 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<double?>(
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<BaseAsset> _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<ShareAssetType>(
context: context,
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
useRootNavigator: false,
);
if (fileType == null || !context.mounted) {
return;
}
await _share(context, ref, fileType);
}
Future<void> _share(BuildContext context, WidgetRef ref, ShareAssetType fileType) async {
final cancelCompleter = Completer<void>();
final progress = ValueNotifier<double?>(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),
);
}
}
@@ -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),
);
}
}
@@ -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 = <Widget>[
..._actionColumnButtons(context, ref, const [RestoreAction(source: .viewer)]),
const ShareActionButton(source: .viewer),
..._actionColumnButtons(context, ref, const [ShareAction(source: .viewer)]),
if (!isInLockedView) ...[
if (!isInTrash) ...[
@@ -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<ArchiveBottomSheet> {
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),
@@ -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),
@@ -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<GeneralBottomSheet> {
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)),
@@ -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<LocalAlbumBottomSheet> {
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),
@@ -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)),
@@ -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<PartnerDetailBottomSh
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: const [
ShareActionButton(source: ActionSource.timeline),
ActionColumnButton(action: ShareAction(source: .timeline)),
DownloadActionButton(source: ActionSource.timeline),
],
slivers: [
@@ -4,6 +4,8 @@ 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/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';
@@ -14,8 +16,6 @@ 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/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/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';
@@ -86,9 +86,9 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
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)),
@@ -82,17 +82,6 @@ class ActionNotifier extends Notifier<void> {
return ActionResult(count: assets.length, success: true);
}
Future<ActionResult> 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<ActionResult> emptyTrash(String userId) async {
try {
final count = await _service.emptyTrash(userId);
@@ -239,30 +228,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
ShareAssetType fileType = ShareAssetType.original,
Completer<void>? 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<ActionResult> downloadAll(ActionSource source) async {
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
try {
-27
View File
@@ -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<ActionService>(
@@ -23,7 +19,6 @@ final actionServiceProvider = Provider<ActionService>(
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<void> shareLink(List<String> remoteIds, BuildContext context) async {
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
}
Future<int> 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<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetType fileType = ShareAssetType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) {
return _assetMediaRepository.shareAssets(
assets,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
}
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
return _downloadRepository.downloadAllAssets(assets);
}
+4 -8
View File
@@ -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)),
@@ -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);
});
});
}
@@ -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<ShareAssetType> sharedFileTypes = [];
@override
void build() {}
@override
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
ShareAssetType fileType = ShareAssetType.original,
Completer<void>? 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<void> 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<void> 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);
});
});
}
@@ -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<void> pumpShare(WidgetTester tester, {Set<BaseAsset>? 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<void> invokeSecondaryAction() async {
final resolved = const ShareAction(source: .timeline).create(actionContext, actionRef);
await resolved!.onSecondaryAction!();
}
List<ShareAssetType> sharedFileTypes() => verify(
() => context.repository.assetMedia.api.shareAssets(
any(),
any(),
fileType: captureAny(named: 'fileType'),
cancelCompleter: any(named: 'cancelCompleter'),
onAssetDownloadProgress: any(named: 'onAssetDownloadProgress'),
),
).captured.cast<ShareAssetType>();
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(const .new(milliseconds: 300));
}
Future<void> 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);
});
});
}
@@ -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<Override> selected(Set<BaseAsset> assets) => [