refactor(mobile): download button in new timeline (#20010)

* download button

* minor improvements
This commit is contained in:
Mert 2025-07-19 12:34:17 +03:00 committed by GitHub
parent fafb88d31c
commit 261818ddd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 170 additions and 21 deletions

View File

@ -782,6 +782,7 @@
"documentation": "Documentation", "documentation": "Documentation",
"done": "Done", "done": "Done",
"download": "Download", "download": "Download",
"download_action_prompt": "Downloading {count} assets",
"download_canceled": "Download canceled", "download_canceled": "Download canceled",
"download_complete": "Download complete", "download_complete": "Download complete",
"download_enqueue": "Download enqueued", "download_enqueue": "Download enqueued",

View File

@ -1,16 +1,54 @@
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_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/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 DownloadActionButton extends ConsumerWidget { class DownloadActionButton extends ConsumerWidget {
const DownloadActionButton({super.key}); final ActionSource source;
const DownloadActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).downloadAll(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: 'download_action_prompt'
.t(context: context, args: {'count': result.count.toString()}),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.success,
);
}
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton( return BaseActionButton(
iconData: Icons.download, iconData: Icons.download,
label: "download".t(context: context), label: "download".t(context: context),
onPressed: () => _onTap(context, ref),
); );
} }
} }

View File

@ -52,7 +52,8 @@ class AssetDetailBottomSheet extends ConsumerWidget {
if (asset.hasRemote) ...[ if (asset.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.viewer), const ShareLinkActionButton(source: ActionSource.viewer),
const ArchiveActionButton(source: ActionSource.viewer), const ArchiveActionButton(source: ActionSource.viewer),
if (!asset.hasLocal) const DownloadActionButton(), if (!asset.hasLocal)
const DownloadActionButton(source: ActionSource.viewer),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.viewer) ? const TrashActionButton(source: ActionSource.viewer)
: const DeletePermanentActionButton(source: ActionSource.viewer), : const DeletePermanentActionButton(source: ActionSource.viewer),

View File

@ -38,7 +38,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline), const UnArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(), const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton( : const DeletePermanentActionButton(

View File

@ -38,7 +38,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
const UnFavoriteActionButton(source: ActionSource.timeline), const UnFavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline),
const DownloadActionButton(), const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton( : const DeletePermanentActionButton(

View File

@ -38,7 +38,7 @@ class GeneralBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(), const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton( : const DeletePermanentActionButton(

View File

@ -18,7 +18,7 @@ class LockedFolderBottomSheet extends ConsumerWidget {
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
actions: [ actions: [
ShareActionButton(source: ActionSource.timeline), ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(), DownloadActionButton(source: ActionSource.timeline),
DeletePermanentActionButton(source: ActionSource.timeline), DeletePermanentActionButton(source: ActionSource.timeline),
RemoveFromLockFolderActionButton(source: ActionSource.timeline), RemoveFromLockFolderActionButton(source: ActionSource.timeline),
], ],

View File

@ -16,7 +16,7 @@ class PartnerDetailBottomSheet extends ConsumerWidget {
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
actions: [ actions: [
ShareActionButton(source: ActionSource.timeline), ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(), DownloadActionButton(source: ActionSource.timeline),
], ],
); );
} }

View File

@ -41,7 +41,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(), const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton( : const DeletePermanentActionButton(

View File

@ -41,7 +41,8 @@ class ActionNotifier extends Notifier<void> {
} }
List<String> _getRemoteIdsForSource(ActionSource source) { List<String> _getRemoteIdsForSource(ActionSource source) {
return _getIdsForSource<RemoteAsset>(source) return _getAssets(source)
.whereType<RemoteAsset>()
.toIds() .toIds()
.toList(growable: false); .toList(growable: false);
} }
@ -63,7 +64,8 @@ class ActionNotifier extends Notifier<void> {
List<String> _getOwnedRemoteIdsForSource(ActionSource source) { List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
final ownerId = ref.read(currentUserProvider)?.id; final ownerId = ref.read(currentUserProvider)?.id;
return _getIdsForSource<RemoteAsset>(source) return _getAssets(source)
.whereType<RemoteAsset>()
.ownedAssets(ownerId) .ownedAssets(ownerId)
.toIds() .toIds()
.toList(growable: false); .toList(growable: false);
@ -331,6 +333,24 @@ class ActionNotifier extends Notifier<void> {
); );
} }
} }
Future<ActionResult> downloadAll(ActionSource source) async {
final assets =
_getAssets(source).whereType<RemoteAsset>().toList(growable: false);
try {
final didEnqueue = await _service.downloadAll(assets);
final enqueueCount = didEnqueue.where((e) => e).length;
return ActionResult(count: enqueueCount, success: true);
} catch (error, stack) {
_logger.severe('Failed to download assets', error, stack);
return ActionResult(
count: assets.length,
success: false,
error: error.toString(),
);
}
}
} }
extension on Iterable<RemoteAsset> { extension on Iterable<RemoteAsset> {

View File

@ -1,10 +1,28 @@
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/download.dart'; import 'package:immich_mobile/utils/download.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); final downloadRepositoryProvider = Provider((ref) => DownloadRepository());
class DownloadRepository { class DownloadRepository {
static final _downloader = FileDownloader();
static final _dummyTask = DownloadTask(
taskId: 'dummy',
url: '',
filename: 'dummy',
group: '',
updates: Updates.statusAndProgress,
);
static final _dummyMetadata = {'part': LivePhotosPart.image, 'id': ''};
void Function(TaskStatusUpdate)? onImageDownloadStatus; void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus; void Function(TaskStatusUpdate)? onVideoDownloadStatus;
@ -14,19 +32,19 @@ class DownloadRepository {
void Function(TaskProgressUpdate)? onTaskProgress; void Function(TaskProgressUpdate)? onTaskProgress;
DownloadRepository() { DownloadRepository() {
FileDownloader().registerCallbacks( _downloader.registerCallbacks(
group: downloadGroupImage, group: downloadGroupImage,
taskStatusCallback: (update) => onImageDownloadStatus?.call(update), taskStatusCallback: (update) => onImageDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update),
); );
FileDownloader().registerCallbacks( _downloader.registerCallbacks(
group: downloadGroupVideo, group: downloadGroupVideo,
taskStatusCallback: (update) => onVideoDownloadStatus?.call(update), taskStatusCallback: (update) => onVideoDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update),
); );
FileDownloader().registerCallbacks( _downloader.registerCallbacks(
group: downloadGroupLivePhoto, group: downloadGroupLivePhoto,
taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update),
@ -34,25 +52,87 @@ class DownloadRepository {
} }
Future<List<bool>> downloadAll(List<DownloadTask> tasks) { Future<List<bool>> downloadAll(List<DownloadTask> tasks) {
return FileDownloader().enqueueAll(tasks); return _downloader.enqueueAll(tasks);
} }
Future<void> deleteAllTrackingRecords() { Future<void> deleteAllTrackingRecords() {
return FileDownloader().database.deleteAllRecords(); return _downloader.database.deleteAllRecords();
} }
Future<bool> cancel(String id) { Future<bool> cancel(String id) {
return FileDownloader().cancelTaskWithId(id); return _downloader.cancelTaskWithId(id);
} }
Future<List<TaskRecord>> getLiveVideoTasks() { Future<List<TaskRecord>> getLiveVideoTasks() {
return FileDownloader().database.allRecordsWithStatus( return _downloader.database.allRecordsWithStatus(
TaskStatus.complete, TaskStatus.complete,
group: downloadGroupLivePhoto, group: downloadGroupLivePhoto,
); );
} }
Future<void> deleteRecordsWithIds(List<String> ids) { Future<void> deleteRecordsWithIds(List<String> ids) {
return FileDownloader().database.deleteRecordsWithIds(ids); return _downloader.database.deleteRecordsWithIds(ids);
}
Future<List<bool>> downloadAllAssets(List<RemoteAsset> assets) async {
if (assets.isEmpty) {
return Future.value(const []);
}
final length = Platform.isAndroid ? assets.length : assets.length * 2;
final tasks = List.filled(length, _dummyTask);
int taskIndex = 0;
final headers = ApiService.getRequestHeaders();
for (final asset in assets) {
if (!asset.isRemoteOnly) {
continue;
}
final id = asset.id;
final livePhotoVideoId = asset.livePhotoVideoId;
final isVideo = asset.isVideo;
final url = getOriginalUrlForRemoteId(id);
if (Platform.isAndroid || livePhotoVideoId == null || isVideo) {
tasks[taskIndex++] = DownloadTask(
taskId: id,
url: url,
headers: headers,
filename: asset.name,
updates: Updates.statusAndProgress,
group: isVideo ? downloadGroupVideo : downloadGroupImage,
);
continue;
}
_dummyMetadata['part'] = LivePhotosPart.image;
_dummyMetadata['id'] = id;
tasks[taskIndex++] = DownloadTask(
taskId: id,
url: url,
headers: headers,
filename: asset.name,
updates: Updates.statusAndProgress,
group: downloadGroupLivePhoto,
metaData: json.encode(_dummyMetadata),
);
_dummyMetadata['part'] = LivePhotosPart.video;
tasks[taskIndex++] = DownloadTask(
taskId: livePhotoVideoId,
url: url,
headers: headers,
filename: asset.name
.toUpperCase()
.replaceAll(RegExp(r"\.(JPG|HEIC)$"), '.MOV'),
updates: Updates.statusAndProgress,
group: downloadGroupLivePhoto,
metaData: json.encode(_dummyMetadata),
);
}
if (taskIndex == 0) {
return Future.value(const []);
}
return _downloader.enqueueAll(tasks.slice(0, taskIndex));
} }
} }

View File

@ -1,3 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
@ -23,6 +25,7 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(driftAlbumApiRepositoryProvider), ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository), ref.watch(remoteAlbumRepository),
ref.watch(assetMediaRepositoryProvider), ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
), ),
); );
@ -33,6 +36,7 @@ class ActionService {
final DriftAlbumApiRepository _albumApiRepository; final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository; final DriftRemoteAlbumRepository _remoteAlbumRepository;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
const ActionService( const ActionService(
this._assetApiRepository, this._assetApiRepository,
@ -41,6 +45,7 @@ class ActionService {
this._albumApiRepository, this._albumApiRepository,
this._remoteAlbumRepository, this._remoteAlbumRepository,
this._assetMediaRepository, this._assetMediaRepository,
this._downloadRepository,
); );
Future<void> shareLink(List<String> remoteIds, BuildContext context) async { Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
@ -191,4 +196,8 @@ class ActionService {
Future<int> shareAssets(List<BaseAsset> assets) { Future<int> shareAssets(List<BaseAsset> assets) {
return _assetMediaRepository.shareAssets(assets); return _assetMediaRepository.shareAssets(assets);
} }
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
return _downloadRepository.downloadAllAssets(assets);
}
} }