mirror of
https://github.com/immich-app/immich.git
synced 2026-07-23 05:44:48 +03:00
Compare commits
1 Commits
feat/uploa
...
feat/delet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8260d84bf5 |
@@ -3,14 +3,11 @@ import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/download.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_location.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/actions/tag.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/upload.action.dart';
|
||||
|
||||
class AssetActions {
|
||||
final AssetDebugAction debug;
|
||||
@@ -22,9 +19,6 @@ class AssetActions {
|
||||
final CleanupLocalAction cleanup;
|
||||
final EditDateTimeAction editDateTime;
|
||||
final EditLocationAction editLocation;
|
||||
final DownloadAction download;
|
||||
final TagAction tag;
|
||||
final UploadAction upload;
|
||||
|
||||
const AssetActions({
|
||||
required this.debug,
|
||||
@@ -36,9 +30,6 @@ class AssetActions {
|
||||
required this.cleanup,
|
||||
required this.editDateTime,
|
||||
required this.editLocation,
|
||||
required this.download,
|
||||
required this.tag,
|
||||
required this.upload,
|
||||
});
|
||||
|
||||
factory AssetActions.from(ActionScope scope, List<BaseAsset> assets) => .new(
|
||||
@@ -51,8 +42,5 @@ class AssetActions {
|
||||
cleanup: CleanupLocalAction(assets: assets, scope: scope),
|
||||
editDateTime: EditDateTimeAction(assets: assets, scope: scope),
|
||||
editLocation: EditLocationAction(assets: assets, scope: scope),
|
||||
download: DownloadAction(assets: assets, scope: scope),
|
||||
tag: TagAction(assets: assets, scope: scope),
|
||||
upload: UploadAction(assets: assets, scope: scope),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.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/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class DownloadAction extends BaseAction {
|
||||
final List<RemoteAsset> assets;
|
||||
|
||||
DownloadAction._({required this.assets, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.download, label: scope.context.t.download);
|
||||
|
||||
factory DownloadAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final remote = AssetFilter(assets).remote().toList(growable: false);
|
||||
return ._(assets: remote, scope: scope, isVisible: remote.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref) = scope;
|
||||
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||
|
||||
await ref.read(downloadRepositoryProvider).downloadAllAssets(assets);
|
||||
|
||||
unawaited(
|
||||
Future.delayed(const Duration(seconds: 1), () async {
|
||||
await backgroundSync.syncLocal();
|
||||
await backgroundSync.hashAssets();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/platform_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
|
||||
class ShareAction extends BaseAction {
|
||||
final List<BaseAsset> assets;
|
||||
|
||||
ShareAction._({required this.assets, required super.scope, super.isVisible})
|
||||
: super(
|
||||
icon: CurrentPlatform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||
label: scope.context.t.share,
|
||||
);
|
||||
|
||||
factory ShareAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final shareable = assets.toList(growable: false);
|
||||
return ._(assets: shareable, scope: scope, isVisible: shareable.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() => _share(scope.ref.read(appConfigProvider).share.fileType);
|
||||
|
||||
@override
|
||||
Future<void> Function()? get onSecondaryAction => _promptQualityAndShare;
|
||||
|
||||
Future<void> _promptQualityAndShare() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
|
||||
// only show preview option when at least one of the assets is not a video
|
||||
// we cant share previews of videos
|
||||
final showPreview = assets.isEmpty || assets.any((asset) => !asset.isVideo);
|
||||
|
||||
final fileType = await showDialog<ShareAssetType>(
|
||||
context: context,
|
||||
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
if (fileType == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(settingsProvider).write(.shareFileType, fileType);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _share(fileType);
|
||||
}
|
||||
|
||||
Future<void> _share(ShareAssetType fileType) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
final cancelCompleter = Completer<void>();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
useRootNavigator: false,
|
||||
builder: (dialogContext) {
|
||||
void finish({required bool failed}) {
|
||||
if (cancelCompleter.isCompleted || !dialogContext.mounted) {
|
||||
return;
|
||||
}
|
||||
if (failed) {
|
||||
ref.read(toastRepositoryProvider).error(context.t.scaffold_body_error_occurred);
|
||||
}
|
||||
dialogContext.pop();
|
||||
}
|
||||
|
||||
unawaited(
|
||||
ref
|
||||
.read(assetMediaRepositoryProvider)
|
||||
.shareAssets(
|
||||
assets,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
.then<void>(
|
||||
(count) => finish(failed: count == 0 && assets.isNotEmpty),
|
||||
onError: (_) => finish(failed: true),
|
||||
),
|
||||
);
|
||||
|
||||
// Show download progress with a "Preparing" message
|
||||
return _SharePreparingDialog(progress: progress);
|
||||
},
|
||||
).then((_) {
|
||||
if (!cancelCompleter.isCompleted) {
|
||||
cancelCompleter.complete();
|
||||
}
|
||||
progress.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _SharePreparingDialog extends StatelessWidget {
|
||||
final ValueNotifier<double?> progress;
|
||||
|
||||
const _SharePreparingDialog({required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Container(margin: const .only(bottom: 12), child: Text(context.t.share_dialog_preparing)),
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: ValueListenableBuilder<double?>(
|
||||
valueListenable: progress,
|
||||
builder: (context, value, _) {
|
||||
final percent = value == null ? null : (value * 100).clamp(0, 100);
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
LinearProgressIndicator(value: value, minHeight: 8.0),
|
||||
if (percent != null)
|
||||
Container(margin: const .only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareFileTypeDialog extends StatelessWidget {
|
||||
final bool showPreview;
|
||||
|
||||
const _ShareFileTypeDialog({this.showPreview = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.select_quality),
|
||||
contentPadding: const .symmetric(vertical: 8),
|
||||
content: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.high_quality_rounded),
|
||||
title: Text(context.t.share_original),
|
||||
onTap: () => context.pop(ShareAssetType.original),
|
||||
),
|
||||
if (showPreview)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_size_select_large_rounded),
|
||||
title: Text(context.t.share_preview),
|
||||
onTap: () => context.pop(ShareAssetType.preview),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.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/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class ShareLinkAction extends BaseAction {
|
||||
final List<String> remoteIds;
|
||||
|
||||
ShareLinkAction._({required this.remoteIds, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.link_rounded, label: scope.context.t.share_link);
|
||||
|
||||
factory ShareLinkAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final remoteIds = AssetFilter(assets).remote().map((asset) => asset.id).toList(growable: false);
|
||||
return ._(remoteIds: remoteIds, scope: scope, isVisible: remoteIds.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async => unawaited(scope.context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||
|
||||
class TagAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
|
||||
TagAction._({required this.assetIds, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.sell_outlined, label: scope.context.t.control_bottom_app_bar_add_tags);
|
||||
|
||||
factory TagAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final assetIds = AssetFilter(assets).owned(scope.authUser.id).map((asset) => asset.id).toList(growable: false);
|
||||
return ._(assetIds: assetIds, scope: scope, isVisible: assetIds.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final results = await showTagPickerModal(context: scope.context);
|
||||
if (results == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tagAssets(selected: results.$1, created: results.$2);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> tagAssets({required Set<String> selected, required Set<String> created}) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
final tagService = ref.read(tagServiceProvider);
|
||||
final tagIds = {...selected};
|
||||
|
||||
if (created.isNotEmpty) {
|
||||
final tags = await tagService.upsertTags(created.toList());
|
||||
tagIds.addAll(tags.map((tag) => tag.id));
|
||||
}
|
||||
if (tagIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final count = await tagService.bulkTagAssets(assetIds, tagIds.toList());
|
||||
ref.invalidate(tagProvider);
|
||||
ref.read(toastRepositoryProvider).success(context.t.tagged_assets(count: count));
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.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.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/asset_filter.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class UploadAction extends BaseAction {
|
||||
final List<LocalAsset> assets;
|
||||
|
||||
final bool showProgress;
|
||||
|
||||
UploadAction._({required this.assets, required this.showProgress, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.backup_outlined, label: scope.context.t.upload);
|
||||
|
||||
factory UploadAction({required Iterable<BaseAsset> assets, required ActionScope scope, bool showProgress = false}) {
|
||||
final local = AssetFilter(assets).backedUp(isBackedUp: false).local().toList(growable: false);
|
||||
return ._(assets: local, scope: scope, showProgress: showProgress, isVisible: local.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showProgress) {
|
||||
await upload();
|
||||
return;
|
||||
}
|
||||
|
||||
final context = scope.context;
|
||||
var isDialogOpen = true;
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _UploadProgressDialog(),
|
||||
).whenComplete(() => isDialogOpen = false),
|
||||
);
|
||||
|
||||
await upload();
|
||||
|
||||
if (isDialogOpen && context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> upload() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
final progress = ref.read(assetUploadProgressProvider.notifier);
|
||||
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 ref
|
||||
.read(foregroundUploadServiceProvider)
|
||||
.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;
|
||||
final wasCancelled = cancelToken.isCompleted;
|
||||
if (!wasCancelled && (uploadedCount != assets.length || failed.isNotEmpty)) {
|
||||
ref.read(toastRepositoryProvider).error(context.t.scaffold_body_error_occurred);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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(context.t.uploading),
|
||||
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;
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
labelText: context.t.cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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 BulkTagAssetsActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const BulkTagAssetsActionButton({super.key, required this.source});
|
||||
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
final result = await ref.read(actionProvider.notifier).tagAssets(source, context);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success
|
||||
? 'tagged_assets'.t(context: context, args: {'count': result.count.toString()})
|
||||
: 'errors.failed_to_tag_assets'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.sell_outlined,
|
||||
label: "control_bottom_app_bar_add_tags".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.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/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class DownloadActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(actionProvider.notifier).downloadAll(source);
|
||||
|
||||
Future.delayed(const Duration(seconds: 1), () async {
|
||||
await backgroundSyncManager.syncLocal();
|
||||
await backgroundSyncManager.hashAssets();
|
||||
});
|
||||
} finally {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final backgroundManager = ref.watch(backgroundSyncProvider);
|
||||
|
||||
return BaseActionButton(
|
||||
iconData: Icons.download,
|
||||
maxWidth: 95,
|
||||
label: "download".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
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/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class _SharePreparingDialog extends StatelessWidget {
|
||||
final ValueNotifier<double?> progress;
|
||||
|
||||
const _SharePreparingDialog({required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()),
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: ValueListenableBuilder<double?>(
|
||||
valueListenable: progress,
|
||||
builder: (context, value, _) {
|
||||
final percent = value == null ? null : (value * 100).clamp(0, 100);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(value: value, minHeight: 8.0),
|
||||
if (percent != null)
|
||||
Container(margin: const EdgeInsets.only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareFileTypeDialog extends StatelessWidget {
|
||||
final bool showPreview;
|
||||
|
||||
const _ShareFileTypeDialog({this.showPreview = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.select_quality),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.high_quality_rounded),
|
||||
title: Text(context.t.share_original),
|
||||
onTap: () => context.pop(ShareAssetType.original),
|
||||
),
|
||||
if (showPreview)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_size_select_large_rounded),
|
||||
title: Text(context.t.share_preview),
|
||||
onTap: () => context.pop(ShareAssetType.preview),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
Set<BaseAsset> _getSelectedAssets(WidgetRef ref) {
|
||||
return switch (source) {
|
||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
|
||||
BaseAsset asset => {asset},
|
||||
null => const {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileType = ref.read(appConfigProvider).share.fileType;
|
||||
await _share(context, ref, fileType);
|
||||
}
|
||||
|
||||
void _onLongPress(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// only show preview option when at least one of the assets is not a video
|
||||
// we cant share previews of videos
|
||||
final assets = _getSelectedAssets(ref);
|
||||
final showPreview = assets.isEmpty || assets.any((asset) => !asset.isVideo);
|
||||
|
||||
final fileType = await showDialog<ShareAssetType>(
|
||||
context: context,
|
||||
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
if (fileType == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(settingsProvider).write(SettingsKey.shareFileType, fileType);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _share(context, ref, fileType);
|
||||
}
|
||||
|
||||
Future<void> _share(BuildContext context, WidgetRef ref, ShareAssetType fileType) async {
|
||||
final cancelCompleter = Completer<void>();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
final preparingDialog = _SharePreparingDialog(progress: progress);
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref
|
||||
.read(actionProvider.notifier)
|
||||
.shareAssets(
|
||||
source,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
.then((ActionResult result) {
|
||||
if (cancelCompleter.isCompleted || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: context.t.scaffold_body_error_occurred,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
buildContext.pop();
|
||||
});
|
||||
|
||||
// Show download progress with a "Preparing" message
|
||||
return preparingDialog;
|
||||
},
|
||||
barrierDismissible: false,
|
||||
useRootNavigator: false,
|
||||
).then((_) {
|
||||
if (!cancelCompleter.isCompleted) {
|
||||
cancelCompleter.complete();
|
||||
}
|
||||
progress.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||
label: context.t.share,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
onLongPressed: () => _onLongPress(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.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';
|
||||
|
||||
class ShareLinkActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const ShareLinkActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
_onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(actionProvider.notifier).shareLink(source, context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.link_rounded,
|
||||
label: "share_link".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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,5 +1,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/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -7,9 +8,9 @@ import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
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/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/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';
|
||||
@@ -39,17 +40,14 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final restore = RestoreAction(assets: assets, scope: scope);
|
||||
final delete = DeleteAction(assets: assets, scope: scope);
|
||||
final editImage = EditAssetAction(assets: assets, scope: scope);
|
||||
final upload = UploadAction(assets: assets, scope: scope, showProgress: true);
|
||||
final actions = <Widget>[
|
||||
if (restore.isVisible) ActionColumnButtonWidget(action: restore),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (editImage.isVisible) ActionColumnButtonWidget(action: editImage),
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (upload.isVisible) ActionColumnButtonWidget(action: upload),
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
if (editImage.isVisible) ActionColumnButtonWidget(action: editImage),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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/share_link_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';
|
||||
@@ -66,8 +67,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
}
|
||||
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
@@ -75,13 +75,9 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasRemote) ...[
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
...[
|
||||
actions.favorite,
|
||||
actions.archive,
|
||||
@@ -91,8 +87,8 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
actions.download,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
],
|
||||
slivers: [
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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/share_link_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';
|
||||
@@ -56,21 +58,16 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
|
||||
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.4,
|
||||
maxChildSize: 0.7,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasRemote) ...[
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
...[
|
||||
actions.favorite,
|
||||
actions.archive,
|
||||
@@ -80,8 +77,8 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
actions.download,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
],
|
||||
slivers: multiselect.hasRemote
|
||||
|
||||
@@ -6,12 +6,16 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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/share_link_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/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -40,6 +44,9 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final tagsEnabled = ref.watch(
|
||||
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
||||
);
|
||||
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
@@ -65,8 +72,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
}
|
||||
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
@@ -85,18 +91,14 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
actions.download,
|
||||
actions.tag,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasRemote) ...[
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.upload)),
|
||||
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
|
||||
@@ -6,8 +6,9 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.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';
|
||||
@@ -72,14 +73,12 @@ class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
...[
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.upload)),
|
||||
const UploadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
@@ -23,10 +25,8 @@ class LockedFolderBottomSheet extends ConsumerWidget {
|
||||
maxChildSize: 0.4,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.download)),
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
const DownloadActionButton(source: ActionSource.timeline),
|
||||
...[
|
||||
actions.delete,
|
||||
LockAction(assets: assets, scope: scope),
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/download.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class PartnerDetailBottomSheet extends ConsumerWidget {
|
||||
const PartnerDetailBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
|
||||
|
||||
return BaseBottomSheet(
|
||||
return const BaseBottomSheet(
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.4,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionColumnButtonWidget(
|
||||
action: TimelineAction(
|
||||
action: DownloadAction(assets: assets, scope: scope),
|
||||
),
|
||||
),
|
||||
ShareActionButton(source: ActionSource.timeline),
|
||||
DownloadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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/share_link_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';
|
||||
@@ -85,13 +86,9 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasRemote) ...[
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
|
||||
if (ownsAlbum) ...[
|
||||
...[
|
||||
@@ -105,7 +102,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
actions.editLocation,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
],
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.download)),
|
||||
const DownloadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
if (ownsAlbum)
|
||||
ActionColumnButtonWidget(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
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';
|
||||
@@ -10,10 +11,13 @@ 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/tag.provider.dart';
|
||||
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/download.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
|
||||
@@ -71,6 +75,29 @@ class ActionNotifier extends Notifier<void> {
|
||||
return _getAssets(source).whereType<RemoteAsset>().toIds().toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _getLocalIdsForSource(ActionSource source, {bool ignoreLocalOnly = false}) {
|
||||
final Set<BaseAsset> assets = _getAssets(source);
|
||||
final List<String> localIds = [];
|
||||
|
||||
for (final asset in assets) {
|
||||
if (ignoreLocalOnly && asset.storage != AssetState.merged) {
|
||||
continue;
|
||||
}
|
||||
if (asset is LocalAsset) {
|
||||
localIds.add(asset.id);
|
||||
} else if (asset is RemoteAsset && asset.localId != null) {
|
||||
localIds.add(asset.localId!);
|
||||
}
|
||||
}
|
||||
|
||||
return localIds;
|
||||
}
|
||||
|
||||
List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
|
||||
final ownerId = ref.read(currentUserProvider)?.id;
|
||||
return _getAssets(source).whereType<RemoteAsset>().ownedAssets(ownerId).toIds().toList(growable: false);
|
||||
}
|
||||
|
||||
Set<BaseAsset> _getAssets(ActionSource source) {
|
||||
return switch (source) {
|
||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||
@@ -81,6 +108,17 @@ class ActionNotifier extends Notifier<void> {
|
||||
};
|
||||
}
|
||||
|
||||
Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.shareLink(ids, context);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to create shared link for assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> emptyTrash(String userId) async {
|
||||
try {
|
||||
final count = await _service.emptyTrash(userId);
|
||||
@@ -101,6 +139,77 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> trashRemoteAndDeleteLocal(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
final localIds = _getLocalIdsForSource(source);
|
||||
try {
|
||||
await _service.trashRemoteAndDeleteLocal(ids, localIds);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to delete assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> deleteRemoteAndLocal(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
final localIds = _getLocalIdsForSource(source);
|
||||
try {
|
||||
await _service.deleteRemoteAndLocal(ids, localIds);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to delete assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> deleteLocal(ActionSource source, BuildContext context) async {
|
||||
final assets = _getAssets(source);
|
||||
bool? backedUpOnly = assets.every((asset) => asset.storage == AssetState.merged)
|
||||
? true
|
||||
: await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => DeleteLocalOnlyDialog(onDeleteLocal: (_) {}),
|
||||
);
|
||||
|
||||
if (backedUpOnly == null) {
|
||||
// User cancelled the dialog
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<String> ids;
|
||||
if (backedUpOnly) {
|
||||
ids = assets.where((asset) => asset.storage == AssetState.merged).map((asset) => asset.localId!).toList();
|
||||
} else {
|
||||
ids = _getLocalIdsForSource(source);
|
||||
}
|
||||
|
||||
try {
|
||||
final deletedCount = await _service.deleteLocal(ids);
|
||||
return ActionResult(count: deletedCount, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to delete assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
final count = await _service.tagAssets(ids, context);
|
||||
if (count == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ref.invalidate(tagProvider);
|
||||
return ActionResult(count: count, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to tag assets', error, stack);
|
||||
ref.invalidate(tagProvider);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> addToAlbum(ActionSource source, RemoteAlbum album) async {
|
||||
final selected = _getAssets(source).toList(growable: false);
|
||||
if (selected.isEmpty) {
|
||||
@@ -179,6 +288,42 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> shareAssets(
|
||||
ActionSource source,
|
||||
BuildContext context, {
|
||||
ShareAssetType fileType = ShareAssetType.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
final ids = _getAssets(source).toList(growable: false);
|
||||
|
||||
try {
|
||||
final count = await _service.shareAssets(
|
||||
ids,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
return ActionResult(count: count, success: count > 0 || ids.isEmpty);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to share assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> upload(
|
||||
ActionSource source, {
|
||||
List<LocalAsset>? assets,
|
||||
@@ -263,4 +408,11 @@ class ActionNotifier extends Notifier<void> {
|
||||
|
||||
extension on Iterable<RemoteAsset> {
|
||||
Iterable<String> toIds() => map((e) => e.id);
|
||||
|
||||
Iterable<RemoteAsset> ownedAssets(String? ownerId) {
|
||||
if (ownerId == null) {
|
||||
return const [];
|
||||
}
|
||||
return whereType<RemoteAsset>().where((a) => a.ownerId == ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,82 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.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/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.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';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||
|
||||
final actionServiceProvider = Provider<ActionService>(
|
||||
(ref) => ActionService(ref.watch(assetApiRepositoryProvider), ref.watch(remoteAssetRepositoryProvider)),
|
||||
(ref) => ActionService(
|
||||
ref.watch(assetApiRepositoryProvider),
|
||||
ref.watch(remoteAssetRepositoryProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(trashedLocalAssetRepository),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(downloadRepositoryProvider),
|
||||
ref.watch(tagServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class ActionService {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final DriftRemoteAlbumRepository _remoteAlbumRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final DownloadRepository _downloadRepository;
|
||||
final TagService _tagService;
|
||||
|
||||
const ActionService(this._assetApiRepository, this._remoteAssetRepository);
|
||||
const ActionService(
|
||||
this._assetApiRepository,
|
||||
this._remoteAssetRepository,
|
||||
this._localAssetRepository,
|
||||
this._albumApiRepository,
|
||||
this._remoteAlbumRepository,
|
||||
this._trashedLocalAssetRepository,
|
||||
this._assetMediaRepository,
|
||||
this._downloadRepository,
|
||||
this._tagService,
|
||||
);
|
||||
|
||||
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
|
||||
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
||||
}
|
||||
|
||||
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .locked);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
|
||||
|
||||
// Ask user if they want to delete local copies
|
||||
if (localIds.isNotEmpty) {
|
||||
await _deleteLocalAssets(localIds);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeFromLockFolder(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
Future<int> emptyTrash(String userId) async {
|
||||
final count = await _assetApiRepository.emptyTrash();
|
||||
@@ -27,6 +90,28 @@ class ActionService {
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<void> trashRemoteAndDeleteLocal(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.delete(remoteIds, false);
|
||||
await _remoteAssetRepository.trash(remoteIds);
|
||||
|
||||
if (localIds.isNotEmpty) {
|
||||
await _deleteLocalAssets(localIds);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteRemoteAndLocal(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.delete(remoteIds, true);
|
||||
await _remoteAssetRepository.delete(remoteIds);
|
||||
|
||||
if (localIds.isNotEmpty) {
|
||||
await _deleteLocalAssets(localIds);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> deleteLocal(List<String> localIds) async {
|
||||
return await _deleteLocalAssets(localIds);
|
||||
}
|
||||
|
||||
Future<bool> updateDescription(String assetId, String description) async {
|
||||
// update remote first, then local to ensure consistency
|
||||
await _assetApiRepository.updateDescription(assetId, description);
|
||||
@@ -42,4 +127,74 @@ class ActionService {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<int?> tagAssets(List<String> remoteIds, BuildContext context) async {
|
||||
final tagResults = await showTagPickerModal(context: context);
|
||||
if (tagResults == null) {
|
||||
// user cancelled
|
||||
return null;
|
||||
}
|
||||
|
||||
final selectedTagIds = Set<String>.from(tagResults.$1);
|
||||
final selectedNewTagValues = tagResults.$2;
|
||||
|
||||
if (selectedNewTagValues.isNotEmpty) {
|
||||
final upsertedTags = await _tagService.upsertTags(selectedNewTagValues.toList());
|
||||
selectedTagIds.addAll(upsertedTags.map((t) => t.id));
|
||||
}
|
||||
if (selectedTagIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
return _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList());
|
||||
}
|
||||
|
||||
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||
final stack = await _assetApiRepository.stack(remoteIds);
|
||||
await _remoteAssetRepository.stack(userId, stack);
|
||||
}
|
||||
|
||||
Future<void> unStack(List<String> stackIds) async {
|
||||
await _remoteAssetRepository.unStack(stackIds);
|
||||
await _assetApiRepository.unStack(stackIds);
|
||||
}
|
||||
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
ShareAssetType fileType = ShareAssetType.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) {
|
||||
return _assetMediaRepository.shareAssets(
|
||||
assets,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
|
||||
return _downloadRepository.downloadAllAssets(assets);
|
||||
}
|
||||
|
||||
Future<bool> setAlbumCover(String albumId, String assetId) async {
|
||||
final owner = await _remoteAlbumRepository.getOwner(albumId);
|
||||
final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, owner, thumbnailAssetId: assetId);
|
||||
await _remoteAlbumRepository.update(updatedAlbum);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<int> _deleteLocalAssets(List<String> localIds) async {
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (deletedIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
|
||||
} else {
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
}
|
||||
return deletedIds.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,21 +14,21 @@ import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/cast.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/download.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/open_in_browser.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/set_profile_picture.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
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/download_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/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_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 {
|
||||
@@ -175,52 +175,47 @@ enum ActionButtonType {
|
||||
bool iconOnly = false,
|
||||
bool menuItem = false,
|
||||
]) {
|
||||
final assets = [context.asset];
|
||||
final scope = ActionScope.from(buildContext, ref);
|
||||
return switch (this) {
|
||||
ActionButtonType.advancedInfo => ActionMenuItemWidget(
|
||||
action: AssetDebugAction(assets: assets, scope: scope),
|
||||
action: AssetDebugAction(assets: [context.asset], scope: scope),
|
||||
),
|
||||
ActionButtonType.share => ActionMenuItemWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.shareLink => ActionMenuItemWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.shareLink => ShareLinkActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.slideshow => ActionMenuItemWidget(action: SlideshowAction(scope: scope)),
|
||||
ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItemWidget(
|
||||
action: ArchiveAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.download => ActionMenuItemWidget(
|
||||
action: DownloadAction(assets: assets, scope: scope),
|
||||
action: ArchiveAction(assets: [context.asset], scope: scope),
|
||||
),
|
||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.restoreTrash => ActionMenuItemWidget(
|
||||
action: RestoreAction(assets: assets, scope: scope),
|
||||
action: RestoreAction(assets: [context.asset], scope: scope),
|
||||
),
|
||||
ActionButtonType.delete => ActionMenuItemWidget(
|
||||
action: DeleteAction(assets: assets, scope: scope),
|
||||
action: DeleteAction(assets: [context.asset], scope: scope),
|
||||
),
|
||||
ActionButtonType.moveToLockFolder => ActionMenuItemWidget(
|
||||
action: LockAction(assets: assets, scope: scope),
|
||||
action: LockAction(assets: [context.asset], scope: scope),
|
||||
),
|
||||
ActionButtonType.removeFromLockFolder => ActionMenuItemWidget(
|
||||
action: LockAction(assets: assets, scope: scope),
|
||||
action: LockAction(assets: [context.asset], scope: scope),
|
||||
),
|
||||
ActionButtonType.deleteLocal => ActionMenuItemWidget(
|
||||
action: CleanupLocalAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.upload => ActionMenuItemWidget(
|
||||
action: UploadAction(assets: assets, scope: scope, showProgress: context.source == ActionSource.viewer),
|
||||
action: CleanupLocalAction(assets: [context.asset], scope: scope),
|
||||
),
|
||||
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.removeFromAlbum => ActionMenuItemWidget(
|
||||
action: RemoveFromAlbumAction(assets: assets, albumId: context.currentAlbum!.id, scope: scope),
|
||||
action: RemoveFromAlbumAction(assets: [context.asset], albumId: context.currentAlbum!.id, scope: scope),
|
||||
),
|
||||
ActionButtonType.setAlbumCover => ActionMenuItemWidget(
|
||||
action: SetAlbumCoverAction(assets: assets, albumId: context.currentAlbum!.id, scope: scope),
|
||||
action: SetAlbumCoverAction(assets: [context.asset], albumId: context.currentAlbum!.id, scope: scope),
|
||||
),
|
||||
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unstack => ActionMenuItemWidget(
|
||||
action: StackAction(assets: assets, scope: scope),
|
||||
action: StackAction(assets: [context.asset], scope: scope),
|
||||
),
|
||||
ActionButtonType.openInBrowser => ActionMenuItemWidget(
|
||||
action: OpenInBrowserAction(remoteId: context.asset.remoteId!, origin: context.timelineOrigin, scope: scope),
|
||||
|
||||
@@ -20,5 +20,5 @@ extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implem
|
||||
AssetFilter<RemoteAsset> trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed);
|
||||
|
||||
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
||||
AssetFilter<BaseAsset> backedUp({bool isBackedUp = true}) => where((asset) => asset.isMerged == isBackedUp);
|
||||
AssetFilter<BaseAsset> backedUp() => where((asset) => asset.isMerged);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -11,8 +10,6 @@ class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||
|
||||
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
||||
|
||||
class MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
class MockPermissionRepository extends Mock implements IPermissionRepository {}
|
||||
|
||||
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||
|
||||
@@ -2,21 +2,32 @@ import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
class MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
void main() {
|
||||
late ActionService sut;
|
||||
|
||||
late MockAssetApiRepository assetApiRepository;
|
||||
late MockRemoteAssetRepository remoteAssetRepository;
|
||||
late MockDriftLocalAssetRepository localAssetRepository;
|
||||
late MockDriftAlbumApiRepository albumApiRepository;
|
||||
late MockRemoteAlbumRepository remoteAlbumRepository;
|
||||
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
|
||||
late MockAssetMediaRepository assetMediaRepository;
|
||||
late MockDownloadRepository downloadRepository;
|
||||
late MockTagService tagService;
|
||||
|
||||
late Drift db;
|
||||
|
||||
@@ -37,8 +48,25 @@ void main() {
|
||||
setUp(() {
|
||||
assetApiRepository = MockAssetApiRepository();
|
||||
remoteAssetRepository = MockRemoteAssetRepository();
|
||||
localAssetRepository = MockDriftLocalAssetRepository();
|
||||
albumApiRepository = MockDriftAlbumApiRepository();
|
||||
remoteAlbumRepository = MockRemoteAlbumRepository();
|
||||
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
assetMediaRepository = MockAssetMediaRepository();
|
||||
downloadRepository = MockDownloadRepository();
|
||||
tagService = MockTagService();
|
||||
|
||||
sut = ActionService(assetApiRepository, remoteAssetRepository);
|
||||
sut = ActionService(
|
||||
assetApiRepository,
|
||||
remoteAssetRepository,
|
||||
localAssetRepository,
|
||||
albumApiRepository,
|
||||
remoteAlbumRepository,
|
||||
trashedLocalAssetRepository,
|
||||
assetMediaRepository,
|
||||
downloadRepository,
|
||||
tagService,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -70,4 +98,50 @@ void main() {
|
||||
verify(() => remoteAssetRepository.updateRating(assetId, null)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionService.deleteLocal', () {
|
||||
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['a', 'b'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
|
||||
test('deletes locally when Android trash handling is disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
const ids = ['c'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => localAssetRepository.delete(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
});
|
||||
|
||||
test('short-circuits when nothing was deleted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['x'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_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/models/exif.model.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:mocktail/mocktail.dart' as mock;
|
||||
@@ -36,8 +33,6 @@ class RepositoryMocks {
|
||||
|
||||
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
||||
final assetApi = AssetApiRepositoryStub(MockAssetApiRepository());
|
||||
final assetMedia = AssetMediaRepositoryStub(MockAssetMediaRepository());
|
||||
final download = DownloadRepositoryStub(MockDownloadRepository());
|
||||
|
||||
RepositoryMocks() {
|
||||
resetAll();
|
||||
@@ -54,8 +49,6 @@ class RepositoryMocks {
|
||||
reset(albumApi);
|
||||
nativeApi.reset();
|
||||
assetApi.reset();
|
||||
assetMedia.reset();
|
||||
download.reset();
|
||||
reset(toast);
|
||||
_stubLocalAlbumRepository();
|
||||
_stubLocalAssetRepository();
|
||||
@@ -63,8 +56,6 @@ class RepositoryMocks {
|
||||
_stubRemoteExifRepository();
|
||||
_stubNativeSyncApi();
|
||||
_stubAssetApiRepository();
|
||||
_stubAssetMediaRepository();
|
||||
_stubDownloadRepository();
|
||||
}
|
||||
|
||||
void _stubRemoteAssetRepository() {
|
||||
@@ -94,14 +85,6 @@ class RepositoryMocks {
|
||||
void _stubAssetApiRepository() {
|
||||
when(assetApi.update).thenAnswer((_) async => {});
|
||||
}
|
||||
|
||||
void _stubAssetMediaRepository() {
|
||||
when(assetMedia.shareAssets).thenAnswer((_) async => 1);
|
||||
}
|
||||
|
||||
void _stubDownloadRepository() {
|
||||
when(download.downloadAllAssets).thenAnswer((_) async => const []);
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceMocks {
|
||||
@@ -110,9 +93,6 @@ class ServiceMocks {
|
||||
final asset = AssetServiceStub(MockAssetService());
|
||||
final album = RemoteAlbumServiceStub(MockRemoteAlbumService());
|
||||
final cleanup = CleanupServiceStub(MockCleanupService());
|
||||
final tag = TagServiceStub(MockTagService());
|
||||
final backgroundSync = MockBackgroundSyncManager();
|
||||
final upload = MockForegroundUploadService();
|
||||
|
||||
ServiceMocks() {
|
||||
resetAll();
|
||||
@@ -125,17 +105,11 @@ class ServiceMocks {
|
||||
asset.reset();
|
||||
album.reset();
|
||||
cleanup.reset();
|
||||
tag.reset();
|
||||
reset(backgroundSync);
|
||||
reset(upload);
|
||||
_stubUserService();
|
||||
_stubPartnerService();
|
||||
_stubAssetService();
|
||||
_stubRemoteAlbumService();
|
||||
_stubCleanupService();
|
||||
_stubTagService();
|
||||
_stubBackgroundSync();
|
||||
_stubForegroundUpload();
|
||||
}
|
||||
|
||||
void _stubUserService() {
|
||||
@@ -173,23 +147,6 @@ class ServiceMocks {
|
||||
void _stubCleanupService() {
|
||||
when(cleanup.deleteLocalAssets).thenAnswer((_) async => 0);
|
||||
}
|
||||
|
||||
void _stubTagService() {
|
||||
when(tag.bulkTagAssets).thenAnswer((_) async => 0);
|
||||
when(tag.upsertTags).thenAnswer((_) async => const []);
|
||||
when(tag.getAllTags).thenAnswer((_) async => const {});
|
||||
}
|
||||
|
||||
void _stubBackgroundSync() {
|
||||
when(() => backgroundSync.syncLocal()).thenAnswer((_) async {});
|
||||
when(() => backgroundSync.hashAssets()).thenAnswer((_) async {});
|
||||
}
|
||||
|
||||
void _stubForegroundUpload() {
|
||||
when(
|
||||
() => upload.uploadManual(any(), cancelToken: any(named: 'cancelToken'), callbacks: any(named: 'callbacks')),
|
||||
).thenAnswer((_) async {});
|
||||
}
|
||||
}
|
||||
|
||||
void _registerFallbacks() {
|
||||
@@ -204,16 +161,8 @@ void _registerFallbacks() {
|
||||
registerFallbackValue(const Option<LatLng>.none());
|
||||
registerFallbackValue(const Option<String>.none());
|
||||
registerFallbackValue(const Option<DateTime>.none());
|
||||
registerFallbackValue(<BaseAsset>[]);
|
||||
registerFallbackValue(<RemoteAsset>[]);
|
||||
registerFallbackValue(<LocalAsset>[]);
|
||||
registerFallbackValue(ShareAssetType.original);
|
||||
registerFallbackValue(const UploadCallbacks());
|
||||
registerFallbackValue(_FakeBuildContext());
|
||||
}
|
||||
|
||||
class _FakeBuildContext extends Fake implements BuildContext {}
|
||||
|
||||
extension type const Stub<T extends Mock>(T mockedClass) {
|
||||
void reset() => mock.reset(mockedClass);
|
||||
}
|
||||
@@ -367,30 +316,3 @@ extension type const AssetApiRepositoryStub(MockAssetApiRepository api) implemen
|
||||
location: any(named: 'location'),
|
||||
);
|
||||
}
|
||||
|
||||
extension type const AssetMediaRepositoryStub(MockAssetMediaRepository api) implements Stub<MockAssetMediaRepository> {
|
||||
Future<int> Function() get shareAssets =>
|
||||
() => api.shareAssets(
|
||||
any(),
|
||||
any(),
|
||||
fileType: any(named: 'fileType'),
|
||||
cancelCompleter: any(named: 'cancelCompleter'),
|
||||
onAssetDownloadProgress: any(named: 'onAssetDownloadProgress'),
|
||||
);
|
||||
}
|
||||
|
||||
extension type const DownloadRepositoryStub(MockDownloadRepository repo) implements Stub<MockDownloadRepository> {
|
||||
Future<List<bool>> Function() get downloadAllAssets =>
|
||||
() => repo.downloadAllAssets(any());
|
||||
}
|
||||
|
||||
extension type const TagServiceStub(MockTagService service) implements Stub<MockTagService> {
|
||||
Future<int> Function() get bulkTagAssets =>
|
||||
() => service.bulkTagAssets(any(), any());
|
||||
|
||||
Future<List<Tag>> Function() get upsertTags =>
|
||||
() => service.upsertTags(any());
|
||||
|
||||
Future<Set<Tag>> Function() get getAllTags =>
|
||||
() => service.getAllTags();
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/download.action.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../repository.mocks.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockDownloadRepository downloadRepo;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
downloadRepo = context.repository.download.repo;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
group('DownloadAction', () {
|
||||
testWidgets('visible when there is a remote asset to download', (tester) async {
|
||||
final action = await tester.pumpActionButton(
|
||||
context,
|
||||
(scope) => DownloadAction(assets: [RemoteAssetFactory.create()], scope: scope),
|
||||
);
|
||||
|
||||
expect(action.isVisible, isTrue);
|
||||
expect(action.icon, Icons.download);
|
||||
expect(action.label, StaticTranslations.instance.download);
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden when the selection has no remote assets', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => DownloadAction(assets: const [], scope: scope));
|
||||
|
||||
expect(action.isVisible, isFalse);
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('enqueues the downloads and re-syncs shortly after', (tester) async {
|
||||
final target = RemoteAssetFactory.create();
|
||||
final backgroundSync = context.service.backgroundSync;
|
||||
|
||||
await tester.pumpTestAction(context, (scope) => DownloadAction(assets: [target], scope: scope));
|
||||
|
||||
verify(() => downloadRepo.downloadAllAssets(any(that: contains(target)))).called(1);
|
||||
|
||||
// Sync is scheduled to run after a 1 second delay
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
verify(() => backgroundSync.syncLocal()).called(1);
|
||||
verify(() => backgroundSync.hashAssets()).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.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/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/share_config.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../repository.mocks.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockAssetMediaRepository mediaRepo;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
mediaRepo = context.repository.assetMedia.api;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> savedQuality(ShareAssetType fileType) => [
|
||||
appConfigProvider.overrideWithValue(defaultConfig.copyWith(share: ShareConfig(fileType: fileType))),
|
||||
];
|
||||
|
||||
RemoteAsset asset({AssetType type = .image}) => RemoteAssetFactory.create(type: type);
|
||||
|
||||
group('ShareAction', () {
|
||||
testWidgets('visible when there are assets to share', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: [asset()], scope: scope));
|
||||
|
||||
expect(action.isVisible, isTrue);
|
||||
expect(action.onSecondaryAction, isNotNull);
|
||||
expect(action.label, StaticTranslations.instance.share);
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden when the selection is empty', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: const [], scope: scope));
|
||||
|
||||
expect(action.isVisible, isFalse);
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('uses the Android share icon on Android', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = .android;
|
||||
final action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: [asset()], scope: scope));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
|
||||
expect(action.icon, Icons.share_rounded);
|
||||
});
|
||||
|
||||
testWidgets('uses the iOS share icon on iOS', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = .iOS;
|
||||
final action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: [asset()], scope: scope));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
|
||||
expect(action.icon, Icons.ios_share_rounded);
|
||||
});
|
||||
});
|
||||
|
||||
group('quality picker', () {
|
||||
testWidgets('offers both original and preview for an image', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: [asset()], scope: scope));
|
||||
|
||||
unawaited(action.onSecondaryAction!());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(StaticTranslations.instance.share_original), findsOneWidget);
|
||||
expect(find.text(StaticTranslations.instance.share_preview), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(StaticTranslations.instance.cancel));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('offers only original for a video', (tester) async {
|
||||
final action = await tester.pumpActionButton(
|
||||
context,
|
||||
(scope) => ShareAction(
|
||||
assets: [asset(type: .video)],
|
||||
scope: scope,
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(action.onSecondaryAction!());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(StaticTranslations.instance.share_original), findsOneWidget);
|
||||
expect(find.text(StaticTranslations.instance.share_preview), findsNothing);
|
||||
|
||||
await tester.tap(find.text(StaticTranslations.instance.cancel));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
});
|
||||
|
||||
group('onAction', () {
|
||||
testWidgets('shares the assets at the quality saved in settings', (tester) async {
|
||||
final target = asset();
|
||||
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
(scope) => ShareAction(assets: [target], scope: scope),
|
||||
overrides: savedQuality(.preview),
|
||||
);
|
||||
await tester.pump(const .new(milliseconds: 500));
|
||||
|
||||
verify(
|
||||
() => mediaRepo.shareAssets(
|
||||
any(that: contains(target)),
|
||||
any(),
|
||||
fileType: .preview,
|
||||
cancelCompleter: any(named: 'cancelCompleter'),
|
||||
onAssetDownloadProgress: any(named: 'onAssetDownloadProgress'),
|
||||
),
|
||||
).called(1);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/tag.action.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../repository.mocks.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockTagService tagService;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
tagService = context.service.tag.service;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
RemoteAsset owned() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
|
||||
|
||||
group('TagAssetsAction', () {
|
||||
testWidgets('visible with an owned remote asset', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => TagAction(assets: [owned()], scope: scope));
|
||||
|
||||
expect(action.isVisible, isTrue);
|
||||
expect(action.icon, Icons.sell_outlined);
|
||||
expect(action.label, StaticTranslations.instance.control_bottom_app_bar_add_tags);
|
||||
});
|
||||
|
||||
testWidgets('hidden for an asset owned by someone else', (tester) async {
|
||||
final action = await tester.pumpActionButton(
|
||||
context,
|
||||
(scope) => TagAction(assets: [RemoteAssetFactory.create()], scope: scope),
|
||||
);
|
||||
|
||||
expect(action.isVisible, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('collects only the owned remote asset ids', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
final action = await tester.pumpActionButton(context, (scope) => TagAction(assets: [mine, theirs], scope: scope));
|
||||
|
||||
expect((action as TagAction).assetIds, [mine.id]);
|
||||
});
|
||||
|
||||
testWidgets('applies the selected tags and toasts the count', (tester) async {
|
||||
final asset = owned();
|
||||
final toast = context.repository.toast;
|
||||
when(context.service.tag.bulkTagAssets).thenAnswer((_) async => 2);
|
||||
|
||||
final action = await tester.pumpActionButton(context, (scope) => TagAction(assets: [asset], scope: scope));
|
||||
|
||||
await (action as TagAction).tagAssets(selected: {'tag'}, created: const {});
|
||||
|
||||
verify(() => tagService.bulkTagAssets([asset.id], ['tag'])).called(1);
|
||||
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||
expect(message, StaticTranslations.instance.tagged_assets(count: 2));
|
||||
});
|
||||
|
||||
testWidgets('creates new tags before applying them', (tester) async {
|
||||
final asset = owned();
|
||||
when(() => tagService.upsertTags(['new'])).thenAnswer((_) async => const [Tag(id: 'tag', value: 'new')]);
|
||||
when(context.service.tag.bulkTagAssets).thenAnswer((_) async => 1);
|
||||
|
||||
final action = await tester.pumpActionButton(context, (scope) => TagAction(assets: [asset], scope: scope));
|
||||
|
||||
await (action as TagAction).tagAssets(selected: const {}, created: {'new'});
|
||||
|
||||
verify(() => tagService.upsertTags(['new'])).called(1);
|
||||
verify(() => tagService.bulkTagAssets([asset.id], ['tag'])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('does nothing when no tags are chosen', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
final action = await tester.pumpActionButton(context, (scope) => TagAction(assets: [asset], scope: scope));
|
||||
|
||||
await (action as TagAction).tagAssets(selected: const {}, created: const {});
|
||||
|
||||
verifyNever(context.service.tag.bulkTagAssets);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/upload.action.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../factories/local_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();
|
||||
});
|
||||
|
||||
void whenUpload(void Function(UploadCallbacks callbacks) simulate) {
|
||||
when(
|
||||
() => uploadService.uploadManual(
|
||||
any(),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
callbacks: any(named: 'callbacks'),
|
||||
),
|
||||
).thenAnswer((inv) async => simulate(inv.namedArguments[#callbacks] as UploadCallbacks));
|
||||
}
|
||||
|
||||
group('UploadAction', () {
|
||||
testWidgets('visible with a local asset', (tester) async {
|
||||
final action = await tester.pumpActionButton(
|
||||
context,
|
||||
(scope) => UploadAction(assets: [LocalAssetFactory.create()], scope: scope),
|
||||
);
|
||||
|
||||
expect(action.isVisible, isTrue);
|
||||
expect(action.icon, Icons.backup_outlined);
|
||||
expect(action.label, StaticTranslations.instance.upload);
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden without any local asset', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => UploadAction(assets: const [], scope: scope));
|
||||
|
||||
expect(action.isVisible, isFalse);
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('uploads the assets with no error toast on success', (tester) async {
|
||||
final asset = LocalAssetFactory.create();
|
||||
final toast = context.repository.toast;
|
||||
whenUpload((cb) => cb.onSuccess?.call(asset.id, 'remote-1'));
|
||||
|
||||
final action =
|
||||
await tester.pumpActionButton(context, (scope) => UploadAction(assets: [asset], scope: scope))
|
||||
as UploadAction;
|
||||
|
||||
await action.upload();
|
||||
await tester.pump(const Duration(seconds: 2)); // flush the delayed progress clear
|
||||
|
||||
verify(
|
||||
() => uploadService.uploadManual(
|
||||
any(that: contains(asset)),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
callbacks: any(named: 'callbacks'),
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(() => toast.error(any()));
|
||||
});
|
||||
|
||||
testWidgets('shows an error toast when an asset fails', (tester) async {
|
||||
final asset = LocalAssetFactory.create();
|
||||
final toast = context.repository.toast;
|
||||
whenUpload((cb) => cb.onError?.call(asset.id, 'boom'));
|
||||
|
||||
final action =
|
||||
await tester.pumpActionButton(context, (scope) => UploadAction(assets: [asset], scope: scope))
|
||||
as UploadAction;
|
||||
|
||||
await action.upload();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
|
||||
verify(() => toast.error(StaticTranslations.instance.scaffold_body_error_occurred)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('shows the progress dialog when showProgress is true', (tester) async {
|
||||
final asset = LocalAssetFactory.create();
|
||||
final gate = Completer<void>();
|
||||
when(
|
||||
() => uploadService.uploadManual(
|
||||
any(),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
callbacks: any(named: 'callbacks'),
|
||||
),
|
||||
).thenAnswer((_) => gate.future);
|
||||
|
||||
await tester.pumpTestAction(context, (scope) => UploadAction(assets: [asset], scope: scope, showProgress: true));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(StaticTranslations.instance.uploading), findsOneWidget);
|
||||
|
||||
gate.complete();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(StaticTranslations.instance.uploading), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,14 +17,9 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/services/cleanup.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/services/gcast.service.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
@@ -56,17 +51,13 @@ class PresentationContext {
|
||||
assetServiceProvider.overrideWithValue(service.asset.service),
|
||||
remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo),
|
||||
remoteExifRepositoryProvider.overrideWithValue(repository.remoteExif.repo),
|
||||
assetMediaRepositoryProvider.overrideWithValue(repository.assetMedia.api),
|
||||
downloadRepositoryProvider.overrideWithValue(repository.download.repo),
|
||||
tagServiceProvider.overrideWithValue(service.tag.service),
|
||||
backgroundSyncProvider.overrideWithValue(service.backgroundSync),
|
||||
partnerServiceProvider.overrideWithValue(service.partner.service),
|
||||
remoteAlbumServiceProvider.overrideWithValue(service.album.service),
|
||||
cleanupServiceProvider.overrideWithValue(service.cleanup.service),
|
||||
gCastServiceProvider.overrideWithValue(MockGCastService()),
|
||||
foregroundUploadServiceProvider.overrideWithValue(service.upload),
|
||||
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier()),
|
||||
toastRepositoryProvider.overrideWithValue(repository.toast),
|
||||
gCastServiceProvider.overrideWithValue(MockGCastService()),
|
||||
];
|
||||
|
||||
static Future<PresentationContext> create() async {
|
||||
|
||||
Reference in New Issue
Block a user