mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
refactor: mobile upload action
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user