#183 bulk renaming
This commit is contained in:
parent
fa862c041e
commit
89173b8bc7
28 changed files with 829 additions and 160 deletions
|
@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
|
||||||
### Added
|
### 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
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
184
lib/model/naming_pattern.dart
Normal file
184
lib/model/naming_pattern.dart
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class NamingPattern {
|
||||||
|
final List<NamingProcessor> processors;
|
||||||
|
|
||||||
|
static final processorPattern = RegExp(r'<(.+?)(,(.+?))?>');
|
||||||
|
static const processorOptionSeparator = ',';
|
||||||
|
static const optionKeyValueSeparator = '=';
|
||||||
|
|
||||||
|
const NamingPattern(this.processors);
|
||||||
|
|
||||||
|
factory NamingPattern.from({
|
||||||
|
required String userPattern,
|
||||||
|
required int entryCount,
|
||||||
|
}) {
|
||||||
|
final processors = <NamingProcessor>[];
|
||||||
|
|
||||||
|
const defaultCounterStart = 1;
|
||||||
|
final defaultCounterPadding = '$entryCount'.length;
|
||||||
|
|
||||||
|
var index = 0;
|
||||||
|
final matches = processorPattern.allMatches(userPattern);
|
||||||
|
matches.forEach((match) {
|
||||||
|
final start = match.start;
|
||||||
|
final end = match.end;
|
||||||
|
if (index < start) {
|
||||||
|
processors.add(LiteralNamingProcessor(userPattern.substring(index, start)));
|
||||||
|
index = start;
|
||||||
|
}
|
||||||
|
final processorKey = match.group(1);
|
||||||
|
final processorOptions = match.group(3);
|
||||||
|
switch (processorKey) {
|
||||||
|
case DateNamingProcessor.key:
|
||||||
|
if (processorOptions != null) {
|
||||||
|
processors.add(DateNamingProcessor(processorOptions.trim()));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case NameNamingProcessor.key:
|
||||||
|
processors.add(const NameNamingProcessor());
|
||||||
|
break;
|
||||||
|
case CounterNamingProcessor.key:
|
||||||
|
int? start, padding;
|
||||||
|
_applyProcessorOptions(processorOptions, (key, value) {
|
||||||
|
final valueInt = int.tryParse(value);
|
||||||
|
if (valueInt != null) {
|
||||||
|
switch (key) {
|
||||||
|
case CounterNamingProcessor.optionStart:
|
||||||
|
start = valueInt;
|
||||||
|
break;
|
||||||
|
case CounterNamingProcessor.optionPadding:
|
||||||
|
padding = valueInt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
processors.add(CounterNamingProcessor(start: start ?? defaultCounterStart, padding: padding ?? defaultCounterPadding));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
debugPrint('unsupported naming processor: ${match.group(0)}');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index = end;
|
||||||
|
});
|
||||||
|
if (index < userPattern.length) {
|
||||||
|
processors.add(LiteralNamingProcessor(userPattern.substring(index, userPattern.length)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NamingPattern(processors);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _applyProcessorOptions(String? processorOptions, void Function(String key, String value) applyOption) {
|
||||||
|
if (processorOptions != null) {
|
||||||
|
processorOptions.split(processorOptionSeparator).map((v) => v.trim()).forEach((kv) {
|
||||||
|
final parts = kv.split(optionKeyValueSeparator);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
final key = parts[0];
|
||||||
|
final value = parts.skip(1).join(optionKeyValueSeparator);
|
||||||
|
applyOption(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int getInsertionOffset(String userPattern, int offset) {
|
||||||
|
offset = offset.clamp(0, userPattern.length);
|
||||||
|
final matches = processorPattern.allMatches(userPattern);
|
||||||
|
for (final match in matches) {
|
||||||
|
final start = match.start;
|
||||||
|
final end = match.end;
|
||||||
|
if (offset <= start) return offset;
|
||||||
|
if (offset <= end) return end;
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String defaultPatternFor(String processorKey) {
|
||||||
|
switch (processorKey) {
|
||||||
|
case DateNamingProcessor.key:
|
||||||
|
return '<$processorKey, yyyyMMdd-HHmmss>';
|
||||||
|
case CounterNamingProcessor.key:
|
||||||
|
case NameNamingProcessor.key:
|
||||||
|
default:
|
||||||
|
return '<$processorKey>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String apply(AvesEntry entry, int index) => processors.map((v) => v.process(entry, index) ?? '').join().trimLeft();
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
abstract class NamingProcessor extends Equatable {
|
||||||
|
const NamingProcessor();
|
||||||
|
|
||||||
|
String? process(AvesEntry entry, int index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class LiteralNamingProcessor extends NamingProcessor {
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [text];
|
||||||
|
|
||||||
|
const LiteralNamingProcessor(this.text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? process(AvesEntry entry, int index) => text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class DateNamingProcessor extends NamingProcessor {
|
||||||
|
static const key = 'date';
|
||||||
|
|
||||||
|
final DateFormat format;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [format.pattern];
|
||||||
|
|
||||||
|
DateNamingProcessor(String pattern) : format = DateFormat(pattern);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? process(AvesEntry entry, int index) {
|
||||||
|
final date = entry.bestDate;
|
||||||
|
return date != null ? format.format(date) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class NameNamingProcessor extends NamingProcessor {
|
||||||
|
static const key = 'name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
|
||||||
|
const NameNamingProcessor();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? process(AvesEntry entry, int index) => entry.filenameWithoutExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class CounterNamingProcessor extends NamingProcessor {
|
||||||
|
final int start;
|
||||||
|
final int padding;
|
||||||
|
|
||||||
|
static const key = 'counter';
|
||||||
|
static const optionStart = 'start';
|
||||||
|
static const optionPadding = 'padding';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [start, padding];
|
||||||
|
|
||||||
|
const CounterNamingProcessor({
|
||||||
|
required this.start,
|
||||||
|
required this.padding,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? process(AvesEntry entry, int index) => '${index + start}'.padLeft(padding, '0');
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import 'package:aves/model/actions/entry_actions.dart';
|
||||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
import 'package:aves/model/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;
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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!};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
220
lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart
Normal file
220
lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/naming_pattern.dart';
|
||||||
|
import 'package:aves/model/settings/settings.dart';
|
||||||
|
import 'package:aves/theme/durations.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/utils/constants.dart';
|
||||||
|
import 'package:aves/widgets/collection/collection_grid.dart';
|
||||||
|
import 'package:aves/widgets/common/basic/menu.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
import 'package:aves/widgets/common/grid/theme.dart';
|
||||||
|
import 'package:aves/widgets/common/identity/buttons.dart';
|
||||||
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/decorated.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class RenameEntrySetPage extends StatefulWidget {
|
||||||
|
static const routeName = '/rename_entry_set';
|
||||||
|
|
||||||
|
final List<AvesEntry> entries;
|
||||||
|
|
||||||
|
const RenameEntrySetPage({
|
||||||
|
Key? key,
|
||||||
|
required this.entries,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RenameEntrySetPage> createState() => _RenameEntrySetPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
|
||||||
|
final TextEditingController _patternTextController = TextEditingController();
|
||||||
|
final ValueNotifier<NamingPattern> _namingPatternNotifier = ValueNotifier<NamingPattern>(const NamingPattern([]));
|
||||||
|
|
||||||
|
static const int previewMax = 10;
|
||||||
|
static const double thumbnailExtent = 48;
|
||||||
|
|
||||||
|
List<AvesEntry> get entries => widget.entries;
|
||||||
|
|
||||||
|
int get entryCount => entries.length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_patternTextController.text = settings.entryRenamingPattern;
|
||||||
|
_patternTextController.addListener(_onUserPatternChange);
|
||||||
|
_onUserPatternChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_patternTextController.removeListener(_onUserPatternChange);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return MediaQueryDataProvider(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(l10n.renameEntrySetPageTitle),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _patternTextController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.renameEntrySetPagePatternFieldLabel,
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MenuIconTheme(
|
||||||
|
child: PopupMenuButton<String>(
|
||||||
|
itemBuilder: (context) {
|
||||||
|
return [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: DateNamingProcessor.key,
|
||||||
|
child: MenuRow(text: l10n.renameProcessorDate, icon: const Icon(AIcons.date)),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: NameNamingProcessor.key,
|
||||||
|
child: MenuRow(text: l10n.renameProcessorName, icon: const Icon(AIcons.name)),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: CounterNamingProcessor.key,
|
||||||
|
child: MenuRow(text: l10n.renameProcessorCounter, icon: const Icon(AIcons.counter)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
onSelected: (key) async {
|
||||||
|
// wait for the popup menu to hide before proceeding with the action
|
||||||
|
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||||
|
_insertProcessor(key);
|
||||||
|
},
|
||||||
|
tooltip: l10n.renameEntrySetPageInsertTooltip,
|
||||||
|
icon: const Icon(AIcons.add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
l10n.renameEntrySetPagePreview,
|
||||||
|
style: Constants.titleTextStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Selector<MediaQueryData, double>(
|
||||||
|
selector: (context, mq) => mq.textScaleFactor,
|
||||||
|
builder: (context, textScaleFactor, child) {
|
||||||
|
final effectiveThumbnailExtent = max(thumbnailExtent, thumbnailExtent * textScaleFactor);
|
||||||
|
return GridTheme(
|
||||||
|
extent: effectiveThumbnailExtent,
|
||||||
|
child: ListView.separated(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = entries[index];
|
||||||
|
final sourceName = entry.filenameWithoutExtension ?? '';
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
DecoratedThumbnail(
|
||||||
|
entry: entry,
|
||||||
|
tileExtent: effectiveThumbnailExtent,
|
||||||
|
selectable: false,
|
||||||
|
highlightable: false,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
sourceName,
|
||||||
|
style: TextStyle(color: Theme.of(context).textTheme.caption!.color),
|
||||||
|
softWrap: false,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValueListenableBuilder<NamingPattern>(
|
||||||
|
valueListenable: _namingPatternNotifier,
|
||||||
|
builder: (context, pattern, child) {
|
||||||
|
return Text(
|
||||||
|
pattern.apply(entry, index),
|
||||||
|
softWrap: false,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
maxLines: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(
|
||||||
|
height: CollectionGrid.spacing,
|
||||||
|
),
|
||||||
|
itemCount: min(entryCount, previewMax),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const Divider(height: 0),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: AvesOutlinedButton(
|
||||||
|
label: l10n.entryActionRename,
|
||||||
|
onPressed: () {
|
||||||
|
settings.entryRenamingPattern = _patternTextController.text;
|
||||||
|
Navigator.pop<NamingPattern>(context, _namingPatternNotifier.value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onUserPatternChange() {
|
||||||
|
_namingPatternNotifier.value = NamingPattern.from(
|
||||||
|
userPattern: _patternTextController.text,
|
||||||
|
entryCount: entryCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertProcessor(String key) {
|
||||||
|
final userPattern = _patternTextController.text;
|
||||||
|
final selection = _patternTextController.selection;
|
||||||
|
_patternTextController.value = _patternTextController.value.replaced(
|
||||||
|
TextRange(
|
||||||
|
start: NamingPattern.getInsertionOffset(userPattern, selection.start),
|
||||||
|
end: NamingPattern.getInsertionOffset(userPattern, selection.end),
|
||||||
|
),
|
||||||
|
NamingPattern.defaultPatternFor(key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import 'package:aves/services/common/image_op_events.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/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;
|
||||||
|
|
|
@ -138,59 +138,64 @@ 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 DefaultTextStyle(
|
return AnimatedBuilder(
|
||||||
style: Theme.of(context).textTheme.bodyText2!.copyWith(
|
animation: pageEntry.metadataChangeNotifier,
|
||||||
shadows: _shadows(context),
|
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<MediaQueryData, Orientation>(
|
||||||
|
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 = <Widget>[];
|
||||||
|
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<MediaQueryData, Orientation>(
|
|
||||||
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 = <Widget>[];
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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');
|
||||||
|
|
||||||
|
|
47
test/model/naming_pattern_test.dart
Normal file
47
test/model/naming_pattern_test.dart
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import 'package:aves/model/naming_pattern.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('mixed processors', () {
|
||||||
|
const entryCount = 42;
|
||||||
|
expect(
|
||||||
|
NamingPattern.from(
|
||||||
|
userPattern: 'pure literal',
|
||||||
|
entryCount: entryCount,
|
||||||
|
).processors,
|
||||||
|
[
|
||||||
|
const LiteralNamingProcessor('pure literal'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
NamingPattern.from(
|
||||||
|
userPattern: 'prefix<date,yyyy-MM-ddTHH:mm:ss>suffix',
|
||||||
|
entryCount: entryCount,
|
||||||
|
).processors,
|
||||||
|
[
|
||||||
|
const LiteralNamingProcessor('prefix'),
|
||||||
|
DateNamingProcessor('yyyy-MM-ddTHH:mm:ss'),
|
||||||
|
const LiteralNamingProcessor('suffix'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
NamingPattern.from(
|
||||||
|
userPattern: '<date,yyyy-MM-ddTHH:mm:ss> <name>',
|
||||||
|
entryCount: entryCount,
|
||||||
|
).processors,
|
||||||
|
[
|
||||||
|
DateNamingProcessor('yyyy-MM-ddTHH:mm:ss'),
|
||||||
|
const LiteralNamingProcessor(' '),
|
||||||
|
const NameNamingProcessor(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insertion offset', () {
|
||||||
|
const userPattern = '<date,yyyy-MM-ddTHH:mm:ss> infix <name>';
|
||||||
|
expect(NamingPattern.getInsertionOffset(userPattern, -1), 0);
|
||||||
|
expect(NamingPattern.getInsertionOffset(userPattern, 1234), userPattern.length);
|
||||||
|
expect(NamingPattern.getInsertionOffset(userPattern, 4), 26);
|
||||||
|
expect(NamingPattern.getInsertionOffset(userPattern, 30), 30);
|
||||||
|
});
|
||||||
|
}
|
|
@ -5,7 +5,16 @@
|
||||||
"themeBrightnessBlack",
|
"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",
|
||||||
|
|
Loading…
Reference in a new issue