diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 88187a327..1e2457480 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -123,6 +123,7 @@ "entryInfoActionEditTags": "Edit tags", "entryInfoActionRemoveMetadata": "Remove metadata", "entryInfoActionExportMetadata": "Export metadata", + "entryInfoActionRemoveLocation": "Remove location", "filterAspectRatioLandscapeLabel": "Landscape", "filterAspectRatioPortraitLabel": "Portrait", diff --git a/lib/model/actions/map_cluster_actions.dart b/lib/model/actions/map_cluster_actions.dart new file mode 100644 index 000000000..8301767fc --- /dev/null +++ b/lib/model/actions/map_cluster_actions.dart @@ -0,0 +1,30 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +enum MapClusterAction { + editLocation, + removeLocation, +} + +extension ExtraMapClusterAction on MapClusterAction { + String getText(BuildContext context) { + switch (this) { + case MapClusterAction.editLocation: + return context.l10n.entryInfoActionEditLocation; + case MapClusterAction.removeLocation: + return context.l10n.entryInfoActionRemoveLocation; + } + } + + Widget getIcon() => Icon(_getIconData()); + + IconData _getIconData() { + switch (this) { + case MapClusterAction.editLocation: + return AIcons.edit; + case MapClusterAction.removeLocation: + return AIcons.clear; + } + } +} \ No newline at end of file diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index 0c5b60298..21ba5d001 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -83,6 +83,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { return dataTypes; } + static final removalLocation = LatLng(0, 0); + Future> editLocation(LatLng? latLng) async { final dataTypes = {}; final metadata = {}; @@ -93,17 +95,15 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // clear every GPS field final exifFields = Map.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null))); // add latitude & longitude, if any - if (latLng != null) { + if (latLng != null && latLng != removalLocation) { final latitude = latLng.latitude; final longitude = latLng.longitude; - if (latitude != 0 && longitude != 0) { - exifFields.addAll({ - MetadataField.exifGpsLatitude: latitude.abs(), - MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth, - MetadataField.exifGpsLongitude: longitude.abs(), - MetadataField.exifGpsLongitudeRef: longitude >= 0 ? Exif.longitudeEast : Exif.longitudeWest, - }); - } + exifFields.addAll({ + MetadataField.exifGpsLatitude: latitude.abs(), + MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth, + MetadataField.exifGpsLongitude: longitude.abs(), + MetadataField.exifGpsLongitudeRef: longitude >= 0 ? Exif.longitudeEast : Exif.longitudeWest, + }); } metadata[MetadataType.exif] = Map.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value))); @@ -119,15 +119,13 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { final mp4Fields = {}; String? iso6709String; - if (latLng != null) { + if (latLng != null && latLng != removalLocation) { final latitude = latLng.latitude; final longitude = latLng.longitude; - if (latitude != 0 && longitude != 0) { - const locale = 'en_US'; - final isoLat = '${latitude >= 0 ? '+' : '-'}${NumberFormat('00.0000', locale).format(latitude.abs())}'; - final isoLon = '${longitude >= 0 ? '+' : '-'}${NumberFormat('000.0000', locale).format(longitude.abs())}'; - iso6709String = '$isoLat$isoLon/'; - } + const locale = 'en_US'; + final isoLat = '${latitude >= 0 ? '+' : '-'}${NumberFormat('00.0000', locale).format(latitude.abs())}'; + final isoLon = '${longitude >= 0 ? '+' : '-'}${NumberFormat('000.0000', locale).format(longitude.abs())}'; + iso6709String = '$isoLat$isoLon/'; } mp4Fields[MetadataField.mp4GpsCoordinates] = iso6709String; diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index ed4e46efb..8b7641536 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -509,7 +509,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware await _edit(context, entries, (entry) => entry.editLocation(location)); } - Future quickLocationByMap(BuildContext context, Set entries, LatLng clusterLocation, CollectionLens mapCollection) async { + Future editLocationByMap(BuildContext context, Set entries, LatLng clusterLocation, CollectionLens mapCollection) async { final editableEntries = await _getEditableItems(context, entries, canEdit: (entry) => entry.canEditLocation); if (editableEntries == null || editableEntries.isEmpty) return null; @@ -530,6 +530,33 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware return location; } + Future removeLocation(BuildContext context, Set entries) async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + content: Text(context.l10n.convertMotionPhotoToStillImageWarningDialogMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.applyButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + + final editableEntries = await _getEditableItems(context, entries, canEdit: (entry) => entry.canEditLocation); + if (editableEntries == null || editableEntries.isEmpty) return; + + await _edit(context, editableEntries, (entry) => entry.editLocation(ExtraAvesEntryMetadataEdition.removalLocation)); + } + Future _editTitleDescription(BuildContext context) async { final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditTitleDescription); if (entries == null || entries.isEmpty) return; diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index a0a209d18..f763b68b3 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -38,8 +38,17 @@ class GeoMap extends StatefulWidget { final MapOverlay? overlayEntry; final UserZoomChangeCallback? onUserZoomChange; final MapTapCallback? onMapTap; - final void Function(LatLng clusterLocation, AvesEntry markerEntry)? onMarkerTap; - final void Function(Offset tapLocalPosition, Set clusterEntries, LatLng clusterLocation, WidgetBuilder markerBuilder)? onMarkerLongPress; + final void Function( + LatLng markerLocation, + AvesEntry markerEntry, + )? onMarkerTap; + final void Function( + LatLng markerLocation, + AvesEntry markerEntry, + Set clusterEntries, + Offset tapLocalPosition, + WidgetBuilder markerBuilder, + )? onMarkerLongPress; final void Function(BuildContext context)? openMapPage; const GeoMap({ @@ -360,15 +369,20 @@ class _GeoMapState extends State { } Fluster> _buildFluster({int nodeSize = 64}) { - final markers = entries.map((entry) { - final latLng = entry.latLng!; - return GeoEntry( - entry: entry, - latitude: latLng.latitude, - longitude: latLng.longitude, - markerId: entry.uri, - ); - }).toList(); + final markers = entries + .map((entry) { + final latLng = entry.latLng; + return latLng != null + ? GeoEntry( + entry: entry, + latitude: latLng.latitude, + longitude: latLng.longitude, + markerId: entry.uri, + ) + : null; + }) + .whereNotNull() + .toList(); return Fluster>( // we keep clustering on the whole range of zooms (including the maximum) @@ -433,8 +447,8 @@ class _GeoMapState extends State { } if (markerEntry == null) return; - final clusterLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!); - onTap(clusterLocation, markerEntry); + final markerLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!); + onTap(markerLocation, markerEntry); } Future _onMarkerLongPress(GeoEntry geoEntry, LatLng tapLocation) async { @@ -451,7 +465,7 @@ class _GeoMapState extends State { } else { markerEntry = geoEntry.entry!; } - final clusterLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!); + final markerLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!); Widget markerBuilder(BuildContext context) => ImageMarker( count: geoEntry.pointsSize, drawArrow: false, @@ -460,7 +474,13 @@ class _GeoMapState extends State { extent: extent, ), ); - onMarkerLongPress(tapLocalPosition, clusterEntries, clusterLocation, markerBuilder); + onMarkerLongPress( + markerLocation, + markerEntry, + clusterEntries, + tapLocalPosition, + markerBuilder, + ); } Widget _decorateMap(BuildContext context, Widget? child) => MapDecorator(child: child); diff --git a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart index 7216f4823..6c176cc7a 100644 --- a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart @@ -1,4 +1,5 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/metadata/enums/enums.dart'; import 'package:aves/model/metadata/enums/location_edit_action.dart'; import 'package:aves/model/settings/enums/coordinate_format.dart'; @@ -341,7 +342,7 @@ class _EditEntryLocationDialogState extends State { Navigator.pop(context, _parseLatLng()); break; case LocationEditAction.remove: - Navigator.pop(context, LatLng(0, 0)); + Navigator.pop(context, ExtraAvesEntryMetadataEdition.removalLocation); break; } } diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index a7b2d0761..5fe35a636 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; -import 'package:aves/model/actions/entry_set_actions.dart'; +import 'package:aves/model/actions/map_cluster_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/filters.dart'; @@ -10,6 +10,7 @@ import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/tag.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/debouncer.dart'; @@ -102,6 +103,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin final ValueNotifier _overlayVisible = ValueNotifier(true); late AnimationController _overlayAnimationController; late Animation _overlayScale, _scrollerSize; + CoordinateFilter? _regionFilter; CollectionLens? get regionCollection => _regionCollectionNotifier.value; @@ -119,7 +121,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin }); } - _dotEntryNotifier.addListener(_updateInfoEntry); + _dotEntryNotifier.addListener(_onSelectedEntryChanged); _overlayAnimationController = AnimationController( duration: context.read().viewerOverlayAnimation, @@ -136,6 +138,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin _overlayVisible.addListener(_onOverlayVisibleChange); _subscriptions.add(_mapController.idleUpdates.listen((event) => _onIdle(event.bounds))); + _subscriptions.add(openingCollection.source.eventBus.on().listen((e) => _updateRegionCollection())); _selectedIndexNotifier.addListener(_onThumbnailIndexChange); Future.delayed(Durations.pageTransitionAnimation * timeDilation + const Duration(seconds: 1), () { @@ -158,12 +161,13 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); - _dotEntryNotifier.removeListener(_updateInfoEntry); + _dotEntryNotifier.value?.metadataChangeNotifier.removeListener(_onMarkerEntryMetadataChanged); + _dotEntryNotifier.removeListener(_onSelectedEntryChanged); _overlayAnimationController.dispose(); _overlayVisible.removeListener(_onOverlayVisibleChange); _mapController.dispose(); _selectedIndexNotifier.removeListener(_onThumbnailIndexChange); - _regionCollectionNotifier.value?.dispose(); + regionCollection?.dispose(); super.dispose(); } @@ -344,6 +348,14 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin } void _onIdle(ZoomedBounds bounds) { + _regionFilter = CoordinateFilter(bounds.sw, bounds.ne); + _updateRegionCollection(); + } + + void _updateRegionCollection() { + final regionFilter = _regionFilter; + if (regionFilter == null) return; + AvesEntry? selectedEntry; if (regionCollection != null) { final regionEntries = regionCollection!.sortedEntries; @@ -351,11 +363,11 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin selectedEntry = selectedIndex != null && 0 <= selectedIndex && selectedIndex < regionEntries.length ? regionEntries[selectedIndex] : null; } - final oldRegionCollection = _regionCollectionNotifier.value; + final oldRegionCollection = regionCollection; final newRegionCollection = openingCollection.copyWith( filters: { ...openingCollection.filters.whereNot((v) => v is CoordinateFilter), - CoordinateFilter(bounds.sw, bounds.ne), + regionFilter, }, ); _regionCollectionNotifier.value = newRegionCollection; @@ -389,11 +401,17 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin void _onThumbnailIndexChange() => _onEntrySelected(_getRegionEntry(_selectedIndexNotifier.value)); void _onEntrySelected(AvesEntry? selectedEntry) { - _dotLocationNotifier.value = selectedEntry?.latLng; + _dotEntryNotifier.value?.metadataChangeNotifier.removeListener(_onMarkerEntryMetadataChanged); _dotEntryNotifier.value = selectedEntry; + selectedEntry?.metadataChangeNotifier.addListener(_onMarkerEntryMetadataChanged); + _onMarkerEntryMetadataChanged(); } - void _updateInfoEntry() { + void _onMarkerEntryMetadataChanged() { + _dotLocationNotifier.value = _dotEntryNotifier.value?.latLng; + } + + void _onSelectedEntryChanged() { final selectedEntry = _dotEntryNotifier.value; if (_infoEntryNotifier.value == null || selectedEntry == null) { _infoEntryNotifier.value = selectedEntry; @@ -461,14 +479,15 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin // cluster context menu Future _onMarkerLongPress( - Offset tapLocalPosition, + LatLng markerLocation, + AvesEntry markerEntry, Set clusterEntries, - LatLng clusterLocation, + Offset tapLocalPosition, WidgetBuilder markerBuilder, ) async { final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox; const touchArea = Size(kMinInteractiveDimension, kMinInteractiveDimension); - final selectedAction = await showMenu( + final selectedAction = await showMenu( context: context, position: RelativeRect.fromRect(tapLocalPosition & touchArea, Offset.zero & overlay.size), items: [ @@ -483,26 +502,36 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin ), ), const PopupMenuDivider(), - _buildMenuItem(EntrySetAction.editLocation), + ...[ + MapClusterAction.editLocation, + MapClusterAction.removeLocation, + ].map(_buildMenuItem), ], ); if (selectedAction != null) { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation); + final delegate = EntrySetActionDelegate(); switch (selectedAction) { - case EntrySetAction.editLocation: - final location = await EntrySetActionDelegate().quickLocationByMap(context, clusterEntries, clusterLocation, openingCollection); + case MapClusterAction.editLocation: + final regionEntries = regionCollection?.sortedEntries ?? []; + final markerIndex = regionEntries.indexOf(markerEntry); + final location = await delegate.editLocationByMap(context, clusterEntries, markerLocation, openingCollection); if (location != null) { + if (markerIndex != -1) { + _selectedIndexNotifier.value = markerIndex; + } _mapController.moveTo(location); } break; - default: + case MapClusterAction.removeLocation: + await delegate.removeLocation(context, clusterEntries); break; } } } - PopupMenuItem _buildMenuItem(EntrySetAction action) { + PopupMenuItem _buildMenuItem(MapClusterAction action) { return PopupMenuItem( value: action, child: MenuIconTheme( diff --git a/untranslated.json b/untranslated.json index dbd663a5a..4f3b6410c 100644 --- a/untranslated.json +++ b/untranslated.json @@ -86,6 +86,7 @@ "entryInfoActionEditTags", "entryInfoActionRemoveMetadata", "entryInfoActionExportMetadata", + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterBinLabel", @@ -593,13 +594,22 @@ "filePickerUseThisFolder" ], + "de": [ + "entryInfoActionRemoveLocation" + ], + "el": [ + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", "settingsViewerShowRatingTags" ], + "es": [ + "entryInfoActionRemoveLocation" + ], + "fa": [ "appName", "welcomeMessage", @@ -687,6 +697,7 @@ "entryInfoActionEditTags", "entryInfoActionRemoveMetadata", "entryInfoActionExportMetadata", + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterBinLabel", @@ -1194,8 +1205,13 @@ "filePickerUseThisFolder" ], + "fr": [ + "entryInfoActionRemoveLocation" + ], + "gl": [ "entryInfoActionExportMetadata", + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -1657,6 +1673,7 @@ "id": [ "entryInfoActionExportMetadata", + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -1671,6 +1688,7 @@ ], "it": [ + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -1680,6 +1698,7 @@ "ja": [ "chipActionFilterIn", "entryInfoActionExportMetadata", + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -1693,8 +1712,21 @@ "settingsWidgetDisplayedItem" ], + "ko": [ + "entryInfoActionRemoveLocation" + ], + + "lt": [ + "entryInfoActionRemoveLocation" + ], + + "nb": [ + "entryInfoActionRemoveLocation" + ], + "nl": [ "entryInfoActionExportMetadata", + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -1715,6 +1747,7 @@ "timeDays", "focalLength", "entryInfoActionExportMetadata", + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -2211,6 +2244,7 @@ "pt": [ "entryInfoActionExportMetadata", + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -2225,6 +2259,7 @@ ], "ro": [ + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -2232,6 +2267,7 @@ ], "ru": [ + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -2247,6 +2283,7 @@ "applyButtonLabel", "entryActionShowGeoTiffOnMap", "videoActionCaptureFrame", + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -2612,7 +2649,12 @@ "filePickerUseThisFolder" ], + "tr": [ + "entryInfoActionRemoveLocation" + ], + "zh": [ + "entryInfoActionRemoveLocation", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel",