#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 location by copying from other item
- Info: edit tags with dynamic placeholders for country / place
- Widget: option to open collection on tap
### Changed

View file

@ -868,6 +868,10 @@
"tagEditorPageNewTagFieldLabel": "New tag",
"tagEditorPageAddTagTooltip": "Add tag",
"tagEditorSectionRecent": "Recent",
"tagEditorSectionPlaceholders": "Placeholders",
"tagPlaceholderCountry": "Country",
"tagPlaceholderPlace": "Place",
"panoramaEnableSensorControl": "Enable 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_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/enums/enums.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 {
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(
context,
MaterialPageRoute(
settings: const RouteSettings(name: TagEditorPage.routeName),
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;
}

View file

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

View file

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