From 055b93006668bd4660664a75e124e9128f78c72d Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:41:30 +0300 Subject: [PATCH] refactor(mobile): share action button in new timeline (#19967) * share asset button * include source * move to repository * formatting --- i18n/en.json | 1 + .../share_action_button.widget.dart | 40 ++++++++- .../asset_viewer/bottom_bar.widget.dart | 2 +- .../asset_viewer/bottom_sheet.widget.dart | 2 +- .../archive_bottom_sheet.widget.dart | 2 +- .../favorite_bottom_sheet.widget.dart | 2 +- .../general_bottom_sheet.widget.dart | 2 +- .../local_album_bottom_sheet.widget.dart | 2 +- .../locked_folder_bottom_sheet.widget.dart | 2 +- .../partner_detail_bottom_sheet.widget.dart | 3 +- .../remote_album_bottom_sheet.widget.dart | 2 +- .../infrastructure/action.provider.dart | 23 ++++- .../repositories/asset_api.repository.dart | 5 ++ .../repositories/asset_media.repository.dart | 84 +++++++++++++++++-- mobile/lib/services/action.service.dart | 10 ++- 15 files changed, 158 insertions(+), 24 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 6cabcecf7d..11df6fc3d3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1693,6 +1693,7 @@ "settings_saved": "Settings saved", "setup_pin_code": "Setup a PIN code", "share": "Share", + "share_action_prompt": "Shared {count} assets", "share_add_photos": "Add photos", "share_assets_selected": "{count} selected", "share_dialog_preparing": "Preparing...", 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 index 6a8864c14f..1b1553dabc 100644 --- a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart @@ -1,12 +1,49 @@ import 'dart:io'; 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/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'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class ShareActionButton extends ConsumerWidget { - const ShareActionButton({super.key}); + final ActionSource source; + + const ShareActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).shareAssets(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (!context.mounted) { + return; + } + + if (!result.success) { + ImmichToast.show( + context: context, + msg: 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + ); + } else if (result.count > 0) { + ImmichToast.show( + context: context, + msg: 'share_action_prompt' + .t(context: context, args: {'count': result.count.toString()}), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.success, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { @@ -14,6 +51,7 @@ class ShareActionButton extends ConsumerWidget { iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, label: 'share'.t(context: context), + 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 a28d5eafa4..9237c3bcdb 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -38,7 +38,7 @@ class ViewerBottomBar extends ConsumerWidget { } final actions = [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.viewer), const _EditActionButton(), if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 0ad87e05e8..6695022d1e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -45,7 +45,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { ); final actions = [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.viewer), if (asset.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.viewer), const ArchiveActionButton(source: ActionSource.viewer), 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 3c21630d2e..7b1175e8be 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 @@ -33,7 +33,7 @@ class ArchiveBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const UnArchiveActionButton(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 d73d3c22e0..0615a857a9 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 @@ -33,7 +33,7 @@ class FavoriteBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const UnFavoriteActionButton(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 6726f18246..61414252da 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 @@ -33,7 +33,7 @@ class GeneralBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.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 d41f8ecb96..2ad0fe7485 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 @@ -16,7 +16,7 @@ class LocalAlbumBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - ShareActionButton(), + ShareActionButton(source: ActionSource.timeline), DeleteLocalActionButton(source: ActionSource.timeline), UploadActionButton(), ], 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 97b2646f32..ef71f3a3a3 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 @@ -17,7 +17,7 @@ class LockedFolderBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - ShareActionButton(), + ShareActionButton(source: ActionSource.timeline), DownloadActionButton(), DeletePermanentActionButton(source: ActionSource.timeline), RemoveFromLockFolderActionButton(source: ActionSource.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 7af8ab7c86..1cdf6f28d6 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 @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.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'; @@ -14,7 +15,7 @@ class PartnerDetailBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - ShareActionButton(), + ShareActionButton(source: ActionSource.timeline), DownloadActionButton(), ], ); 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 1ed662884a..c2d0d5c85a 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 @@ -36,7 +36,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 25b5783137..b53417d021 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -58,15 +58,18 @@ class ActionNotifier extends Notifier { .toList(growable: false); } - Iterable _getIdsForSource(ActionSource source) { - final Set assets = switch (source) { + Set _getAssets(ActionSource source) { + return switch (source) { ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, ActionSource.viewer => switch (ref.read(currentAssetNotifier)) { BaseAsset asset => {asset}, null => const {}, }, }; + } + Iterable _getIdsForSource(ActionSource source) { + final Set assets = _getAssets(source); return switch (T) { const (RemoteAsset) => assets.whereType(), const (LocalAsset) => assets.whereType(), @@ -266,6 +269,22 @@ class ActionNotifier extends Notifier { ); } } + + Future shareAssets(ActionSource source) async { + final ids = _getAssets(source).toList(growable: false); + + try { + final count = await _service.shareAssets(ids); + return ActionResult(count: count, success: true); + } catch (error, stack) { + _logger.severe('Failed to share assets', error, stack); + return ActionResult( + count: ids.length, + success: false, + error: error.toString(), + ); + } + } } extension on Iterable { diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 0dff309172..cd49369a2a 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -83,6 +84,10 @@ class AssetApiRepository extends ApiRepository { ); } + Future downloadAsset(String id) { + return _api.downloadAssetWithHttpInfo(id); + } + _mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) { AssetVisibilityEnum.timeline => AssetVisibility.timeline, AssetVisibilityEnum.hidden => AssetVisibility.hidden, diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index bb1e6f414f..8708ce9cfd 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -1,27 +1,40 @@ +import 'dart:io'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart' as asset_entity; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/utils/hash.dart'; -import 'package:photo_manager/photo_manager.dart' hide AssetType; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/response_extensions.dart'; +import 'package:share_plus/share_plus.dart'; -final assetMediaRepositoryProvider = - Provider((ref) => const AssetMediaRepository()); +final assetMediaRepositoryProvider = Provider( + (ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)), +); class AssetMediaRepository { - const AssetMediaRepository(); + final AssetApiRepository _assetApiRepository; + static final Logger _log = Logger("AssetMediaRepository"); + + const AssetMediaRepository(this._assetApiRepository); + Future> deleteAll(List ids) => PhotoManager.editor.deleteWithIds(ids); - Future get(String id) async { + Future get(String id) async { final entity = await AssetEntity.fromId(id); return toAsset(entity); } - static Asset? toAsset(AssetEntity? local) { + static asset_entity.Asset? toAsset(AssetEntity? local) { if (local == null) return null; - final Asset asset = Asset( + final asset_entity.Asset asset = asset_entity.Asset( checksum: "", localId: local.id, ownerId: fastHash(Store.get(StoreKey.currentUser).id), @@ -29,7 +42,7 @@ class AssetMediaRepository { fileModifiedAt: local.modifiedDateTime, updatedAt: local.modifiedDateTime, durationInSeconds: local.duration, - type: AssetType.values[local.typeInt], + type: asset_entity.AssetType.values[local.typeInt], fileName: local.title!, width: local.width, height: local.height, @@ -57,4 +70,57 @@ class AssetMediaRepository { // otherwise using the `entity.title` would return a random GUID return await entity.titleAsync; } + + // TODO: make this more efficient + Future shareAssets(List assets) async { + final downloadedXFiles = []; + + for (var asset in assets) { + final localId = (asset is LocalAsset) + ? asset.id + : asset is RemoteAsset + ? asset.localId + : null; + if (localId != null) { + File? f = + await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0) + .originFile; + downloadedXFiles.add(XFile(f!.path)); + } else if (asset is RemoteAsset) { + final tempDir = await getTemporaryDirectory(); + final name = asset.name; + final tempFile = await File('${tempDir.path}/$name').create(); + final res = await _assetApiRepository.downloadAsset(asset.id); + + if (res.statusCode != 200) { + _log.severe("Download for $name failed", res.toLoggerString()); + continue; + } + + await tempFile.writeAsBytes(res.bodyBytes); + downloadedXFiles.add(XFile(tempFile.path)); + } else { + _log.warning("Asset type not supported for sharing: $asset"); + continue; + } + } + + if (downloadedXFiles.isEmpty) { + _log.warning("No asset can be retrieved for share"); + return 0; + } + + final result = await Share.shareXFiles(downloadedXFiles); + + for (var file in downloadedXFiles) { + try { + await File(file.path).delete(); + } catch (e) { + _log.warning("Failed to delete temporary file: ${file.path}", e); + } + } + return result.status == ShareResultStatus.success + ? downloadedXFiles.length + : 0; + } } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index bb2e1514e8..9559c5d316 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/repositories/asset_media.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/location_picker.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; import 'package:riverpod_annotation/riverpod_annotation.dart'; final actionServiceProvider = Provider( @@ -124,12 +124,12 @@ class ActionService { List remoteIds, BuildContext context, ) async { - LatLng? initialLatLng; + maplibre.LatLng? initialLatLng; if (remoteIds.length == 1) { final exif = await _remoteAssetRepository.getExif(remoteIds[0]); if (exif?.latitude != null && exif?.longitude != null) { - initialLatLng = LatLng(exif!.latitude!, exif.longitude!); + initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!); } } @@ -165,4 +165,8 @@ class ActionService { return removedCount; } + + Future shareAssets(List assets) { + return _assetMediaRepository.shareAssets(assets); + } }