mirror of
https://github.com/immich-app/immich.git
synced 2026-05-30 19:35:19 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 206992605e |
@@ -2133,11 +2133,6 @@
|
||||
"share_assets_selected": "{count} selected",
|
||||
"share_dialog_preparing": "Preparing...",
|
||||
"share_link": "Share Link",
|
||||
"share_quality_original": "Original",
|
||||
"share_quality_original_subtitle": "Send the full-quality original file",
|
||||
"share_quality_preview": "Preview (JPEG)",
|
||||
"share_quality_preview_subtitle": "Send a smaller, compressed JPEG",
|
||||
"share_quality_title": "Share as",
|
||||
"shared": "Shared",
|
||||
"shared_album_activities_input_disable": "Comment is disabled",
|
||||
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
||||
|
||||
@@ -192,43 +192,30 @@ class RemoteAlbumService {
|
||||
required UserDto uploader,
|
||||
required AlbumAssetCandidates candidates,
|
||||
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
|
||||
Completer<void>? cancelToken,
|
||||
}) async {
|
||||
int addedCount = 0;
|
||||
if (candidates.remoteAssetIds.isNotEmpty) {
|
||||
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
|
||||
}
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
addedCount += await _uploadAndAddLocals(albumId, uploader, candidates.localAssetsToUpload, uploadCallbacks);
|
||||
addedCount += await _uploadAndAddLocals(
|
||||
albumId,
|
||||
uploader,
|
||||
candidates.localAssetsToUpload,
|
||||
uploadCallbacks,
|
||||
cancelToken,
|
||||
);
|
||||
}
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/// Creates an album, seeding it with already-remote asset IDs, then uploads
|
||||
/// local-only assets and links each one as it finishes.
|
||||
Future<RemoteAlbum> createAlbumWithAssets({
|
||||
required String title,
|
||||
required UserDto owner,
|
||||
String? description,
|
||||
AlbumAssetCandidates candidates = const AlbumAssetCandidates(remoteAssetIds: [], localAssetsToUpload: []),
|
||||
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
|
||||
}) async {
|
||||
final album = await createAlbum(
|
||||
title: title,
|
||||
owner: owner,
|
||||
description: description,
|
||||
assetIds: candidates.remoteAssetIds,
|
||||
);
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
await _uploadAndAddLocals(album.id, owner, candidates.localAssetsToUpload, uploadCallbacks);
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
Future<int> _uploadAndAddLocals(
|
||||
String albumId,
|
||||
UserDto uploader,
|
||||
List<LocalAsset> localAssets,
|
||||
UploadCallbacks userCallbacks,
|
||||
Completer<void>? cancelToken,
|
||||
) async {
|
||||
int addedCount = 0;
|
||||
final pendingAdds = <Future<void>>[];
|
||||
@@ -258,7 +245,7 @@ class RemoteAlbumService {
|
||||
return;
|
||||
}
|
||||
pendingAdds.add(
|
||||
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
|
||||
linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
|
||||
.then<void>((added) {
|
||||
addedCount += added;
|
||||
})
|
||||
@@ -269,7 +256,7 @@ class RemoteAlbumService {
|
||||
},
|
||||
);
|
||||
|
||||
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
|
||||
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks, cancelToken: cancelToken);
|
||||
await Future.wait(pendingAdds);
|
||||
return addedCount;
|
||||
}
|
||||
@@ -288,7 +275,7 @@ class RemoteAlbumService {
|
||||
/// `remote_asset_entity` row from the local source so the FK-protected
|
||||
/// junction insert succeeds. Sync overwrites the placeholder later with
|
||||
/// the authoritative server data.
|
||||
Future<int> _linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async {
|
||||
Future<int> linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async {
|
||||
final result = await _albumApiRepository.addAssets(albumId, [remoteId]);
|
||||
if (result.added.isEmpty) {
|
||||
return 0;
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
/// The quality at which an asset is shared.
|
||||
enum ShareAssetQuality {
|
||||
/// Share the full original file. Read from the device when available,
|
||||
/// otherwise downloaded from the server.
|
||||
original,
|
||||
|
||||
/// Share the server-generated JPEG preview. Smaller and faster to send, and
|
||||
/// never exposes the original (e.g. RAW) file.
|
||||
preview,
|
||||
}
|
||||
|
||||
/// Where the bytes of a shared asset are obtained from.
|
||||
enum ShareSourceKind {
|
||||
/// Read the original file directly from the device.
|
||||
localFile,
|
||||
|
||||
/// Download the full original file from the server.
|
||||
remoteOriginal,
|
||||
|
||||
/// Download the server-generated JPEG preview from the server.
|
||||
remotePreview,
|
||||
}
|
||||
|
||||
/// Resolved instruction describing how a single asset should be shared.
|
||||
///
|
||||
/// This is a plain value object produced by [resolveShareSource] so that the
|
||||
/// decision of *what* to share can be unit-tested independently from the side
|
||||
/// effects of actually reading/downloading and handing the file to the OS share
|
||||
/// sheet.
|
||||
class ShareSource {
|
||||
final ShareSourceKind kind;
|
||||
|
||||
/// Device asset id, set when [kind] is [ShareSourceKind.localFile].
|
||||
final String? localId;
|
||||
|
||||
/// Server asset id, set when [kind] requires a download.
|
||||
final String? remoteId;
|
||||
|
||||
const ShareSource._({required this.kind, this.localId, this.remoteId});
|
||||
|
||||
const ShareSource.localFile(String id) : this._(kind: ShareSourceKind.localFile, localId: id);
|
||||
|
||||
const ShareSource.remoteOriginal(String id) : this._(kind: ShareSourceKind.remoteOriginal, remoteId: id);
|
||||
|
||||
const ShareSource.remotePreview(String id) : this._(kind: ShareSourceKind.remotePreview, remoteId: id);
|
||||
|
||||
/// Whether the file is read from the device instead of downloaded.
|
||||
bool get isLocal => kind == ShareSourceKind.localFile;
|
||||
|
||||
/// Whether the JPEG preview (rather than the original) is shared.
|
||||
bool get isPreview => kind == ShareSourceKind.remotePreview;
|
||||
|
||||
/// Whether the file has to be fetched from the server.
|
||||
bool get requiresDownload => kind != ShareSourceKind.localFile;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is ShareSource && other.kind == kind && other.localId == localId && other.remoteId == remoteId);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(kind, localId, remoteId);
|
||||
|
||||
@override
|
||||
String toString() => 'ShareSource(kind: $kind, localId: $localId, remoteId: $remoteId)';
|
||||
}
|
||||
|
||||
/// Whether [asset] can be shared as a JPEG preview.
|
||||
///
|
||||
/// The preview is generated and stored by the server, so it only exists for
|
||||
/// assets that have a remote copy. It is a still JPEG, so it only makes sense
|
||||
/// for images - sharing a still frame of a video would be surprising.
|
||||
bool canShareAsPreview(BaseAsset asset) => asset.isImage && asset.remoteId != null;
|
||||
|
||||
/// Whether offering a quality choice is meaningful for [assets].
|
||||
///
|
||||
/// The choice only matters when at least one asset can actually provide a JPEG
|
||||
/// preview; otherwise sharing always falls back to the original and the picker
|
||||
/// would be a no-op.
|
||||
bool shouldOfferShareQualityChoice(Iterable<BaseAsset> assets) => assets.any(canShareAsPreview);
|
||||
|
||||
/// Resolves how [asset] should be shared at the requested [quality].
|
||||
///
|
||||
/// Handles all three asset states - local-only, remote-only and merged - and
|
||||
/// degrades gracefully when the requested quality is not available:
|
||||
///
|
||||
/// * [ShareAssetQuality.preview] needs a remote image. For videos or
|
||||
/// local-only assets it is not available, so the original is shared instead.
|
||||
/// * [ShareAssetQuality.original] prefers the on-device file, but server-side
|
||||
/// edits only exist remotely, so an edited asset must be downloaded.
|
||||
///
|
||||
/// Returns `null` when the asset can neither be read locally nor downloaded.
|
||||
ShareSource? resolveShareSource(BaseAsset asset, ShareAssetQuality quality) {
|
||||
final localId = asset.localId;
|
||||
final remoteId = asset.remoteId;
|
||||
|
||||
if (quality == ShareAssetQuality.preview && canShareAsPreview(asset)) {
|
||||
// canShareAsPreview guarantees a non-null remoteId.
|
||||
return ShareSource.remotePreview(remoteId!);
|
||||
}
|
||||
|
||||
// Original quality.
|
||||
// The on-device file is the true original, but an edited asset only carries
|
||||
// its edits on the server, so prefer the remote copy in that case.
|
||||
if (localId != null && !asset.isEdited) {
|
||||
return ShareSource.localFile(localId);
|
||||
}
|
||||
|
||||
if (remoteId != null) {
|
||||
return ShareSource.remoteOriginal(remoteId);
|
||||
}
|
||||
|
||||
// Local-only asset flagged as edited: there is no remote to download from, so
|
||||
// fall back to the local file.
|
||||
if (localId != null) {
|
||||
return ShareSource.localFile(localId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Builds the filename to use for the shared file.
|
||||
///
|
||||
/// Path separators are stripped to keep the name safe to write to a temporary
|
||||
/// directory. Previews are always JPEG, so the extension is normalized to
|
||||
/// `.jpg` (the original might be e.g. a `.CR2`/`.dng` RAW file).
|
||||
String shareFilename(BaseAsset asset, ShareSource source) {
|
||||
final sanitized = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
|
||||
if (!source.isPreview) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
final dotIndex = sanitized.lastIndexOf('.');
|
||||
final base = dotIndex > 0 ? sanitized.substring(0, dotIndex) : sanitized;
|
||||
return '$base.jpg';
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -142,13 +143,18 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
return;
|
||||
}
|
||||
|
||||
final addedCount = await ref.read(remoteAlbumProvider.notifier).addAssets(album.id, [latest.remoteId!]);
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.viewer, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (addedCount == 0) {
|
||||
if (!result.success) {
|
||||
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.count == 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
|
||||
@@ -159,7 +165,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
|
||||
// Invalidate using the asset's remote ID to refresh the "Appears in" list
|
||||
// Refresh the "Appears in" list on the asset's info panel.
|
||||
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ 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/utils/share_asset.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.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/presentation/widgets/action_buttons/share_quality_picker.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';
|
||||
@@ -62,19 +60,6 @@ class ShareActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the user pick the quality, but only when at least one asset can
|
||||
// actually be shared as a JPEG preview - otherwise the original is the only
|
||||
// option and the picker would be a pointless extra tap.
|
||||
var quality = ShareAssetQuality.original;
|
||||
final assets = ref.read(actionProvider.notifier).getShareableAssets(source);
|
||||
if (shouldOfferShareQualityChoice(assets)) {
|
||||
final selected = await showShareQualityPicker(context);
|
||||
if (selected == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
quality = selected;
|
||||
}
|
||||
|
||||
final cancelCompleter = Completer<void>();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
final preparingDialog = _SharePreparingDialog(progress: progress);
|
||||
@@ -86,7 +71,6 @@ class ShareActionButton extends ConsumerWidget {
|
||||
.shareAssets(
|
||||
source,
|
||||
context,
|
||||
quality: quality,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/utils/share_asset.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
/// Shows a bottom sheet letting the user pick the quality used for sharing.
|
||||
///
|
||||
/// Resolves to the chosen [ShareAssetQuality], or `null` when the sheet is
|
||||
/// dismissed without making a choice (sharing should then be aborted).
|
||||
Future<ShareAssetQuality?> showShareQualityPicker(BuildContext context) {
|
||||
return showModalBottomSheet<ShareAssetQuality>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (_) => const _ShareQualityPicker(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ShareQualityPicker extends StatelessWidget {
|
||||
const _ShareQualityPicker();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'share_quality_title'.t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.high_quality_outlined),
|
||||
title: Text('share_quality_original'.t(context: context)),
|
||||
subtitle: Text('share_quality_original_subtitle'.t(context: context)),
|
||||
onTap: () => Navigator.of(context).pop(ShareAssetQuality.original),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.image_outlined),
|
||||
title: Text('share_quality_preview'.t(context: context)),
|
||||
subtitle: Text('share_quality_preview_subtitle'.t(context: context)),
|
||||
onTap: () => Navigator.of(context).pop(ShareAssetQuality.preview),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
@@ -746,12 +745,10 @@ class AddToAlbumHeader extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Future<void> onCreateAlbum() async {
|
||||
final selectedAssets = ref.read(multiSelectProvider).selectedAssets;
|
||||
final newAlbum = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.createAlbum(
|
||||
title: "Untitled Album",
|
||||
assetIds: ref.read(multiSelectProvider).selectedAssets.map((e) => (e as RemoteAsset).id).toList(),
|
||||
);
|
||||
.createAlbumWithAssets(title: "Untitled Album", assets: selectedAssets);
|
||||
|
||||
if (newAlbum == null) {
|
||||
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
|
||||
/// Pinned banner sliver that surfaces in-flight album uploads directly under
|
||||
/// the album app bar. Renders nothing while the queue is empty. Tapping the
|
||||
@@ -165,6 +166,8 @@ class _PendingUploadsSheet extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final failedCount = pending.where((p) => p.failed).length;
|
||||
final inFlightCount = pending.length - failedCount;
|
||||
final canAbort = inFlightCount > 0 && ref.watch(manualUploadCancelTokenProvider) != null;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
@@ -183,7 +186,21 @@ class _PendingUploadsSheet extends ConsumerWidget {
|
||||
style: context.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (failedCount > 0)
|
||||
if (canAbort)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
final cancelToken = ref.read(manualUploadCancelTokenProvider);
|
||||
if (cancelToken != null && !cancelToken.isCompleted) {
|
||||
cancelToken.complete();
|
||||
}
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
ref.read(pendingAlbumUploadsProvider(albumId).notifier).clear();
|
||||
},
|
||||
icon: const Icon(Icons.stop_circle_outlined, size: 18),
|
||||
label: Text('cancel'.t(context: context)),
|
||||
style: TextButton.styleFrom(foregroundColor: context.colorScheme.error),
|
||||
)
|
||||
else if (failedCount > 0)
|
||||
TextButton.icon(
|
||||
onPressed: () => ref.read(pendingAlbumUploadsProvider(albumId).notifier).clearFailed(),
|
||||
icon: const Icon(Icons.clear_rounded, size: 18),
|
||||
|
||||
@@ -3,9 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||
@@ -25,7 +23,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action
|
||||
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';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
@@ -63,37 +61,23 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
||||
);
|
||||
|
||||
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||
final selectedAssets = multiselect.selectedAssets;
|
||||
if (selectedAssets.isEmpty) {
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
|
||||
final addedCount = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
|
||||
|
||||
if (selectedAssets.length != remoteAssets.length) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context),
|
||||
);
|
||||
if (!result.success) {
|
||||
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (addedCount != remoteAssets.length) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {"album": album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.count == 0
|
||||
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
|
||||
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onKeyboardExpand() {
|
||||
@@ -131,12 +115,10 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: multiselect.hasRemote
|
||||
? [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
]
|
||||
: [],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,78 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_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/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';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class LocalAlbumBottomSheet extends ConsumerWidget {
|
||||
class LocalAlbumBottomSheet extends ConsumerStatefulWidget {
|
||||
const LocalAlbumBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const BaseBottomSheet(
|
||||
ConsumerState<LocalAlbumBottomSheet> createState() => _LocalAlbumBottomSheetState();
|
||||
}
|
||||
|
||||
class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
|
||||
late final DraggableScrollableController sheetController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
sheetController = DraggableScrollableController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
sheetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.count == 0
|
||||
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
|
||||
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onKeyboardExpand() {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.4,
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
actions: const [
|
||||
ShareActionButton(source: ActionSource.timeline),
|
||||
DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
UploadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-21
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
@@ -21,7 +20,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_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';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -56,29 +55,28 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
|
||||
|
||||
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||
final selectedAssets = multiselect.selectedAssets;
|
||||
if (selectedAssets.isEmpty) {
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final addedCount = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
|
||||
|
||||
if (addedCount != selectedAssets.length) {
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name}),
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}),
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.count == 0
|
||||
? 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name})
|
||||
: 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onKeyboardExpand() {
|
||||
@@ -118,10 +116,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||
],
|
||||
slivers: ownsAlbum
|
||||
? [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
]
|
||||
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand)]
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@ class AlbumPendingUploadsNotifier extends AutoDisposeFamilyNotifier<List<Pending
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = const [];
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void _syncKeepAlive() {
|
||||
if (state.isEmpty) {
|
||||
_keepAliveLink?.close();
|
||||
|
||||
@@ -5,13 +5,15 @@ import 'package:background_downloader/background_downloader.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/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/share_asset.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
@@ -374,6 +376,52 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> addToAlbum(ActionSource source, RemoteAlbum album) async {
|
||||
final selected = _getAssets(source).toList(growable: false);
|
||||
if (selected.isEmpty) {
|
||||
return const ActionResult(count: 0, success: true);
|
||||
}
|
||||
|
||||
final candidates = RemoteAlbumService.categorizeCandidates(selected);
|
||||
final remoteIds = candidates.remoteAssetIds;
|
||||
final localAssets = candidates.localAssetsToUpload;
|
||||
final albumNotifier = ref.read(remoteAlbumProvider.notifier);
|
||||
|
||||
int addedRemote = 0;
|
||||
if (remoteIds.isNotEmpty) {
|
||||
try {
|
||||
addedRemote = await albumNotifier.addAssets(album.id, remoteIds);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to add assets to album ${album.id}', error, stack);
|
||||
return ActionResult(count: 0, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the selection available for retry if the remote add fails. Once the
|
||||
// album mutation succeeds, clear timeline selection so upload overlays can render.
|
||||
if (source == ActionSource.timeline) {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
|
||||
if (localAssets.isEmpty) {
|
||||
return ActionResult(count: addedRemote, success: true);
|
||||
}
|
||||
|
||||
final uploadResult = await upload(
|
||||
source,
|
||||
assets: localAssets,
|
||||
onAssetUploaded: (asset, remoteId) async {
|
||||
await albumNotifier.linkUploadedAssetToAlbum(album.id, asset, remoteId);
|
||||
},
|
||||
);
|
||||
|
||||
return ActionResult(
|
||||
count: addedRemote + uploadResult.count,
|
||||
success: uploadResult.success,
|
||||
error: uploadResult.error,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
try {
|
||||
@@ -462,13 +510,9 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The assets that a share action would operate on for the given [source].
|
||||
List<BaseAsset> getShareableAssets(ActionSource source) => _getAssets(source).toList(growable: false);
|
||||
|
||||
Future<ActionResult> shareAssets(
|
||||
ActionSource source,
|
||||
BuildContext context, {
|
||||
ShareAssetQuality quality = ShareAssetQuality.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
@@ -478,7 +522,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
await _service.shareAssets(
|
||||
ids,
|
||||
context,
|
||||
quality: quality,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
@@ -501,8 +544,16 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
|
||||
Future<ActionResult> upload(
|
||||
ActionSource source, {
|
||||
List<LocalAsset>? assets,
|
||||
FutureOr<void> Function(LocalAsset asset, String remoteId)? onAssetUploaded,
|
||||
}) async {
|
||||
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
|
||||
final assetById = {for (final a in assetsToUpload) a.id: a};
|
||||
final uploadedAssetIds = <String>{};
|
||||
final failedAssetIds = <String>{};
|
||||
final postUploadTasks = <Future<void>>[];
|
||||
|
||||
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
|
||||
final cancelToken = Completer<void>();
|
||||
@@ -524,16 +575,43 @@ class ActionNotifier extends Notifier<void> {
|
||||
},
|
||||
onSuccess: (localAssetId, remoteAssetId) {
|
||||
progressNotifier.remove(localAssetId);
|
||||
uploadedAssetIds.add(localAssetId);
|
||||
final asset = assetById[localAssetId];
|
||||
final callback = onAssetUploaded;
|
||||
if (asset != null && callback != null) {
|
||||
postUploadTasks.add(
|
||||
Future.sync(() => callback(asset, remoteAssetId)).catchError((Object error, StackTrace stack) {
|
||||
failedAssetIds.add(localAssetId);
|
||||
progressNotifier.setError(localAssetId);
|
||||
_logger.warning('Post-upload callback failed for $localAssetId', error, stack);
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (localAssetId, errorMessage) {
|
||||
failedAssetIds.add(localAssetId);
|
||||
progressNotifier.setError(localAssetId);
|
||||
},
|
||||
),
|
||||
);
|
||||
return ActionResult(count: assetsToUpload.length, success: true);
|
||||
|
||||
await Future.wait(postUploadTasks);
|
||||
final successCount = uploadedAssetIds.difference(failedAssetIds).length;
|
||||
final isSuccess = successCount == assetsToUpload.length && failedAssetIds.isEmpty;
|
||||
|
||||
return ActionResult(
|
||||
count: successCount,
|
||||
success: isSuccess,
|
||||
error: isSuccess ? null : 'Failed to upload ${assetsToUpload.length - successCount} assets',
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed manually upload assets', error, stack);
|
||||
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
|
||||
|
||||
return ActionResult(
|
||||
count: uploadedAssetIds.difference(failedAssetIds).length,
|
||||
success: false,
|
||||
error: error.toString(),
|
||||
);
|
||||
} finally {
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
@@ -207,6 +208,22 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
return added;
|
||||
}
|
||||
|
||||
/// Links a freshly-uploaded local asset to an album using its new remote ID,
|
||||
/// upserting a placeholder remote asset row so the local DB join survives
|
||||
/// until the next sync catches up.
|
||||
Future<int> linkUploadedAssetToAlbum(String albumId, LocalAsset source, String remoteId) async {
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
throw Exception('User not logged in');
|
||||
}
|
||||
|
||||
final added = await _remoteAlbumService.linkUploadedAssetToAlbum(albumId, remoteId, currentUser, source);
|
||||
if (added > 0) {
|
||||
await _refreshAlbumInState(albumId);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/// Adds a heterogeneous asset selection to an album. Already-remote assets
|
||||
/// are linked immediately; local-only assets are queued in
|
||||
/// [pendingAlbumUploadsProvider] (so the album page can show them with
|
||||
@@ -221,11 +238,18 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
final pendingNotifier = ref.read(pendingAlbumUploadsProvider(albumId).notifier);
|
||||
pendingNotifier.enqueue(candidates.localAssetsToUpload);
|
||||
|
||||
Completer<void>? cancelToken;
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
cancelToken = Completer<void>();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
||||
}
|
||||
|
||||
try {
|
||||
final added = await _remoteAlbumService.addAssetsToAlbum(
|
||||
albumId: albumId,
|
||||
uploader: currentUser,
|
||||
candidates: candidates,
|
||||
cancelToken: cancelToken,
|
||||
uploadCallbacks: UploadCallbacks(
|
||||
onProgress: (localAssetId, _, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
@@ -245,6 +269,15 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
}
|
||||
_logger.severe('Failed to add assets to album $albumId', error, stack);
|
||||
rethrow;
|
||||
} finally {
|
||||
if (cancelToken != null) {
|
||||
if (cancelToken.isCompleted) {
|
||||
pendingNotifier.clear();
|
||||
}
|
||||
if (ref.read(manualUploadCancelTokenProvider) == cancelToken) {
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/share_asset.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
@@ -109,7 +108,6 @@ class AssetMediaRepository {
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
ShareAssetQuality quality = ShareAssetQuality.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
@@ -138,16 +136,13 @@ class AssetMediaRepository {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final source = resolveShareSource(asset, quality);
|
||||
if (source == null) {
|
||||
_log.warning("Asset has no shareable source: $asset");
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.isLocal) {
|
||||
File? f = await AssetEntity(id: source.localId!, width: 1, height: 1, typeInt: 0).originFile;
|
||||
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();
|
||||
@@ -155,16 +150,19 @@ class AssetMediaRepository {
|
||||
tempFiles.add(f);
|
||||
}
|
||||
} else {
|
||||
final remoteId = source.remoteId!;
|
||||
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 taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
|
||||
final sanitizedFilename = shareFilename(asset, source);
|
||||
final url = source.isPreview
|
||||
? getPreviewUrlForRemoteId(remoteId, edited: asset.isEdited)
|
||||
: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited);
|
||||
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
|
||||
final task = DownloadTask(
|
||||
taskId: taskId,
|
||||
url: url,
|
||||
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
filename: sanitizedFilename,
|
||||
baseDirectory: BaseDirectory.temporary,
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/share_asset.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
@@ -273,14 +272,12 @@ class ActionService {
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
ShareAssetQuality quality = ShareAssetQuality.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) {
|
||||
return _assetMediaRepository.shareAssets(
|
||||
assets,
|
||||
context,
|
||||
quality: quality,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
|
||||
@@ -16,10 +16,6 @@ String getThumbnailUrlForRemoteId(
|
||||
return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url;
|
||||
}
|
||||
|
||||
String getPreviewUrlForRemoteId(final String id, {bool edited = true}) {
|
||||
return getThumbnailUrlForRemoteId(id, type: AssetMediaSize.preview, edited: edited);
|
||||
}
|
||||
|
||||
String getPlaybackUrlForRemoteId(final String id) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/share_asset.dart';
|
||||
|
||||
LocalAsset _local({
|
||||
String id = 'local-1',
|
||||
String? remoteId,
|
||||
AssetType type = AssetType.image,
|
||||
bool isEdited = false,
|
||||
String name = 'photo.jpg',
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
remoteId: remoteId,
|
||||
name: name,
|
||||
type: type,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025),
|
||||
playbackStyle: type == AssetType.video ? AssetPlaybackStyle.video : AssetPlaybackStyle.image,
|
||||
isEdited: isEdited,
|
||||
);
|
||||
}
|
||||
|
||||
RemoteAsset _remote({
|
||||
String id = 'remote-1',
|
||||
String? localId,
|
||||
AssetType type = AssetType.image,
|
||||
bool isEdited = false,
|
||||
String name = 'photo.jpg',
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id,
|
||||
localId: localId,
|
||||
name: name,
|
||||
ownerId: 'owner-1',
|
||||
checksum: 'checksum-1',
|
||||
type: type,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025),
|
||||
isEdited: isEdited,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('canShareAsPreview', () {
|
||||
test('true for a remote-only image', () {
|
||||
expect(canShareAsPreview(_remote()), isTrue);
|
||||
});
|
||||
|
||||
test('true for a merged image regardless of which model carries it', () {
|
||||
expect(canShareAsPreview(_remote(localId: 'local-1')), isTrue);
|
||||
expect(canShareAsPreview(_local(remoteId: 'remote-1')), isTrue);
|
||||
});
|
||||
|
||||
test('false for a local-only image (no server preview exists)', () {
|
||||
expect(canShareAsPreview(_local()), isFalse);
|
||||
});
|
||||
|
||||
test('false for videos even when remote (preview is a still JPEG)', () {
|
||||
expect(canShareAsPreview(_remote(type: AssetType.video)), isFalse);
|
||||
expect(canShareAsPreview(_remote(localId: 'local-1', type: AssetType.video)), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldOfferShareQualityChoice', () {
|
||||
test('false for an empty selection', () {
|
||||
expect(shouldOfferShareQualityChoice(const []), isFalse);
|
||||
});
|
||||
|
||||
test('false when nothing can provide a preview', () {
|
||||
expect(shouldOfferShareQualityChoice([_local(), _remote(type: AssetType.video)]), isFalse);
|
||||
});
|
||||
|
||||
test('true when at least one asset can provide a preview', () {
|
||||
expect(shouldOfferShareQualityChoice([_local(), _remote()]), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveShareSource - local-only', () {
|
||||
final asset = _local();
|
||||
|
||||
test('original reads the local file', () {
|
||||
expect(resolveShareSource(asset, ShareAssetQuality.original), const ShareSource.localFile('local-1'));
|
||||
});
|
||||
|
||||
test('preview falls back to the local file (no remote preview available)', () {
|
||||
expect(resolveShareSource(asset, ShareAssetQuality.preview), const ShareSource.localFile('local-1'));
|
||||
});
|
||||
|
||||
test('edited local-only asset still falls back to the local file', () {
|
||||
final edited = _local(isEdited: true);
|
||||
expect(resolveShareSource(edited, ShareAssetQuality.original), const ShareSource.localFile('local-1'));
|
||||
expect(resolveShareSource(edited, ShareAssetQuality.preview), const ShareSource.localFile('local-1'));
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveShareSource - remote-only', () {
|
||||
final asset = _remote();
|
||||
|
||||
test('original downloads the original', () {
|
||||
expect(resolveShareSource(asset, ShareAssetQuality.original), const ShareSource.remoteOriginal('remote-1'));
|
||||
});
|
||||
|
||||
test('preview downloads the preview', () {
|
||||
expect(resolveShareSource(asset, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1'));
|
||||
});
|
||||
|
||||
test('edited remote video downloads the original even when preview is requested', () {
|
||||
final video = _remote(type: AssetType.video, isEdited: true);
|
||||
expect(resolveShareSource(video, ShareAssetQuality.preview), const ShareSource.remoteOriginal('remote-1'));
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveShareSource - merged', () {
|
||||
final mergedFromRemote = _remote(localId: 'local-1');
|
||||
final mergedFromLocal = _local(remoteId: 'remote-1');
|
||||
|
||||
test('original prefers the local file when not edited', () {
|
||||
expect(resolveShareSource(mergedFromRemote, ShareAssetQuality.original), const ShareSource.localFile('local-1'));
|
||||
expect(resolveShareSource(mergedFromLocal, ShareAssetQuality.original), const ShareSource.localFile('local-1'));
|
||||
});
|
||||
|
||||
test('preview downloads the preview from the server', () {
|
||||
expect(resolveShareSource(mergedFromRemote, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1'));
|
||||
expect(resolveShareSource(mergedFromLocal, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1'));
|
||||
});
|
||||
|
||||
test('edited asset downloads the original instead of using the stale local file', () {
|
||||
final edited = _remote(localId: 'local-1', isEdited: true);
|
||||
expect(resolveShareSource(edited, ShareAssetQuality.original), const ShareSource.remoteOriginal('remote-1'));
|
||||
});
|
||||
|
||||
test('edited asset can still share the (edited) preview', () {
|
||||
final edited = _remote(localId: 'local-1', isEdited: true);
|
||||
expect(resolveShareSource(edited, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1'));
|
||||
});
|
||||
|
||||
test('video uses the local file even when preview is requested', () {
|
||||
final video = _remote(localId: 'local-1', type: AssetType.video);
|
||||
expect(resolveShareSource(video, ShareAssetQuality.preview), const ShareSource.localFile('local-1'));
|
||||
});
|
||||
});
|
||||
|
||||
group('ShareSource helpers', () {
|
||||
test('expose the right flags', () {
|
||||
const local = ShareSource.localFile('a');
|
||||
const original = ShareSource.remoteOriginal('b');
|
||||
const preview = ShareSource.remotePreview('c');
|
||||
|
||||
expect(local.isLocal, isTrue);
|
||||
expect(local.requiresDownload, isFalse);
|
||||
expect(local.isPreview, isFalse);
|
||||
|
||||
expect(original.requiresDownload, isTrue);
|
||||
expect(original.isPreview, isFalse);
|
||||
|
||||
expect(preview.requiresDownload, isTrue);
|
||||
expect(preview.isPreview, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('shareFilename', () {
|
||||
test('keeps the original filename for non-preview sources', () {
|
||||
final asset = _remote(name: 'IMG_0001.HEIC');
|
||||
expect(shareFilename(asset, const ShareSource.remoteOriginal('remote-1')), 'IMG_0001.HEIC');
|
||||
expect(shareFilename(asset, const ShareSource.localFile('local-1')), 'IMG_0001.HEIC');
|
||||
});
|
||||
|
||||
test('normalizes the extension to .jpg for preview sources', () {
|
||||
final raw = _remote(name: 'IMG_0001.CR2');
|
||||
expect(shareFilename(raw, const ShareSource.remotePreview('remote-1')), 'IMG_0001.jpg');
|
||||
});
|
||||
|
||||
test('appends .jpg when the preview asset has no extension', () {
|
||||
final asset = _remote(name: 'no_extension');
|
||||
expect(shareFilename(asset, const ShareSource.remotePreview('remote-1')), 'no_extension.jpg');
|
||||
});
|
||||
|
||||
test('sanitizes path separators in the filename', () {
|
||||
final asset = _remote(name: 'sub/dir\\file.png');
|
||||
expect(shareFilename(asset, const ShareSource.remoteOriginal('remote-1')), 'sub_dir_file.png');
|
||||
expect(shareFilename(asset, const ShareSource.remotePreview('remote-1')), 'sub_dir_file.jpg');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user