diff --git a/CHANGELOG.md b/CHANGELOG.md index 2724e8b8b..aa0e88994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index 7a8f02ae1..568b0be85 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -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() + 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) }) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 82bbdcf2e..fcb3247b9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -62,8 +62,7 @@ abstract class ImageProvider { open suspend fun renameMultiple( activity: Activity, - newFileName: String, - entries: List, + entriesToNewName: Map, isCancelledOp: CancelCheck, callback: ImageOpCallback, ) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 4708e909d..0d5f1f643 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -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, + entriesToNewName: Map, 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, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2c54d3c54..cd3294c9b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": { diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index 65ce55e5a..f78daabeb 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -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; } diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 5b21dfa55..d43f54cf3 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -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: diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 7446ff8e4..1a9ad51b5 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -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; diff --git a/lib/model/entry.dart b/lib/model/entry.dart index c64578158..0631fe22b 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -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; } diff --git a/lib/model/naming_pattern.dart b/lib/model/naming_pattern.dart new file mode 100644 index 000000000..2cbe98dfc --- /dev/null +++ b/lib/model/naming_pattern.dart @@ -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 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 = []; + + 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 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 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 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 get props => [start, padding]; + + const CounterNamingProcessor({ + required this.start, + required this.padding, + }); + + @override + String? process(AvesEntry entry, int index) => '${index + start}'.padLeft(padding, '0'); +} diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 38008b3d3..04802a572 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -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; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 02eaef8f8..33b75103f 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -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? get topEntryIds => getStringList(topEntryIdsKey)?.map(int.tryParse).whereNotNull().toList(); set topEntryIds(List? newValue) => setAndNotify(topEntryIdsKey, newValue?.map((id) => id.toString()).whereNotNull().toList()); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index cec81045a..e1dc3c4fa 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -230,38 +230,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } } - Future renameEntry(AvesEntry entry, String newName, {required bool persist}) async { - if (newName == entry.filenameWithoutExtension) return true; - - pauseMonitoring(); - final completer = Completer(); - final processed = {}; - 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 renameAlbum(String sourceAlbum, String destinationAlbum, Set entries, Set movedOps) async { final oldFilter = AlbumFilter(sourceAlbum, 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: addEntries(movedEntries); break; @@ -357,6 +325,29 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM eventBus.fire(EntryMovedEvent(moveType, movedEntries)); } + Future updateAfterRename({ + required Set todoEntries, + required Set movedOps, + required bool persist, + }) async { + if (movedOps.isEmpty) return; + + final movedEntries = {}; + await Future.forEach(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 init({ diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index 46c3210ca..e8fe36277 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -92,9 +92,9 @@ abstract class MediaFileService { required NameConflictStrategy nameConflictStrategy, }); - Stream rename( - Iterable entries, { - required String newName, + Stream rename({ + String? opId, + required Map entriesToNewName, }); Future> captureFrame( @@ -392,16 +392,16 @@ class PlatformMediaFileService implements MediaFileService { } @override - Stream rename( - Iterable entries, { - required String newName, + Stream rename({ + String? opId, + required Map entriesToNewName, }) { try { return _opStreamChannel .receiveBroadcastStream({ '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)); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 0f0c3ee28..eeabd06ee 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -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; diff --git a/lib/utils/collection_utils.dart b/lib/utils/collection_utils.dart index 56332e4c1..2f5db0ce1 100644 --- a/lib/utils/collection_utils.dart +++ b/lib/utils/collection_utils.dart @@ -4,6 +4,12 @@ extension ExtraMapNullableKey on Map { Map whereNotNullKey() => {for (var v in keys.whereNotNull()) v: this[v]!}; } +extension ExtraMapNullableValue on Map { + Map whereNotNullValue() => {for (var kv in entries.where((kv) => kv.value != null)) kv.key: kv.value!}; +} + extension ExtraMapNullableKeyValue on Map { Map whereNotNullKey() => {for (var v in keys.whereNotNull()) v: this[v]}; + + Map whereNotNullValue() => {for (var kv in entries.where((kv) => kv.value != null)) kv.key: kv.value!}; } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index c6cde915d..1a4860ef0 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -452,6 +452,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.restore: case EntrySetAction.copy: case EntrySetAction.move: + case EntrySetAction.rename: case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index db4b5f609..57225c150 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -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 _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 _delete(BuildContext context) async { final entries = _getTargetItems(context); @@ -312,6 +310,40 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _leaveSelectionMode(context); } + Future _rename(BuildContext context) async { + final entries = _getTargetItems(context).toList(); + + final pattern = await Navigator.push( + 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 _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 _edit( BuildContext context, Set 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; } diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 38f63b35d..77f0cb831 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -203,6 +203,55 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { ); } + Future rename( + BuildContext context, { + required Map 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(); + source.pauseMonitoring(); + final opId = mediaFileService.newOpId; + await showOpReport( + 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 _checkUndatedItems(BuildContext context, Set entries) async { final undatedItems = entries.where((entry) { if (!entry.isCatalogued) return false; diff --git a/lib/widgets/dialogs/entry_editors/rename_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart similarity index 85% rename from lib/widgets/dialogs/entry_editors/rename_dialog.dart rename to lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart index fd184473f..b3c5875a2 100644 --- a/lib/widgets/dialogs/entry_editors/rename_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart @@ -77,19 +77,21 @@ class _RenameEntryDialogState extends State { 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 _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); } } } diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart new file mode 100644 index 000000000..d3d03adc4 --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart @@ -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 entries; + + const RenameEntrySetPage({ + Key? key, + required this.entries, + }) : super(key: key); + + @override + State createState() => _RenameEntrySetPageState(); +} + +class _RenameEntrySetPageState extends State { + final TextEditingController _patternTextController = TextEditingController(); + final ValueNotifier _namingPatternNotifier = ValueNotifier(const NamingPattern([])); + + static const int previewMax = 10; + static const double thumbnailExtent = 48; + + List 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( + 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( + 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( + 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(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), + ); + } +} diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index affd156d3..b2650a91b 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -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().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>().value == AppMode.main; diff --git a/lib/widgets/viewer/overlay/details.dart b/lib/widgets/viewer/overlay/details.dart index 8d21aad87..9ebff5b71 100644 --- a/lib/widgets/viewer/overlay/details.dart +++ b/lib/widgets/viewer/overlay/details.dart @@ -138,59 +138,64 @@ 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 DefaultTextStyle( - style: Theme.of(context).textTheme.bodyText2!.copyWith( - shadows: _shadows(context), + 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), + ), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + child: Padding( + padding: padding, + child: Selector( + selector: (context, mq) => mq.orientation, + builder: (context, orientation, child) { + final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; + final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; + final collapsedShooting = twoColumns && showShooting; + final collapsedLocation = twoColumns && !showShooting; + + final rows = []; + if (positionTitle.isNotEmpty) { + rows.add(positionTitle); + rows.add(const SizedBox(height: _interRowPadding)); + } + if (twoColumns) { + rows.add( + Row( + children: [ + _buildDateSubRow(subRowWidth), + if (collapsedShooting) _buildShootingSubRow(context, subRowWidth), + if (collapsedLocation) _buildLocationSubRow(context, subRowWidth), + ], + ), + ); + } else { + rows.add(_buildDateSubRow(subRowWidth)); + if (showShooting) { + rows.add(_buildShootingFullRow(context, subRowWidth)); + } + } + if (!collapsedLocation) { + rows.add(_buildLocationFullRow(context)); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: rows, + ); + }, + ), ), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - child: Padding( - padding: padding, - child: Selector( - selector: (context, mq) => mq.orientation, - builder: (context, orientation, child) { - final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; - final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; - final collapsedShooting = twoColumns && showShooting; - final collapsedLocation = twoColumns && !showShooting; - - final rows = []; - if (positionTitle.isNotEmpty) { - rows.add(positionTitle); - rows.add(const SizedBox(height: _interRowPadding)); - } - if (twoColumns) { - rows.add( - Row( - children: [ - _buildDateSubRow(subRowWidth), - if (collapsedShooting) _buildShootingSubRow(context, subRowWidth), - if (collapsedLocation) _buildLocationSubRow(context, subRowWidth), - ], - ), - ); - } else { - rows.add(_buildDateSubRow(subRowWidth)); - if (showShooting) { - rows.add(_buildShootingFullRow(context, subRowWidth)); - } - } - if (!collapsedLocation) { - rows.add(_buildLocationFullRow(context)); - } - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: rows, - ); - }, - ), - ), + ); + }, ); } diff --git a/test/fake/media_file_service.dart b/test/fake/media_file_service.dart index 22cbb16b5..71223b179 100644 --- a/test/fake/media_file_service.dart +++ b/test/fake/media_file_service.dart @@ -7,12 +7,14 @@ import 'media_store_service.dart'; class FakeMediaFileService extends Fake implements MediaFileService { @override - Stream rename( - Iterable entries, { - required String newName, + Stream rename({ + String? opId, + required Map 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, }, )); diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index c3ed0d756..813137f95 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -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, }, ); diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 68a3e9229..f0957910e 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -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'); diff --git a/test/model/naming_pattern_test.dart b/test/model/naming_pattern_test.dart new file mode 100644 index 000000000..c7991e805 --- /dev/null +++ b/test/model/naming_pattern_test.dart @@ -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: 'prefixsuffix', + entryCount: entryCount, + ).processors, + [ + const LiteralNamingProcessor('prefix'), + DateNamingProcessor('yyyy-MM-ddTHH:mm:ss'), + const LiteralNamingProcessor('suffix'), + ], + ); + expect( + NamingPattern.from( + userPattern: ' ', + entryCount: entryCount, + ).processors, + [ + DateNamingProcessor('yyyy-MM-ddTHH:mm:ss'), + const LiteralNamingProcessor(' '), + const NameNamingProcessor(), + ], + ); + }); + + test('insertion offset', () { + const userPattern = ' infix '; + 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); + }); +} diff --git a/untranslated.json b/untranslated.json index 13e575b5d..a504ad57d 100644 --- a/untranslated.json +++ b/untranslated.json @@ -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",