diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt index f039131e1..9dddda6b1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt @@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.calls import android.app.Activity import android.graphics.Rect import android.net.Uri +import android.util.Log import com.bumptech.glide.Glide import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend @@ -14,6 +15,7 @@ import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider +import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator import io.flutter.plugin.common.MethodCall @@ -34,6 +36,7 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { "getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) } "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) } + "cancelFileOp" -> safe(call, result, ::cancelFileOp) "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) } else -> result.notImplemented() @@ -138,6 +141,19 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { } } + private fun cancelFileOp(call: MethodCall, result: MethodChannel.Result) { + val opId = call.argument("opId") + if (opId == null) { + result.error("cancelFileOp-args", "failed because of missing arguments", null) + return + } + + Log.i(LOG_TAG, "cancelling file op $opId") + cancelledOps.add(opId) + + result.success(null) + } + private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } val desiredName = call.argument("desiredName") @@ -169,6 +185,9 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { } companion object { + private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/media_file" + + val cancelledOps = HashSet() } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index d75c7b44f..a305180b2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log +import deckers.thibault.aves.channel.calls.MediaFileHandler.Companion.cancelledOps import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.NameConflictStrategy @@ -24,11 +25,13 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments private lateinit var handler: Handler private var op: String? = null + private var opId: String? = null private val entryMapList = ArrayList() init { if (arguments is Map<*, *>) { op = arguments["op"] as String? + opId = arguments["id"] as String? @Suppress("unchecked_cast") val rawEntries = arguments["entries"] as List? if (rawEntries != null) { @@ -74,6 +77,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments } private fun endOfStream() { + cancelledOps.remove(opId) handler.post { try { eventSink.endOfStream() @@ -97,14 +101,18 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments return } - for (entryMap in entryMapList) { - val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } - val path = entryMap["path"] as String? - val mimeType = entryMap["mimeType"] as String? - if (uri != null && mimeType != null) { - val result: FieldMap = hashMapOf( - "uri" to uri.toString(), - ) + val entries = entryMapList.map(::AvesEntry) + for (entry in entries) { + val uri = entry.uri + val path = entry.path + val mimeType = entry.mimeType + + val result: FieldMap = hashMapOf( + "uri" to uri.toString(), + ) + if (isCancelledOp()) { + result["skipped"] = true + } else { try { provider.delete(activity, uri, path, mimeType) result["success"] = true @@ -112,8 +120,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments Log.w(LOG_TAG, "failed to delete entry with path=$path", e) result["success"] = false } - success(result) } + success(result) } endOfStream() } @@ -173,7 +181,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, object : ImageOpCallback { + provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, ::isCancelledOp, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) }) @@ -201,13 +209,15 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments } val entries = entryMapList.map(::AvesEntry) - provider.renameMultiple(activity, newName, entries, object : ImageOpCallback { + provider.renameMultiple(activity, newName, entries, ::isCancelledOp, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message) }) endOfStream() } + private fun isCancelledOp() = cancelledOps.contains(opId) + companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/media_op_stream" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index e74547232..1e04ca4ac 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -47,11 +47,25 @@ abstract class ImageProvider { throw UnsupportedOperationException("`delete` is not supported by this image provider") } - open suspend fun moveMultiple(activity: Activity, copy: Boolean, targetDir: String, nameConflictStrategy: NameConflictStrategy, entries: List, callback: ImageOpCallback) { + open suspend fun moveMultiple( + activity: Activity, + copy: Boolean, + targetDir: String, + nameConflictStrategy: NameConflictStrategy, + entries: List, + isCancelledOp: CancelCheck, + callback: ImageOpCallback, + ) { callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider")) } - open suspend fun renameMultiple(activity: Activity, newFileName: String, entries: List, callback: ImageOpCallback) { + open suspend fun renameMultiple( + activity: Activity, + newFileName: String, + entries: List, + isCancelledOp: CancelCheck, + callback: ImageOpCallback, + ) { callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider")) } @@ -937,3 +951,5 @@ abstract class ImageProvider { } } } + +typealias CancelCheck = () -> Boolean diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index be6731028..649228c7b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -331,6 +331,7 @@ class MediaStoreImageProvider : ImageProvider() { targetDir: String, nameConflictStrategy: NameConflictStrategy, entries: List, + isCancelledOp: CancelCheck, callback: ImageOpCallback, ) { val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) @@ -366,7 +367,7 @@ class MediaStoreImageProvider : ImageProvider() { // - there is no documentation regarding support for usage with removable storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage try { - val newFields = moveSingle( + val newFields = if (isCancelledOp()) skippedFieldMap else moveSingle( activity = activity, sourcePath = sourcePath, sourceUri = sourceUri, @@ -505,6 +506,7 @@ class MediaStoreImageProvider : ImageProvider() { activity: Activity, newFileName: String, entries: List, + isCancelledOp: CancelCheck, callback: ImageOpCallback, ) { for (entry in entries) { @@ -519,7 +521,7 @@ class MediaStoreImageProvider : ImageProvider() { if (sourcePath != null) { try { - val newFields = renameSingle( + val newFields = if (isCancelledOp()) skippedFieldMap else renameSingle( activity = activity, mimeType = mimeType, oldMediaUri = sourceUri, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8ac9fddae..47035d6ea 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -39,6 +39,8 @@ "continueButtonLabel": "CONTINUE", "@continueButtonLabel": {}, + "cancelTooltip": "Cancel", + "@cancelTooltip": {}, "changeTooltip": "Change", "@changeTooltip": {}, "clearTooltip": "Clear", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 4ebb4930f..18c00c86b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -15,6 +15,7 @@ "hideButtonLabel": "MASQUER", "continueButtonLabel": "CONTINUER", + "cancelTooltip": "Annuler", "changeTooltip": "Modifier", "clearTooltip": "Effacer", "previousTooltip": "Précédent", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5d134e0a2..75d6b0b1c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -15,6 +15,7 @@ "hideButtonLabel": "숨기기", "continueButtonLabel": "다음", + "cancelTooltip": "취소", "changeTooltip": "변경", "clearTooltip": "초기화", "previousTooltip": "이전", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5cdfbc833..730b74295 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -15,6 +15,7 @@ "hideButtonLabel": "СКРЫТЬ", "continueButtonLabel": "ПРОДОЛЖИТЬ", + "cancelTooltip": "Отмена", "changeTooltip": "Изменить", "clearTooltip": "Очистить", "previousTooltip": "Предыдущий", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index f42169aeb..1584e22e5 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -703,8 +703,8 @@ class AvesEntry { Future delete() { final completer = Completer(); - mediaFileService.delete([this]).listen( - (event) => completer.complete(event.success), + mediaFileService.delete(entries: {this}).listen( + (event) => completer.complete(event.success && !event.skipped), onError: completer.completeError, onDone: () { if (!completer.isCompleted) { diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 978f9957b..75ca4ada2 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -176,7 +176,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM processed.add, onError: (error) => reportService.recordError('renameEntry failed with error=$error', null), onDone: () async { - final successOps = processed.where((e) => e.success).toSet(); + final successOps = processed.where((e) => e.success && !e.skipped).toSet(); if (successOps.isEmpty) { completer.complete(false); return; diff --git a/lib/services/common/image_op_events.dart b/lib/services/common/image_op_events.dart index e65f8f8da..13863ad3f 100644 --- a/lib/services/common/image_op_events.dart +++ b/lib/services/common/image_op_events.dart @@ -3,65 +3,87 @@ import 'package:flutter/foundation.dart'; @immutable class ImageOpEvent extends Equatable { - final bool success; + final bool success, skipped; final String uri; @override - List get props => [success, uri]; + List get props => [success, skipped, uri]; const ImageOpEvent({ required this.success, + required this.skipped, required this.uri, }); factory ImageOpEvent.fromMap(Map map) { + final skipped = map['skipped'] ?? false; return ImageOpEvent( - success: map['success'] ?? false, + success: (map['success'] ?? false) || skipped, + skipped: skipped, uri: map['uri'], ); } } +@immutable class MoveOpEvent extends ImageOpEvent { final Map newFields; - const MoveOpEvent({required bool success, required String uri, required this.newFields}) - : super( + @override + List get props => [success, skipped, uri, newFields]; + + const MoveOpEvent({ + required bool success, + required bool skipped, + required String uri, + required this.newFields, + }) : super( success: success, + skipped: skipped, uri: uri, ); factory MoveOpEvent.fromMap(Map map) { + final newFields = map['newFields'] ?? {}; + final skipped = (map['skipped'] ?? false) || (newFields['skipped'] ?? false); return MoveOpEvent( - success: map['success'] ?? false, + success: (map['success'] ?? false) || skipped, + skipped: skipped, uri: map['uri'], - newFields: map['newFields'] ?? {}, + newFields: newFields, ); } - - @override - String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}'; } +@immutable class ExportOpEvent extends MoveOpEvent { final int? pageId; @override - List get props => [success, uri, pageId]; + List get props => [success, skipped, uri, pageId, newFields]; - const ExportOpEvent({required bool success, required String uri, this.pageId, required Map newFields}) - : super( + const ExportOpEvent({ + required bool success, + required bool skipped, + required String uri, + this.pageId, + required Map newFields, + }) : super( success: success, + skipped: skipped, uri: uri, newFields: newFields, ); factory ExportOpEvent.fromMap(Map map) { + final newFields = map['newFields'] ?? {}; + final skipped = (map['skipped'] ?? false) || (newFields['skipped'] ?? false); return ExportOpEvent( - success: map['success'] ?? false, + success: (map['success'] ?? false) || skipped, + skipped: skipped, uri: map['uri'], pageId: map['pageId'], - newFields: map['newFields'] ?? {}, + newFields: newFields, ); } } diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index 1b6f3f14e..9c65a6c44 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -15,6 +15,8 @@ import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; abstract class MediaFileService { + String get newOpId; + Future getEntry(String uri, String? mimeType); Future getSvg( @@ -68,10 +70,16 @@ abstract class MediaFileService { Future? resumeLoading(Object taskKey); - Stream delete(Iterable entries); + Future cancelFileOp(String opId); - Stream move( - Iterable entries, { + Stream delete({ + String? opId, + required Iterable entries, + }); + + Stream move({ + String? opId, + required Iterable entries, required bool copy, required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, @@ -120,6 +128,9 @@ class PlatformMediaFileService implements MediaFileService { }; } + @override + String get newOpId => DateTime.now().millisecondsSinceEpoch.toString(); + @override Future getEntry(String uri, String? mimeType) async { try { @@ -298,10 +309,25 @@ class PlatformMediaFileService implements MediaFileService { Future? resumeLoading(Object taskKey) => servicePolicy.resume(taskKey); @override - Stream delete(Iterable entries) { + Future cancelFileOp(String opId) async { + try { + await platform.invokeMethod('cancelFileOp', { + 'opId': opId, + }); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } + + @override + Stream delete({ + String? opId, + required Iterable entries, + }) { try { return _opStreamChannel.receiveBroadcastStream({ 'op': 'delete', + 'id': opId, 'entries': entries.map(_toPlatformEntryMap).toList(), }).map((event) => ImageOpEvent.fromMap(event)); } on PlatformException catch (e, stack) { @@ -311,8 +337,9 @@ class PlatformMediaFileService implements MediaFileService { } @override - Stream move( - Iterable entries, { + Stream move({ + String? opId, + required Iterable entries, required bool copy, required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, @@ -320,6 +347,7 @@ class PlatformMediaFileService implements MediaFileService { try { return _opStreamChannel.receiveBroadcastStream({ 'op': 'move', + 'id': opId, 'entries': entries.map(_toPlatformEntryMap).toList(), 'copy': copy, 'destinationPath': destinationAlbum, diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index b54225f18..ae9c0a772 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -36,6 +36,7 @@ class AIcons { static const IconData add = Icons.add_circle_outline; static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData addTag = MdiIcons.tagPlusOutline; + static const IconData cancel = Icons.cancel_outlined; static const IconData replay10 = Icons.replay_10_outlined; static const IconData skip10 = Icons.forward_10_outlined; static const IconData captureFrame = Icons.screenshot_outlined; diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 3537fbb6b..c338551ba 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -247,19 +247,23 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; source.pauseMonitoring(); + final opId = mediaFileService.newOpId; showOpReport( context: context, - opStream: mediaFileService.delete(selectedItems), + opStream: mediaFileService.delete(opId: opId, entries: selectedItems), itemCount: todoCount, + onCancel: () => mediaFileService.cancelFileOp(opId), onDone: (processed) async { - final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); + final successOps = processed.where((e) => e.success).toSet(); + final deletedOps = successOps.where((e) => !e.skipped).toSet(); + final deletedUris = deletedOps.map((event) => event.uri).toSet(); await source.removeEntries(deletedUris); selection.browse(); source.resumeMonitoring(); - final deletedCount = deletedUris.length; - if (deletedCount < todoCount) { - final count = todoCount - deletedCount; + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); } @@ -324,18 +328,21 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa } source.pauseMonitoring(); + final opId = mediaFileService.newOpId; showOpReport( context: context, opStream: mediaFileService.move( - todoItems, + opId: opId, + entries: todoItems, copy: copy, destinationAlbum: destinationAlbum, nameConflictStrategy: nameConflictStrategy, ), itemCount: todoCount, + onCancel: () => mediaFileService.cancelFileOp(opId), onDone: (processed) async { final successOps = processed.where((e) => e.success).toSet(); - final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet(); + final movedOps = successOps.where((e) => !e.skipped).toSet(); await source.updateAfterMove( todoEntries: todoItems, copy: copy, @@ -417,15 +424,17 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa showOpReport( context: context, opStream: Stream.fromIterable(todoItems).asyncMap((entry) async { + // TODO TLAD [cancel] allow cancelling edit op final dataTypes = await op(entry); - return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri); + return ImageOpEvent(success: dataTypes.isNotEmpty, skipped: false, uri: entry.uri); }).asBroadcastStream(), itemCount: todoCount, onDone: (processed) async { final successOps = processed.where((e) => e.success).toSet(); + final editedOps = successOps.where((e) => !e.skipped).toSet(); selection.browse(); source.resumeMonitoring(); - unawaited(source.refreshUris(successOps.map((v) => v.uri).toSet())); + unawaited(source.refreshUris(editedOps.map((v) => v.uri).toSet())); final l10n = context.l10n; final successCount = successOps.length; diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index b21060554..ef77e106d 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -6,6 +6,9 @@ import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/accessibility_service.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:percent_indicator/circular_percent_indicator.dart'; @@ -60,13 +63,16 @@ mixin FeedbackMixin { required BuildContext context, required Stream opStream, required int itemCount, + VoidCallback? onCancel, void Function(Set processed)? onDone, }) { late OverlayEntry _opReportOverlayEntry; + // TODO TLAD prevent current page/state pop by `back` button when there is file op report overlay _opReportOverlayEntry = OverlayEntry( builder: (context) => ReportOverlay( opStream: opStream, itemCount: itemCount, + onCancel: onCancel, onDone: (processed) { _opReportOverlayEntry.remove(); onDone?.call(processed); @@ -80,12 +86,14 @@ mixin FeedbackMixin { class ReportOverlay extends StatefulWidget { final Stream opStream; final int itemCount; + final VoidCallback? onCancel; final void Function(Set processed) onDone; const ReportOverlay({ Key? key, required this.opStream, required this.itemCount, + required this.onCancel, required this.onDone, }) : super(key: key); @@ -136,56 +144,71 @@ class _ReportOverlayState extends State> with SingleTickerPr @override Widget build(BuildContext context) { final progressColor = Theme.of(context).colorScheme.secondary; - return AbsorbPointer( - child: StreamBuilder( - stream: opStream, - builder: (context, snapshot) { - final processedCount = processed.length.toDouble(); - final total = widget.itemCount; - assert(processedCount <= total); - final percent = min(1.0, processedCount / total); - final animate = context.select((v) => v.accessibilityAnimations.animate); - return FadeTransition( - opacity: _animation, - child: Container( - decoration: const BoxDecoration( - gradient: RadialGradient( - colors: [ - Colors.black, - Colors.black54, - ], - ), - ), - child: Center( - child: Stack( - children: [ - if (animate) - Container( - width: radius, - height: radius, - padding: const EdgeInsets.all(strokeWidth / 2), - child: CircularProgressIndicator( - color: progressColor.withOpacity(.1), - strokeWidth: strokeWidth, - ), - ), - CircularPercentIndicator( - percent: percent, - lineWidth: strokeWidth, - radius: radius, - backgroundColor: Colors.white24, - progressColor: progressColor, - animation: animate, - center: Text(NumberFormat.percentPattern().format(percent)), - animateFromLastPercent: true, - ), - ], - ), + return StreamBuilder( + stream: opStream, + builder: (context, snapshot) { + final processedCount = processed.length.toDouble(); + final total = widget.itemCount; + final percent = min(1.0, processedCount / total); + final animate = context.select((v) => v.accessibilityAnimations.animate); + return FadeTransition( + opacity: _animation, + child: Container( + decoration: const BoxDecoration( + gradient: RadialGradient( + colors: [ + Colors.black, + Colors.black54, + ], ), ), - ); - }, - ), + child: Center( + child: Stack( + children: [ + if (animate) + Container( + width: radius, + height: radius, + padding: const EdgeInsets.all(strokeWidth / 2), + child: CircularProgressIndicator( + color: progressColor.withOpacity(.1), + strokeWidth: strokeWidth, + ), + ), + CircularPercentIndicator( + percent: percent, + lineWidth: strokeWidth, + radius: radius, + backgroundColor: Colors.white24, + progressColor: progressColor, + animation: animate, + center: Text( + NumberFormat.percentPattern().format(percent), + style: const TextStyle(fontSize: 18), + ), + animateFromLastPercent: true, + ), + if (widget.onCancel != null) + Material( + color: Colors.transparent, + child: SizedBox.square( + dimension: radius, + child: Align( + alignment: const FractionalOffset(0.5, 0.8), + child: IconButton( + icon: const Icon(AIcons.cancel), + onPressed: widget.onCancel, + tooltip: context.l10n.cancelTooltip, + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, ); } } diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index 0ee31819f..beaa7d5c1 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -195,19 +195,23 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return; source.pauseMonitoring(); + final opId = mediaFileService.newOpId; showOpReport( context: context, - opStream: mediaFileService.delete(todoEntries), + opStream: mediaFileService.delete(opId: opId, entries: todoEntries), itemCount: todoCount, + onCancel: () => mediaFileService.cancelFileOp(opId), onDone: (processed) async { - final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); + final successOps = processed.where((event) => event.success); + final deletedOps = successOps.where((e) => !e.skipped).toSet(); + final deletedUris = deletedOps.map((event) => event.uri).toSet(); await source.removeEntries(deletedUris); _browse(context); source.resumeMonitoring(); - final deletedCount = deletedUris.length; - if (deletedCount < todoCount) { - final count = todoCount - deletedCount; + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; showFeedbackWithMessenger(context, messenger, l10n.collectionDeleteFailureFeedback(count)); } @@ -255,25 +259,29 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { } source.pauseMonitoring(); + final opId = mediaFileService.newOpId; showOpReport( context: context, opStream: mediaFileService.move( - todoEntries, + opId: opId, + entries: todoEntries, copy: false, destinationAlbum: destinationAlbum, // there should be no file conflict, as the target directory itself does not exist nameConflictStrategy: NameConflictStrategy.rename, ), itemCount: todoCount, + onCancel: () => mediaFileService.cancelFileOp(opId), onDone: (processed) async { - final movedOps = processed.where((e) => e.success).toSet(); + final successOps = processed.where((e) => e.success).toSet(); + final movedOps = successOps.where((e) => !e.skipped).toSet(); await source.renameAlbum(album, destinationAlbum, todoEntries, movedOps); _browse(context); source.resumeMonitoring(); - final movedCount = movedOps.length; - if (movedCount < todoCount) { - final count = todoCount - movedCount; + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; showFeedbackWithMessenger(context, messenger, l10n.collectionMoveFailureFeedback(count)); } else { showFeedbackWithMessenger(context, messenger, l10n.genericSuccessFeedback); diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 7e79b672e..97b7e37af 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -244,14 +244,15 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ), itemCount: selectionCount, onDone: (processed) { - final exportOps = processed.where((e) => e.success); - final exportCount = exportOps.length; + final successOps = processed.where((e) => e.success).toSet(); + final exportedOps = successOps.where((e) => !e.skipped).toSet(); + final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet(); final isMainMode = context.read>().value == AppMode.main; source.resumeMonitoring(); - source.refreshUris(exportOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet()); + source.refreshUris(newUris); - final showAction = isMainMode && exportCount > 0 + final showAction = isMainMode && newUris.isNotEmpty ? SnackBarAction( label: context.l10n.showButtonLabel, onPressed: () async { @@ -272,7 +273,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix )); final delayDuration = context.read().staggeredAnimationPageTarget; await Future.delayed(delayDuration + Durations.highlightScrollInitDelay); - final newUris = exportOps.map((v) => v.newFields['uri'] as String?).toSet(); final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); if (targetEntry != null) { highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); @@ -280,8 +280,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix }, ) : null; - if (exportCount < selectionCount) { - final count = selectionCount - exportCount; + final successCount = successOps.length; + if (successCount < selectionCount) { + final count = selectionCount - successCount; showFeedback( context, context.l10n.collectionExportFailureFeedback(count), diff --git a/test/fake/media_file_service.dart b/test/fake/media_file_service.dart index 8af17306d..70b5e07f4 100644 --- a/test/fake/media_file_service.dart +++ b/test/fake/media_file_service.dart @@ -15,6 +15,7 @@ class FakeMediaFileService extends Fake implements MediaFileService { final entry = entries.first; return Stream.value(MoveOpEvent( success: true, + skipped: false, uri: entry.uri, newFields: { 'uri': 'content://media/external/images/media/$contentId', diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index 3447dbecb..b32a368d9 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -47,6 +47,7 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { final newContentId = nextContentId; return MoveOpEvent( success: true, + skipped: false, uri: entry.uri, newFields: { 'uri': 'content://media/external/images/media/$newContentId',