#183 bulk renaming
This commit is contained in:
parent
fa862c041e
commit
89173b8bc7
28 changed files with 829 additions and 160 deletions
|
@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
|
|||
### Added
|
||||
|
||||
- Theme: light/dark/black and poly/monochrome settings
|
||||
- Collection: bulk renaming
|
||||
- Video: speed and muted state indicators
|
||||
- Info: option to set date from other item
|
||||
- Info: improved DNG tags display
|
||||
|
|
|
@ -199,27 +199,34 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
}
|
||||
|
||||
private suspend fun rename() {
|
||||
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
|
||||
if (arguments !is Map<*, *>) {
|
||||
endOfStream()
|
||||
return
|
||||
}
|
||||
|
||||
val newName = arguments["newName"] as String?
|
||||
if (newName == null) {
|
||||
val rawEntryMap = arguments["entriesToNewName"] as Map<*, *>?
|
||||
if (rawEntryMap == null || rawEntryMap.isEmpty()) {
|
||||
error("rename-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val entriesToNewName = HashMap<AvesEntry, String>()
|
||||
rawEntryMap.forEach {
|
||||
@Suppress("unchecked_cast")
|
||||
val rawEntry = it.key as FieldMap
|
||||
val newName = it.value as String
|
||||
entriesToNewName[AvesEntry(rawEntry)] = newName
|
||||
}
|
||||
|
||||
// assume same provider for all entries
|
||||
val firstEntry = entryMapList.first()
|
||||
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
|
||||
val firstEntry = entriesToNewName.keys.first()
|
||||
val provider = getProvider(firstEntry.uri)
|
||||
if (provider == null) {
|
||||
error("rename-provider", "failed to find provider for entry=$firstEntry", null)
|
||||
return
|
||||
}
|
||||
|
||||
val entries = entryMapList.map(::AvesEntry)
|
||||
provider.renameMultiple(activity, newName, entries, ::isCancelledOp, object : ImageOpCallback {
|
||||
provider.renameMultiple(activity, entriesToNewName, ::isCancelledOp, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = success(fields)
|
||||
override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message)
|
||||
})
|
||||
|
|
|
@ -62,8 +62,7 @@ abstract class ImageProvider {
|
|||
|
||||
open suspend fun renameMultiple(
|
||||
activity: Activity,
|
||||
newFileName: String,
|
||||
entries: List<AvesEntry>,
|
||||
entriesToNewName: Map<AvesEntry, String>,
|
||||
isCancelledOp: CancelCheck,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
|
|
|
@ -191,7 +191,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
val pathColumn = cursor.getColumnIndexOrThrow(MediaColumns.PATH)
|
||||
val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)
|
||||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||
val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)
|
||||
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
|
@ -229,7 +228,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
"height" to height,
|
||||
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
||||
"sizeBytes" to cursor.getLong(sizeColumn),
|
||||
"title" to cursor.getString(titleColumn),
|
||||
"dateModifiedSecs" to dateModifiedSecs,
|
||||
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
||||
"durationMillis" to durationMillis,
|
||||
|
@ -587,12 +585,14 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
|
||||
override suspend fun renameMultiple(
|
||||
activity: Activity,
|
||||
newFileName: String,
|
||||
entries: List<AvesEntry>,
|
||||
entriesToNewName: Map<AvesEntry, String>,
|
||||
isCancelledOp: CancelCheck,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
for (entry in entries) {
|
||||
for (kv in entriesToNewName) {
|
||||
val entry = kv.key
|
||||
val newFileName = kv.value
|
||||
|
||||
val sourceUri = entry.uri
|
||||
val sourcePath = entry.path
|
||||
val mimeType = entry.mimeType
|
||||
|
@ -602,7 +602,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
"success" to false,
|
||||
)
|
||||
|
||||
if (sourcePath != null) {
|
||||
// prevent naming with a `.` prefix as it would hide the file and remove it from the Media Store
|
||||
if (sourcePath != null && !newFileName.startsWith('.')) {
|
||||
try {
|
||||
val newFields = if (isCancelledOp()) skippedFieldMap else renameSingle(
|
||||
activity = activity,
|
||||
|
@ -763,8 +764,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
|
||||
val projection = arrayOf(
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
MediaStore.MediaColumns.TITLE,
|
||||
)
|
||||
try {
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
|
@ -774,8 +773,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
newFields["contentId"] = uri.tryParseId()
|
||||
newFields["path"] = path
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) }
|
||||
cursor.close()
|
||||
return newFields
|
||||
}
|
||||
|
@ -846,8 +843,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
MediaColumns.PATH,
|
||||
MediaStore.MediaColumns.MIME_TYPE,
|
||||
MediaStore.MediaColumns.SIZE,
|
||||
// TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`?
|
||||
MediaStore.MediaColumns.TITLE,
|
||||
MediaStore.MediaColumns.WIDTH,
|
||||
MediaStore.MediaColumns.HEIGHT,
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
|
|
|
@ -315,6 +315,15 @@
|
|||
"renameAlbumDialogLabel": "New name",
|
||||
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists",
|
||||
|
||||
"renameEntrySetPageTitle": "Rename",
|
||||
"renameEntrySetPagePatternFieldLabel": "Naming pattern",
|
||||
"renameEntrySetPageInsertTooltip": "Insert field",
|
||||
"renameEntrySetPagePreview": "Preview",
|
||||
|
||||
"renameProcessorCounter": "Counter",
|
||||
"renameProcessorDate": "Date",
|
||||
"renameProcessorName": "Name",
|
||||
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Delete this album and its item?} other{Delete this album and its {count} items?}}",
|
||||
"@deleteSingleAlbumConfirmationDialogMessage": {
|
||||
"placeholders": {
|
||||
|
@ -460,6 +469,12 @@
|
|||
"count": {}
|
||||
}
|
||||
},
|
||||
"collectionRenameFailureFeedback": "{count, plural, =1{Failed to rename 1 item} other{Failed to rename {count} items}}",
|
||||
"@collectionRenameFailureFeedback": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
"collectionEditFailureFeedback": "{count, plural, =1{Failed to edit 1 item} other{Failed to edit {count} items}}",
|
||||
"@collectionEditFailureFeedback": {
|
||||
"placeholders": {
|
||||
|
@ -484,6 +499,12 @@
|
|||
"count": {}
|
||||
}
|
||||
},
|
||||
"collectionRenameSuccessFeedback": "{count, plural, =1{Renamed 1 item} other{Renamed {count} items}}",
|
||||
"@collectionRenameSuccessFeedback": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
"collectionEditSuccessFeedback": "{count, plural, =1{Edited 1 item} other{Edited {count} items}}",
|
||||
"@collectionEditSuccessFeedback": {
|
||||
"placeholders": {
|
||||
|
|
|
@ -124,7 +124,7 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return AIcons.unpin;
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.rename:
|
||||
return AIcons.rename;
|
||||
return AIcons.name;
|
||||
case ChipSetAction.setCover:
|
||||
return AIcons.setCover;
|
||||
}
|
||||
|
|
|
@ -201,7 +201,7 @@ extension ExtraEntryAction on EntryAction {
|
|||
case EntryAction.print:
|
||||
return AIcons.print;
|
||||
case EntryAction.rename:
|
||||
return AIcons.rename;
|
||||
return AIcons.name;
|
||||
case EntryAction.copy:
|
||||
return AIcons.copy;
|
||||
case EntryAction.move:
|
||||
|
|
|
@ -23,6 +23,7 @@ enum EntrySetAction {
|
|||
restore,
|
||||
copy,
|
||||
move,
|
||||
rename,
|
||||
toggleFavourite,
|
||||
rotateCCW,
|
||||
rotateCW,
|
||||
|
@ -68,6 +69,7 @@ class EntrySetActions {
|
|||
EntrySetAction.restore,
|
||||
EntrySetAction.copy,
|
||||
EntrySetAction.move,
|
||||
EntrySetAction.rename,
|
||||
EntrySetAction.toggleFavourite,
|
||||
EntrySetAction.map,
|
||||
EntrySetAction.stats,
|
||||
|
@ -81,6 +83,7 @@ class EntrySetActions {
|
|||
EntrySetAction.delete,
|
||||
EntrySetAction.copy,
|
||||
EntrySetAction.move,
|
||||
EntrySetAction.rename,
|
||||
EntrySetAction.toggleFavourite,
|
||||
EntrySetAction.map,
|
||||
EntrySetAction.stats,
|
||||
|
@ -137,6 +140,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return context.l10n.collectionActionCopy;
|
||||
case EntrySetAction.move:
|
||||
return context.l10n.collectionActionMove;
|
||||
case EntrySetAction.rename:
|
||||
return context.l10n.entryActionRename;
|
||||
case EntrySetAction.toggleFavourite:
|
||||
// different data depending on toggle state
|
||||
return context.l10n.entryActionAddFavourite;
|
||||
|
@ -200,6 +205,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return AIcons.copy;
|
||||
case EntrySetAction.move:
|
||||
return AIcons.move;
|
||||
case EntrySetAction.rename:
|
||||
return AIcons.name;
|
||||
case EntrySetAction.toggleFavourite:
|
||||
// different data depending on toggle state
|
||||
return AIcons.favourite;
|
||||
|
|
|
@ -167,6 +167,7 @@ class AvesEntry {
|
|||
_directory = null;
|
||||
_filename = null;
|
||||
_extension = null;
|
||||
_bestTitle = null;
|
||||
}
|
||||
|
||||
String? get path => _path;
|
||||
|
@ -455,7 +456,7 @@ class AvesEntry {
|
|||
String? _bestTitle;
|
||||
|
||||
String? get bestTitle {
|
||||
_bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : sourceTitle;
|
||||
_bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : (filenameWithoutExtension ?? sourceTitle);
|
||||
return _bestTitle;
|
||||
}
|
||||
|
||||
|
|
184
lib/model/naming_pattern.dart
Normal file
184
lib/model/naming_pattern.dart
Normal file
|
@ -0,0 +1,184 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
@immutable
|
||||
class NamingPattern {
|
||||
final List<NamingProcessor> processors;
|
||||
|
||||
static final processorPattern = RegExp(r'<(.+?)(,(.+?))?>');
|
||||
static const processorOptionSeparator = ',';
|
||||
static const optionKeyValueSeparator = '=';
|
||||
|
||||
const NamingPattern(this.processors);
|
||||
|
||||
factory NamingPattern.from({
|
||||
required String userPattern,
|
||||
required int entryCount,
|
||||
}) {
|
||||
final processors = <NamingProcessor>[];
|
||||
|
||||
const defaultCounterStart = 1;
|
||||
final defaultCounterPadding = '$entryCount'.length;
|
||||
|
||||
var index = 0;
|
||||
final matches = processorPattern.allMatches(userPattern);
|
||||
matches.forEach((match) {
|
||||
final start = match.start;
|
||||
final end = match.end;
|
||||
if (index < start) {
|
||||
processors.add(LiteralNamingProcessor(userPattern.substring(index, start)));
|
||||
index = start;
|
||||
}
|
||||
final processorKey = match.group(1);
|
||||
final processorOptions = match.group(3);
|
||||
switch (processorKey) {
|
||||
case DateNamingProcessor.key:
|
||||
if (processorOptions != null) {
|
||||
processors.add(DateNamingProcessor(processorOptions.trim()));
|
||||
}
|
||||
break;
|
||||
case NameNamingProcessor.key:
|
||||
processors.add(const NameNamingProcessor());
|
||||
break;
|
||||
case CounterNamingProcessor.key:
|
||||
int? start, padding;
|
||||
_applyProcessorOptions(processorOptions, (key, value) {
|
||||
final valueInt = int.tryParse(value);
|
||||
if (valueInt != null) {
|
||||
switch (key) {
|
||||
case CounterNamingProcessor.optionStart:
|
||||
start = valueInt;
|
||||
break;
|
||||
case CounterNamingProcessor.optionPadding:
|
||||
padding = valueInt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
processors.add(CounterNamingProcessor(start: start ?? defaultCounterStart, padding: padding ?? defaultCounterPadding));
|
||||
break;
|
||||
default:
|
||||
debugPrint('unsupported naming processor: ${match.group(0)}');
|
||||
break;
|
||||
}
|
||||
index = end;
|
||||
});
|
||||
if (index < userPattern.length) {
|
||||
processors.add(LiteralNamingProcessor(userPattern.substring(index, userPattern.length)));
|
||||
}
|
||||
|
||||
return NamingPattern(processors);
|
||||
}
|
||||
|
||||
static void _applyProcessorOptions(String? processorOptions, void Function(String key, String value) applyOption) {
|
||||
if (processorOptions != null) {
|
||||
processorOptions.split(processorOptionSeparator).map((v) => v.trim()).forEach((kv) {
|
||||
final parts = kv.split(optionKeyValueSeparator);
|
||||
if (parts.length >= 2) {
|
||||
final key = parts[0];
|
||||
final value = parts.skip(1).join(optionKeyValueSeparator);
|
||||
applyOption(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static int getInsertionOffset(String userPattern, int offset) {
|
||||
offset = offset.clamp(0, userPattern.length);
|
||||
final matches = processorPattern.allMatches(userPattern);
|
||||
for (final match in matches) {
|
||||
final start = match.start;
|
||||
final end = match.end;
|
||||
if (offset <= start) return offset;
|
||||
if (offset <= end) return end;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
static String defaultPatternFor(String processorKey) {
|
||||
switch (processorKey) {
|
||||
case DateNamingProcessor.key:
|
||||
return '<$processorKey, yyyyMMdd-HHmmss>';
|
||||
case CounterNamingProcessor.key:
|
||||
case NameNamingProcessor.key:
|
||||
default:
|
||||
return '<$processorKey>';
|
||||
}
|
||||
}
|
||||
|
||||
String apply(AvesEntry entry, int index) => processors.map((v) => v.process(entry, index) ?? '').join().trimLeft();
|
||||
}
|
||||
|
||||
@immutable
|
||||
abstract class NamingProcessor extends Equatable {
|
||||
const NamingProcessor();
|
||||
|
||||
String? process(AvesEntry entry, int index);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class LiteralNamingProcessor extends NamingProcessor {
|
||||
final String text;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [text];
|
||||
|
||||
const LiteralNamingProcessor(this.text);
|
||||
|
||||
@override
|
||||
String? process(AvesEntry entry, int index) => text;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class DateNamingProcessor extends NamingProcessor {
|
||||
static const key = 'date';
|
||||
|
||||
final DateFormat format;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [format.pattern];
|
||||
|
||||
DateNamingProcessor(String pattern) : format = DateFormat(pattern);
|
||||
|
||||
@override
|
||||
String? process(AvesEntry entry, int index) {
|
||||
final date = entry.bestDate;
|
||||
return date != null ? format.format(date) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class NameNamingProcessor extends NamingProcessor {
|
||||
static const key = 'name';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
|
||||
const NameNamingProcessor();
|
||||
|
||||
@override
|
||||
String? process(AvesEntry entry, int index) => entry.filenameWithoutExtension;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class CounterNamingProcessor extends NamingProcessor {
|
||||
final int start;
|
||||
final int padding;
|
||||
|
||||
static const key = 'counter';
|
||||
static const optionStart = 'start';
|
||||
static const optionPadding = 'padding';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [start, padding];
|
||||
|
||||
const CounterNamingProcessor({
|
||||
required this.start,
|
||||
required this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
String? process(AvesEntry entry, int index) => '${index + start}'.padLeft(padding, '0');
|
||||
}
|
|
@ -2,6 +2,7 @@ import 'package:aves/model/actions/entry_actions.dart';
|
|||
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/naming_pattern.dart';
|
||||
import 'package:aves/model/settings/enums/enums.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
|
@ -15,9 +16,10 @@ class SettingsDefaults {
|
|||
static const canUseAnalysisService = true;
|
||||
static const isInstalledAppAccessAllowed = false;
|
||||
static const isErrorReportingAllowed = false;
|
||||
static const tileLayout = TileLayout.grid;
|
||||
static const themeBrightness = AvesThemeBrightness.system;
|
||||
static const themeColorMode = AvesThemeColorMode.polychrome;
|
||||
static const tileLayout = TileLayout.grid;
|
||||
static const entryRenamingPattern = '<${DateNamingProcessor.key}, yyyyMMdd-HHmmss> <${NameNamingProcessor.key}>';
|
||||
|
||||
// navigation
|
||||
static const mustBackTwiceToExit = true;
|
||||
|
|
|
@ -46,6 +46,7 @@ class Settings extends ChangeNotifier {
|
|||
static const catalogTimeZoneKey = 'catalog_time_zone';
|
||||
static const tileExtentPrefixKey = 'tile_extent_';
|
||||
static const tileLayoutPrefixKey = 'tile_layout_';
|
||||
static const entryRenamingPatternKey = 'entry_renaming_pattern';
|
||||
static const topEntryIdsKey = 'top_entry_ids';
|
||||
|
||||
// navigation
|
||||
|
@ -264,6 +265,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
void setTileLayout(String routeName, TileLayout newValue) => setAndNotify(tileLayoutPrefixKey + routeName, newValue.toString());
|
||||
|
||||
String get entryRenamingPattern => getString(entryRenamingPatternKey) ?? SettingsDefaults.entryRenamingPattern;
|
||||
|
||||
set entryRenamingPattern(String newValue) => setAndNotify(entryRenamingPatternKey, newValue);
|
||||
|
||||
List<int>? get topEntryIds => getStringList(topEntryIdsKey)?.map(int.tryParse).whereNotNull().toList();
|
||||
|
||||
set topEntryIds(List<int>? newValue) => setAndNotify(topEntryIdsKey, newValue?.map((id) => id.toString()).whereNotNull().toList());
|
||||
|
|
|
@ -230,38 +230,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
}
|
||||
|
||||
Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
|
||||
if (newName == entry.filenameWithoutExtension) return true;
|
||||
|
||||
pauseMonitoring();
|
||||
final completer = Completer<bool>();
|
||||
final processed = <MoveOpEvent>{};
|
||||
mediaFileService.rename({entry}, newName: '$newName${entry.extension}').listen(
|
||||
processed.add,
|
||||
onError: (error) => reportService.recordError('renameEntry failed with error=$error', null),
|
||||
onDone: () async {
|
||||
final successOps = processed.where((e) => e.success && !e.skipped).toSet();
|
||||
if (successOps.isEmpty) {
|
||||
completer.complete(false);
|
||||
return;
|
||||
}
|
||||
final newFields = successOps.first.newFields;
|
||||
if (newFields.isEmpty) {
|
||||
completer.complete(false);
|
||||
return;
|
||||
}
|
||||
await _moveEntry(entry, newFields, persist: persist);
|
||||
entry.metadataChangeNotifier.notify();
|
||||
eventBus.fire(EntryMovedEvent(MoveType.move, {entry}));
|
||||
completer.complete(true);
|
||||
},
|
||||
);
|
||||
|
||||
final success = await completer.future;
|
||||
resumeMonitoring();
|
||||
return success;
|
||||
}
|
||||
|
||||
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> entries, Set<MoveOpEvent> movedOps) async {
|
||||
final oldFilter = AlbumFilter(sourceAlbum, null);
|
||||
final newFilter = AlbumFilter(destinationAlbum, null);
|
||||
|
@ -357,6 +325,29 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
eventBus.fire(EntryMovedEvent(moveType, movedEntries));
|
||||
}
|
||||
|
||||
Future<void> updateAfterRename({
|
||||
required Set<AvesEntry> todoEntries,
|
||||
required Set<MoveOpEvent> movedOps,
|
||||
required bool persist,
|
||||
}) async {
|
||||
if (movedOps.isEmpty) return;
|
||||
|
||||
final movedEntries = <AvesEntry>{};
|
||||
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
|
||||
final newFields = movedOp.newFields;
|
||||
if (newFields.isNotEmpty) {
|
||||
final sourceUri = movedOp.uri;
|
||||
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
|
||||
if (entry != null) {
|
||||
movedEntries.add(entry);
|
||||
await _moveEntry(entry, newFields, persist: persist);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eventBus.fire(EntryMovedEvent(MoveType.move, movedEntries));
|
||||
}
|
||||
|
||||
SourceInitializationState get initState => SourceInitializationState.none;
|
||||
|
||||
Future<void> init({
|
||||
|
|
|
@ -92,9 +92,9 @@ abstract class MediaFileService {
|
|||
required NameConflictStrategy nameConflictStrategy,
|
||||
});
|
||||
|
||||
Stream<MoveOpEvent> rename(
|
||||
Iterable<AvesEntry> entries, {
|
||||
required String newName,
|
||||
Stream<MoveOpEvent> rename({
|
||||
String? opId,
|
||||
required Map<AvesEntry, String> entriesToNewName,
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> captureFrame(
|
||||
|
@ -392,16 +392,16 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
}
|
||||
|
||||
@override
|
||||
Stream<MoveOpEvent> rename(
|
||||
Iterable<AvesEntry> entries, {
|
||||
required String newName,
|
||||
Stream<MoveOpEvent> rename({
|
||||
String? opId,
|
||||
required Map<AvesEntry, String> entriesToNewName,
|
||||
}) {
|
||||
try {
|
||||
return _opStreamChannel
|
||||
.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'rename',
|
||||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||
'newName': newName,
|
||||
'id': opId,
|
||||
'entriesToNewName': entriesToNewName.map((key, value) => MapEntry(_toPlatformEntryMap(key), value)),
|
||||
})
|
||||
.where((event) => event is Map)
|
||||
.map((event) => MoveOpEvent.fromMap(event as Map));
|
||||
|
|
|
@ -12,6 +12,7 @@ class AIcons {
|
|||
static const IconData bin = Icons.delete_outlined;
|
||||
static const IconData broken = Icons.broken_image_outlined;
|
||||
static const IconData checked = Icons.done_outlined;
|
||||
static const IconData counter = Icons.plus_one_outlined;
|
||||
static const IconData date = Icons.calendar_today_outlined;
|
||||
static const IconData disc = Icons.fiber_manual_record;
|
||||
static const IconData display = Icons.light_mode_outlined;
|
||||
|
@ -75,6 +76,7 @@ class AIcons {
|
|||
static const IconData move = MdiIcons.fileMoveOutline;
|
||||
static const IconData mute = Icons.volume_off_outlined;
|
||||
static const IconData unmute = Icons.volume_up_outlined;
|
||||
static const IconData name = Icons.abc_outlined;
|
||||
static const IconData newTier = Icons.fiber_new_outlined;
|
||||
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||
static const IconData pin = Icons.push_pin_outlined;
|
||||
|
@ -83,7 +85,6 @@ class AIcons {
|
|||
static const IconData pause = Icons.pause;
|
||||
static const IconData print = Icons.print_outlined;
|
||||
static const IconData refresh = Icons.refresh_outlined;
|
||||
static const IconData rename = Icons.title_outlined;
|
||||
static const IconData replay10 = Icons.replay_10_outlined;
|
||||
static const IconData skip10 = Icons.forward_10_outlined;
|
||||
static const IconData reset = Icons.restart_alt_outlined;
|
||||
|
|
|
@ -4,6 +4,12 @@ extension ExtraMapNullableKey<K extends Object, V> on Map<K?, V> {
|
|||
Map<K, V> whereNotNullKey() => <K, V>{for (var v in keys.whereNotNull()) v: this[v]!};
|
||||
}
|
||||
|
||||
extension ExtraMapNullableValue<K extends Object, V> on Map<K, V?> {
|
||||
Map<K, V> whereNotNullValue() => <K, V>{for (var kv in entries.where((kv) => kv.value != null)) kv.key: kv.value!};
|
||||
}
|
||||
|
||||
extension ExtraMapNullableKeyValue<K extends Object, V> on Map<K?, V?> {
|
||||
Map<K, V?> whereNotNullKey() => <K, V?>{for (var v in keys.whereNotNull()) v: this[v]};
|
||||
|
||||
Map<K?, V> whereNotNullValue() => <K?, V>{for (var kv in entries.where((kv) => kv.value != null)) kv.key: kv.value!};
|
||||
}
|
||||
|
|
|
@ -452,6 +452,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
case EntrySetAction.restore:
|
||||
case EntrySetAction.copy:
|
||||
case EntrySetAction.move:
|
||||
case EntrySetAction.rename:
|
||||
case EntrySetAction.toggleFavourite:
|
||||
case EntrySetAction.rotateCCW:
|
||||
case EntrySetAction.rotateCW:
|
||||
|
|
|
@ -9,6 +9,7 @@ import 'package:aves/model/entry_metadata_edition.dart';
|
|||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/metadata/date_modifier.dart';
|
||||
import 'package:aves/model/naming_pattern.dart';
|
||||
import 'package:aves/model/query.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/settings/enums/enums.dart';
|
||||
|
@ -19,6 +20,7 @@ import 'package:aves/model/source/collection_source.dart';
|
|||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/collection_utils.dart';
|
||||
import 'package:aves/utils/mime_utils.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
|
@ -28,11 +30,13 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
|||
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart';
|
||||
import 'package:aves/widgets/map/map_page.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:aves/widgets/stats/stats_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
|
@ -78,6 +82,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
case EntrySetAction.share:
|
||||
case EntrySetAction.copy:
|
||||
case EntrySetAction.move:
|
||||
case EntrySetAction.rename:
|
||||
case EntrySetAction.toggleFavourite:
|
||||
case EntrySetAction.rotateCCW:
|
||||
case EntrySetAction.rotateCW:
|
||||
|
@ -127,6 +132,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
case EntrySetAction.restore:
|
||||
case EntrySetAction.copy:
|
||||
case EntrySetAction.move:
|
||||
case EntrySetAction.rename:
|
||||
case EntrySetAction.toggleFavourite:
|
||||
case EntrySetAction.rotateCCW:
|
||||
case EntrySetAction.rotateCW:
|
||||
|
@ -185,6 +191,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
case EntrySetAction.move:
|
||||
_move(context, moveType: MoveType.move);
|
||||
break;
|
||||
case EntrySetAction.rename:
|
||||
_rename(context);
|
||||
break;
|
||||
case EntrySetAction.toggleFavourite:
|
||||
_toggleFavourite(context);
|
||||
break;
|
||||
|
@ -243,17 +252,6 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
_leaveSelectionMode(context);
|
||||
}
|
||||
|
||||
Future<void> _toggleFavourite(BuildContext context) async {
|
||||
final entries = _getTargetItems(context);
|
||||
if (entries.every((entry) => entry.isFavourite)) {
|
||||
await favourites.removeEntries(entries);
|
||||
} else {
|
||||
await favourites.add(entries);
|
||||
}
|
||||
|
||||
_leaveSelectionMode(context);
|
||||
}
|
||||
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final entries = _getTargetItems(context);
|
||||
|
||||
|
@ -312,6 +310,40 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
_leaveSelectionMode(context);
|
||||
}
|
||||
|
||||
Future<void> _rename(BuildContext context) async {
|
||||
final entries = _getTargetItems(context).toList();
|
||||
|
||||
final pattern = await Navigator.push<NamingPattern>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: RenameEntrySetPage.routeName),
|
||||
builder: (context) => RenameEntrySetPage(
|
||||
entries: entries,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (pattern == null) return;
|
||||
|
||||
final entriesToNewName = Map.fromEntries(entries.mapIndexed((index, entry) {
|
||||
final newName = pattern.apply(entry, index);
|
||||
return MapEntry(entry, '$newName${entry.extension}');
|
||||
})).whereNotNullValue();
|
||||
await rename(context, entriesToNewName: entriesToNewName, persist: true);
|
||||
|
||||
_leaveSelectionMode(context);
|
||||
}
|
||||
|
||||
Future<void> _toggleFavourite(BuildContext context) async {
|
||||
final entries = _getTargetItems(context);
|
||||
if (entries.every((entry) => entry.isFavourite)) {
|
||||
await favourites.removeEntries(entries);
|
||||
} else {
|
||||
await favourites.add(entries);
|
||||
}
|
||||
|
||||
_leaveSelectionMode(context);
|
||||
}
|
||||
|
||||
Future<void> _edit(
|
||||
BuildContext context,
|
||||
Set<AvesEntry> todoItems,
|
||||
|
@ -409,7 +441,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
if (confirmed == null || !confirmed) return null;
|
||||
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation);
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
return supported;
|
||||
}
|
||||
|
||||
|
|
|
@ -203,6 +203,55 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> rename(
|
||||
BuildContext context, {
|
||||
required Map<AvesEntry, String> entriesToNewName,
|
||||
required bool persist,
|
||||
VoidCallback? onSuccess,
|
||||
}) async {
|
||||
final entries = entriesToNewName.keys.toSet();
|
||||
final todoCount = entries.length;
|
||||
assert(todoCount > 0);
|
||||
|
||||
if (!await checkStoragePermission(context, entries)) return;
|
||||
|
||||
if (!await _checkUndatedItems(context, entries)) return;
|
||||
|
||||
final source = context.read<CollectionSource>();
|
||||
source.pauseMonitoring();
|
||||
final opId = mediaFileService.newOpId;
|
||||
await showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
opStream: mediaFileService.rename(
|
||||
opId: opId,
|
||||
entriesToNewName: entriesToNewName,
|
||||
),
|
||||
itemCount: todoCount,
|
||||
onCancel: () => mediaFileService.cancelFileOp(opId),
|
||||
onDone: (processed) async {
|
||||
final successOps = processed.where((e) => e.success).toSet();
|
||||
final movedOps = successOps.where((e) => !e.skipped).toSet();
|
||||
await source.updateAfterRename(
|
||||
todoEntries: entries,
|
||||
movedOps: movedOps,
|
||||
persist: persist,
|
||||
);
|
||||
source.resumeMonitoring();
|
||||
|
||||
final l10n = context.l10n;
|
||||
final successCount = successOps.length;
|
||||
if (successCount < todoCount) {
|
||||
final count = todoCount - successCount;
|
||||
showFeedback(context, l10n.collectionRenameFailureFeedback(count));
|
||||
} else {
|
||||
final count = movedOps.length;
|
||||
showFeedback(context, l10n.collectionRenameSuccessFeedback(count));
|
||||
onSuccess?.call();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _checkUndatedItems(BuildContext context, Set<AvesEntry> entries) async {
|
||||
final undatedItems = entries.where((entry) {
|
||||
if (!entry.isCatalogued) return false;
|
||||
|
|
|
@ -77,19 +77,21 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
|||
|
||||
String _buildEntryPath(String name) {
|
||||
if (name.isEmpty) return '';
|
||||
return pContext.join(entry.directory!, name + entry.extension!);
|
||||
return pContext.join(entry.directory!, '$name${entry.extension}');
|
||||
}
|
||||
|
||||
String get newName => _nameController.text.trimLeft();
|
||||
|
||||
Future<void> _validate() async {
|
||||
final newName = _nameController.text;
|
||||
final path = _buildEntryPath(newName);
|
||||
final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound;
|
||||
_isValidNotifier.value = newName.isNotEmpty && !exists;
|
||||
final _newName = newName;
|
||||
final path = _buildEntryPath(_newName);
|
||||
final exists = _newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound;
|
||||
_isValidNotifier.value = _newName.isNotEmpty && !exists;
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) {
|
||||
if (_isValidNotifier.value) {
|
||||
Navigator.pop(context, _nameController.text);
|
||||
Navigator.pop(context, newName);
|
||||
}
|
||||
}
|
||||
}
|
220
lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart
Normal file
220
lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart
Normal file
|
@ -0,0 +1,220 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/naming_pattern.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/collection/collection_grid.dart';
|
||||
import 'package:aves/widgets/common/basic/menu.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/grid/theme.dart';
|
||||
import 'package:aves/widgets/common/identity/buttons.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/decorated.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class RenameEntrySetPage extends StatefulWidget {
|
||||
static const routeName = '/rename_entry_set';
|
||||
|
||||
final List<AvesEntry> entries;
|
||||
|
||||
const RenameEntrySetPage({
|
||||
Key? key,
|
||||
required this.entries,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<RenameEntrySetPage> createState() => _RenameEntrySetPageState();
|
||||
}
|
||||
|
||||
class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
|
||||
final TextEditingController _patternTextController = TextEditingController();
|
||||
final ValueNotifier<NamingPattern> _namingPatternNotifier = ValueNotifier<NamingPattern>(const NamingPattern([]));
|
||||
|
||||
static const int previewMax = 10;
|
||||
static const double thumbnailExtent = 48;
|
||||
|
||||
List<AvesEntry> get entries => widget.entries;
|
||||
|
||||
int get entryCount => entries.length;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_patternTextController.text = settings.entryRenamingPattern;
|
||||
_patternTextController.addListener(_onUserPatternChange);
|
||||
_onUserPatternChange();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_patternTextController.removeListener(_onUserPatternChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.renameEntrySetPageTitle),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _patternTextController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.renameEntrySetPagePatternFieldLabel,
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
),
|
||||
MenuIconTheme(
|
||||
child: PopupMenuButton<String>(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: DateNamingProcessor.key,
|
||||
child: MenuRow(text: l10n.renameProcessorDate, icon: const Icon(AIcons.date)),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: NameNamingProcessor.key,
|
||||
child: MenuRow(text: l10n.renameProcessorName, icon: const Icon(AIcons.name)),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CounterNamingProcessor.key,
|
||||
child: MenuRow(text: l10n.renameProcessorCounter, icon: const Icon(AIcons.counter)),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (key) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
_insertProcessor(key);
|
||||
},
|
||||
tooltip: l10n.renameEntrySetPageInsertTooltip,
|
||||
icon: const Icon(AIcons.add),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
l10n.renameEntrySetPagePreview,
|
||||
style: Constants.titleTextStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.textScaleFactor,
|
||||
builder: (context, textScaleFactor, child) {
|
||||
final effectiveThumbnailExtent = max(thumbnailExtent, thumbnailExtent * textScaleFactor);
|
||||
return GridTheme(
|
||||
extent: effectiveThumbnailExtent,
|
||||
child: ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final sourceName = entry.filenameWithoutExtension ?? '';
|
||||
return Row(
|
||||
children: [
|
||||
DecoratedThumbnail(
|
||||
entry: entry,
|
||||
tileExtent: effectiveThumbnailExtent,
|
||||
selectable: false,
|
||||
highlightable: false,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
sourceName,
|
||||
style: TextStyle(color: Theme.of(context).textTheme.caption!.color),
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ValueListenableBuilder<NamingPattern>(
|
||||
valueListenable: _namingPatternNotifier,
|
||||
builder: (context, pattern, child) {
|
||||
return Text(
|
||||
pattern.apply(entry, index),
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(
|
||||
height: CollectionGrid.spacing,
|
||||
),
|
||||
itemCount: min(entryCount, previewMax),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const Divider(height: 0),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: AvesOutlinedButton(
|
||||
label: l10n.entryActionRename,
|
||||
onPressed: () {
|
||||
settings.entryRenamingPattern = _patternTextController.text;
|
||||
Navigator.pop<NamingPattern>(context, _namingPatternNotifier.value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onUserPatternChange() {
|
||||
_namingPatternNotifier.value = NamingPattern.from(
|
||||
userPattern: _patternTextController.text,
|
||||
entryCount: entryCount,
|
||||
);
|
||||
}
|
||||
|
||||
void _insertProcessor(String key) {
|
||||
final userPattern = _patternTextController.text;
|
||||
final selection = _patternTextController.selection;
|
||||
_patternTextController.value = _patternTextController.value.replaced(
|
||||
TextRange(
|
||||
start: NamingPattern.getInsertionOffset(userPattern, selection.start),
|
||||
end: NamingPattern.getInsertionOffset(userPattern, selection.end),
|
||||
),
|
||||
NamingPattern.defaultPatternFor(key),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@ import 'package:aves/services/common/image_op_events.dart';
|
|||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/media/enums.dart';
|
||||
import 'package:aves/services/media/media_file_service.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/entry_storage.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
|
@ -24,7 +25,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
|||
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/entry_editors/rename_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||
import 'package:aves/widgets/viewer/action/printer.dart';
|
||||
|
@ -36,6 +37,7 @@ import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
|||
import 'package:aves/widgets/viewer/video/conductor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
|
@ -314,17 +316,16 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
context: context,
|
||||
builder: (context) => RenameEntryDialog(entry: entry),
|
||||
);
|
||||
if (newName == null || newName.isEmpty) return;
|
||||
if (newName == null || newName.isEmpty || newName == entry.filenameWithoutExtension) return;
|
||||
|
||||
if (!await checkStoragePermission(context, {entry})) return;
|
||||
|
||||
final success = await context.read<CollectionSource>().renameEntry(entry, newName, persist: _isMainMode(context));
|
||||
|
||||
if (success) {
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
} else {
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
await rename(
|
||||
context,
|
||||
entriesToNewName: {entry: '$newName${entry.extension}'},
|
||||
persist: _isMainMode(context),
|
||||
onSuccess: entry.metadataChangeNotifier.notify,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
||||
|
|
|
@ -138,9 +138,12 @@ class ViewerDetailOverlayContent extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final infoMaxWidth = availableWidth - padding.horizontal;
|
||||
final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController);
|
||||
final showShooting = settings.showOverlayShootingDetails;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: pageEntry.metadataChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController);
|
||||
return DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.bodyText2!.copyWith(
|
||||
shadows: _shadows(context),
|
||||
|
@ -192,6 +195,8 @@ class ViewerDetailOverlayContent extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDateSubRow(double subRowWidth) => SizedBox(
|
||||
|
|
|
@ -7,12 +7,14 @@ import 'media_store_service.dart';
|
|||
|
||||
class FakeMediaFileService extends Fake implements MediaFileService {
|
||||
@override
|
||||
Stream<MoveOpEvent> rename(
|
||||
Iterable<AvesEntry> entries, {
|
||||
required String newName,
|
||||
Stream<MoveOpEvent> rename({
|
||||
String? opId,
|
||||
required Map<AvesEntry, String> entriesToNewName,
|
||||
}) {
|
||||
final contentId = FakeMediaStoreService.nextId;
|
||||
final entry = entries.first;
|
||||
final kv = entriesToNewName.entries.first;
|
||||
final entry = kv.key;
|
||||
final newName = kv.value;
|
||||
return Stream.value(MoveOpEvent(
|
||||
success: true,
|
||||
skipped: false,
|
||||
|
@ -21,8 +23,6 @@ class FakeMediaFileService extends Fake implements MediaFileService {
|
|||
'uri': 'content://media/external/images/media/$contentId',
|
||||
'contentId': contentId,
|
||||
'path': '${entry.directory}/$newName',
|
||||
'displayName': newName,
|
||||
'title': newName.substring(0, newName.length - entry.extension!.length),
|
||||
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
|
||||
},
|
||||
));
|
||||
|
|
|
@ -45,7 +45,7 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
|
|||
);
|
||||
}
|
||||
|
||||
static MoveOpEvent moveOpEventFor(AvesEntry entry, String sourceAlbum, String destinationAlbum) {
|
||||
static MoveOpEvent moveOpEventForMove(AvesEntry entry, String sourceAlbum, String destinationAlbum) {
|
||||
final newContentId = nextId;
|
||||
return MoveOpEvent(
|
||||
success: true,
|
||||
|
@ -55,8 +55,22 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
|
|||
'uri': 'content://media/external/images/media/$newContentId',
|
||||
'contentId': newContentId,
|
||||
'path': entry.path!.replaceFirst(sourceAlbum, destinationAlbum),
|
||||
'displayName': '${entry.filenameWithoutExtension}${entry.extension}',
|
||||
'title': entry.filenameWithoutExtension,
|
||||
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static MoveOpEvent moveOpEventForRename(AvesEntry entry, String newName) {
|
||||
final newContentId = nextId;
|
||||
final oldName = entry.filenameWithoutExtension!;
|
||||
return MoveOpEvent(
|
||||
success: true,
|
||||
skipped: false,
|
||||
uri: entry.uri,
|
||||
newFields: {
|
||||
'uri': 'content://media/external/images/media/$newContentId',
|
||||
'contentId': newContentId,
|
||||
'path': entry.path!.replaceFirst(oldName, newName),
|
||||
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
|
||||
},
|
||||
);
|
||||
|
|
|
@ -190,7 +190,13 @@ void main() {
|
|||
await image1.toggleFavourite();
|
||||
const albumFilter = AlbumFilter(testAlbum, 'whatever');
|
||||
await covers.set(albumFilter, image1.id);
|
||||
await source.renameEntry(image1, 'image1b.jpg', persist: true);
|
||||
await source.updateAfterRename(
|
||||
todoEntries: {image1},
|
||||
movedOps: {
|
||||
FakeMediaStoreService.moveOpEventForRename(image1, 'image1b.jpg'),
|
||||
},
|
||||
persist: true,
|
||||
);
|
||||
|
||||
expect(favourites.count, 1);
|
||||
expect(image1.isFavourite, true);
|
||||
|
@ -236,7 +242,7 @@ void main() {
|
|||
moveType: MoveType.move,
|
||||
destinationAlbums: {destinationAlbum},
|
||||
movedOps: {
|
||||
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
|
||||
FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum),
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -260,7 +266,7 @@ void main() {
|
|||
moveType: MoveType.move,
|
||||
destinationAlbums: {destinationAlbum},
|
||||
movedOps: {
|
||||
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
|
||||
FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum),
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -285,7 +291,7 @@ void main() {
|
|||
moveType: MoveType.move,
|
||||
destinationAlbums: {destinationAlbum},
|
||||
movedOps: {
|
||||
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
|
||||
FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum),
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -307,7 +313,7 @@ void main() {
|
|||
await source.renameAlbum(sourceAlbum, destinationAlbum, {
|
||||
image1
|
||||
}, {
|
||||
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
|
||||
FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum),
|
||||
});
|
||||
albumFilter = const AlbumFilter(destinationAlbum, 'whatever');
|
||||
|
||||
|
|
47
test/model/naming_pattern_test.dart
Normal file
47
test/model/naming_pattern_test.dart
Normal file
|
@ -0,0 +1,47 @@
|
|||
import 'package:aves/model/naming_pattern.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('mixed processors', () {
|
||||
const entryCount = 42;
|
||||
expect(
|
||||
NamingPattern.from(
|
||||
userPattern: 'pure literal',
|
||||
entryCount: entryCount,
|
||||
).processors,
|
||||
[
|
||||
const LiteralNamingProcessor('pure literal'),
|
||||
],
|
||||
);
|
||||
expect(
|
||||
NamingPattern.from(
|
||||
userPattern: 'prefix<date,yyyy-MM-ddTHH:mm:ss>suffix',
|
||||
entryCount: entryCount,
|
||||
).processors,
|
||||
[
|
||||
const LiteralNamingProcessor('prefix'),
|
||||
DateNamingProcessor('yyyy-MM-ddTHH:mm:ss'),
|
||||
const LiteralNamingProcessor('suffix'),
|
||||
],
|
||||
);
|
||||
expect(
|
||||
NamingPattern.from(
|
||||
userPattern: '<date,yyyy-MM-ddTHH:mm:ss> <name>',
|
||||
entryCount: entryCount,
|
||||
).processors,
|
||||
[
|
||||
DateNamingProcessor('yyyy-MM-ddTHH:mm:ss'),
|
||||
const LiteralNamingProcessor(' '),
|
||||
const NameNamingProcessor(),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('insertion offset', () {
|
||||
const userPattern = '<date,yyyy-MM-ddTHH:mm:ss> infix <name>';
|
||||
expect(NamingPattern.getInsertionOffset(userPattern, -1), 0);
|
||||
expect(NamingPattern.getInsertionOffset(userPattern, 1234), userPattern.length);
|
||||
expect(NamingPattern.getInsertionOffset(userPattern, 4), 26);
|
||||
expect(NamingPattern.getInsertionOffset(userPattern, 30), 30);
|
||||
});
|
||||
}
|
|
@ -5,7 +5,16 @@
|
|||
"themeBrightnessBlack",
|
||||
"moveUndatedConfirmationDialogMessage",
|
||||
"moveUndatedConfirmationDialogSetDate",
|
||||
"renameEntrySetPageTitle",
|
||||
"renameEntrySetPagePatternFieldLabel",
|
||||
"renameEntrySetPageInsertTooltip",
|
||||
"renameEntrySetPagePreview",
|
||||
"renameProcessorCounter",
|
||||
"renameProcessorDate",
|
||||
"renameProcessorName",
|
||||
"editEntryDateDialogCopyItem",
|
||||
"collectionRenameFailureFeedback",
|
||||
"collectionRenameSuccessFeedback",
|
||||
"settingsConfirmationDialogMoveUndatedItems",
|
||||
"settingsSectionDisplay",
|
||||
"settingsThemeBrightness",
|
||||
|
@ -18,7 +27,16 @@
|
|||
"themeBrightnessBlack",
|
||||
"moveUndatedConfirmationDialogMessage",
|
||||
"moveUndatedConfirmationDialogSetDate",
|
||||
"renameEntrySetPageTitle",
|
||||
"renameEntrySetPagePatternFieldLabel",
|
||||
"renameEntrySetPageInsertTooltip",
|
||||
"renameEntrySetPagePreview",
|
||||
"renameProcessorCounter",
|
||||
"renameProcessorDate",
|
||||
"renameProcessorName",
|
||||
"editEntryDateDialogCopyItem",
|
||||
"collectionRenameFailureFeedback",
|
||||
"collectionRenameSuccessFeedback",
|
||||
"settingsConfirmationDialogMoveUndatedItems",
|
||||
"settingsSectionDisplay",
|
||||
"settingsThemeBrightness",
|
||||
|
@ -31,7 +49,16 @@
|
|||
"themeBrightnessBlack",
|
||||
"moveUndatedConfirmationDialogMessage",
|
||||
"moveUndatedConfirmationDialogSetDate",
|
||||
"renameEntrySetPageTitle",
|
||||
"renameEntrySetPagePatternFieldLabel",
|
||||
"renameEntrySetPageInsertTooltip",
|
||||
"renameEntrySetPagePreview",
|
||||
"renameProcessorCounter",
|
||||
"renameProcessorDate",
|
||||
"renameProcessorName",
|
||||
"editEntryDateDialogCopyItem",
|
||||
"collectionRenameFailureFeedback",
|
||||
"collectionRenameSuccessFeedback",
|
||||
"settingsConfirmationDialogMoveUndatedItems",
|
||||
"settingsSectionDisplay",
|
||||
"settingsThemeBrightness",
|
||||
|
@ -50,7 +77,16 @@
|
|||
"themeBrightnessBlack",
|
||||
"moveUndatedConfirmationDialogMessage",
|
||||
"moveUndatedConfirmationDialogSetDate",
|
||||
"renameEntrySetPageTitle",
|
||||
"renameEntrySetPagePatternFieldLabel",
|
||||
"renameEntrySetPageInsertTooltip",
|
||||
"renameEntrySetPagePreview",
|
||||
"renameProcessorCounter",
|
||||
"renameProcessorDate",
|
||||
"renameProcessorName",
|
||||
"editEntryDateDialogCopyItem",
|
||||
"collectionRenameFailureFeedback",
|
||||
"collectionRenameSuccessFeedback",
|
||||
"settingsConfirmationDialogMoveUndatedItems",
|
||||
"settingsViewerShowOverlayThumbnails",
|
||||
"settingsVideoControlsTile",
|
||||
|
@ -70,7 +106,16 @@
|
|||
"themeBrightnessBlack",
|
||||
"moveUndatedConfirmationDialogMessage",
|
||||
"moveUndatedConfirmationDialogSetDate",
|
||||
"renameEntrySetPageTitle",
|
||||
"renameEntrySetPagePatternFieldLabel",
|
||||
"renameEntrySetPageInsertTooltip",
|
||||
"renameEntrySetPagePreview",
|
||||
"renameProcessorCounter",
|
||||
"renameProcessorDate",
|
||||
"renameProcessorName",
|
||||
"editEntryDateDialogCopyItem",
|
||||
"collectionRenameFailureFeedback",
|
||||
"collectionRenameSuccessFeedback",
|
||||
"settingsConfirmationDialogMoveUndatedItems",
|
||||
"settingsSectionDisplay",
|
||||
"settingsThemeBrightness",
|
||||
|
@ -83,7 +128,16 @@
|
|||
"themeBrightnessBlack",
|
||||
"moveUndatedConfirmationDialogMessage",
|
||||
"moveUndatedConfirmationDialogSetDate",
|
||||
"renameEntrySetPageTitle",
|
||||
"renameEntrySetPagePatternFieldLabel",
|
||||
"renameEntrySetPageInsertTooltip",
|
||||
"renameEntrySetPagePreview",
|
||||
"renameProcessorCounter",
|
||||
"renameProcessorDate",
|
||||
"renameProcessorName",
|
||||
"editEntryDateDialogCopyItem",
|
||||
"collectionRenameFailureFeedback",
|
||||
"collectionRenameSuccessFeedback",
|
||||
"settingsConfirmationDialogMoveUndatedItems",
|
||||
"settingsSectionDisplay",
|
||||
"settingsThemeBrightness",
|
||||
|
@ -96,7 +150,16 @@
|
|||
"themeBrightnessBlack",
|
||||
"moveUndatedConfirmationDialogMessage",
|
||||
"moveUndatedConfirmationDialogSetDate",
|
||||
"renameEntrySetPageTitle",
|
||||
"renameEntrySetPagePatternFieldLabel",
|
||||
"renameEntrySetPageInsertTooltip",
|
||||
"renameEntrySetPagePreview",
|
||||
"renameProcessorCounter",
|
||||
"renameProcessorDate",
|
||||
"renameProcessorName",
|
||||
"editEntryDateDialogCopyItem",
|
||||
"collectionRenameFailureFeedback",
|
||||
"collectionRenameSuccessFeedback",
|
||||
"settingsConfirmationDialogMoveUndatedItems",
|
||||
"settingsSectionDisplay",
|
||||
"settingsThemeBrightness",
|
||||
|
@ -109,7 +172,16 @@
|
|||
"themeBrightnessBlack",
|
||||
"moveUndatedConfirmationDialogMessage",
|
||||
"moveUndatedConfirmationDialogSetDate",
|
||||
"renameEntrySetPageTitle",
|
||||
"renameEntrySetPagePatternFieldLabel",
|
||||
"renameEntrySetPageInsertTooltip",
|
||||
"renameEntrySetPagePreview",
|
||||
"renameProcessorCounter",
|
||||
"renameProcessorDate",
|
||||
"renameProcessorName",
|
||||
"editEntryDateDialogCopyItem",
|
||||
"collectionRenameFailureFeedback",
|
||||
"collectionRenameSuccessFeedback",
|
||||
"settingsConfirmationDialogMoveUndatedItems",
|
||||
"settingsSectionDisplay",
|
||||
"settingsThemeBrightness",
|
||||
|
|
Loading…
Reference in a new issue