mirror of
https://github.com/immich-app/immich.git
synced 2026-06-12 11:01:45 -07:00
feat: image quality option in sharing (#28918)
* feat: share with quality options * merge main * clean up * refactor * translation * translation * add settings and default behavior * fix: lint * cleanup * merge main --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
@@ -915,6 +915,8 @@
|
||||
"deduplicate_all": "Deduplicate All",
|
||||
"default_locale": "Default Locale",
|
||||
"default_locale_description": "Format dates and numbers based on your browser locale",
|
||||
"default_quality_subtitle": "Quality used when tapping share. Long press the share button to choose each time.",
|
||||
"default_share_quality": "Default share quality",
|
||||
"delete": "Delete",
|
||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||
"delete_action_prompt": "{count} deleted",
|
||||
@@ -2084,6 +2086,7 @@
|
||||
"select_person": "Select person",
|
||||
"select_person_to_tag": "Select a person to tag",
|
||||
"select_photos": "Select photos",
|
||||
"select_quality": "Select quality",
|
||||
"select_trash_all": "Select trash all",
|
||||
"select_user_for_sharing_page_err_album": "Failed to create album",
|
||||
"selected": "Selected",
|
||||
@@ -2147,6 +2150,8 @@
|
||||
"share_assets_selected": "{count} selected",
|
||||
"share_dialog_preparing": "Preparing...",
|
||||
"share_link": "Share Link",
|
||||
"share_original": "Use original (large)",
|
||||
"share_preview": "Use thumbnail (small)",
|
||||
"shared": "Shared",
|
||||
"shared_album_activities_input_disable": "Comment is disabled",
|
||||
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
||||
|
||||
@@ -13,6 +13,8 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
enum ActionSource { timeline, viewer }
|
||||
|
||||
enum ShareAssetType { original, preview }
|
||||
|
||||
enum CleanupStep { selectDate, scan, delete }
|
||||
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/image_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/share_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
|
||||
@@ -30,6 +31,7 @@ class AppConfig {
|
||||
final AlbumConfig album;
|
||||
final BackupConfig backup;
|
||||
final NetworkConfig network;
|
||||
final ShareConfig share;
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
@@ -43,6 +45,7 @@ class AppConfig {
|
||||
this.album = const .new(),
|
||||
this.backup = const .new(),
|
||||
this.network = const .new(),
|
||||
this.share = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
@@ -57,6 +60,7 @@ class AppConfig {
|
||||
AlbumConfig? album,
|
||||
BackupConfig? backup,
|
||||
NetworkConfig? network,
|
||||
ShareConfig? share,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
theme: theme ?? this.theme,
|
||||
@@ -69,6 +73,7 @@ class AppConfig {
|
||||
album: album ?? this.album,
|
||||
backup: backup ?? this.backup,
|
||||
network: network ?? this.network,
|
||||
share: share ?? this.share,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -85,15 +90,16 @@ class AppConfig {
|
||||
other.slideshow == slideshow &&
|
||||
other.album == album &&
|
||||
other.backup == backup &&
|
||||
other.network == network);
|
||||
other.network == network &&
|
||||
other.share == share);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
|
||||
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network, share);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network)';
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share)';
|
||||
|
||||
T read<T extends Object>(SettingsKey<T> key) =>
|
||||
(switch (key) {
|
||||
@@ -135,6 +141,7 @@ class AppConfig {
|
||||
.cleanupKeepAlbumIds => cleanup.keepAlbumIds,
|
||||
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
|
||||
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
|
||||
.shareFileType => share.fileType,
|
||||
.slideshowTransition => slideshow.transition,
|
||||
.slideshowRepeat => slideshow.repeat,
|
||||
.slideshowDuration => slideshow.duration,
|
||||
@@ -186,6 +193,7 @@ class AppConfig {
|
||||
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
|
||||
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
|
||||
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
|
||||
.shareFileType => copyWith(share: share.copyWith(fileType: value as ShareAssetType)),
|
||||
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
|
||||
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
|
||||
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
|
||||
class ShareConfig {
|
||||
final ShareAssetType fileType;
|
||||
|
||||
const ShareConfig({this.fileType = ShareAssetType.original});
|
||||
|
||||
ShareConfig copyWith({ShareAssetType? fileType}) => ShareConfig(fileType: fileType ?? this.fileType);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is ShareConfig && other.fileType == fileType);
|
||||
|
||||
@override
|
||||
int get hashCode => fileType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'ShareConfig(fileType: $fileType)';
|
||||
}
|
||||
@@ -66,6 +66,9 @@ enum SettingsKey<T extends Object> {
|
||||
cleanupCutoffDaysAgo<int>(),
|
||||
cleanupDefaultsInitialized<bool>(),
|
||||
|
||||
// Share
|
||||
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)),
|
||||
|
||||
// Slideshow
|
||||
slideshowTransition<bool>(),
|
||||
slideshowRepeat<bool>(),
|
||||
|
||||
@@ -42,6 +42,7 @@ class BaseActionButton extends ConsumerWidget {
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPressed,
|
||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ 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/settings_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_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/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';
|
||||
|
||||
@@ -48,6 +50,34 @@ class _SharePreparingDialog extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareFileTypeDialog extends StatelessWidget {
|
||||
const _ShareFileTypeDialog();
|
||||
|
||||
@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),
|
||||
),
|
||||
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;
|
||||
@@ -60,6 +90,35 @@ class ShareActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileType = ref.read(appConfigProvider).share.fileType;
|
||||
await _share(context, ref, fileType);
|
||||
}
|
||||
|
||||
void _onLongPress(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileType = await showDialog<ShareAssetType>(
|
||||
context: context,
|
||||
builder: (_) => const _ShareFileTypeDialog(),
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
if (fileType == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(settingsProvider).write(SettingsKey.shareFileType, fileType);
|
||||
|
||||
if (!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);
|
||||
@@ -71,6 +130,7 @@ class ShareActionButton extends ConsumerWidget {
|
||||
.shareAssets(
|
||||
source,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
@@ -84,7 +144,7 @@ class ShareActionButton extends ConsumerWidget {
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
msg: context.t.scaffold_body_error_occurred,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
@@ -110,10 +170,11 @@ class ShareActionButton extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||
label: 'share'.t(context: context),
|
||||
label: context.t.share,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
onLongPressed: () => _onLongPress(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,19 +514,21 @@ 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 {
|
||||
await _service.shareAssets(
|
||||
final count = await _service.shareAssets(
|
||||
ids,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
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());
|
||||
|
||||
@@ -6,24 +6,34 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.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/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider)));
|
||||
typedef _ShareFile = ({File file, bool cleanup, String displayName});
|
||||
|
||||
final assetMediaRepositoryProvider = Provider(
|
||||
(ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider), ref.watch(storageRepositoryProvider)),
|
||||
);
|
||||
|
||||
class AssetMediaRepository {
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final StorageRepository _storageRepository;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._nativeSyncApi);
|
||||
const AssetMediaRepository(this._nativeSyncApi, this._storageRepository);
|
||||
|
||||
Future<bool> _androidSupportsTrash() async {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -105,9 +115,149 @@ class AssetMediaRepository {
|
||||
);
|
||||
}
|
||||
|
||||
String _sanitizeFilename(String filename) {
|
||||
return filename.replaceAll(RegExp(r'[\\/]'), '_');
|
||||
}
|
||||
|
||||
String _getPreviewFilename(BaseAsset asset) {
|
||||
final sanitizedFilename = _sanitizeFilename(asset.name);
|
||||
final baseName = p.basenameWithoutExtension(sanitizedFilename);
|
||||
final fallbackName = asset.remoteId ?? asset.localId ?? 'asset';
|
||||
return '${baseName.isEmpty ? fallbackName : baseName}-preview.jpg';
|
||||
}
|
||||
|
||||
bool _isCancelled(Completer<void>? cancelCompleter) => cancelCompleter?.isCompleted ?? false;
|
||||
|
||||
Future<_ShareFile?> _getLocalOriginalShareFile(BaseAsset asset, String localId) async {
|
||||
final file = await _storageRepository.getFileForAsset(localId);
|
||||
if (file == null) {
|
||||
_log.warning("Local original file not found for sharing: $asset");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (file: file, cleanup: CurrentPlatform.isIOS, displayName: _sanitizeFilename(asset.name));
|
||||
}
|
||||
|
||||
Future<_ShareFile?> _downloadRemoteShareFile({
|
||||
required String taskId,
|
||||
required String url,
|
||||
required String displayName,
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) async {
|
||||
final task = DownloadTask(
|
||||
taskId: taskId,
|
||||
url: url,
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
filename: '$taskId-$displayName',
|
||||
baseDirectory: BaseDirectory.temporary,
|
||||
group: kShareDownloadGroup,
|
||||
updates: Updates.statusAndProgress,
|
||||
);
|
||||
final downloader = FileDownloader();
|
||||
final statusUpdate = await downloader.download(
|
||||
task,
|
||||
onProgress: (value) {
|
||||
if (_isCancelled(cancelCompleter)) {
|
||||
unawaited(downloader.cancelTaskWithId(taskId));
|
||||
return;
|
||||
}
|
||||
onProgress(value);
|
||||
},
|
||||
);
|
||||
|
||||
if (_isCancelled(cancelCompleter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (statusUpdate.status == TaskStatus.complete) {
|
||||
return (file: File(await task.filePath()), cleanup: true, displayName: displayName);
|
||||
}
|
||||
|
||||
_log.severe("Download for $displayName failed with status ${statusUpdate.status}", statusUpdate.exception);
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<_ShareFile?> _getRemoteOriginalShareFile(
|
||||
BaseAsset asset,
|
||||
String remoteId, {
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) {
|
||||
return _downloadRemoteShareFile(
|
||||
taskId: 'share-original-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
|
||||
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
|
||||
displayName: _sanitizeFilename(asset.name),
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_ShareFile?> _getRemotePreviewShareFile(
|
||||
BaseAsset asset,
|
||||
String remoteId, {
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) {
|
||||
return _downloadRemoteShareFile(
|
||||
taskId: 'share-preview-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
|
||||
url: getThumbnailUrlForRemoteId(remoteId, type: AssetMediaSize.preview, edited: asset.isEdited),
|
||||
displayName: _getPreviewFilename(asset),
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_ShareFile?> _getOriginalShareFile(
|
||||
BaseAsset asset, {
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) {
|
||||
final localId = asset.localId;
|
||||
if (localId != null && !asset.isEdited) {
|
||||
return _getLocalOriginalShareFile(asset, localId);
|
||||
}
|
||||
|
||||
final remoteId = asset.remoteId;
|
||||
if (remoteId == null) {
|
||||
_log.warning("Asset has no remote ID for sharing: $asset");
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
return _getRemoteOriginalShareFile(asset, remoteId, cancelCompleter: cancelCompleter, onProgress: onProgress);
|
||||
}
|
||||
|
||||
Future<_ShareFile?> _getPreviewShareFile(
|
||||
BaseAsset asset, {
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) async {
|
||||
final remoteId = asset.remoteId;
|
||||
if (remoteId != null) {
|
||||
final remotePreview = await _getRemotePreviewShareFile(
|
||||
asset,
|
||||
remoteId,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
if (remotePreview != null || asset.isEdited) {
|
||||
return remotePreview;
|
||||
}
|
||||
}
|
||||
|
||||
final localId = asset.localId;
|
||||
if (localId != null) {
|
||||
return _getLocalOriginalShareFile(asset, localId);
|
||||
}
|
||||
|
||||
_log.warning("Asset has no local or remote ID for preview sharing: $asset");
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
ShareAssetType fileType = ShareAssetType.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
@@ -129,75 +279,42 @@ class AssetMediaRepository {
|
||||
|
||||
updateProgress();
|
||||
|
||||
for (var asset in assets) {
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
// if cancelled, delete any temp files created so far
|
||||
for (final asset in assets) {
|
||||
if (_isCancelled(cancelCompleter)) {
|
||||
await _cleanupTempFiles(tempFiles);
|
||||
return 0;
|
||||
}
|
||||
|
||||
final localId = (asset is LocalAsset)
|
||||
? asset.id
|
||||
: asset is RemoteAsset
|
||||
? asset.localId
|
||||
: null;
|
||||
if (localId != null && !asset.isEdited) {
|
||||
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
|
||||
downloadedXFiles.add(XFile(f!.path));
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
if (CurrentPlatform.isIOS) {
|
||||
tempFiles.add(f);
|
||||
}
|
||||
} else {
|
||||
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
|
||||
if (remoteId == null) {
|
||||
_log.warning("Asset has no remote ID for sharing: $asset");
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
final shareFile = switch (fileType) {
|
||||
ShareAssetType.original => await _getOriginalShareFile(
|
||||
asset,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: updateProgress,
|
||||
),
|
||||
ShareAssetType.preview => await _getPreviewShareFile(
|
||||
asset,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: updateProgress,
|
||||
),
|
||||
};
|
||||
|
||||
final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
|
||||
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
|
||||
final task = DownloadTask(
|
||||
taskId: taskId,
|
||||
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
filename: sanitizedFilename,
|
||||
baseDirectory: BaseDirectory.temporary,
|
||||
group: kShareDownloadGroup,
|
||||
updates: Updates.statusAndProgress,
|
||||
);
|
||||
final statusUpdate = await FileDownloader().download(
|
||||
task,
|
||||
onProgress: (value) {
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
unawaited(FileDownloader().cancelTaskWithId(taskId));
|
||||
return;
|
||||
}
|
||||
updateProgress(value);
|
||||
},
|
||||
);
|
||||
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
await _cleanupTempFiles(tempFiles);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (statusUpdate.status == TaskStatus.complete) {
|
||||
final filePath = await task.filePath();
|
||||
final file = File(filePath);
|
||||
tempFiles.add(file);
|
||||
downloadedXFiles.add(XFile(filePath));
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
if (_isCancelled(cancelCompleter)) {
|
||||
await _cleanupTempFiles(tempFiles);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (shareFile == null) {
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadedXFiles.add(XFile(shareFile.file.path, name: shareFile.displayName));
|
||||
if (shareFile.cleanup) {
|
||||
tempFiles.add(shareFile.file);
|
||||
}
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
if (downloadedXFiles.isEmpty) {
|
||||
@@ -205,7 +322,7 @@ class AssetMediaRepository {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
if (_isCancelled(cancelCompleter)) {
|
||||
await _cleanupTempFiles(tempFiles);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -272,12 +272,14 @@ class ActionService {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/widgets/settings/preference_settings/haptic_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/preference_settings/share_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/preference_settings/theme_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
|
||||
@@ -8,7 +9,7 @@ class PreferenceSetting extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const preferenceSettings = [ThemeSetting(), HapticSetting()];
|
||||
const preferenceSettings = [ThemeSetting(), HapticSetting(), ShareSetting()];
|
||||
|
||||
return const SettingsSubPageScaffold(settings: preferenceSettings, showDivider: true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
|
||||
|
||||
class ShareSetting extends HookConsumerWidget {
|
||||
const ShareSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fileType = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.share.fileType)));
|
||||
|
||||
void onChanged(ShareAssetType? value) {
|
||||
if (value != null) {
|
||||
fileType.value = value;
|
||||
ref.read(settingsProvider).write(SettingsKey.shareFileType, value);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingGroupTitle(title: context.t.default_share_quality, icon: Icons.ios_share_outlined),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(
|
||||
context.t.default_quality_subtitle,
|
||||
style: context.textTheme.bodyMedium!.copyWith(color: context.textTheme.bodyMedium!.color!.withAlpha(215)),
|
||||
),
|
||||
),
|
||||
SettingsRadioListTile(
|
||||
groups: [
|
||||
SettingsRadioGroup(title: context.t.share_original, value: ShareAssetType.original),
|
||||
SettingsRadioGroup(title: context.t.share_preview, value: ShareAssetType.preview),
|
||||
],
|
||||
groupBy: fileType.value,
|
||||
onRadioChanged: onChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user