#183 bulk renaming

This commit is contained in:
Thibault Deckers 2022-03-23 17:37:35 +09:00
parent fa862c041e
commit 89173b8bc7
28 changed files with 829 additions and 160 deletions

View file

@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Theme: light/dark/black and poly/monochrome settings - Theme: light/dark/black and poly/monochrome settings
- Collection: bulk renaming
- Video: speed and muted state indicators - Video: speed and muted state indicators
- Info: option to set date from other item - Info: option to set date from other item
- Info: improved DNG tags display - Info: improved DNG tags display

View file

@ -199,27 +199,34 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
} }
private suspend fun rename() { private suspend fun rename() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) { if (arguments !is Map<*, *>) {
endOfStream() endOfStream()
return return
} }
val newName = arguments["newName"] as String? val rawEntryMap = arguments["entriesToNewName"] as Map<*, *>?
if (newName == null) { if (rawEntryMap == null || rawEntryMap.isEmpty()) {
error("rename-args", "failed because of missing arguments", null) error("rename-args", "failed because of missing arguments", null)
return 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 // assume same provider for all entries
val firstEntry = entryMapList.first() val firstEntry = entriesToNewName.keys.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) } val provider = getProvider(firstEntry.uri)
if (provider == null) { if (provider == null) {
error("rename-provider", "failed to find provider for entry=$firstEntry", null) error("rename-provider", "failed to find provider for entry=$firstEntry", null)
return return
} }
val entries = entryMapList.map(::AvesEntry) provider.renameMultiple(activity, entriesToNewName, ::isCancelledOp, 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)
}) })

View file

@ -62,8 +62,7 @@ abstract class ImageProvider {
open suspend fun renameMultiple( open suspend fun renameMultiple(
activity: Activity, activity: Activity,
newFileName: String, entriesToNewName: Map<AvesEntry, String>,
entries: List<AvesEntry>,
isCancelledOp: CancelCheck, isCancelledOp: CancelCheck,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {

View file

@ -191,7 +191,6 @@ class MediaStoreImageProvider : ImageProvider() {
val pathColumn = cursor.getColumnIndexOrThrow(MediaColumns.PATH) val pathColumn = cursor.getColumnIndexOrThrow(MediaColumns.PATH)
val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE) val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE) val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
@ -229,7 +228,6 @@ class MediaStoreImageProvider : ImageProvider() {
"height" to height, "height" to height,
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0, "sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
"sizeBytes" to cursor.getLong(sizeColumn), "sizeBytes" to cursor.getLong(sizeColumn),
"title" to cursor.getString(titleColumn),
"dateModifiedSecs" to dateModifiedSecs, "dateModifiedSecs" to dateModifiedSecs,
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null, "sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
"durationMillis" to durationMillis, "durationMillis" to durationMillis,
@ -587,12 +585,14 @@ class MediaStoreImageProvider : ImageProvider() {
override suspend fun renameMultiple( override suspend fun renameMultiple(
activity: Activity, activity: Activity,
newFileName: String, entriesToNewName: Map<AvesEntry, String>,
entries: List<AvesEntry>,
isCancelledOp: CancelCheck, isCancelledOp: CancelCheck,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
for (entry in entries) { for (kv in entriesToNewName) {
val entry = kv.key
val newFileName = kv.value
val sourceUri = entry.uri val sourceUri = entry.uri
val sourcePath = entry.path val sourcePath = entry.path
val mimeType = entry.mimeType val mimeType = entry.mimeType
@ -602,7 +602,8 @@ class MediaStoreImageProvider : ImageProvider() {
"success" to false, "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 { try {
val newFields = if (isCancelledOp()) skippedFieldMap else renameSingle( val newFields = if (isCancelledOp()) skippedFieldMap else renameSingle(
activity = activity, 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 // we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
val projection = arrayOf( val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED, MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.TITLE,
) )
try { try {
val cursor = context.contentResolver.query(uri, projection, null, null, null) val cursor = context.contentResolver.query(uri, projection, null, null, null)
@ -774,8 +773,6 @@ class MediaStoreImageProvider : ImageProvider() {
newFields["contentId"] = uri.tryParseId() newFields["contentId"] = uri.tryParseId()
newFields["path"] = path newFields["path"] = path
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } 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() cursor.close()
return newFields return newFields
} }
@ -846,8 +843,6 @@ class MediaStoreImageProvider : ImageProvider() {
MediaColumns.PATH, MediaColumns.PATH,
MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.MIME_TYPE,
MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.SIZE,
// TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`?
MediaStore.MediaColumns.TITLE,
MediaStore.MediaColumns.WIDTH, MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT, MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.DATE_MODIFIED, MediaStore.MediaColumns.DATE_MODIFIED,

View file

@ -315,6 +315,15 @@
"renameAlbumDialogLabel": "New name", "renameAlbumDialogLabel": "New name",
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists", "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": "{count, plural, =1{Delete this album and its item?} other{Delete this album and its {count} items?}}",
"@deleteSingleAlbumConfirmationDialogMessage": { "@deleteSingleAlbumConfirmationDialogMessage": {
"placeholders": { "placeholders": {
@ -460,6 +469,12 @@
"count": {} "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": "{count, plural, =1{Failed to edit 1 item} other{Failed to edit {count} items}}",
"@collectionEditFailureFeedback": { "@collectionEditFailureFeedback": {
"placeholders": { "placeholders": {
@ -484,6 +499,12 @@
"count": {} "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": "{count, plural, =1{Edited 1 item} other{Edited {count} items}}",
"@collectionEditSuccessFeedback": { "@collectionEditSuccessFeedback": {
"placeholders": { "placeholders": {

View file

@ -124,7 +124,7 @@ extension ExtraChipSetAction on ChipSetAction {
return AIcons.unpin; return AIcons.unpin;
// selecting (single filter) // selecting (single filter)
case ChipSetAction.rename: case ChipSetAction.rename:
return AIcons.rename; return AIcons.name;
case ChipSetAction.setCover: case ChipSetAction.setCover:
return AIcons.setCover; return AIcons.setCover;
} }

View file

@ -201,7 +201,7 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.print: case EntryAction.print:
return AIcons.print; return AIcons.print;
case EntryAction.rename: case EntryAction.rename:
return AIcons.rename; return AIcons.name;
case EntryAction.copy: case EntryAction.copy:
return AIcons.copy; return AIcons.copy;
case EntryAction.move: case EntryAction.move:

View file

@ -23,6 +23,7 @@ enum EntrySetAction {
restore, restore,
copy, copy,
move, move,
rename,
toggleFavourite, toggleFavourite,
rotateCCW, rotateCCW,
rotateCW, rotateCW,
@ -68,6 +69,7 @@ class EntrySetActions {
EntrySetAction.restore, EntrySetAction.restore,
EntrySetAction.copy, EntrySetAction.copy,
EntrySetAction.move, EntrySetAction.move,
EntrySetAction.rename,
EntrySetAction.toggleFavourite, EntrySetAction.toggleFavourite,
EntrySetAction.map, EntrySetAction.map,
EntrySetAction.stats, EntrySetAction.stats,
@ -81,6 +83,7 @@ class EntrySetActions {
EntrySetAction.delete, EntrySetAction.delete,
EntrySetAction.copy, EntrySetAction.copy,
EntrySetAction.move, EntrySetAction.move,
EntrySetAction.rename,
EntrySetAction.toggleFavourite, EntrySetAction.toggleFavourite,
EntrySetAction.map, EntrySetAction.map,
EntrySetAction.stats, EntrySetAction.stats,
@ -137,6 +140,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.collectionActionCopy; return context.l10n.collectionActionCopy;
case EntrySetAction.move: case EntrySetAction.move:
return context.l10n.collectionActionMove; return context.l10n.collectionActionMove;
case EntrySetAction.rename:
return context.l10n.entryActionRename;
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:
// different data depending on toggle state // different data depending on toggle state
return context.l10n.entryActionAddFavourite; return context.l10n.entryActionAddFavourite;
@ -200,6 +205,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.copy; return AIcons.copy;
case EntrySetAction.move: case EntrySetAction.move:
return AIcons.move; return AIcons.move;
case EntrySetAction.rename:
return AIcons.name;
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:
// different data depending on toggle state // different data depending on toggle state
return AIcons.favourite; return AIcons.favourite;

View file

@ -167,6 +167,7 @@ class AvesEntry {
_directory = null; _directory = null;
_filename = null; _filename = null;
_extension = null; _extension = null;
_bestTitle = null;
} }
String? get path => _path; String? get path => _path;
@ -455,7 +456,7 @@ class AvesEntry {
String? _bestTitle; String? _bestTitle;
String? get bestTitle { String? get bestTitle {
_bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : sourceTitle; _bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : (filenameWithoutExtension ?? sourceTitle);
return _bestTitle; return _bestTitle;
} }

View 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');
}

View file

@ -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/actions/entry_set_actions.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.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/settings/enums/enums.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
@ -15,9 +16,10 @@ class SettingsDefaults {
static const canUseAnalysisService = true; static const canUseAnalysisService = true;
static const isInstalledAppAccessAllowed = false; static const isInstalledAppAccessAllowed = false;
static const isErrorReportingAllowed = false; static const isErrorReportingAllowed = false;
static const tileLayout = TileLayout.grid;
static const themeBrightness = AvesThemeBrightness.system; static const themeBrightness = AvesThemeBrightness.system;
static const themeColorMode = AvesThemeColorMode.polychrome; static const themeColorMode = AvesThemeColorMode.polychrome;
static const tileLayout = TileLayout.grid;
static const entryRenamingPattern = '<${DateNamingProcessor.key}, yyyyMMdd-HHmmss> <${NameNamingProcessor.key}>';
// navigation // navigation
static const mustBackTwiceToExit = true; static const mustBackTwiceToExit = true;

View file

@ -46,6 +46,7 @@ class Settings extends ChangeNotifier {
static const catalogTimeZoneKey = 'catalog_time_zone'; static const catalogTimeZoneKey = 'catalog_time_zone';
static const tileExtentPrefixKey = 'tile_extent_'; static const tileExtentPrefixKey = 'tile_extent_';
static const tileLayoutPrefixKey = 'tile_layout_'; static const tileLayoutPrefixKey = 'tile_layout_';
static const entryRenamingPatternKey = 'entry_renaming_pattern';
static const topEntryIdsKey = 'top_entry_ids'; static const topEntryIdsKey = 'top_entry_ids';
// navigation // navigation
@ -264,6 +265,10 @@ class Settings extends ChangeNotifier {
void setTileLayout(String routeName, TileLayout newValue) => setAndNotify(tileLayoutPrefixKey + routeName, newValue.toString()); 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(); 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()); set topEntryIds(List<int>? newValue) => setAndNotify(topEntryIdsKey, newValue?.map((id) => id.toString()).whereNotNull().toList());

View file

@ -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 { Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> entries, Set<MoveOpEvent> movedOps) async {
final oldFilter = AlbumFilter(sourceAlbum, null); final oldFilter = AlbumFilter(sourceAlbum, null);
final newFilter = AlbumFilter(destinationAlbum, null); final newFilter = AlbumFilter(destinationAlbum, null);
@ -338,7 +306,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}); });
} }
switch(moveType) { switch (moveType) {
case MoveType.copy: case MoveType.copy:
addEntries(movedEntries); addEntries(movedEntries);
break; break;
@ -357,6 +325,29 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
eventBus.fire(EntryMovedEvent(moveType, movedEntries)); 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; SourceInitializationState get initState => SourceInitializationState.none;
Future<void> init({ Future<void> init({

View file

@ -92,9 +92,9 @@ abstract class MediaFileService {
required NameConflictStrategy nameConflictStrategy, required NameConflictStrategy nameConflictStrategy,
}); });
Stream<MoveOpEvent> rename( Stream<MoveOpEvent> rename({
Iterable<AvesEntry> entries, { String? opId,
required String newName, required Map<AvesEntry, String> entriesToNewName,
}); });
Future<Map<String, dynamic>> captureFrame( Future<Map<String, dynamic>> captureFrame(
@ -392,16 +392,16 @@ class PlatformMediaFileService implements MediaFileService {
} }
@override @override
Stream<MoveOpEvent> rename( Stream<MoveOpEvent> rename({
Iterable<AvesEntry> entries, { String? opId,
required String newName, required Map<AvesEntry, String> entriesToNewName,
}) { }) {
try { try {
return _opStreamChannel return _opStreamChannel
.receiveBroadcastStream(<String, dynamic>{ .receiveBroadcastStream(<String, dynamic>{
'op': 'rename', 'op': 'rename',
'entries': entries.map(_toPlatformEntryMap).toList(), 'id': opId,
'newName': newName, 'entriesToNewName': entriesToNewName.map((key, value) => MapEntry(_toPlatformEntryMap(key), value)),
}) })
.where((event) => event is Map) .where((event) => event is Map)
.map((event) => MoveOpEvent.fromMap(event as Map)); .map((event) => MoveOpEvent.fromMap(event as Map));

View file

@ -12,6 +12,7 @@ class AIcons {
static const IconData bin = Icons.delete_outlined; static const IconData bin = Icons.delete_outlined;
static const IconData broken = Icons.broken_image_outlined; static const IconData broken = Icons.broken_image_outlined;
static const IconData checked = Icons.done_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 date = Icons.calendar_today_outlined;
static const IconData disc = Icons.fiber_manual_record; static const IconData disc = Icons.fiber_manual_record;
static const IconData display = Icons.light_mode_outlined; static const IconData display = Icons.light_mode_outlined;
@ -75,6 +76,7 @@ class AIcons {
static const IconData move = MdiIcons.fileMoveOutline; static const IconData move = MdiIcons.fileMoveOutline;
static const IconData mute = Icons.volume_off_outlined; static const IconData mute = Icons.volume_off_outlined;
static const IconData unmute = Icons.volume_up_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 newTier = Icons.fiber_new_outlined;
static const IconData openOutside = Icons.open_in_new_outlined; static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined; static const IconData pin = Icons.push_pin_outlined;
@ -83,7 +85,6 @@ class AIcons {
static const IconData pause = Icons.pause; static const IconData pause = Icons.pause;
static const IconData print = Icons.print_outlined; static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_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 replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_10_outlined; static const IconData skip10 = Icons.forward_10_outlined;
static const IconData reset = Icons.restart_alt_outlined; static const IconData reset = Icons.restart_alt_outlined;

View file

@ -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]!}; 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?> { 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?> 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!};
} }

View file

@ -452,6 +452,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.restore: case EntrySetAction.restore:
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.rename:
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW: case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:

View file

@ -9,6 +9,7 @@ import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/metadata/date_modifier.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/query.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/enums/enums.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/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/collection_utils.dart';
import 'package:aves/utils/mime_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/entry_editor.dart';
import 'package:aves/widgets/common/action_mixins/feedback.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/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_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/map/map_page.dart';
import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/stats/stats_page.dart'; import 'package:aves/widgets/stats/stats_page.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -78,6 +82,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.share: case EntrySetAction.share:
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.rename:
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW: case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
@ -127,6 +132,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.restore: case EntrySetAction.restore:
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.rename:
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW: case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
@ -185,6 +191,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.move: case EntrySetAction.move:
_move(context, moveType: MoveType.move); _move(context, moveType: MoveType.move);
break; break;
case EntrySetAction.rename:
_rename(context);
break;
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:
_toggleFavourite(context); _toggleFavourite(context);
break; break;
@ -243,17 +252,6 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_leaveSelectionMode(context); _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 { Future<void> _delete(BuildContext context) async {
final entries = _getTargetItems(context); final entries = _getTargetItems(context);
@ -312,6 +310,40 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_leaveSelectionMode(context); _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( Future<void> _edit(
BuildContext context, BuildContext context,
Set<AvesEntry> todoItems, Set<AvesEntry> todoItems,
@ -409,7 +441,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (confirmed == null || !confirmed) return null; if (confirmed == null || !confirmed) return null;
// wait for the dialog to hide as applying the change may block the UI // 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; return supported;
} }

View file

@ -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 { Future<bool> _checkUndatedItems(BuildContext context, Set<AvesEntry> entries) async {
final undatedItems = entries.where((entry) { final undatedItems = entries.where((entry) {
if (!entry.isCatalogued) return false; if (!entry.isCatalogued) return false;

View file

@ -77,19 +77,21 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
String _buildEntryPath(String name) { String _buildEntryPath(String name) {
if (name.isEmpty) return ''; 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 { Future<void> _validate() async {
final newName = _nameController.text; final _newName = newName;
final path = _buildEntryPath(newName); final path = _buildEntryPath(_newName);
final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound; final exists = _newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound;
_isValidNotifier.value = newName.isNotEmpty && !exists; _isValidNotifier.value = _newName.isNotEmpty && !exists;
} }
void _submit(BuildContext context) { void _submit(BuildContext context) {
if (_isValidNotifier.value) { if (_isValidNotifier.value) {
Navigator.pop(context, _nameController.text); Navigator.pop(context, newName);
} }
} }
} }

View 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),
);
}
}

View file

@ -15,6 +15,7 @@ import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
import 'package:aves/services/media/media_file_service.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/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/action_mixins/entry_storage.dart';
import 'package:aves/widgets/common/action_mixins/feedback.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/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_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/dialogs/export_entry_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/viewer/action/printer.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:aves/widgets/viewer/video/conductor.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -314,17 +316,16 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
context: context, context: context,
builder: (context) => RenameEntryDialog(entry: entry), 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; // wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
final success = await context.read<CollectionSource>().renameEntry(entry, newName, persist: _isMainMode(context)); await rename(
context,
if (success) { entriesToNewName: {entry: '$newName${entry.extension}'},
showFeedback(context, context.l10n.genericSuccessFeedback); persist: _isMainMode(context),
} else { onSuccess: entry.metadataChangeNotifier.notify,
showFeedback(context, context.l10n.genericFailureFeedback); );
}
} }
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main; bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;

View file

@ -138,9 +138,12 @@ class ViewerDetailOverlayContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final infoMaxWidth = availableWidth - padding.horizontal; final infoMaxWidth = availableWidth - padding.horizontal;
final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController);
final showShooting = settings.showOverlayShootingDetails; final showShooting = settings.showOverlayShootingDetails;
return AnimatedBuilder(
animation: pageEntry.metadataChangeNotifier,
builder: (context, child) {
final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController);
return DefaultTextStyle( return DefaultTextStyle(
style: Theme.of(context).textTheme.bodyText2!.copyWith( style: Theme.of(context).textTheme.bodyText2!.copyWith(
shadows: _shadows(context), shadows: _shadows(context),
@ -192,6 +195,8 @@ class ViewerDetailOverlayContent extends StatelessWidget {
), ),
), ),
); );
},
);
} }
Widget _buildDateSubRow(double subRowWidth) => SizedBox( Widget _buildDateSubRow(double subRowWidth) => SizedBox(

View file

@ -7,12 +7,14 @@ import 'media_store_service.dart';
class FakeMediaFileService extends Fake implements MediaFileService { class FakeMediaFileService extends Fake implements MediaFileService {
@override @override
Stream<MoveOpEvent> rename( Stream<MoveOpEvent> rename({
Iterable<AvesEntry> entries, { String? opId,
required String newName, required Map<AvesEntry, String> entriesToNewName,
}) { }) {
final contentId = FakeMediaStoreService.nextId; 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( return Stream.value(MoveOpEvent(
success: true, success: true,
skipped: false, skipped: false,
@ -21,8 +23,6 @@ class FakeMediaFileService extends Fake implements MediaFileService {
'uri': 'content://media/external/images/media/$contentId', 'uri': 'content://media/external/images/media/$contentId',
'contentId': contentId, 'contentId': contentId,
'path': '${entry.directory}/$newName', 'path': '${entry.directory}/$newName',
'displayName': newName,
'title': newName.substring(0, newName.length - entry.extension!.length),
'dateModifiedSecs': FakeMediaStoreService.dateSecs, 'dateModifiedSecs': FakeMediaStoreService.dateSecs,
}, },
)); ));

View file

@ -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; final newContentId = nextId;
return MoveOpEvent( return MoveOpEvent(
success: true, success: true,
@ -55,8 +55,22 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
'uri': 'content://media/external/images/media/$newContentId', 'uri': 'content://media/external/images/media/$newContentId',
'contentId': newContentId, 'contentId': newContentId,
'path': entry.path!.replaceFirst(sourceAlbum, destinationAlbum), 'path': entry.path!.replaceFirst(sourceAlbum, destinationAlbum),
'displayName': '${entry.filenameWithoutExtension}${entry.extension}', 'dateModifiedSecs': FakeMediaStoreService.dateSecs,
'title': entry.filenameWithoutExtension, },
);
}
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, 'dateModifiedSecs': FakeMediaStoreService.dateSecs,
}, },
); );

View file

@ -190,7 +190,13 @@ void main() {
await image1.toggleFavourite(); await image1.toggleFavourite();
const albumFilter = AlbumFilter(testAlbum, 'whatever'); const albumFilter = AlbumFilter(testAlbum, 'whatever');
await covers.set(albumFilter, image1.id); 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(favourites.count, 1);
expect(image1.isFavourite, true); expect(image1.isFavourite, true);
@ -236,7 +242,7 @@ void main() {
moveType: MoveType.move, moveType: MoveType.move,
destinationAlbums: {destinationAlbum}, destinationAlbums: {destinationAlbum},
movedOps: { movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum),
}, },
); );
@ -260,7 +266,7 @@ void main() {
moveType: MoveType.move, moveType: MoveType.move,
destinationAlbums: {destinationAlbum}, destinationAlbums: {destinationAlbum},
movedOps: { movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum),
}, },
); );
@ -285,7 +291,7 @@ void main() {
moveType: MoveType.move, moveType: MoveType.move,
destinationAlbums: {destinationAlbum}, destinationAlbums: {destinationAlbum},
movedOps: { movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum),
}, },
); );
@ -307,7 +313,7 @@ void main() {
await source.renameAlbum(sourceAlbum, destinationAlbum, { await source.renameAlbum(sourceAlbum, destinationAlbum, {
image1 image1
}, { }, {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum),
}); });
albumFilter = const AlbumFilter(destinationAlbum, 'whatever'); albumFilter = const AlbumFilter(destinationAlbum, 'whatever');

View 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);
});
}

View file

@ -5,7 +5,16 @@
"themeBrightnessBlack", "themeBrightnessBlack",
"moveUndatedConfirmationDialogMessage", "moveUndatedConfirmationDialogMessage",
"moveUndatedConfirmationDialogSetDate", "moveUndatedConfirmationDialogSetDate",
"renameEntrySetPageTitle",
"renameEntrySetPagePatternFieldLabel",
"renameEntrySetPageInsertTooltip",
"renameEntrySetPagePreview",
"renameProcessorCounter",
"renameProcessorDate",
"renameProcessorName",
"editEntryDateDialogCopyItem", "editEntryDateDialogCopyItem",
"collectionRenameFailureFeedback",
"collectionRenameSuccessFeedback",
"settingsConfirmationDialogMoveUndatedItems", "settingsConfirmationDialogMoveUndatedItems",
"settingsSectionDisplay", "settingsSectionDisplay",
"settingsThemeBrightness", "settingsThemeBrightness",
@ -18,7 +27,16 @@
"themeBrightnessBlack", "themeBrightnessBlack",
"moveUndatedConfirmationDialogMessage", "moveUndatedConfirmationDialogMessage",
"moveUndatedConfirmationDialogSetDate", "moveUndatedConfirmationDialogSetDate",
"renameEntrySetPageTitle",
"renameEntrySetPagePatternFieldLabel",
"renameEntrySetPageInsertTooltip",
"renameEntrySetPagePreview",
"renameProcessorCounter",
"renameProcessorDate",
"renameProcessorName",
"editEntryDateDialogCopyItem", "editEntryDateDialogCopyItem",
"collectionRenameFailureFeedback",
"collectionRenameSuccessFeedback",
"settingsConfirmationDialogMoveUndatedItems", "settingsConfirmationDialogMoveUndatedItems",
"settingsSectionDisplay", "settingsSectionDisplay",
"settingsThemeBrightness", "settingsThemeBrightness",
@ -31,7 +49,16 @@
"themeBrightnessBlack", "themeBrightnessBlack",
"moveUndatedConfirmationDialogMessage", "moveUndatedConfirmationDialogMessage",
"moveUndatedConfirmationDialogSetDate", "moveUndatedConfirmationDialogSetDate",
"renameEntrySetPageTitle",
"renameEntrySetPagePatternFieldLabel",
"renameEntrySetPageInsertTooltip",
"renameEntrySetPagePreview",
"renameProcessorCounter",
"renameProcessorDate",
"renameProcessorName",
"editEntryDateDialogCopyItem", "editEntryDateDialogCopyItem",
"collectionRenameFailureFeedback",
"collectionRenameSuccessFeedback",
"settingsConfirmationDialogMoveUndatedItems", "settingsConfirmationDialogMoveUndatedItems",
"settingsSectionDisplay", "settingsSectionDisplay",
"settingsThemeBrightness", "settingsThemeBrightness",
@ -50,7 +77,16 @@
"themeBrightnessBlack", "themeBrightnessBlack",
"moveUndatedConfirmationDialogMessage", "moveUndatedConfirmationDialogMessage",
"moveUndatedConfirmationDialogSetDate", "moveUndatedConfirmationDialogSetDate",
"renameEntrySetPageTitle",
"renameEntrySetPagePatternFieldLabel",
"renameEntrySetPageInsertTooltip",
"renameEntrySetPagePreview",
"renameProcessorCounter",
"renameProcessorDate",
"renameProcessorName",
"editEntryDateDialogCopyItem", "editEntryDateDialogCopyItem",
"collectionRenameFailureFeedback",
"collectionRenameSuccessFeedback",
"settingsConfirmationDialogMoveUndatedItems", "settingsConfirmationDialogMoveUndatedItems",
"settingsViewerShowOverlayThumbnails", "settingsViewerShowOverlayThumbnails",
"settingsVideoControlsTile", "settingsVideoControlsTile",
@ -70,7 +106,16 @@
"themeBrightnessBlack", "themeBrightnessBlack",
"moveUndatedConfirmationDialogMessage", "moveUndatedConfirmationDialogMessage",
"moveUndatedConfirmationDialogSetDate", "moveUndatedConfirmationDialogSetDate",
"renameEntrySetPageTitle",
"renameEntrySetPagePatternFieldLabel",
"renameEntrySetPageInsertTooltip",
"renameEntrySetPagePreview",
"renameProcessorCounter",
"renameProcessorDate",
"renameProcessorName",
"editEntryDateDialogCopyItem", "editEntryDateDialogCopyItem",
"collectionRenameFailureFeedback",
"collectionRenameSuccessFeedback",
"settingsConfirmationDialogMoveUndatedItems", "settingsConfirmationDialogMoveUndatedItems",
"settingsSectionDisplay", "settingsSectionDisplay",
"settingsThemeBrightness", "settingsThemeBrightness",
@ -83,7 +128,16 @@
"themeBrightnessBlack", "themeBrightnessBlack",
"moveUndatedConfirmationDialogMessage", "moveUndatedConfirmationDialogMessage",
"moveUndatedConfirmationDialogSetDate", "moveUndatedConfirmationDialogSetDate",
"renameEntrySetPageTitle",
"renameEntrySetPagePatternFieldLabel",
"renameEntrySetPageInsertTooltip",
"renameEntrySetPagePreview",
"renameProcessorCounter",
"renameProcessorDate",
"renameProcessorName",
"editEntryDateDialogCopyItem", "editEntryDateDialogCopyItem",
"collectionRenameFailureFeedback",
"collectionRenameSuccessFeedback",
"settingsConfirmationDialogMoveUndatedItems", "settingsConfirmationDialogMoveUndatedItems",
"settingsSectionDisplay", "settingsSectionDisplay",
"settingsThemeBrightness", "settingsThemeBrightness",
@ -96,7 +150,16 @@
"themeBrightnessBlack", "themeBrightnessBlack",
"moveUndatedConfirmationDialogMessage", "moveUndatedConfirmationDialogMessage",
"moveUndatedConfirmationDialogSetDate", "moveUndatedConfirmationDialogSetDate",
"renameEntrySetPageTitle",
"renameEntrySetPagePatternFieldLabel",
"renameEntrySetPageInsertTooltip",
"renameEntrySetPagePreview",
"renameProcessorCounter",
"renameProcessorDate",
"renameProcessorName",
"editEntryDateDialogCopyItem", "editEntryDateDialogCopyItem",
"collectionRenameFailureFeedback",
"collectionRenameSuccessFeedback",
"settingsConfirmationDialogMoveUndatedItems", "settingsConfirmationDialogMoveUndatedItems",
"settingsSectionDisplay", "settingsSectionDisplay",
"settingsThemeBrightness", "settingsThemeBrightness",
@ -109,7 +172,16 @@
"themeBrightnessBlack", "themeBrightnessBlack",
"moveUndatedConfirmationDialogMessage", "moveUndatedConfirmationDialogMessage",
"moveUndatedConfirmationDialogSetDate", "moveUndatedConfirmationDialogSetDate",
"renameEntrySetPageTitle",
"renameEntrySetPagePatternFieldLabel",
"renameEntrySetPageInsertTooltip",
"renameEntrySetPagePreview",
"renameProcessorCounter",
"renameProcessorDate",
"renameProcessorName",
"editEntryDateDialogCopyItem", "editEntryDateDialogCopyItem",
"collectionRenameFailureFeedback",
"collectionRenameSuccessFeedback",
"settingsConfirmationDialogMoveUndatedItems", "settingsConfirmationDialogMoveUndatedItems",
"settingsSectionDisplay", "settingsSectionDisplay",
"settingsThemeBrightness", "settingsThemeBrightness",