refactor: mobile upload action

This commit is contained in:
shenlong-tanwen
2026-07-27 15:11:53 +05:30
parent 83594d0589
commit bd1f728436
7 changed files with 379 additions and 161 deletions
@@ -0,0 +1,145 @@
import 'dart:async';
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/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
final _stateProvider = Provider.family.autoDispose<List<LocalAsset>?, ActionSource>((ref, source) {
final AssetsActionState(:assets) = ref.watch(assetsActionProvider(source));
final local = assets.backedUp(isBackedUp: false).local().toList(growable: false);
return local.isEmpty ? null : local;
});
class UploadAction extends AssetActionBuilder {
final bool showProgress;
const UploadAction({required super.source, this.showProgress = false});
@override
ActionData? build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(_stateProvider(source));
if (assets == null) {
return null;
}
return .new(icon: Icons.backup_outlined, label: context.t.upload, onAction: () => _upload(context, ref, assets));
}
Future<void> _upload(BuildContext context, WidgetRef ref, List<LocalAsset> assets) async {
try {
if (!showProgress) {
await uploadAssets(context, ref, assets);
return;
}
// The dialog is not awaited: it stays up while the upload runs and is
// dismissed below, unless the user cancelled it themselves first
var isDialogOpen = true;
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const _UploadProgressDialog(),
).whenComplete(() => isDialogOpen = false),
);
await uploadAssets(context, ref, assets);
if (isDialogOpen && context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to upload the assets");
}
}
}
@visibleForTesting
Future<void> uploadAssets(BuildContext context, WidgetRef ref, List<LocalAsset> assets) async {
final progress = ref.read(assetUploadProgressProvider.notifier);
final uploads = ref.read(foregroundUploadServiceProvider);
final toast = ref.read(toastRepositoryProvider);
final errorMessage = context.t.scaffold_body_error_occurred;
final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
final uploaded = <String>{};
final failed = <String>{};
for (final asset in assets) {
progress.setProgress(asset.id, 0.0);
}
try {
await uploads.uploadManual(
assets,
cancelToken: cancelToken,
callbacks: UploadCallbacks(
onProgress: (id, _, bytes, total) => progress.setProgress(id, total > 0 ? bytes / total : 0.0),
onSuccess: (id, _) {
uploaded.add(id);
progress.remove(id);
},
onError: (id, _) {
failed.add(id);
progress.setError(id);
},
),
);
} finally {
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
}
final uploadedCount = uploaded.difference(failed).length;
if (!cancelToken.isCompleted && (uploadedCount != assets.length || failed.isNotEmpty)) {
toast.error(errorMessage);
}
unawaited(Future.delayed(const Duration(seconds: 2), progress.clear));
}
class _UploadProgressDialog extends ConsumerWidget {
const _UploadProgressDialog();
@override
Widget build(BuildContext context, WidgetRef ref) {
final progressMap = ref.watch(assetUploadProgressProvider);
final values = progressMap.values.where((value) => value >= 0).toList(growable: false);
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
final hasError = progressMap.values.any((value) => value < 0);
return AlertDialog(
title: Text(context.t.uploading),
content: Column(
mainAxisSize: .min,
children: [
if (hasError)
const Icon(Icons.error_outline, color: Colors.red, size: 48)
else
CircularProgressIndicator(value: progress > 0 ? progress : null),
const SizedBox(height: 16),
Text(hasError ? context.t.scaffold_body_error_occurred : '${(progress * 100).toInt()}%'),
],
),
actions: [
ImmichTextButton(
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
Navigator.of(context, rootNavigator: true).pop();
},
labelText: context.t.cancel,
),
],
);
}
}
@@ -1,152 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
class UploadActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UploadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final isTimeline = source == ActionSource.timeline;
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
List<LocalAsset>? assets;
var isUploadDialogOpen = false;
var wasUploadCancelled = false;
Future<void>? uploadDialogFuture;
if (source == ActionSource.timeline) {
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
if (assets.isEmpty) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
} else {
isUploadDialogOpen = true;
uploadDialogFuture =
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => _UploadProgressDialog(
onCancel: () {
wasUploadCancelled = true;
},
),
).whenComplete(() {
isUploadDialogOpen = false;
});
unawaited(uploadDialogFuture);
}
var success = false;
if (!isTimeline && viewerIntentFilePath != null) {
final viewIntentService = ref.read(viewIntentServiceProvider);
viewIntentService.markUploadActive(viewerIntentFilePath);
var hasError = false;
try {
await ref
.read(foregroundUploadServiceProvider)
.uploadShareIntent(
[File(viewerIntentFilePath)],
onError: (_, _) {
hasError = true;
},
);
} finally {
await viewIntentService.markUploadInactive(viewerIntentFilePath);
}
success = !hasError;
} else {
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
success = result.success;
}
if (!isTimeline && context.mounted && isUploadDialogOpen) {
Navigator.of(context, rootNavigator: true).pop();
}
if (context.mounted && !success && !wasUploadCancelled) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.backup_outlined,
label: "upload".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
class _UploadProgressDialog extends ConsumerWidget {
final VoidCallback onCancel;
const _UploadProgressDialog({required this.onCancel});
@override
Widget build(BuildContext context, WidgetRef ref) {
final progressMap = ref.watch(assetUploadProgressProvider);
// Calculate overall progress from all assets
final values = progressMap.values.where((v) => v >= 0).toList();
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
final hasError = progressMap.values.any((v) => v < 0);
final percentage = (progress * 100).toInt();
return AlertDialog(
title: Text('uploading'.t(context: context)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasError)
const Icon(Icons.error_outline, color: Colors.red, size: 48)
else
CircularProgressIndicator(value: progress > 0 ? progress : null),
const SizedBox(height: 16),
Text(hasError ? 'Error' : '$percentage%'),
],
),
actions: [
ImmichTextButton(
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
onCancel();
Navigator.of(context, rootNavigator: true).pop();
},
labelText: 'cancel'.t(context: context),
),
],
);
}
}
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
@@ -8,8 +7,8 @@ import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/edit_asset.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/upload.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_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/ocr_toggle_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
@@ -49,7 +48,7 @@ class ViewerBottomBar extends ConsumerWidget {
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
..._actionColumnButtons(context, ref, const [UploadAction(source: .viewer, showProgress: true)]),
..._actionColumnButtons(context, ref, const [EditAssetAction(source: .viewer)]),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
@@ -4,6 +4,7 @@ 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/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/upload.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/tag.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
@@ -15,7 +16,6 @@ import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.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';
@@ -93,7 +93,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
],
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const ActionColumnButton(action: CleanupLocalAction(source: .timeline)),
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: UploadAction(source: .timeline)),
],
slivers: [
const AddToAlbumHeader(),
@@ -4,9 +4,9 @@ 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/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/upload.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.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';
@@ -69,7 +69,7 @@ class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
ActionColumnButton(action: ShareAction(source: .timeline)),
ActionColumnButton(action: DeleteAction(source: .timeline)),
ActionColumnButton(action: CleanupLocalAction(source: .timeline)),
UploadActionButton(source: ActionSource.timeline),
ActionColumnButton(action: UploadAction(source: .timeline)),
],
slivers: [
const AddToAlbumHeader(),
+4 -2
View File
@@ -24,9 +24,9 @@ import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/similar_photos.action.dart';
import 'package:immich_mobile/presentation/actions/slideshow.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/upload.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class ActionButtonContext {
@@ -184,7 +184,9 @@ enum ActionButtonType {
ActionButtonType.moveToLockFolder ||
ActionButtonType.removeFromLockFolder => ActionMenuItem(action: LockAction(source: context.source)),
ActionButtonType.deleteLocal => ActionMenuItem(action: CleanupLocalAction(source: context.source)),
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.upload => ActionMenuItem(
action: UploadAction(source: context.source, showProgress: context.source == ActionSource.viewer),
),
ActionButtonType.removeFromAlbum => ActionMenuItem(
action: RemoveFromAlbumAction(source: context.source, albumId: context.currentAlbum!.id),
),
@@ -0,0 +1,224 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/upload.action.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../../service.mocks.dart';
import '../../factories/local_asset_factory.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockForegroundUploadService uploadService;
setUp(() async {
context = await PresentationContext.create();
uploadService = context.service.upload;
});
tearDown(() {
context.dispose();
});
List<Override> uploadOverrides() => [
foregroundUploadServiceProvider.overrideWithValue(uploadService),
toastRepositoryProvider.overrideWithValue(context.repository.toast),
];
Future<void> pumpUpload(WidgetTester tester, Set<BaseAsset> selection, {bool showProgress = false}) =>
tester.pumpTestWidget(
context,
ActionIconButton(
action: UploadAction(source: .timeline, showProgress: showProgress),
),
overrides: [...context.selected(selection), ...uploadOverrides()],
);
Future<void> settleUpload(WidgetTester tester) async {
await tester.pump();
await tester.pump(const .new(seconds: 2));
await tester.pumpAndSettle();
}
void answerUploadWith({Set<String> succeeded = const {}, Set<String> failed = const {}, Completer<void>? until}) {
when(
() => uploadService.uploadManual(
any(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).thenAnswer((invocation) async {
if (until != null) {
await until.future;
}
final callbacks = invocation.namedArguments[#callbacks] as UploadCallbacks;
for (final id in succeeded) {
callbacks.onSuccess?.call(id, id);
}
for (final id in failed) {
callbacks.onError?.call(id, 'boom');
}
});
}
group('UploadAction', () {
testWidgets('uploads the selected local assets', (tester) async {
final asset = LocalAssetFactory.create();
answerUploadWith(succeeded: {asset.id});
await pumpUpload(tester, {asset});
await tester.tap(find.byType(ImmichIconButton));
await settleUpload(tester);
final uploaded =
verify(
() => uploadService.uploadManual(
captureAny(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).captured.single
as List<LocalAsset>;
expect(uploaded.map((a) => a.id), [asset.id]);
});
testWidgets('ignores assets that are already backed up', (tester) async {
final notBackedUp = LocalAssetFactory.create();
answerUploadWith(succeeded: {notBackedUp.id});
await pumpUpload(tester, {notBackedUp, LocalAssetFactory.create(remoteId: 'already-there')});
await tester.tap(find.byType(ImmichIconButton));
await settleUpload(tester);
final uploaded =
verify(
() => uploadService.uploadManual(
captureAny(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).captured.single
as List<LocalAsset>;
expect(uploaded.map((a) => a.id), [notBackedUp.id]);
});
testWidgets('reports an error when an asset fails to upload', (tester) async {
final asset = LocalAssetFactory.create();
answerUploadWith(failed: {asset.id});
await pumpUpload(tester, {asset});
await tester.tap(find.byType(ImmichIconButton));
await settleUpload(tester);
final message = verify(() => context.repository.toast.error(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.scaffold_body_error_occurred);
});
testWidgets('stays quiet when everything uploaded', (tester) async {
final asset = LocalAssetFactory.create();
answerUploadWith(succeeded: {asset.id});
await pumpUpload(tester, {asset});
await tester.tap(find.byType(ImmichIconButton));
await settleUpload(tester);
verifyNever(() => context.repository.toast.error(any()));
});
testWidgets('treats a cancelled upload as deliberate, not a failure', (tester) async {
final asset = LocalAssetFactory.create();
when(
() => uploadService.uploadManual(
any(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).thenAnswer((invocation) async {
(invocation.namedArguments[#cancelToken] as Completer<void>).complete();
});
await pumpUpload(tester, {asset});
await tester.tap(find.byType(ImmichIconButton));
await settleUpload(tester);
verifyNever(() => context.repository.toast.error(any()));
});
testWidgets('shows the progress dialog while uploading and closes it after', (tester) async {
final asset = LocalAssetFactory.create();
final uploading = Completer<void>();
answerUploadWith(succeeded: {asset.id}, until: uploading);
await pumpUpload(tester, {asset}, showProgress: true);
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
expect(find.text(StaticTranslations.instance.uploading), findsOneWidget);
uploading.complete();
await settleUpload(tester);
expect(find.text(StaticTranslations.instance.uploading), findsNothing);
});
testWidgets('shows no dialog when not asked to', (tester) async {
final asset = LocalAssetFactory.create();
answerUploadWith(succeeded: {asset.id});
await pumpUpload(tester, {asset});
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
expect(find.text(StaticTranslations.instance.uploading), findsNothing);
await settleUpload(tester);
});
testWidgets('is hidden for a remote asset, which has nothing to upload', (tester) async {
await pumpUpload(tester, {RemoteAssetFactory.create()});
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('is hidden when every local asset is already backed up', (tester) async {
await pumpUpload(tester, {LocalAssetFactory.create(remoteId: 'already-there')});
expect(find.byType(ImmichIconButton), findsNothing);
});
});
group('uploadAssets', () {
testWidgets('clears the tracked progress once the upload settles', (tester) async {
final asset = LocalAssetFactory.create();
answerUploadWith(succeeded: {asset.id});
late WidgetRef capturedRef;
await tester.pumpTestWidget(
context,
Consumer(
builder: (_, ref, _) {
capturedRef = ref;
return const SizedBox.shrink();
},
),
overrides: uploadOverrides(),
);
await uploadAssets(tester.element(find.byType(SizedBox)), capturedRef, [asset]);
await settleUpload(tester);
expect(capturedRef.read(assetUploadProgressProvider), isEmpty);
expect(capturedRef.read(manualUploadCancelTokenProvider), isNull);
});
});
}