cancellable file op: move/copy/delete
This commit is contained in:
parent
d16158d8e7
commit
eee3452e3e
19 changed files with 259 additions and 113 deletions
|
@ -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>()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -39,6 +39,8 @@
|
||||||
"continueButtonLabel": "CONTINUE",
|
"continueButtonLabel": "CONTINUE",
|
||||||
"@continueButtonLabel": {},
|
"@continueButtonLabel": {},
|
||||||
|
|
||||||
|
"cancelTooltip": "Cancel",
|
||||||
|
"@cancelTooltip": {},
|
||||||
"changeTooltip": "Change",
|
"changeTooltip": "Change",
|
||||||
"@changeTooltip": {},
|
"@changeTooltip": {},
|
||||||
"clearTooltip": "Clear",
|
"clearTooltip": "Clear",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
"hideButtonLabel": "숨기기",
|
"hideButtonLabel": "숨기기",
|
||||||
"continueButtonLabel": "다음",
|
"continueButtonLabel": "다음",
|
||||||
|
|
||||||
|
"cancelTooltip": "취소",
|
||||||
"changeTooltip": "변경",
|
"changeTooltip": "변경",
|
||||||
"clearTooltip": "초기화",
|
"clearTooltip": "초기화",
|
||||||
"previousTooltip": "이전",
|
"previousTooltip": "이전",
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
"hideButtonLabel": "СКРЫТЬ",
|
"hideButtonLabel": "СКРЫТЬ",
|
||||||
"continueButtonLabel": "ПРОДОЛЖИТЬ",
|
"continueButtonLabel": "ПРОДОЛЖИТЬ",
|
||||||
|
|
||||||
|
"cancelTooltip": "Отмена",
|
||||||
"changeTooltip": "Изменить",
|
"changeTooltip": "Изменить",
|
||||||
"clearTooltip": "Очистить",
|
"clearTooltip": "Очистить",
|
||||||
"previousTooltip": "Предыдущий",
|
"previousTooltip": "Предыдущий",
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in a new issue