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.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<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) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val desiredName = call.argument<String>("desiredName")
@ -169,6 +185,9 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
}
companion object {
private val LOG_TAG = LogUtils.createTag<MediaFileHandler>()
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.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<FieldMap>()
init {
if (arguments is Map<*, *>) {
op = arguments["op"] as String?
opId = arguments["id"] as String?
@Suppress("unchecked_cast")
val rawEntries = arguments["entries"] as List<FieldMap>?
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<ImageOpStreamHandler>()
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")
}
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"))
}
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"))
}
@ -937,3 +951,5 @@ abstract class ImageProvider {
}
}
}
typealias CancelCheck = () -> Boolean

View file

@ -331,6 +331,7 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir: String,
nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>,
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<AvesEntry>,
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,

View file

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

View file

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

View file

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

View file

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

View file

@ -703,8 +703,8 @@ class AvesEntry {
Future<bool> delete() {
final completer = Completer<bool>();
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) {

View file

@ -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;

View file

@ -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<Object?> get props => [success, uri];
List<Object?> 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<Object?> 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<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})
: 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,
);
}
}

View file

@ -15,6 +15,8 @@ import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart';
abstract class MediaFileService {
String get newOpId;
Future<AvesEntry?> getEntry(String uri, String? mimeType);
Future<Uint8List> getSvg(
@ -68,10 +70,16 @@ abstract class MediaFileService {
Future<T>? resumeLoading<T>(Object taskKey);
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries);
Future<void> cancelFileOp(String opId);
Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
Stream<ImageOpEvent> delete({
String? opId,
required Iterable<AvesEntry> entries,
});
Stream<MoveOpEvent> move({
String? opId,
required Iterable<AvesEntry> 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<AvesEntry?> getEntry(String uri, String? mimeType) async {
try {
@ -298,10 +309,25 @@ class PlatformMediaFileService implements MediaFileService {
Future<T>? resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
@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 {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'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<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
Stream<MoveOpEvent> move({
String? opId,
required Iterable<AvesEntry> entries,
required bool copy,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
@ -320,6 +347,7 @@ class PlatformMediaFileService implements MediaFileService {
try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move',
'id': opId,
'entries': entries.map(_toPlatformEntryMap).toList(),
'copy': copy,
'destinationPath': destinationAlbum,

View file

@ -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;

View file

@ -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<ImageOpEvent>(
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<MoveOpEvent>(
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<ImageOpEvent>(
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;

View file

@ -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<T> opStream,
required int itemCount,
VoidCallback? onCancel,
void Function(Set<T> 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<T>(
opStream: opStream,
itemCount: itemCount,
onCancel: onCancel,
onDone: (processed) {
_opReportOverlayEntry.remove();
onDone?.call(processed);
@ -80,12 +86,14 @@ mixin FeedbackMixin {
class ReportOverlay<T> extends StatefulWidget {
final Stream<T> opStream;
final int itemCount;
final VoidCallback? onCancel;
final void Function(Set<T> 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<T> extends State<ReportOverlay<T>> with SingleTickerPr
@override
Widget build(BuildContext context) {
final progressColor = Theme.of(context).colorScheme.secondary;
return AbsorbPointer(
child: StreamBuilder<T>(
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<Settings, bool>((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<T>(
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<Settings, bool>((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,
),
),
),
),
],
),
),
),
);
},
);
}
}

View file

@ -195,19 +195,23 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return;
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<ImageOpEvent>(
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<AlbumFilter> {
}
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<MoveOpEvent>(
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);

View file

@ -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<ValueNotifier<AppMode>>().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<DurationsData>().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),

View file

@ -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',

View file

@ -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',