feat: drift manual upload (#20101)

This commit is contained in:
Alex 2025-07-23 09:20:52 -05:00 committed by GitHub
parent 08122d6871
commit 1a35a01149
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 114 additions and 9 deletions

View File

@ -1941,11 +1941,13 @@
"updated_at": "Updated",
"updated_password": "Updated password",
"upload": "Upload",
"upload_action_prompt": "{count} queued for upload",
"upload_concurrency": "Upload concurrency",
"upload_details": "Upload Details",
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
"upload_dialog_title": "Upload Asset",
"upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.",
"upload_finished": "Upload finished",
"upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}",
"upload_skipped_duplicates": "Skipped {count, plural, one {# duplicate asset} other {# duplicate assets}}",
"upload_status_duplicates": "Duplicates",
@ -1954,6 +1956,7 @@
"upload_success": "Upload success, refresh the page to see new upload assets.",
"upload_to_immich": "Upload to Immich ({count})",
"uploading": "Uploading",
"uploading_media": "Uploading media",
"url": "URL",
"usage": "Usage",
"use_biometric": "Use biometric",

View File

@ -141,6 +141,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])
..addColumns([assetCountExp, dateExp])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))

View File

@ -203,6 +203,19 @@ class ImmichAppState extends ConsumerState<ImmichApp>
),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kManualUploadGroup,
running: TaskNotification(
'uploading_media'.tr(),
'${'file_name'.tr()}: {displayName}',
),
complete: TaskNotification(
'upload_finished'.tr(),
'${'file_name'.tr()}: {displayName}',
),
progressBar: true,
);
}
Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async {

View File

@ -1,16 +1,50 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UploadActionButton extends ConsumerWidget {
const UploadActionButton({super.key});
final ActionSource source;
const UploadActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).upload(source);
final successMessage = 'upload_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success
? successMessage
: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
ref.read(multiSelectProvider.notifier).reset();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.backup_outlined,
label: "upload".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -37,6 +38,8 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly)
const UploadActionButton(source: ActionSource.viewer),
if (asset.hasRemote && isOwner)
const ArchiveActionButton(source: ActionSource.viewer),
];

View File

@ -63,7 +63,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
],
if (asset.storage == AssetState.local) ...[
const DeleteLocalActionButton(source: ActionSource.viewer),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
];

View File

@ -53,7 +53,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
],
);

View File

@ -53,7 +53,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
],
);

View File

@ -56,7 +56,7 @@ class GeneralBottomSheet extends ConsumerWidget {
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
],
);

View File

@ -18,7 +18,7 @@ class LocalAlbumBottomSheet extends ConsumerWidget {
actions: [
ShareActionButton(source: ActionSource.timeline),
DeleteLocalActionButton(source: ActionSource.timeline),
UploadActionButton(),
UploadActionButton(source: ActionSource.timeline),
],
);
}

View File

@ -56,7 +56,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
RemoveFromAlbumActionButton(
source: ActionSource.timeline,

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/drift_backup.service.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -32,12 +33,14 @@ class ActionResult {
class ActionNotifier extends Notifier<void> {
final Logger _logger = Logger('ActionNotifier');
late ActionService _service;
late DriftBackupService _backupService;
ActionNotifier() : super();
@override
void build() {
_service = ref.watch(actionServiceProvider);
_backupService = ref.watch(driftBackupServiceProvider);
}
List<String> _getRemoteIdsForSource(ActionSource source) {
@ -368,6 +371,21 @@ class ActionNotifier extends Notifier<void> {
);
}
}
Future<ActionResult> upload(ActionSource source) async {
final assets = _getAssets(source).whereType<LocalAsset>().toList();
try {
await _backupService.manualBackup(assets);
return ActionResult(count: assets.length, success: true);
} catch (error, stack) {
_logger.severe('Failed manually upload assets', error, stack);
return ActionResult(
count: assets.length,
success: false,
error: error.toString(),
);
}
}
}
extension on Iterable<RemoteAsset> {

View File

@ -20,6 +20,11 @@ class UploadRepository {
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (_) => {},
taskProgressCallback: (_) => {},
);
}
void enqueueAll(List<UploadTask> tasks) {

View File

@ -26,6 +26,7 @@ final driftBackupServiceProvider = Provider<DriftBackupService>(
),
);
// TODO: Rename to UploadService after removing Isar
class DriftBackupService {
DriftBackupService(
this._backupRepository,
@ -56,6 +57,24 @@ class DriftBackupService {
return _backupRepository.getBackupCount(userId);
}
Future<void> manualBackup(List<LocalAsset> localAssets) async {
List<UploadTask> tasks = [];
for (final asset in localAssets) {
final task = await _getUploadTask(
asset,
group: kManualUploadGroup,
priority: 1, // High priority after upload motion photo part
);
if (task != null) {
tasks.add(task);
}
}
if (tasks.isNotEmpty) {
_uploadService.enqueueTasks(tasks);
}
}
Future<void> backup(
String userId,
void Function(EnqueueStatus status) onEnqueueTasks,
@ -147,7 +166,11 @@ class DriftBackupService {
}
}
Future<UploadTask?> _getUploadTask(LocalAsset asset) async {
Future<UploadTask?> _getUploadTask(
LocalAsset asset, {
String group = kBackupGroup,
int? priority,
}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
@ -193,7 +216,8 @@ class DriftBackupService {
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
group: kBackupGroup,
group: group,
priority: priority,
);
}