#242 info: edit tags with dynamic placeholders for country / place

This commit is contained in:
Thibault Deckers 2022-10-26 12:11:30 +02:00
parent 9fa977a7c1
commit 8ed8787c24
6 changed files with 232 additions and 50 deletions

View file

@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Info: edit MP4 metadata (date / location / title / description / rating / tags / rotation) - Info: edit MP4 metadata (date / location / title / description / rating / tags / rotation)
- Info: edit location by copying from other item - Info: edit location by copying from other item
- Info: edit tags with dynamic placeholders for country / place
- Widget: option to open collection on tap - Widget: option to open collection on tap
### Changed ### Changed

View file

@ -868,6 +868,10 @@
"tagEditorPageNewTagFieldLabel": "New tag", "tagEditorPageNewTagFieldLabel": "New tag",
"tagEditorPageAddTagTooltip": "Add tag", "tagEditorPageAddTagTooltip": "Add tag",
"tagEditorSectionRecent": "Recent", "tagEditorSectionRecent": "Recent",
"tagEditorSectionPlaceholders": "Placeholders",
"tagPlaceholderCountry": "Country",
"tagPlaceholderPlace": "Place",
"panoramaEnableSensorControl": "Enable sensor control", "panoramaEnableSensorControl": "Enable sensor control",
"panoramaDisableSensorControl": "Disable sensor control", "panoramaDisableSensorControl": "Disable sensor control",

View file

@ -0,0 +1,95 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
class PlaceholderFilter extends CollectionFilter {
static const type = 'placeholder';
static const _country = 'country';
static const _place = 'place';
final String placeholder;
late final IconData _icon;
static final country = PlaceholderFilter._private(_country);
static final place = PlaceholderFilter._private(_place);
@override
List<Object?> get props => [placeholder];
PlaceholderFilter._private(this.placeholder) : super(reversed: false) {
switch (placeholder) {
case _country:
case _place:
_icon = AIcons.location;
break;
}
}
factory PlaceholderFilter.fromMap(Map<String, dynamic> json) {
return PlaceholderFilter._private(
json['placeholder'],
);
}
@override
Map<String, dynamic> toMap() => {
'type': type,
'placeholder': placeholder,
};
Future<String?> toTag(AvesEntry entry) async {
switch (placeholder) {
case _country:
case _place:
if (!entry.isCatalogued) {
await entry.catalog(background: false, force: false, persist: true);
}
if (!entry.hasGps) return null;
if (!entry.hasFineAddress) {
await entry.locate(background: false, force: false, geocoderLocale: settings.appliedLocale);
}
final address = entry.addressDetails;
if (address == null) return null;
if (placeholder == _country) return address.countryName;
if (placeholder == _place) return address.place;
break;
}
return null;
}
@override
EntryFilter get positiveTest => (entry) => throw Exception('this is not a test');
@override
bool get exclusiveProp => false;
@override
String get universalLabel => placeholder;
@override
String getLabel(BuildContext context) {
switch (placeholder) {
case _country:
return context.l10n.tagPlaceholderCountry;
case _place:
return context.l10n.tagPlaceholderPlace;
default:
return placeholder;
}
}
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size);
@override
String get category => type;
@override
String get key => '$type-$placeholder';
}

View file

@ -1,5 +1,8 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/placeholder.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums/enums.dart'; import 'package:aves/model/metadata/enums/enums.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
@ -74,17 +77,32 @@ mixin EntryEditorMixin {
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async { Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null; if (entries.isEmpty) return null;
final tagsByEntry = Map.fromEntries(entries.map((v) => MapEntry(v, v.tags.toSet()))); final filtersByEntry = Map.fromEntries(entries.map((v) {
// use `<CollectionFilter>{...}` instead of `toSet()` to circumvent an implicit typing issue, as of Dart v2.18.2
final filters = <CollectionFilter>{...v.tags.map(TagFilter.new)};
return MapEntry(v, filters);
}));
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: TagEditorPage.routeName), settings: const RouteSettings(name: TagEditorPage.routeName),
builder: (context) => TagEditorPage( builder: (context) => TagEditorPage(
tagsByEntry: tagsByEntry, filtersByEntry: filtersByEntry,
), ),
), ),
); );
final tagsByEntry = <AvesEntry, Set<String>>{};
await Future.forEach(filtersByEntry.entries, (kv) async {
final entry = kv.key;
final filters = kv.value;
final tags = filters.whereType<TagFilter>().map((v) => v.tag).toSet();
tagsByEntry[entry] = tags;
final placeholderTags = await Future.wait(filters.whereType<PlaceholderFilter>().map((v) => v.toTag(entry)));
tags.addAll(placeholderTags.whereNotNull().where((v) => v.isNotEmpty));
});
return tagsByEntry; return tagsByEntry;
} }

View file

@ -1,6 +1,8 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/placeholder.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
@ -9,18 +11,17 @@ import 'package:aves/widgets/common/expandable_filter_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class TagEditorPage extends StatefulWidget { class TagEditorPage extends StatefulWidget {
static const routeName = '/info/tag_editor'; static const routeName = '/info/tag_editor';
final Map<AvesEntry, Set<String>> tagsByEntry; final Map<AvesEntry, Set<CollectionFilter>> filtersByEntry;
const TagEditorPage({ const TagEditorPage({
super.key, super.key,
required this.tagsByEntry, required this.filtersByEntry,
}); });
@override @override
@ -31,14 +32,15 @@ class _TagEditorPageState extends State<TagEditorPage> {
final TextEditingController _newTagTextController = TextEditingController(); final TextEditingController _newTagTextController = TextEditingController();
final FocusNode _newTagTextFocusNode = FocusNode(); final FocusNode _newTagTextFocusNode = FocusNode();
final ValueNotifier<String?> _expandedSectionNotifier = ValueNotifier(null); final ValueNotifier<String?> _expandedSectionNotifier = ValueNotifier(null);
late final List<String> _topTags; late final List<CollectionFilter> _topTags;
late final List<PlaceholderFilter> _placeholders = [PlaceholderFilter.country, PlaceholderFilter.place];
static final List<String> _recentTags = []; static final List<CollectionFilter> _recentTags = [];
static const Color untaggedColor = Colors.blueGrey; static const Color untaggedColor = Colors.blueGrey;
static const int tagHistoryCount = 10; static const int tagHistoryCount = 10;
Map<AvesEntry, Set<String>> get tagsByEntry => widget.tagsByEntry; Map<AvesEntry, Set<CollectionFilter>> get tagsByEntry => widget.filtersByEntry;
@override @override
void initState() { void initState() {
@ -50,11 +52,11 @@ class _TagEditorPageState extends State<TagEditorPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final showCount = tagsByEntry.length > 1; final showCount = tagsByEntry.length > 1;
final Map<String, int> entryCountByTag = {}; final Map<CollectionFilter, int> entryCountByTag = {};
tagsByEntry.entries.forEach((kv) { tagsByEntry.entries.forEach((kv) {
kv.value.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); kv.value.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1);
}); });
List<MapEntry<String, int>> sortedTags = _sortEntryCountByTag(entryCountByTag); List<MapEntry<CollectionFilter, int>> sortedTags = _sortEntryCountByTag(entryCountByTag);
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
@ -76,9 +78,10 @@ class _TagEditorPageState extends State<TagEditorPage> {
valueListenable: _newTagTextController, valueListenable: _newTagTextController,
builder: (context, value, child) { builder: (context, value, child) {
final upQuery = value.text.trim().toUpperCase(); final upQuery = value.text.trim().toUpperCase();
bool containQuery(String s) => s.toUpperCase().contains(upQuery); bool containQuery(CollectionFilter v) => v.getLabel(context).toUpperCase().contains(upQuery);
final recentFilters = _recentTags.where(containQuery).map(TagFilter.new).toList(); final recentFilters = _recentTags.where(containQuery).toList();
final topTagFilters = _topTags.where(containQuery).map(TagFilter.new).toList(); final topTagFilters = _topTags.where(containQuery).toList();
final placeholderFilters = _placeholders.where(containQuery).toList();
return ListView( return ListView(
children: [ children: [
Padding( Padding(
@ -95,7 +98,7 @@ class _TagEditorPageState extends State<TagEditorPage> {
), ),
autofocus: true, autofocus: true,
onSubmitted: (newTag) { onSubmitted: (newTag) {
_addTag(newTag); _addCustomTag(newTag);
_newTagTextFocusNode.requestFocus(); _newTagTextFocusNode.requestFocus();
}, },
), ),
@ -105,7 +108,7 @@ class _TagEditorPageState extends State<TagEditorPage> {
builder: (context, value, child) { builder: (context, value, child) {
return IconButton( return IconButton(
icon: const Icon(AIcons.add), icon: const Icon(AIcons.add),
onPressed: value.text.isEmpty ? null : () => _addTag(_newTagTextController.text), onPressed: value.text.isEmpty ? null : () => _addCustomTag(_newTagTextController.text),
tooltip: l10n.tagEditorPageAddTagTooltip, tooltip: l10n.tagEditorPageAddTagTooltip,
); );
}, },
@ -138,13 +141,12 @@ class _TagEditorPageState extends State<TagEditorPage> {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: sortedTags.map((kv) { children: sortedTags.map((kv) {
final tag = kv.key;
return AvesFilterChip( return AvesFilterChip(
filter: TagFilter(tag), filter: kv.key,
removable: true, removable: true,
showGenericIcon: false, showGenericIcon: false,
leadingOverride: showCount ? _TagCount(count: kv.value) : null, leadingOverride: showCount ? _TagCount(count: kv.value) : null,
onTap: (filter) => _removeTag(tag), onTap: _removeTag,
onLongPress: null, onLongPress: null,
); );
}).toList(), }).toList(),
@ -167,6 +169,12 @@ class _TagEditorPageState extends State<TagEditorPage> {
expandedNotifier: _expandedSectionNotifier, expandedNotifier: _expandedSectionNotifier,
onTap: _addTag, onTap: _addTag,
), ),
_FilterRow(
title: l10n.tagEditorSectionPlaceholders,
filters: placeholderFilters,
expandedNotifier: _expandedSectionNotifier,
onTap: _addTag,
),
], ],
); );
}, },
@ -184,49 +192,54 @@ class _TagEditorPageState extends State<TagEditorPage> {
visibleEntries?.forEach((entry) { visibleEntries?.forEach((entry) {
entry.tags.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); entry.tags.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1);
}); });
List<MapEntry<String, int>> sortedTopTags = _sortEntryCountByTag(entryCountByTag); List<MapEntry<CollectionFilter, int>> sortedTopTags = _sortEntryCountByTag(entryCountByTag.map((key, value) => MapEntry(TagFilter(key), value)));
_topTags = sortedTopTags.map((kv) => kv.key).toList(); _topTags = sortedTopTags.map((kv) => kv.key).toList();
} }
List<MapEntry<String, int>> _sortEntryCountByTag(Map<String, int> entryCountByTag) { List<MapEntry<CollectionFilter, int>> _sortEntryCountByTag(Map<CollectionFilter, int> entryCountByTag) {
return entryCountByTag.entries.toList() return entryCountByTag.entries.toList()
..sort((kv1, kv2) { ..sort((kv1, kv2) {
final c = kv2.value.compareTo(kv1.value); final c = kv2.value.compareTo(kv1.value);
return c != 0 ? c : compareAsciiUpperCaseNatural(kv1.key, kv2.key); return c != 0 ? c : kv1.key.compareTo(kv2.key);
}); });
} }
void _reset() { void _reset() {
setState(() => tagsByEntry.forEach((entry, tags) { setState(() => tagsByEntry.forEach((entry, tags) {
final Set<TagFilter> originalFilters = entry.tags.map(TagFilter.new).toSet();
tags tags
..clear() ..clear()
..addAll(entry.tags); ..addAll(originalFilters);
})); }));
} }
void _addTag(String newTag) { void _addCustomTag(String newTag) {
if (newTag.isNotEmpty) { if (newTag.isNotEmpty) {
setState(() { _addTag(TagFilter(newTag));
_recentTags
..remove(newTag)
..insert(0, newTag)
..removeRange(min(tagHistoryCount, _recentTags.length), _recentTags.length);
tagsByEntry.forEach((entry, tags) => tags.add(newTag));
});
_newTagTextController.clear();
} }
} }
void _removeTag(String tag) { void _addTag(CollectionFilter newTag) {
setState(() => tagsByEntry.forEach((entry, tags) => tags.remove(tag))); setState(() {
_recentTags
..remove(newTag)
..insert(0, newTag)
..removeRange(min(tagHistoryCount, _recentTags.length), _recentTags.length);
tagsByEntry.forEach((entry, tags) => tags.add(newTag));
});
_newTagTextController.clear();
}
void _removeTag(CollectionFilter filter) {
setState(() => tagsByEntry.forEach((entry, filters) => filters.remove(filter)));
} }
} }
class _FilterRow extends StatelessWidget { class _FilterRow extends StatelessWidget {
final String title; final String title;
final List<TagFilter> filters; final List<CollectionFilter> filters;
final ValueNotifier<String?> expandedNotifier; final ValueNotifier<String?> expandedNotifier;
final void Function(String tag) onTap; final void Function(CollectionFilter filter) onTap;
const _FilterRow({ const _FilterRow({
required this.title, required this.title,
@ -244,7 +257,7 @@ class _FilterRow extends StatelessWidget {
filters: filters, filters: filters,
expandedNotifier: expandedNotifier, expandedNotifier: expandedNotifier,
showGenericIcon: false, showGenericIcon: false,
onTap: (filter) => onTap((filter as TagFilter).tag), onTap: onTap,
onLongPress: null, onLongPress: null,
); );
} }

View file

@ -1,17 +1,26 @@
{ {
"de": [ "de": [
"editEntryLocationDialogSetCustom" "editEntryLocationDialogSetCustom",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"el": [ "el": [
"editEntryLocationDialogSetCustom" "editEntryLocationDialogSetCustom",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"es": [ "es": [
"widgetOpenPageHome", "widgetOpenPageHome",
"widgetOpenPageCollection", "widgetOpenPageCollection",
"widgetOpenPageViewer", "widgetOpenPageViewer",
"editEntryLocationDialogSetCustom" "editEntryLocationDialogSetCustom",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"fa": [ "fa": [
@ -582,6 +591,9 @@
"tagEditorPageNewTagFieldLabel", "tagEditorPageNewTagFieldLabel",
"tagEditorPageAddTagTooltip", "tagEditorPageAddTagTooltip",
"tagEditorSectionRecent", "tagEditorSectionRecent",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace",
"panoramaEnableSensorControl", "panoramaEnableSensorControl",
"panoramaDisableSensorControl", "panoramaDisableSensorControl",
"sourceViewerPageTitle", "sourceViewerPageTitle",
@ -593,7 +605,10 @@
], ],
"fr": [ "fr": [
"editEntryLocationDialogSetCustom" "editEntryLocationDialogSetCustom",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"gl": [ "gl": [
@ -1031,6 +1046,9 @@
"tagEditorPageNewTagFieldLabel", "tagEditorPageNewTagFieldLabel",
"tagEditorPageAddTagTooltip", "tagEditorPageAddTagTooltip",
"tagEditorSectionRecent", "tagEditorSectionRecent",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace",
"panoramaEnableSensorControl", "panoramaEnableSensorControl",
"panoramaDisableSensorControl", "panoramaDisableSensorControl",
"sourceViewerPageTitle", "sourceViewerPageTitle",
@ -1057,11 +1075,17 @@
"settingsSlideshowAnimatedZoomEffect", "settingsSlideshowAnimatedZoomEffect",
"settingsWidgetOpenPage", "settingsWidgetOpenPage",
"statsTopAlbumsSectionTitle", "statsTopAlbumsSectionTitle",
"wallpaperUseScrollEffect" "wallpaperUseScrollEffect",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"it": [ "it": [
"editEntryLocationDialogSetCustom" "editEntryLocationDialogSetCustom",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"ja": [ "ja": [
@ -1097,11 +1121,17 @@
"settingsWidgetOpenPage", "settingsWidgetOpenPage",
"statsTopAlbumsSectionTitle", "statsTopAlbumsSectionTitle",
"viewerInfoLabelDescription", "viewerInfoLabelDescription",
"wallpaperUseScrollEffect" "wallpaperUseScrollEffect",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"ko": [ "ko": [
"editEntryLocationDialogSetCustom" "editEntryLocationDialogSetCustom",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"nb": [ "nb": [
@ -1210,7 +1240,10 @@
"mapEmptyRegion", "mapEmptyRegion",
"viewerInfoOpenEmbeddedFailureFeedback", "viewerInfoOpenEmbeddedFailureFeedback",
"viewerInfoSearchEmpty", "viewerInfoSearchEmpty",
"wallpaperUseScrollEffect" "wallpaperUseScrollEffect",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"nl": [ "nl": [
@ -1219,7 +1252,10 @@
"durationDialogSeconds", "durationDialogSeconds",
"editEntryLocationDialogSetCustom", "editEntryLocationDialogSetCustom",
"aboutLinkPolicy", "aboutLinkPolicy",
"policyPageTitle" "policyPageTitle",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"pl": [ "pl": [
@ -1697,6 +1733,9 @@
"tagEditorPageNewTagFieldLabel", "tagEditorPageNewTagFieldLabel",
"tagEditorPageAddTagTooltip", "tagEditorPageAddTagTooltip",
"tagEditorSectionRecent", "tagEditorSectionRecent",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace",
"panoramaEnableSensorControl", "panoramaEnableSensorControl",
"panoramaDisableSensorControl", "panoramaDisableSensorControl",
"sourceViewerPageTitle", "sourceViewerPageTitle",
@ -1708,11 +1747,17 @@
], ],
"pt": [ "pt": [
"editEntryLocationDialogSetCustom" "editEntryLocationDialogSetCustom",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"ru": [ "ru": [
"editEntryLocationDialogSetCustom" "editEntryLocationDialogSetCustom",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"tr": [ "tr": [
@ -1774,10 +1819,16 @@
"statsTopAlbumsSectionTitle", "statsTopAlbumsSectionTitle",
"viewerSetWallpaperButtonLabel", "viewerSetWallpaperButtonLabel",
"viewerInfoLabelDescription", "viewerInfoLabelDescription",
"wallpaperUseScrollEffect" "wallpaperUseScrollEffect",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
], ],
"zh": [ "zh": [
"editEntryLocationDialogSetCustom" "editEntryLocationDialogSetCustom",
"tagEditorSectionPlaceholders",
"tagPlaceholderCountry",
"tagPlaceholderPlace"
] ]
} }