cancellable file op: move/copy/delete

This commit is contained in:
Thibault Deckers 2021-12-09 12:42:13 +09:00
parent d16158d8e7
commit eee3452e3e
19 changed files with 259 additions and 113 deletions

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.calls
import android.app.Activity import android.app.Activity
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.util.Log
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend 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.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider 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.MimeTypes
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.MethodCall 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) } "getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) } "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
"cancelFileOp" -> safe(call, result, ::cancelFileOp)
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) } "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented() 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<String>("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) { private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val desiredName = call.argument<String>("desiredName") val desiredName = call.argument<String>("desiredName")
@ -169,6 +185,9 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<MediaFileHandler>()
const val CHANNEL = "deckers.thibault/aves/media_file" const val CHANNEL = "deckers.thibault/aves/media_file"
val cancelledOps = HashSet<String>()
} }
} }

View file

@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import deckers.thibault.aves.channel.calls.MediaFileHandler.Companion.cancelledOps
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy 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 lateinit var handler: Handler
private var op: String? = null private var op: String? = null
private var opId: String? = null
private val entryMapList = ArrayList<FieldMap>() private val entryMapList = ArrayList<FieldMap>()
init { init {
if (arguments is Map<*, *>) { if (arguments is Map<*, *>) {
op = arguments["op"] as String? op = arguments["op"] as String?
opId = arguments["id"] as String?
@Suppress("unchecked_cast") @Suppress("unchecked_cast")
val rawEntries = arguments["entries"] as List<FieldMap>? val rawEntries = arguments["entries"] as List<FieldMap>?
if (rawEntries != null) { if (rawEntries != null) {
@ -74,6 +77,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
} }
private fun endOfStream() { private fun endOfStream() {
cancelledOps.remove(opId)
handler.post { handler.post {
try { try {
eventSink.endOfStream() eventSink.endOfStream()
@ -97,14 +101,18 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
return return
} }
for (entryMap in entryMapList) { val entries = entryMapList.map(::AvesEntry)
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } for (entry in entries) {
val path = entryMap["path"] as String? val uri = entry.uri
val mimeType = entryMap["mimeType"] as String? val path = entry.path
if (uri != null && mimeType != null) { val mimeType = entry.mimeType
val result: FieldMap = hashMapOf(
"uri" to uri.toString(), val result: FieldMap = hashMapOf(
) "uri" to uri.toString(),
)
if (isCancelledOp()) {
result["skipped"] = true
} else {
try { try {
provider.delete(activity, uri, path, mimeType) provider.delete(activity, uri, path, mimeType)
result["success"] = true 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) Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
result["success"] = false result["success"] = false
} }
success(result)
} }
success(result)
} }
endOfStream() endOfStream()
} }
@ -173,7 +181,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry) 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 onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) 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) 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 onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message) override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message)
}) })
endOfStream() endOfStream()
} }
private fun isCancelledOp() = cancelledOps.contains(opId)
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>() private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/media_op_stream" const val CHANNEL = "deckers.thibault/aves/media_op_stream"

View file

@ -47,11 +47,25 @@ abstract class ImageProvider {
throw UnsupportedOperationException("`delete` is not supported by this image provider") throw UnsupportedOperationException("`delete` is not supported by this image provider")
} }
open suspend fun moveMultiple(activity: Activity, copy: Boolean, targetDir: String, nameConflictStrategy: NameConflictStrategy, entries: List<AvesEntry>, callback: ImageOpCallback) { open suspend fun moveMultiple(
activity: Activity,
copy: Boolean,
targetDir: String,
nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider")) callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider"))
} }
open suspend fun renameMultiple(activity: Activity, newFileName: String, entries: List<AvesEntry>, callback: ImageOpCallback) { open suspend fun renameMultiple(
activity: Activity,
newFileName: String,
entries: List<AvesEntry>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider")) callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider"))
} }
@ -937,3 +951,5 @@ abstract class ImageProvider {
} }
} }
} }
typealias CancelCheck = () -> Boolean

View file

@ -331,6 +331,7 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir: String, targetDir: String,
nameConflictStrategy: NameConflictStrategy, nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>, entries: List<AvesEntry>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
@ -366,7 +367,7 @@ class MediaStoreImageProvider : ImageProvider() {
// - there is no documentation regarding support for usage with removable storage // - 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 // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try { try {
val newFields = moveSingle( val newFields = if (isCancelledOp()) skippedFieldMap else moveSingle(
activity = activity, activity = activity,
sourcePath = sourcePath, sourcePath = sourcePath,
sourceUri = sourceUri, sourceUri = sourceUri,
@ -505,6 +506,7 @@ class MediaStoreImageProvider : ImageProvider() {
activity: Activity, activity: Activity,
newFileName: String, newFileName: String,
entries: List<AvesEntry>, entries: List<AvesEntry>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
for (entry in entries) { for (entry in entries) {
@ -519,7 +521,7 @@ class MediaStoreImageProvider : ImageProvider() {
if (sourcePath != null) { if (sourcePath != null) {
try { try {
val newFields = renameSingle( val newFields = if (isCancelledOp()) skippedFieldMap else renameSingle(
activity = activity, activity = activity,
mimeType = mimeType, mimeType = mimeType,
oldMediaUri = sourceUri, oldMediaUri = sourceUri,

View file

@ -39,6 +39,8 @@
"continueButtonLabel": "CONTINUE", "continueButtonLabel": "CONTINUE",
"@continueButtonLabel": {}, "@continueButtonLabel": {},
"cancelTooltip": "Cancel",
"@cancelTooltip": {},
"changeTooltip": "Change", "changeTooltip": "Change",
"@changeTooltip": {}, "@changeTooltip": {},
"clearTooltip": "Clear", "clearTooltip": "Clear",

View file

@ -15,6 +15,7 @@
"hideButtonLabel": "MASQUER", "hideButtonLabel": "MASQUER",
"continueButtonLabel": "CONTINUER", "continueButtonLabel": "CONTINUER",
"cancelTooltip": "Annuler",
"changeTooltip": "Modifier", "changeTooltip": "Modifier",
"clearTooltip": "Effacer", "clearTooltip": "Effacer",
"previousTooltip": "Précédent", "previousTooltip": "Précédent",

View file

@ -15,6 +15,7 @@
"hideButtonLabel": "숨기기", "hideButtonLabel": "숨기기",
"continueButtonLabel": "다음", "continueButtonLabel": "다음",
"cancelTooltip": "취소",
"changeTooltip": "변경", "changeTooltip": "변경",
"clearTooltip": "초기화", "clearTooltip": "초기화",
"previousTooltip": "이전", "previousTooltip": "이전",

View file

@ -15,6 +15,7 @@
"hideButtonLabel": "СКРЫТЬ", "hideButtonLabel": "СКРЫТЬ",
"continueButtonLabel": "ПРОДОЛЖИТЬ", "continueButtonLabel": "ПРОДОЛЖИТЬ",
"cancelTooltip": "Отмена",
"changeTooltip": "Изменить", "changeTooltip": "Изменить",
"clearTooltip": "Очистить", "clearTooltip": "Очистить",
"previousTooltip": "Предыдущий", "previousTooltip": "Предыдущий",

View file

@ -703,8 +703,8 @@ class AvesEntry {
Future<bool> delete() { Future<bool> delete() {
final completer = Completer<bool>(); final completer = Completer<bool>();
mediaFileService.delete([this]).listen( mediaFileService.delete(entries: {this}).listen(
(event) => completer.complete(event.success), (event) => completer.complete(event.success && !event.skipped),
onError: completer.completeError, onError: completer.completeError,
onDone: () { onDone: () {
if (!completer.isCompleted) { if (!completer.isCompleted) {

View file

@ -176,7 +176,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
processed.add, processed.add,
onError: (error) => reportService.recordError('renameEntry failed with error=$error', null), onError: (error) => reportService.recordError('renameEntry failed with error=$error', null),
onDone: () async { onDone: () async {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((e) => e.success && !e.skipped).toSet();
if (successOps.isEmpty) { if (successOps.isEmpty) {
completer.complete(false); completer.complete(false);
return; return;

View file

@ -3,65 +3,87 @@ import 'package:flutter/foundation.dart';
@immutable @immutable
class ImageOpEvent extends Equatable { class ImageOpEvent extends Equatable {
final bool success; final bool success, skipped;
final String uri; final String uri;
@override @override
List<Object?> get props => [success, uri]; List<Object?> get props => [success, skipped, uri];
const ImageOpEvent({ const ImageOpEvent({
required this.success, required this.success,
required this.skipped,
required this.uri, required this.uri,
}); });
factory ImageOpEvent.fromMap(Map map) { factory ImageOpEvent.fromMap(Map map) {
final skipped = map['skipped'] ?? false;
return ImageOpEvent( return ImageOpEvent(
success: map['success'] ?? false, success: (map['success'] ?? false) || skipped,
skipped: skipped,
uri: map['uri'], uri: map['uri'],
); );
} }
} }
@immutable
class MoveOpEvent extends ImageOpEvent { class MoveOpEvent extends ImageOpEvent {
final Map newFields; final Map newFields;
const MoveOpEvent({required bool success, required String uri, required this.newFields}) @override
: super( List<Object?> get props => [success, skipped, uri, newFields];
const MoveOpEvent({
required bool success,
required bool skipped,
required String uri,
required this.newFields,
}) : super(
success: success, success: success,
skipped: skipped,
uri: uri, uri: uri,
); );
factory MoveOpEvent.fromMap(Map map) { factory MoveOpEvent.fromMap(Map map) {
final newFields = map['newFields'] ?? {};
final skipped = (map['skipped'] ?? false) || (newFields['skipped'] ?? false);
return MoveOpEvent( return MoveOpEvent(
success: map['success'] ?? false, success: (map['success'] ?? false) || skipped,
skipped: skipped,
uri: map['uri'], 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 { class ExportOpEvent extends MoveOpEvent {
final int? pageId; final int? pageId;
@override @override
List<Object?> get props => [success, uri, pageId]; List<Object?> get props => [success, skipped, uri, pageId, newFields];
const ExportOpEvent({required bool success, required String uri, this.pageId, required Map newFields}) const ExportOpEvent({
: super( required bool success,
required bool skipped,
required String uri,
this.pageId,
required Map newFields,
}) : super(
success: success, success: success,
skipped: skipped,
uri: uri, uri: uri,
newFields: newFields, newFields: newFields,
); );
factory ExportOpEvent.fromMap(Map map) { factory ExportOpEvent.fromMap(Map map) {
final newFields = map['newFields'] ?? {};
final skipped = (map['skipped'] ?? false) || (newFields['skipped'] ?? false);
return ExportOpEvent( return ExportOpEvent(
success: map['success'] ?? false, success: (map['success'] ?? false) || skipped,
skipped: skipped,
uri: map['uri'], uri: map['uri'],
pageId: map['pageId'], pageId: map['pageId'],
newFields: map['newFields'] ?? {}, newFields: newFields,
); );
} }
} }

View file

@ -15,6 +15,8 @@ import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
abstract class MediaFileService { abstract class MediaFileService {
String get newOpId;
Future<AvesEntry?> getEntry(String uri, String? mimeType); Future<AvesEntry?> getEntry(String uri, String? mimeType);
Future<Uint8List> getSvg( Future<Uint8List> getSvg(
@ -68,10 +70,16 @@ abstract class MediaFileService {
Future<T>? resumeLoading<T>(Object taskKey); Future<T>? resumeLoading<T>(Object taskKey);
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries); Future<void> cancelFileOp(String opId);
Stream<MoveOpEvent> move( Stream<ImageOpEvent> delete({
Iterable<AvesEntry> entries, { String? opId,
required Iterable<AvesEntry> entries,
});
Stream<MoveOpEvent> move({
String? opId,
required Iterable<AvesEntry> entries,
required bool copy, required bool copy,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy, required NameConflictStrategy nameConflictStrategy,
@ -120,6 +128,9 @@ class PlatformMediaFileService implements MediaFileService {
}; };
} }
@override
String get newOpId => DateTime.now().millisecondsSinceEpoch.toString();
@override @override
Future<AvesEntry?> getEntry(String uri, String? mimeType) async { Future<AvesEntry?> getEntry(String uri, String? mimeType) async {
try { try {
@ -298,10 +309,25 @@ class PlatformMediaFileService implements MediaFileService {
Future<T>? resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey); Future<T>? resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
@override @override
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) { Future<void> cancelFileOp(String opId) async {
try {
await platform.invokeMethod('cancelFileOp', <String, dynamic>{
'opId': opId,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
@override
Stream<ImageOpEvent> delete({
String? opId,
required Iterable<AvesEntry> entries,
}) {
try { try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{ return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete', 'op': 'delete',
'id': opId,
'entries': entries.map(_toPlatformEntryMap).toList(), 'entries': entries.map(_toPlatformEntryMap).toList(),
}).map((event) => ImageOpEvent.fromMap(event)); }).map((event) => ImageOpEvent.fromMap(event));
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
@ -311,8 +337,9 @@ class PlatformMediaFileService implements MediaFileService {
} }
@override @override
Stream<MoveOpEvent> move( Stream<MoveOpEvent> move({
Iterable<AvesEntry> entries, { String? opId,
required Iterable<AvesEntry> entries,
required bool copy, required bool copy,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy, required NameConflictStrategy nameConflictStrategy,
@ -320,6 +347,7 @@ class PlatformMediaFileService implements MediaFileService {
try { try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{ return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move', 'op': 'move',
'id': opId,
'entries': entries.map(_toPlatformEntryMap).toList(), 'entries': entries.map(_toPlatformEntryMap).toList(),
'copy': copy, 'copy': copy,
'destinationPath': destinationAlbum, 'destinationPath': destinationAlbum,

View file

@ -36,6 +36,7 @@ class AIcons {
static const IconData add = Icons.add_circle_outline; static const IconData add = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData addTag = MdiIcons.tagPlusOutline; static const IconData addTag = MdiIcons.tagPlusOutline;
static const IconData cancel = Icons.cancel_outlined;
static const IconData replay10 = Icons.replay_10_outlined; static const IconData replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_10_outlined; static const IconData skip10 = Icons.forward_10_outlined;
static const IconData captureFrame = Icons.screenshot_outlined; static const IconData captureFrame = Icons.screenshot_outlined;

View file

@ -247,19 +247,23 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
source.pauseMonitoring(); source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: mediaFileService.delete(selectedItems), opStream: mediaFileService.delete(opId: opId, entries: selectedItems),
itemCount: todoCount, itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async { 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); await source.removeEntries(deletedUris);
selection.browse(); selection.browse();
source.resumeMonitoring(); source.resumeMonitoring();
final deletedCount = deletedUris.length; final successCount = successOps.length;
if (deletedCount < todoCount) { if (successCount < todoCount) {
final count = todoCount - deletedCount; final count = todoCount - successCount;
showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count));
} }
@ -324,18 +328,21 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
} }
source.pauseMonitoring(); source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<MoveOpEvent>( showOpReport<MoveOpEvent>(
context: context, context: context,
opStream: mediaFileService.move( opStream: mediaFileService.move(
todoItems, opId: opId,
entries: todoItems,
copy: copy, copy: copy,
destinationAlbum: destinationAlbum, destinationAlbum: destinationAlbum,
nameConflictStrategy: nameConflictStrategy, nameConflictStrategy: nameConflictStrategy,
), ),
itemCount: todoCount, itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet(); 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( await source.updateAfterMove(
todoEntries: todoItems, todoEntries: todoItems,
copy: copy, copy: copy,
@ -417,15 +424,17 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async { opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
// TODO TLAD [cancel] allow cancelling edit op
final dataTypes = await op(entry); final dataTypes = await op(entry);
return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri); return ImageOpEvent(success: dataTypes.isNotEmpty, skipped: false, uri: entry.uri);
}).asBroadcastStream(), }).asBroadcastStream(),
itemCount: todoCount, itemCount: todoCount,
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((e) => e.success).toSet();
final editedOps = successOps.where((e) => !e.skipped).toSet();
selection.browse(); selection.browse();
source.resumeMonitoring(); 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 l10n = context.l10n;
final successCount = successOps.length; final successCount = successOps.length;

View file

@ -6,6 +6,9 @@ import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/accessibility_service.dart'; import 'package:aves/services/accessibility_service.dart';
import 'package:aves/theme/durations.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:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:percent_indicator/circular_percent_indicator.dart'; import 'package:percent_indicator/circular_percent_indicator.dart';
@ -60,13 +63,16 @@ mixin FeedbackMixin {
required BuildContext context, required BuildContext context,
required Stream<T> opStream, required Stream<T> opStream,
required int itemCount, required int itemCount,
VoidCallback? onCancel,
void Function(Set<T> processed)? onDone, void Function(Set<T> processed)? onDone,
}) { }) {
late OverlayEntry _opReportOverlayEntry; late OverlayEntry _opReportOverlayEntry;
// TODO TLAD prevent current page/state pop by `back` button when there is file op report overlay
_opReportOverlayEntry = OverlayEntry( _opReportOverlayEntry = OverlayEntry(
builder: (context) => ReportOverlay<T>( builder: (context) => ReportOverlay<T>(
opStream: opStream, opStream: opStream,
itemCount: itemCount, itemCount: itemCount,
onCancel: onCancel,
onDone: (processed) { onDone: (processed) {
_opReportOverlayEntry.remove(); _opReportOverlayEntry.remove();
onDone?.call(processed); onDone?.call(processed);
@ -80,12 +86,14 @@ mixin FeedbackMixin {
class ReportOverlay<T> extends StatefulWidget { class ReportOverlay<T> extends StatefulWidget {
final Stream<T> opStream; final Stream<T> opStream;
final int itemCount; final int itemCount;
final VoidCallback? onCancel;
final void Function(Set<T> processed) onDone; final void Function(Set<T> processed) onDone;
const ReportOverlay({ const ReportOverlay({
Key? key, Key? key,
required this.opStream, required this.opStream,
required this.itemCount, required this.itemCount,
required this.onCancel,
required this.onDone, required this.onDone,
}) : super(key: key); }) : super(key: key);
@ -136,56 +144,71 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final progressColor = Theme.of(context).colorScheme.secondary; final progressColor = Theme.of(context).colorScheme.secondary;
return AbsorbPointer( return StreamBuilder<T>(
child: StreamBuilder<T>( stream: opStream,
stream: opStream, builder: (context, snapshot) {
builder: (context, snapshot) { final processedCount = processed.length.toDouble();
final processedCount = processed.length.toDouble(); final total = widget.itemCount;
final total = widget.itemCount; final percent = min(1.0, processedCount / total);
assert(processedCount <= total); final animate = context.select<Settings, bool>((v) => v.accessibilityAnimations.animate);
final percent = min(1.0, processedCount / total); return FadeTransition(
final animate = context.select<Settings, bool>((v) => v.accessibilityAnimations.animate); opacity: _animation,
return FadeTransition( child: Container(
opacity: _animation, decoration: const BoxDecoration(
child: Container( gradient: RadialGradient(
decoration: const BoxDecoration( colors: [
gradient: RadialGradient( Colors.black,
colors: [ Colors.black54,
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,
),
],
),
), ),
), ),
); 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,
),
),
),
),
],
),
),
),
);
},
); );
} }
} }

View file

@ -195,19 +195,23 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return; if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return;
source.pauseMonitoring(); source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: mediaFileService.delete(todoEntries), opStream: mediaFileService.delete(opId: opId, entries: todoEntries),
itemCount: todoCount, itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async { 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); await source.removeEntries(deletedUris);
_browse(context); _browse(context);
source.resumeMonitoring(); source.resumeMonitoring();
final deletedCount = deletedUris.length; final successCount = successOps.length;
if (deletedCount < todoCount) { if (successCount < todoCount) {
final count = todoCount - deletedCount; final count = todoCount - successCount;
showFeedbackWithMessenger(context, messenger, l10n.collectionDeleteFailureFeedback(count)); showFeedbackWithMessenger(context, messenger, l10n.collectionDeleteFailureFeedback(count));
} }
@ -255,25 +259,29 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
} }
source.pauseMonitoring(); source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<MoveOpEvent>( showOpReport<MoveOpEvent>(
context: context, context: context,
opStream: mediaFileService.move( opStream: mediaFileService.move(
todoEntries, opId: opId,
entries: todoEntries,
copy: false, copy: false,
destinationAlbum: destinationAlbum, destinationAlbum: destinationAlbum,
// there should be no file conflict, as the target directory itself does not exist // there should be no file conflict, as the target directory itself does not exist
nameConflictStrategy: NameConflictStrategy.rename, nameConflictStrategy: NameConflictStrategy.rename,
), ),
itemCount: todoCount, itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async { 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); await source.renameAlbum(album, destinationAlbum, todoEntries, movedOps);
_browse(context); _browse(context);
source.resumeMonitoring(); source.resumeMonitoring();
final movedCount = movedOps.length; final successCount = successOps.length;
if (movedCount < todoCount) { if (successCount < todoCount) {
final count = todoCount - movedCount; final count = todoCount - successCount;
showFeedbackWithMessenger(context, messenger, l10n.collectionMoveFailureFeedback(count)); showFeedbackWithMessenger(context, messenger, l10n.collectionMoveFailureFeedback(count));
} else { } else {
showFeedbackWithMessenger(context, messenger, l10n.genericSuccessFeedback); showFeedbackWithMessenger(context, messenger, l10n.genericSuccessFeedback);

View file

@ -244,14 +244,15 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
), ),
itemCount: selectionCount, itemCount: selectionCount,
onDone: (processed) { onDone: (processed) {
final exportOps = processed.where((e) => e.success); final successOps = processed.where((e) => e.success).toSet();
final exportCount = exportOps.length; final exportedOps = successOps.where((e) => !e.skipped).toSet();
final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet();
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main; final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
source.resumeMonitoring(); 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( ? SnackBarAction(
label: context.l10n.showButtonLabel, label: context.l10n.showButtonLabel,
onPressed: () async { onPressed: () async {
@ -272,7 +273,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
)); ));
final delayDuration = context.read<DurationsData>().staggeredAnimationPageTarget; final delayDuration = context.read<DurationsData>().staggeredAnimationPageTarget;
await Future.delayed(delayDuration + Durations.highlightScrollInitDelay); 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)); final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
if (targetEntry != null) { if (targetEntry != null) {
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
@ -280,8 +280,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}, },
) )
: null; : null;
if (exportCount < selectionCount) { final successCount = successOps.length;
final count = selectionCount - exportCount; if (successCount < selectionCount) {
final count = selectionCount - successCount;
showFeedback( showFeedback(
context, context,
context.l10n.collectionExportFailureFeedback(count), context.l10n.collectionExportFailureFeedback(count),

View file

@ -15,6 +15,7 @@ class FakeMediaFileService extends Fake implements MediaFileService {
final entry = entries.first; final entry = entries.first;
return Stream.value(MoveOpEvent( return Stream.value(MoveOpEvent(
success: true, success: true,
skipped: false,
uri: entry.uri, uri: entry.uri,
newFields: { newFields: {
'uri': 'content://media/external/images/media/$contentId', 'uri': 'content://media/external/images/media/$contentId',

View file

@ -47,6 +47,7 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
final newContentId = nextContentId; final newContentId = nextContentId;
return MoveOpEvent( return MoveOpEvent(
success: true, success: true,
skipped: false,
uri: entry.uri, uri: entry.uri,
newFields: { newFields: {
'uri': 'content://media/external/images/media/$newContentId', 'uri': 'content://media/external/images/media/$newContentId',