diff --git a/CHANGELOG.md b/CHANGELOG.md index e99dd80d3..0e0684822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,14 @@ All notable changes to this project will be documented in this file. ### Changed +- Info: editing tags now requires explicitly tapping the save button - upgraded Flutter to stable v3.7.12 ### Fixed - Video: switching to PiP when going home with gesture navigation - Viewer: multi-page context update when removing burst entries +- Info: editing tags with placeholders - prevent editing item when Exif editing changes mime type - parsing videos with skippable boxes in `meta` box diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4bc25f2df..bc5ab6045 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -46,6 +46,7 @@ "applyButtonLabel": "APPLY", "deleteButtonLabel": "DELETE", + "discardButtonLabel": "DISCARD", "nextButtonLabel": "NEXT", "showButtonLabel": "SHOW", "hideButtonLabel": "HIDE", diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 34248cb63..32656ee08 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -82,26 +82,27 @@ mixin EntryEditorMixin { Future>?> selectTags(BuildContext context, Set entries) async { if (entries.isEmpty) return null; - final filtersByEntry = Map.fromEntries(entries.map((v) { + final oldTagsByEntry = Map.fromEntries(entries.map((v) { return MapEntry(v, v.tags.map(TagFilter.new).toSet()); })); - await Navigator.maybeOf(context)?.push( - MaterialPageRoute( - settings: const RouteSettings(name: TagEditorPage.routeName), - builder: (context) => TagEditorPage( - filtersByEntry: filtersByEntry, - ), - ), - ); + final filtersByEntry = await Navigator.maybeOf(context)?.push>>( + MaterialPageRoute( + settings: const RouteSettings(name: TagEditorPage.routeName), + builder: (context) => TagEditorPage( + tagsByEntry: oldTagsByEntry, + ), + ), + ) ?? + oldTagsByEntry; - final tagsByEntry = >{}; + final newTagsByEntry = >{}; await Future.forEach(filtersByEntry.entries, (kv) async { final entry = kv.key; final filters = kv.value; - tagsByEntry[entry] = await getTagsFromFilters(filters, entry); + newTagsByEntry[entry] = await getTagsFromFilters(filters, entry); }); - return tagsByEntry; + return newTagsByEntry; } Future> getTagsFromFilters(Set filters, AvesEntry entry) async { diff --git a/lib/widgets/common/action_mixins/vault_aware.dart b/lib/widgets/common/action_mixins/vault_aware.dart index 13b4d9ddd..137591878 100644 --- a/lib/widgets/common/action_mixins/vault_aware.dart +++ b/lib/widgets/common/action_mixins/vault_aware.dart @@ -30,8 +30,9 @@ mixin VaultAwareMixin on FeedbackMixin { localizedReason: context.l10n.authenticateToUnlockVault, ); } on PlatformException catch (e, stack) { - if (e.code != 'auth_in_progress') { + if (!{'auth_in_progress', 'NotAvailable'}.contains(e.code)) { // `auth_in_progress`: `Authentication in progress` + // `NotAvailable`: `Required security features not enabled` await reportService.recordError(e, stack); } } diff --git a/lib/widgets/dialogs/entry_editors/tag_editor_page.dart b/lib/widgets/dialogs/entry_editors/tag_editor_page.dart index 1fc090ca2..2e20feea5 100644 --- a/lib/widgets/dialogs/entry_editors/tag_editor_page.dart +++ b/lib/widgets/dialogs/entry_editors/tag_editor_page.dart @@ -10,17 +10,18 @@ import 'package:aves/widgets/common/basic/scaffold.dart'; 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/dialogs/aves_dialog.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class TagEditorPage extends StatefulWidget { static const routeName = '/info/tag_editor'; - final Map> filtersByEntry; + final Map> tagsByEntry; const TagEditorPage({ super.key, - required this.filtersByEntry, + required this.tagsByEntry, }); @override @@ -31,6 +32,7 @@ class _TagEditorPageState extends State { final TextEditingController _newTagTextController = TextEditingController(); final FocusNode _newTagTextFocusNode = FocusNode(); final ValueNotifier _expandedSectionNotifier = ValueNotifier(null); + late final Map> filtersByEntry; late final List _topTags; final List _userAddedFilters = []; @@ -41,11 +43,10 @@ class _TagEditorPageState extends State { PlaceholderFilter.place, ]; - Map> get tagsByEntry => widget.filtersByEntry; - @override void initState() { super.initState(); + filtersByEntry = widget.tagsByEntry.map((key, value) => MapEntry(key, value.cast().toSet())); _expandedSectionNotifier.value = settings.tagEditorExpandedSection; _expandedSectionNotifier.addListener(() => settings.tagEditorExpandedSection = _expandedSectionNotifier.value); _initTopTags(); @@ -61,149 +62,176 @@ class _TagEditorPageState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; - final showCount = tagsByEntry.length > 1; + final showCount = filtersByEntry.length > 1; final Map entryCountByTag = {}; - tagsByEntry.entries.forEach((kv) { + filtersByEntry.entries.forEach((kv) { kv.value.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); }); List> sortedTags = _sortCurrentTags(entryCountByTag); - return AvesScaffold( - appBar: AppBar( - title: Text(l10n.tagEditorPageTitle), - actions: [ - IconButton( - icon: const Icon(AIcons.reset), - onPressed: _reset, - tooltip: l10n.resetTooltip, - ), - ], - ), - body: SafeArea( - child: ValueListenableBuilder( - valueListenable: _expandedSectionNotifier, - builder: (context, expandedSection, child) { - return ValueListenableBuilder( - valueListenable: _newTagTextController, - builder: (context, value, child) { - final upQuery = value.text.trim().toUpperCase(); - bool containQuery(CollectionFilter v) => v.getLabel(context).toUpperCase().contains(upQuery); - final recentFilters = settings.recentTags.where(containQuery).toList(); - final topTagFilters = _topTags.where(containQuery).toList(); - final placeholderFilters = _placeholders.where(containQuery).toList(); - return ListView( - children: [ - Padding( - padding: const EdgeInsetsDirectional.only(start: 8, end: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: _newTagTextController, - focusNode: _newTagTextFocusNode, - decoration: InputDecoration( - labelText: l10n.tagEditorPageNewTagFieldLabel, + return WillPopScope( + onWillPop: () async { + if (!_isModified) return true; + + final confirmed = await showDialog( + context: context, + builder: (context) => AvesDialog( + content: Text(context.l10n.genericDangerWarningDialogMessage), + actions: [ + const CancelButton(), + TextButton( + onPressed: () => Navigator.maybeOf(context)?.pop(true), + child: Text(context.l10n.discardButtonLabel), + ), + ], + ), + routeSettings: const RouteSettings(name: AvesDialog.warningRouteName), + ) ?? + false; + return confirmed; + }, + child: AvesScaffold( + appBar: AppBar( + title: Text(l10n.tagEditorPageTitle), + actions: [ + IconButton( + icon: const Icon(AIcons.reset), + onPressed: _reset, + tooltip: l10n.resetTooltip, + ), + IconButton( + icon: const Icon(AIcons.apply), + onPressed: () => Navigator.maybeOf(context)?.pop(filtersByEntry), + tooltip: l10n.saveTooltip, + ), + ], + ), + body: SafeArea( + child: ValueListenableBuilder( + valueListenable: _expandedSectionNotifier, + builder: (context, expandedSection, child) { + return ValueListenableBuilder( + valueListenable: _newTagTextController, + builder: (context, value, child) { + final upQuery = value.text.trim().toUpperCase(); + bool containQuery(CollectionFilter v) => v.getLabel(context).toUpperCase().contains(upQuery); + final recentFilters = settings.recentTags.where(containQuery).toList(); + final topTagFilters = _topTags.where(containQuery).toList(); + final placeholderFilters = _placeholders.where(containQuery).toList(); + return ListView( + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(start: 8, end: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _newTagTextController, + focusNode: _newTagTextFocusNode, + decoration: InputDecoration( + labelText: l10n.tagEditorPageNewTagFieldLabel, + ), + autofocus: true, + onSubmitted: (newTag) { + _addCustomTag(newTag); + _newTagTextFocusNode.requestFocus(); + }, ), - autofocus: true, - onSubmitted: (newTag) { - _addCustomTag(newTag); - _newTagTextFocusNode.requestFocus(); + ), + ValueListenableBuilder( + valueListenable: _newTagTextController, + builder: (context, value, child) { + return IconButton( + icon: const Icon(AIcons.add), + onPressed: value.text.isEmpty ? null : () => _addCustomTag(_newTagTextController.text), + tooltip: l10n.tagEditorPageAddTagTooltip, + ); }, ), - ), - ValueListenableBuilder( - valueListenable: _newTagTextController, - builder: (context, value, child) { - return IconButton( - icon: const Icon(AIcons.add), - onPressed: value.text.isEmpty ? null : () => _addCustomTag(_newTagTextController.text), - tooltip: l10n.tagEditorPageAddTagTooltip, - ); - }, - ), - Selector( - selector: (context, s) => s.tagEditorCurrentFilterSectionExpanded, - builder: (context, isExpanded, child) { - return IconButton( - icon: Icon(isExpanded ? AIcons.collapse : AIcons.expand), - onPressed: sortedTags.isEmpty ? null : () => settings.tagEditorCurrentFilterSectionExpanded = !isExpanded, - tooltip: isExpanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, - ); - }, - ), - ], + Selector( + selector: (context, s) => s.tagEditorCurrentFilterSectionExpanded, + builder: (context, isExpanded, child) { + return IconButton( + icon: Icon(isExpanded ? AIcons.collapse : AIcons.expand), + onPressed: sortedTags.isEmpty ? null : () => settings.tagEditorCurrentFilterSectionExpanded = !isExpanded, + tooltip: isExpanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, + ); + }, + ), + ], + ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: AnimatedCrossFade( - firstChild: ConstrainedBox( - constraints: const BoxConstraints(minHeight: AvesFilterChip.minChipHeight), - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(AIcons.tagUntagged, color: untaggedColor), - const SizedBox(width: 8), - Text( - l10n.filterNoTagLabel, - style: const TextStyle(color: untaggedColor), - ), - ], + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: AnimatedCrossFade( + firstChild: ConstrainedBox( + constraints: const BoxConstraints(minHeight: AvesFilterChip.minChipHeight), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(AIcons.tagUntagged, color: untaggedColor), + const SizedBox(width: 8), + Text( + l10n.filterNoTagLabel, + style: const TextStyle(color: untaggedColor), + ), + ], + ), ), ), + secondChild: ExpandableFilterRow( + filters: sortedTags.map((kv) => kv.key).toList(), + isExpanded: context.select((v) => v.tagEditorCurrentFilterSectionExpanded), + showGenericIcon: false, + leadingBuilder: showCount + ? (filter) => _TagCount( + count: sortedTags.firstWhere((kv) => kv.key == filter).value, + ) + : null, + onTap: (filter) { + if (filtersByEntry.keys.length > 1) { + // for multiple entries, set tag for all of them + filtersByEntry.forEach((entry, filters) => filters.add(filter)); + setState(() {}); + } else { + // for single entry, remove tag (like pressing on the remove icon) + _removeTag(filter); + } + }, + onRemove: _removeTag, + onLongPress: null, + ), + crossFadeState: sortedTags.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: Durations.tagEditorTransition, ), - secondChild: ExpandableFilterRow( - filters: sortedTags.map((kv) => kv.key).toList(), - isExpanded: context.select((v) => v.tagEditorCurrentFilterSectionExpanded), - showGenericIcon: false, - leadingBuilder: showCount - ? (filter) => _TagCount( - count: sortedTags.firstWhere((kv) => kv.key == filter).value, - ) - : null, - onTap: (filter) { - if (tagsByEntry.keys.length > 1) { - // for multiple entries, set tag for all of them - tagsByEntry.forEach((entry, filters) => filters.add(filter)); - setState(() {}); - } else { - // for single entry, remove tag (like pressing on the remove icon) - _removeTag(filter); - } - }, - onRemove: _removeTag, - onLongPress: null, - ), - crossFadeState: sortedTags.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond, - duration: Durations.tagEditorTransition, ), - ), - const Divider(height: 0), - _FilterRow( - title: l10n.statsTopTagsSectionTitle, - filters: topTagFilters, - expandedNotifier: _expandedSectionNotifier, - onTap: _addTag, - ), - _FilterRow( - title: l10n.tagEditorSectionRecent, - filters: recentFilters, - expandedNotifier: _expandedSectionNotifier, - onTap: _addTag, - ), - _FilterRow( - title: l10n.tagEditorSectionPlaceholders, - filters: placeholderFilters, - expandedNotifier: _expandedSectionNotifier, - onTap: _addTag, - ), - ], - ); - }, - ); - }, + const Divider(height: 0), + _FilterRow( + title: l10n.statsTopTagsSectionTitle, + filters: topTagFilters, + expandedNotifier: _expandedSectionNotifier, + onTap: _addTag, + ), + _FilterRow( + title: l10n.tagEditorSectionRecent, + filters: recentFilters, + expandedNotifier: _expandedSectionNotifier, + onTap: _addTag, + ), + _FilterRow( + title: l10n.tagEditorSectionPlaceholders, + filters: placeholderFilters, + expandedNotifier: _expandedSectionNotifier, + onTap: _addTag, + ), + ], + ); + }, + ); + }, + ), ), ), ); @@ -239,10 +267,19 @@ class _TagEditorPageState extends State { }); } + bool get _isModified { + for (final kv in filtersByEntry.entries) { + final oldFilters = kv.key.tags.map(TagFilter.new).toSet(); + final newFilters = kv.value; + if (newFilters.length != oldFilters.length || !newFilters.containsAll(oldFilters)) return true; + } + return false; + } + void _reset() { _userAddedFilters.clear(); - tagsByEntry.forEach((entry, tags) { - final Set originalFilters = entry.tags.map(TagFilter.new).toSet(); + filtersByEntry.forEach((entry, tags) { + final originalFilters = entry.tags.map(TagFilter.new).toSet(); tags ..clear() ..addAll(originalFilters); @@ -263,14 +300,14 @@ class _TagEditorPageState extends State { _userAddedFilters ..remove(filter) ..add(filter); - tagsByEntry.forEach((entry, tags) => tags.add(filter)); + filtersByEntry.forEach((entry, tags) => tags.add(filter)); _newTagTextController.clear(); setState(() {}); } void _removeTag(CollectionFilter filter) { _userAddedFilters.remove(filter); - tagsByEntry.forEach((entry, filters) => filters.remove(filter)); + filtersByEntry.forEach((entry, filters) => filters.remove(filter)); setState(() {}); } } diff --git a/untranslated.json b/untranslated.json index 72c914e7a..a3c3f0457 100644 --- a/untranslated.json +++ b/untranslated.json @@ -6,6 +6,7 @@ "timeMinutes", "timeDays", "focalLength", + "discardButtonLabel", "pickTooltip", "sourceStateLoading", "sourceStateCataloguing", @@ -614,6 +615,7 @@ ], "ckb": [ + "discardButtonLabel", "chipActionGoToPlacePage", "chipActionShowCountryStates", "entryActionRotateCCW", @@ -1194,7 +1196,28 @@ "filePickerUseThisFolder" ], + "cs": [ + "discardButtonLabel" + ], + + "de": [ + "discardButtonLabel" + ], + + "el": [ + "discardButtonLabel" + ], + + "es": [ + "discardButtonLabel" + ], + + "eu": [ + "discardButtonLabel" + ], + "fa": [ + "discardButtonLabel", "clearTooltip", "chipActionGoToPlacePage", "chipActionLock", @@ -1678,8 +1701,13 @@ "filePickerUseThisFolder" ], + "fr": [ + "discardButtonLabel" + ], + "gl": [ "columnCount", + "discardButtonLabel", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -2203,6 +2231,7 @@ "focalLength", "applyButtonLabel", "deleteButtonLabel", + "discardButtonLabel", "nextButtonLabel", "showButtonLabel", "hideButtonLabel", @@ -2840,6 +2869,7 @@ ], "hi": [ + "discardButtonLabel", "resetTooltip", "saveTooltip", "pickTooltip", @@ -3464,12 +3494,22 @@ "filePickerUseThisFolder" ], + "hu": [ + "discardButtonLabel" + ], + + "id": [ + "discardButtonLabel" + ], + "it": [ + "discardButtonLabel", "settingsCollectionBurstPatternsTile" ], "ja": [ "columnCount", + "discardButtonLabel", "chipActionShowCountryStates", "chipActionCreateVault", "chipActionConfigureVault", @@ -3499,8 +3539,13 @@ "statsTopStatesSectionTitle" ], + "ko": [ + "discardButtonLabel" + ], + "lt": [ "columnCount", + "discardButtonLabel", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -3556,6 +3601,7 @@ ], "nb": [ + "discardButtonLabel", "chipActionShowCountryStates", "viewerActionLock", "viewerActionUnlock", @@ -3576,6 +3622,7 @@ "nl": [ "columnCount", + "discardButtonLabel", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -3644,6 +3691,7 @@ "nn": [ "columnCount", + "discardButtonLabel", "sourceStateCataloguing", "chipActionGoToPlacePage", "chipActionLock", @@ -3984,6 +4032,7 @@ "focalLength", "applyButtonLabel", "deleteButtonLabel", + "discardButtonLabel", "nextButtonLabel", "continueButtonLabel", "cancelTooltip", @@ -4551,7 +4600,20 @@ "filePickerUseThisFolder" ], + "pl": [ + "discardButtonLabel" + ], + + "pt": [ + "discardButtonLabel" + ], + + "ro": [ + "discardButtonLabel" + ], + "ru": [ + "discardButtonLabel", "chipActionShowCountryStates", "viewerActionLock", "viewerActionUnlock", @@ -4574,6 +4636,7 @@ "itemCount", "columnCount", "timeSeconds", + "discardButtonLabel", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -5011,6 +5074,7 @@ "timeDays", "focalLength", "applyButtonLabel", + "discardButtonLabel", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -5372,6 +5436,7 @@ ], "tr": [ + "discardButtonLabel", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -5417,7 +5482,12 @@ "tagPlaceholderState" ], + "uk": [ + "discardButtonLabel" + ], + "zh": [ + "discardButtonLabel", "chipActionGoToPlacePage", "viewerActionLock", "viewerActionUnlock", @@ -5469,6 +5539,7 @@ "zh_Hant": [ "columnCount", + "discardButtonLabel", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates",