diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index e62247176..0466552bd 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -815,6 +815,55 @@ abstract class ImageProvider { modifier: FieldMap, callback: ImageOpCallback, ) { + if (modifier.containsKey("exif")) { + val fields = modifier["exif"] as Map<*, *>? + if (fields != null && fields.isNotEmpty()) { + if (!editExif(context, path, uri, mimeType, callback) { exif -> + var setLocation = false + fields.forEach { kv -> + val tag = kv.key as String? + if (tag != null) { + val value = kv.value + if (value == null) { + // remove attribute + exif.setAttribute(tag, value) + } else { + when (tag) { + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF -> { + setLocation = true + } + else -> { + if (value is String) { + exif.setAttribute(tag, value) + } else { + Log.w(LOG_TAG, "failed to set Exif attribute $tag because value=$value is not a string") + } + } + } + } + } + } + if (setLocation) { + val latAbs = (fields[ExifInterface.TAG_GPS_LATITUDE] as Number?)?.toDouble() + val latRef = fields[ExifInterface.TAG_GPS_LATITUDE_REF] as String? + val lngAbs = (fields[ExifInterface.TAG_GPS_LONGITUDE] as Number?)?.toDouble() + val lngRef = fields[ExifInterface.TAG_GPS_LONGITUDE_REF] as String? + if (latAbs != null && latRef != null && lngAbs != null && lngRef != null) { + val latitude = if (latRef == ExifInterface.LATITUDE_SOUTH) -latAbs else latAbs + val longitude = if (lngRef == ExifInterface.LONGITUDE_WEST) -lngAbs else lngAbs + exif.setLatLong(latitude, longitude) + } else { + Log.w(LOG_TAG, "failed to set Exif location with latAbs=$latAbs, latRef=$latRef, lngAbs=$lngAbs, lngRef=$lngRef") + } + } + exif.saveAttributes() + }) return + } + } + if (modifier.containsKey("iptc")) { val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance() if (!editIptc( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d8eeb588b..0407e89cb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -97,6 +97,7 @@ "videoActionSettings": "Settings", "entryInfoActionEditDate": "Edit date & time", + "entryInfoActionEditLocation": "Edit location", "entryInfoActionEditRating": "Edit rating", "entryInfoActionEditTags": "Edit tags", "entryInfoActionRemoveMetadata": "Remove metadata", @@ -315,6 +316,13 @@ "editEntryDateDialogHours": "Hours", "editEntryDateDialogMinutes": "Minutes", + "editEntryLocationDialogTitle": "Location", + "editEntryLocationDialogChooseOnMapTooltip": "Choose on map", + "editEntryLocationDialogLatitude": "Latitude", + "editEntryLocationDialogLongitude": "Longitude", + + "locationPickerUseThisLocationButton": "Use this location", + "editEntryRatingDialogTitle": "Rating", "removeEntryMetadataDialogTitle": "Metadata Removal", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index ffc6ca198..d23c65e02 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; enum EntryInfoAction { // general editDate, + editLocation, editRating, editTags, removeMetadata, @@ -15,6 +16,7 @@ enum EntryInfoAction { class EntryInfoActions { static const all = [ EntryInfoAction.editDate, + EntryInfoAction.editLocation, EntryInfoAction.editRating, EntryInfoAction.editTags, EntryInfoAction.removeMetadata, @@ -28,6 +30,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntryInfoAction.editLocation: + return context.l10n.entryInfoActionEditLocation; case EntryInfoAction.editRating: return context.l10n.entryInfoActionEditRating; case EntryInfoAction.editTags: @@ -49,6 +53,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return AIcons.date; + case EntryInfoAction.editLocation: + return AIcons.location; case EntryInfoAction.editRating: return AIcons.editRating; case EntryInfoAction.editTags: diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 16f5cb237..20ee4573b 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -237,6 +237,8 @@ class AvesEntry { bool get canEditDate => canEdit && (canEditExif || canEditXmp); + bool get canEditLocation => canEdit && canEditExif; + bool get canEditRating => canEdit && canEditXmp; bool get canEditTags => canEdit && canEditXmp; @@ -497,10 +499,13 @@ class AvesEntry { } Future locate({required bool background, required bool force, required Locale geocoderLocale}) async { - if (!hasGps) return; - await _locateCountry(force: force); - if (await availability.canLocatePlaces) { - await locatePlace(background: background, force: force, geocoderLocale: geocoderLocale); + if (hasGps) { + await _locateCountry(force: force); + if (await availability.canLocatePlaces) { + await locatePlace(background: background, force: force, geocoderLocale: geocoderLocale); + } + } else { + addressDetails = null; } } diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index a69300c39..ad14a3923 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -4,12 +4,15 @@ import 'dart:io'; import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/metadata/fields.dart'; +import 'package:aves/ref/exif.dart'; import 'package:aves/ref/iptc.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/metadata/xmp.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/xmp_utils.dart'; import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:xml/xml.dart'; @@ -73,6 +76,40 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { return dataTypes; } + Future> editLocation(LatLng? latLng) async { + final Set dataTypes = {}; + + await _missingDateCheckAndExifEdit(dataTypes); + + // clear every GPS field + final exifFields = Map.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null))); + // add latitude & longitude, if any + if (latLng != null) { + 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, + }); + } + } + + final metadata = { + MetadataType.exif: Map.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.exifInterfaceTag!, kv.value))), + }; + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.catalog, + EntryDataType.address, + }); + } + return dataTypes; + } + Future> _changeOrientation(Future> Function() apply) async { final Set dataTypes = {}; diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 6018b9bfc..73d648463 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -1,4 +1,5 @@ import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/metadata/fields.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -9,7 +10,7 @@ class DateModifier extends Equatable { MetadataField.exifDate, MetadataField.exifDateOriginal, MetadataField.exifDateDigitized, - MetadataField.exifGpsDate, + MetadataField.exifGpsDatestamp, MetadataField.xmpCreateDate, ]; diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 530dc932b..22b20e2db 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -1,10 +1,4 @@ -enum MetadataField { - exifDate, - exifDateOriginal, - exifDateDigitized, - exifGpsDate, - xmpCreateDate, -} +import 'package:aves/model/metadata/fields.dart'; enum DateEditAction { setCustom, @@ -91,35 +85,6 @@ extension ExtraMetadataType on MetadataType { } } -extension ExtraMetadataField on MetadataField { - MetadataType get type { - switch (this) { - case MetadataField.exifDate: - case MetadataField.exifDateOriginal: - case MetadataField.exifDateDigitized: - case MetadataField.exifGpsDate: - return MetadataType.exif; - case MetadataField.xmpCreateDate: - return MetadataType.xmp; - } - } - - String? toExifInterfaceTag() { - switch (this) { - case MetadataField.exifDate: - return 'DateTime'; - case MetadataField.exifDateOriginal: - return 'DateTimeOriginal'; - case MetadataField.exifDateDigitized: - return 'DateTimeDigitized'; - case MetadataField.exifGpsDate: - return 'GPSDateStamp'; - case MetadataField.xmpCreateDate: - return null; - } - } -} - extension ExtraDateFieldSource on DateFieldSource { MetadataField? toMetadataField() { switch (this) { @@ -132,7 +97,7 @@ extension ExtraDateFieldSource on DateFieldSource { case DateFieldSource.exifDateDigitized: return MetadataField.exifDateDigitized; case DateFieldSource.exifGpsDate: - return MetadataField.exifGpsDate; + return MetadataField.exifGpsDatestamp; } } -} +} \ No newline at end of file diff --git a/lib/model/metadata/fields.dart b/lib/model/metadata/fields.dart new file mode 100644 index 000000000..040e5eaaa --- /dev/null +++ b/lib/model/metadata/fields.dart @@ -0,0 +1,199 @@ +import 'package:aves/model/metadata/enums.dart'; + +enum MetadataField { + exifDate, + exifDateOriginal, + exifDateDigitized, + exifGpsAltitude, + exifGpsAltitudeRef, + exifGpsAreaInformation, + exifGpsDatestamp, + exifGpsDestBearing, + exifGpsDestBearingRef, + exifGpsDestDistance, + exifGpsDestDistanceRef, + exifGpsDestLatitude, + exifGpsDestLatitudeRef, + exifGpsDestLongitude, + exifGpsDestLongitudeRef, + exifGpsDifferential, + exifGpsDOP, + exifGpsHPositioningError, + exifGpsImgDirection, + exifGpsImgDirectionRef, + exifGpsLatitude, + exifGpsLatitudeRef, + exifGpsLongitude, + exifGpsLongitudeRef, + exifGpsMapDatum, + exifGpsMeasureMode, + exifGpsProcessingMethod, + exifGpsSatellites, + exifGpsSpeed, + exifGpsSpeedRef, + exifGpsStatus, + exifGpsTimestamp, + exifGpsTrack, + exifGpsTrackRef, + exifGpsVersionId, + xmpCreateDate, +} + +class MetadataFields { + static const Set exifGpsFields = { + MetadataField.exifGpsAltitude, + MetadataField.exifGpsAltitudeRef, + MetadataField.exifGpsAreaInformation, + MetadataField.exifGpsDatestamp, + MetadataField.exifGpsDestBearing, + MetadataField.exifGpsDestBearingRef, + MetadataField.exifGpsDestDistance, + MetadataField.exifGpsDestDistanceRef, + MetadataField.exifGpsDestLatitude, + MetadataField.exifGpsDestLatitudeRef, + MetadataField.exifGpsDestLongitude, + MetadataField.exifGpsDestLongitudeRef, + MetadataField.exifGpsDifferential, + MetadataField.exifGpsDOP, + MetadataField.exifGpsHPositioningError, + MetadataField.exifGpsImgDirection, + MetadataField.exifGpsImgDirectionRef, + MetadataField.exifGpsLatitude, + MetadataField.exifGpsLatitudeRef, + MetadataField.exifGpsLongitude, + MetadataField.exifGpsLongitudeRef, + MetadataField.exifGpsMapDatum, + MetadataField.exifGpsMeasureMode, + MetadataField.exifGpsProcessingMethod, + MetadataField.exifGpsSatellites, + MetadataField.exifGpsSpeed, + MetadataField.exifGpsSpeedRef, + MetadataField.exifGpsStatus, + MetadataField.exifGpsTimestamp, + MetadataField.exifGpsTrack, + MetadataField.exifGpsTrackRef, + MetadataField.exifGpsVersionId, + }; +} + +extension ExtraMetadataField on MetadataField { + MetadataType get type { + switch (this) { + case MetadataField.exifDate: + case MetadataField.exifDateOriginal: + case MetadataField.exifDateDigitized: + case MetadataField.exifGpsAltitude: + case MetadataField.exifGpsAltitudeRef: + case MetadataField.exifGpsAreaInformation: + case MetadataField.exifGpsDatestamp: + case MetadataField.exifGpsDestBearing: + case MetadataField.exifGpsDestBearingRef: + case MetadataField.exifGpsDestDistance: + case MetadataField.exifGpsDestDistanceRef: + case MetadataField.exifGpsDestLatitude: + case MetadataField.exifGpsDestLatitudeRef: + case MetadataField.exifGpsDestLongitude: + case MetadataField.exifGpsDestLongitudeRef: + case MetadataField.exifGpsDifferential: + case MetadataField.exifGpsDOP: + case MetadataField.exifGpsHPositioningError: + case MetadataField.exifGpsImgDirection: + case MetadataField.exifGpsImgDirectionRef: + case MetadataField.exifGpsLatitude: + case MetadataField.exifGpsLatitudeRef: + case MetadataField.exifGpsLongitude: + case MetadataField.exifGpsLongitudeRef: + case MetadataField.exifGpsMapDatum: + case MetadataField.exifGpsMeasureMode: + case MetadataField.exifGpsProcessingMethod: + case MetadataField.exifGpsSatellites: + case MetadataField.exifGpsSpeed: + case MetadataField.exifGpsSpeedRef: + case MetadataField.exifGpsStatus: + case MetadataField.exifGpsTimestamp: + case MetadataField.exifGpsTrack: + case MetadataField.exifGpsTrackRef: + case MetadataField.exifGpsVersionId: + return MetadataType.exif; + case MetadataField.xmpCreateDate: + return MetadataType.xmp; + } + } + + String? get exifInterfaceTag { + switch (this) { + case MetadataField.exifDate: + return 'DateTime'; + case MetadataField.exifDateOriginal: + return 'DateTimeOriginal'; + case MetadataField.exifDateDigitized: + return 'DateTimeDigitized'; + case MetadataField.exifGpsAltitude: + return 'GPSAltitude'; + case MetadataField.exifGpsAltitudeRef: + return 'GPSAltitudeRef'; + case MetadataField.exifGpsAreaInformation: + return 'GPSAreaInformation'; + case MetadataField.exifGpsDatestamp: + return 'GPSDateStamp'; + case MetadataField.exifGpsDestBearing: + return 'GPSDestBearing'; + case MetadataField.exifGpsDestBearingRef: + return 'GPSDestBearingRef'; + case MetadataField.exifGpsDestDistance: + return 'GPSDestDistance'; + case MetadataField.exifGpsDestDistanceRef: + return 'GPSDestDistanceRef'; + case MetadataField.exifGpsDestLatitude: + return 'GPSDestLatitude'; + case MetadataField.exifGpsDestLatitudeRef: + return 'GPSDestLatitudeRef'; + case MetadataField.exifGpsDestLongitude: + return 'GPSDestLongitude'; + case MetadataField.exifGpsDestLongitudeRef: + return 'GPSDestLongitudeRef'; + case MetadataField.exifGpsDifferential: + return 'GPSDifferential'; + case MetadataField.exifGpsDOP: + return 'GPSDOP'; + case MetadataField.exifGpsHPositioningError: + return 'GPSHPositioningError'; + case MetadataField.exifGpsImgDirection: + return 'GPSImgDirection'; + case MetadataField.exifGpsImgDirectionRef: + return 'GPSImgDirectionRef'; + case MetadataField.exifGpsLatitude: + return 'GPSLatitude'; + case MetadataField.exifGpsLatitudeRef: + return 'GPSLatitudeRef'; + case MetadataField.exifGpsLongitude: + return 'GPSLongitude'; + case MetadataField.exifGpsLongitudeRef: + return 'GPSLongitudeRef'; + case MetadataField.exifGpsMapDatum: + return 'GPSMapDatum'; + case MetadataField.exifGpsMeasureMode: + return 'GPSMeasureMode'; + case MetadataField.exifGpsProcessingMethod: + return 'GPSProcessingMethod'; + case MetadataField.exifGpsSatellites: + return 'GPSSatellites'; + case MetadataField.exifGpsSpeed: + return 'GPSSpeed'; + case MetadataField.exifGpsSpeedRef: + return 'GPSSpeedRef'; + case MetadataField.exifGpsStatus: + return 'GPSStatus'; + case MetadataField.exifGpsTimestamp: + return 'GPSTimeStamp'; + case MetadataField.exifGpsTrack: + return 'GPSTrack'; + case MetadataField.exifGpsTrackRef: + return 'GPSTrackRef'; + case MetadataField.exifGpsVersionId: + return 'GPSVersionID'; + case MetadataField.xmpCreateDate: + return null; + } + } +} diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 64bed85ae..25fe1eb68 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -146,9 +146,14 @@ mixin AlbumMixin on SourceBase { _filterEntryCountMap.clear(); _filterRecentEntryMap.clear(); } else { - directories ??= entries!.map((entry) => entry.directory).toSet(); - directories.forEach(_filterEntryCountMap.remove); - directories.forEach(_filterRecentEntryMap.remove); + directories ??= {}; + if (entries != null) { + directories.addAll(entries.map((entry) => entry.directory).whereNotNull()); + } + directories.forEach((directory) { + _filterEntryCountMap.remove(directory); + _filterRecentEntryMap.remove(directory); + }); } eventBus.fire(AlbumSummaryInvalidatedEvent(directories)); } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 75ca4ada2..4c1591ba3 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -84,8 +84,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM _visibleEntries = null; _sortedEntriesByDate = null; invalidateAlbumFilterSummary(entries: entries); - invalidateCountryFilterSummary(entries); - invalidateTagFilterSummary(entries); + invalidateCountryFilterSummary(entries: entries); + invalidateTagFilterSummary(entries: entries); } void updateDerivedFilters([Set? entries]) { @@ -292,6 +292,18 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future refreshEntry(AvesEntry entry, Set dataTypes) async { await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); + + // update/delete in DB + final contentId = entry.contentId!; + if (dataTypes.contains(EntryDataType.catalog)) { + await metadataDb.updateMetadataId(contentId, entry.catalogMetadata); + onCatalogMetadataChanged(); + } + if (dataTypes.contains(EntryDataType.address)) { + await metadataDb.updateAddressId(contentId, entry.addressDetails); + onAddressMetadataChanged(); + } + updateDerivedFilters({entry}); eventBus.fire(EntryRefreshedEvent({entry})); } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index e150ceb9a..eff0c4625 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -176,16 +176,21 @@ mixin LocationMixin on SourceBase { final Map _filterEntryCountMap = {}; final Map _filterRecentEntryMap = {}; - void invalidateCountryFilterSummary([Set? entries]) { + void invalidateCountryFilterSummary({Set? entries, Set? countryCodes}) { if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return; - Set? countryCodes; - if (entries == null) { + if (entries == null && countryCodes == null) { _filterEntryCountMap.clear(); _filterRecentEntryMap.clear(); } else { - countryCodes = entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull().toSet(); - countryCodes.forEach(_filterEntryCountMap.remove); + countryCodes ??= {}; + if (entries != null) { + countryCodes.addAll(entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull()); + } + countryCodes.forEach((countryCode) { + _filterEntryCountMap.remove(countryCode); + _filterRecentEntryMap.remove(countryCode); + }); } eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes)); } diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 121371d63..16583cc2a 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -77,16 +77,21 @@ mixin TagMixin on SourceBase { final Map _filterEntryCountMap = {}; final Map _filterRecentEntryMap = {}; - void invalidateTagFilterSummary([Set? entries]) { + void invalidateTagFilterSummary({Set? entries, Set? tags}) { if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return; - Set? tags; - if (entries == null) { + if (entries == null && tags == null) { _filterEntryCountMap.clear(); _filterRecentEntryMap.clear(); } else { - tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags).toSet(); - tags.forEach(_filterEntryCountMap.remove); + tags ??= {}; + if (entries != null) { + tags.addAll(entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags)); + } + tags.forEach((tag) { + _filterEntryCountMap.remove(tag); + _filterRecentEntryMap.remove(tag); + }); } eventBus.fire(TagSummaryInvalidatedEvent(tags)); } diff --git a/lib/ref/exif.dart b/lib/ref/exif.dart index 26fa9bb6d..cd3ecad91 100644 --- a/lib/ref/exif.dart +++ b/lib/ref/exif.dart @@ -1,4 +1,11 @@ class Exif { + // constants used by GPS related Exif tags + // they are locale independent + static const String latitudeNorth = 'N'; + static const String latitudeSouth = 'S'; + static const String longitudeEast = 'E'; + static const String longitudeWest = 'W'; + static String getColorSpaceDescription(String valueString) { final value = int.tryParse(valueString); if (value == null) return valueString; diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 164b5d7e9..1c40637cd 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/metadata/fields.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; @@ -77,7 +78,7 @@ class PlatformMetadataEditService implements MetadataEditService { 'entry': _toPlatformEntryMap(entry), 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch, 'shiftMinutes': modifier.shiftMinutes, - 'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.toExifInterfaceTag()).whereNotNull().toList(), + 'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.exifInterfaceTag).whereNotNull().toList(), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index c483486b2..40e6143bf 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -1,6 +1,6 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/catalog.dart'; -import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/metadata/fields.dart'; import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/panorama.dart'; @@ -236,7 +236,7 @@ class PlatformMetadataFetchService implements MetadataFetchService { 'mimeType': entry.mimeType, 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, - 'field': field.toExifInterfaceTag(), + 'field': field.exifInterfaceTag, }); if (result is int) { return dateTimeFromMillis(result, isUtc: false); diff --git a/lib/widgets/about/bug_report.dart b/lib/widgets/about/bug_report.dart index e2112718e..978123c10 100644 --- a/lib/widgets/about/bug_report.dart +++ b/lib/widgets/about/bug_report.dart @@ -29,6 +29,7 @@ class BugReport extends StatefulWidget { } class _BugReportState extends State with FeedbackMixin { + final ScrollController _infoScrollController = ScrollController(); late Future _infoLoader; bool _showInstructions = false; @@ -92,8 +93,14 @@ class _BugReportState extends State with FeedbackMixin { ), ), child: Scrollbar( - child: Padding( - padding: const EdgeInsetsDirectional.only(start: 8, end: 16), + // when using `Scrollbar.isAlwaysShown`, a controller must be provided + // and used by both the `Scrollbar` and the `Scrollable`, but + // as of Flutter v2.8.1, `SelectableText` does not allow passing the `scrollController` + // so we wrap it in a `SingleChildScrollView` + controller: _infoScrollController, + child: SingleChildScrollView( + padding: const EdgeInsetsDirectional.only(start: 8, top: 4, end: 16, bottom: 4), + controller: _infoScrollController, child: SelectableText(info), ), ), @@ -136,7 +143,7 @@ class _BugReportState extends State with FeedbackMixin { AvesOutlinedButton( label: buttonText, onPressed: onPressed, - ) + ), ], ), ); diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 9b84e7d32..6e67e4865 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -1,14 +1,17 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_date_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_location_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_rating_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_tags_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/remove_metadata_dialog.dart'; import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; mixin EntryEditorMixin { Future selectDateModifier(BuildContext context, Set entries) async { @@ -23,10 +26,23 @@ mixin EntryEditorMixin { return modifier; } + Future selectLocation(BuildContext context, Set entries, CollectionLens? collection) async { + if (entries.isEmpty) return null; + + final location = await showDialog( + context: context, + builder: (context) => EditEntryLocationDialog( + entry: entries.first, + collection: collection, + ), + ); + return location; + } + Future selectRating(BuildContext context, Set entries) async { if (entries.isEmpty) return null; - final rating = await showDialog( + final rating = await showDialog( context: context, builder: (context) => EditEntryRatingDialog( entry: entries.first, diff --git a/lib/widgets/common/map/controller.dart b/lib/widgets/common/map/controller.dart index d47cd92d1..4bb280bb2 100644 --- a/lib/widgets/common/map/controller.dart +++ b/lib/widgets/common/map/controller.dart @@ -12,6 +12,8 @@ class AvesMapController { Stream get idleUpdates => _events.where((event) => event is MapIdleUpdate).cast(); + Stream get markerLocationChanges => _events.where((event) => event is MapMarkerLocationChangeEvent).cast(); + void dispose() { _streamController.close(); } @@ -19,6 +21,8 @@ class AvesMapController { void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng)); void notifyIdle(ZoomedBounds bounds) => _streamController.add(MapIdleUpdate(bounds)); + + void notifyMarkerLocationChange() => _streamController.add(MapMarkerLocationChangeEvent()); } class MapControllerMoveEvent { @@ -32,3 +36,5 @@ class MapIdleUpdate { MapIdleUpdate(this.bounds); } + +class MapMarkerLocationChangeEvent {} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 5d52b8f29..f52cae77e 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:aves/model/entry.dart'; @@ -23,17 +24,18 @@ import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:fluster/fluster.dart'; import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; class GeoMap extends StatefulWidget { final AvesMapController? controller; final Listenable? collectionListenable; final List entries; - final AvesEntry? initialEntry; + final LatLng? initialCenter; final ValueNotifier isAnimatingNotifier; - final ValueNotifier? dotEntryNotifier; + final ValueNotifier? dotLocationNotifier; final UserZoomChangeCallback? onUserZoomChange; - final VoidCallback? onMapTap; + final void Function(LatLng location)? onMapTap; final MarkerTapCallback? onMarkerTap; final MapOpener? openMapPage; @@ -45,9 +47,9 @@ class GeoMap extends StatefulWidget { this.controller, this.collectionListenable, required this.entries, - this.initialEntry, + this.initialCenter, required this.isAnimatingNotifier, - this.dotEntryNotifier, + this.dotLocationNotifier, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, @@ -59,6 +61,8 @@ class GeoMap extends StatefulWidget { } class _GeoMapState extends State { + final List _subscriptions = []; + // as of google_maps_flutter v2.0.6, Google map initialization is blocking // cf https://github.com/flutter/flutter/issues/28493 // it is especially severe the first time, but still significant afterwards @@ -78,15 +82,7 @@ class _GeoMapState extends State { @override void initState() { super.initState(); - final initialEntry = widget.initialEntry; - final points = (initialEntry != null ? [initialEntry] : entries).map((v) => v.latLng!).toSet(); - final bounds = ZoomedBounds.fromPoints( - points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, - collocationZoom: settings.infoMapZoom, - ); - _boundsNotifier = ValueNotifier(bounds.copyWith( - zoom: max(bounds.zoom, minInitialZoom), - )); + _boundsNotifier = ValueNotifier(_initBounds()); _registerWidget(widget); _onCollectionChanged(); } @@ -106,10 +102,17 @@ class _GeoMapState extends State { void _registerWidget(GeoMap widget) { widget.collectionListenable?.addListener(_onCollectionChanged); + final controller = widget.controller; + if (controller != null) { + _subscriptions.add(controller.markerLocationChanges.listen((event) => _onCollectionChanged())); + } } void _unregisterWidget(GeoMap widget) { widget.collectionListenable?.removeListener(_onCollectionChanged); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); } @override @@ -119,6 +122,16 @@ class _GeoMapState extends State { if (onTap == null) return; final clusterId = geoEntry.clusterId; + AvesEntry? markerEntry; + if (clusterId != null) { + final uri = geoEntry.childMarkerId; + markerEntry = entries.firstWhereOrNull((v) => v.uri == uri); + } else { + markerEntry = geoEntry.entry; + } + + if (markerEntry == null) return; + Set getClusterEntries() { if (clusterId == null) { return {geoEntry.entry!}; @@ -135,17 +148,8 @@ class _GeoMapState extends State { return points.map((geoEntry) => geoEntry.entry!).toSet(); } - AvesEntry? markerEntry; - if (clusterId != null) { - final uri = geoEntry.childMarkerId; - markerEntry = entries.firstWhereOrNull((v) => v.uri == uri); - } else { - markerEntry = geoEntry.entry; - } - - if (markerEntry != null) { - onTap(markerEntry, getClusterEntries); - } + final clusterAverageLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!); + onTap(clusterAverageLocation, markerEntry, getClusterEntries); } return FutureBuilder( @@ -176,7 +180,7 @@ class _GeoMapState extends State { style: mapStyle, markerClusterBuilder: _buildMarkerClusters, markerWidgetBuilder: _buildMarkerWidget, - dotEntryNotifier: widget.dotEntryNotifier, + dotLocationNotifier: widget.dotLocationNotifier, onUserZoomChange: widget.onUserZoomChange, onMapTap: widget.onMapTap, onMarkerTap: _onMarkerTap, @@ -191,7 +195,7 @@ class _GeoMapState extends State { style: mapStyle, markerClusterBuilder: _buildMarkerClusters, markerWidgetBuilder: _buildMarkerWidget, - dotEntryNotifier: widget.dotEntryNotifier, + dotLocationNotifier: widget.dotLocationNotifier, markerSize: Size( GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2, GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.markerArrowSize.height, @@ -264,6 +268,18 @@ class _GeoMapState extends State { ); } + ZoomedBounds _initBounds() { + final initialCenter = widget.initialCenter; + final points = initialCenter != null ? {initialCenter} : entries.map((v) => v.latLng!).toSet(); + final bounds = ZoomedBounds.fromPoints( + points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, + collocationZoom: settings.infoMapZoom, + ); + return bounds.copyWith( + zoom: max(bounds.zoom, minInitialZoom), + ); + } + void _onCollectionChanged() { _defaultMarkerCluster = _buildFluster(); _slowMarkerCluster = null; @@ -328,4 +344,4 @@ class MarkerKey extends LocalKey with EquatableMixin { typedef MarkerClusterBuilder = Map Function(); typedef MarkerWidgetBuilder = Widget Function(MarkerKey key); typedef UserZoomChangeCallback = void Function(double zoom); -typedef MarkerTapCallback = void Function(AvesEntry markerEntry, Set Function() getClusterEntries); +typedef MarkerTapCallback = void Function(LatLng averageLocation, AvesEntry markerEntry, Set Function() getClusterEntries); diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index 83f631abd..c00462773 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/utils/change_notifier.dart'; @@ -27,9 +26,9 @@ class EntryGoogleMap extends StatefulWidget { final EntryMapStyle style; final MarkerClusterBuilder markerClusterBuilder; final MarkerWidgetBuilder markerWidgetBuilder; - final ValueNotifier? dotEntryNotifier; + final ValueNotifier? dotLocationNotifier; final UserZoomChangeCallback? onUserZoomChange; - final VoidCallback? onMapTap; + final void Function(ll.LatLng location)? onMapTap; final void Function(GeoEntry geoEntry)? onMarkerTap; final MapOpener? openMapPage; @@ -43,7 +42,7 @@ class EntryGoogleMap extends StatefulWidget { required this.style, required this.markerClusterBuilder, required this.markerWidgetBuilder, - required this.dotEntryNotifier, + required this.dotLocationNotifier, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, @@ -170,13 +169,13 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse }); final interactive = context.select((v) => v.interactive); - return ValueListenableBuilder( - valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null), - builder: (context, dotEntry, child) { + return ValueListenableBuilder( + valueListenable: widget.dotLocationNotifier ?? ValueNotifier(null), + builder: (context, dotLocation, child) { return GoogleMap( initialCameraPosition: CameraPosition( bearing: -bounds.rotation, - target: _toGoogleLatLng(bounds.center), + target: _toGoogleLatLng(bounds.projectedCenter), zoom: bounds.zoom, ), onMapCreated: (controller) async { @@ -205,19 +204,19 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse myLocationButtonEnabled: false, markers: { ...markers, - if (dotEntry != null && _dotMarkerBitmap != null) + if (dotLocation != null && _dotMarkerBitmap != null) Marker( markerId: const MarkerId('dot'), anchor: const Offset(.5, .5), consumeTapEvents: true, icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!), - position: _toGoogleLatLng(dotEntry.latLng!), + position: _toGoogleLatLng(dotLocation), zIndex: 1, ) }, onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), onCameraIdle: _onIdle, - onTap: (position) => widget.onMapTap?.call(), + onTap: (position) => widget.onMapTap?.call(_fromGoogleLatLng(position)), ); }, ); @@ -243,8 +242,8 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse final sw = bounds.southwest; final ne = bounds.northeast; boundsNotifier.value = ZoomedBounds( - sw: ll.LatLng(sw.latitude, sw.longitude), - ne: ll.LatLng(ne.latitude, ne.longitude), + sw: _fromGoogleLatLng(sw), + ne: _fromGoogleLatLng(ne), zoom: zoom, rotation: rotation, ); @@ -262,7 +261,7 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse if (controller == null) return; await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition( - target: _toGoogleLatLng(bounds.center), + target: _toGoogleLatLng(bounds.projectedCenter), zoom: bounds.zoom, ))); } @@ -283,7 +282,9 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse } // `LatLng` used by `google_maps_flutter` is not the one from `latlong2` package - LatLng _toGoogleLatLng(ll.LatLng latLng) => LatLng(latLng.latitude, latLng.longitude); + LatLng _toGoogleLatLng(ll.LatLng location) => LatLng(location.latitude, location.longitude); + + ll.LatLng _fromGoogleLatLng(LatLng location) => ll.LatLng(location.latitude, location.longitude); MapType _toMapType(EntryMapStyle style) { switch (style) { diff --git a/lib/widgets/common/map/google/marker_generator.dart b/lib/widgets/common/map/google/marker_generator.dart index b296d0575..ded1b979c 100644 --- a/lib/widgets/common/map/google/marker_generator.dart +++ b/lib/widgets/common/map/google/marker_generator.dart @@ -107,9 +107,14 @@ class _MarkerGeneratorItem { state = MarkerGeneratorItemState.rendering; final boundary = _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary; if (boundary.hasSize && boundary.size != Size.zero) { - final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - bytes = byteData?.buffer.asUint8List(); + try { + final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + bytes = byteData?.buffer.asUint8List(); + } catch (error) { + // happens when widget is offscreen + debugPrint('failed to render image for key=$_globalKey with error=$error'); + } } state = bytes != null ? MarkerGeneratorItemState.done : MarkerGeneratorItemState.waiting; } diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index cfdcb0eb5..f04a5d464 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; @@ -29,10 +28,10 @@ class EntryLeafletMap extends StatefulWidget { final EntryMapStyle style; final MarkerClusterBuilder markerClusterBuilder; final MarkerWidgetBuilder markerWidgetBuilder; - final ValueNotifier? dotEntryNotifier; + final ValueNotifier? dotLocationNotifier; final Size markerSize, dotMarkerSize; final UserZoomChangeCallback? onUserZoomChange; - final VoidCallback? onMapTap; + final void Function(LatLng location)? onMapTap; final void Function(GeoEntry geoEntry)? onMarkerTap; final MapOpener? openMapPage; @@ -46,7 +45,7 @@ class EntryLeafletMap extends StatefulWidget { required this.style, required this.markerClusterBuilder, required this.markerWidgetBuilder, - required this.dotEntryNotifier, + required this.dotLocationNotifier, required this.markerSize, required this.dotMarkerSize, this.onUserZoomChange, @@ -154,7 +153,7 @@ class _EntryLeafletMapState extends State with TickerProviderSt return FlutterMap( options: MapOptions( - center: bounds.center, + center: bounds.projectedCenter, zoom: bounds.zoom, rotation: bounds.rotation, minZoom: widget.minZoom, @@ -162,7 +161,7 @@ class _EntryLeafletMapState extends State with TickerProviderSt // TODO TLAD [map] as of flutter_map v0.14.0, `doubleTapZoom` does not move when zoom is already maximal // this could be worked around with https://github.com/fleaflet/flutter_map/pull/960 interactiveFlags: interactive ? InteractiveFlag.all : InteractiveFlag.none, - onTap: (tapPosition, point) => widget.onMapTap?.call(), + onTap: (tapPosition, point) => widget.onMapTap?.call(point), controller: _leafletMapController, ), mapController: _leafletMapController, @@ -182,14 +181,14 @@ class _EntryLeafletMapState extends State with TickerProviderSt rotateAlignment: Alignment.bottomCenter, ), ), - ValueListenableBuilder( - valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null), - builder: (context, dotEntry, child) => MarkerLayerWidget( + ValueListenableBuilder( + valueListenable: widget.dotLocationNotifier ?? ValueNotifier(null), + builder: (context, dotLocation, child) => MarkerLayerWidget( options: MarkerLayerOptions( markers: [ - if (dotEntry != null) + if (dotLocation != null) Marker( - point: dotEntry.latLng!, + point: dotLocation, builder: (context) => const DotMarker(), width: dotMarkerSize.width, height: dotMarkerSize.height, diff --git a/lib/widgets/common/map/zoomed_bounds.dart b/lib/widgets/common/map/zoomed_bounds.dart index db65719c9..b0d375145 100644 --- a/lib/widgets/common/map/zoomed_bounds.dart +++ b/lib/widgets/common/map/zoomed_bounds.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:aves/utils/geo_utils.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @immutable @@ -13,7 +14,17 @@ class ZoomedBounds extends Equatable { // returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster List get boundingBox => [sw.longitude, sw.latitude, ne.longitude, ne.latitude]; - LatLng get center => GeoUtils.getLatLngCenter([sw, ne]); + // Map services (Google Maps, OpenStreetMap) use the spherical Mercator projection (EPSG 3857). + static const _crs = Epsg3857(); + + // The projected center appears visually in the middle of the bounds. + LatLng get projectedCenter { + final swPoint = _crs.latLngToPoint(sw, zoom); + final nePoint = _crs.latLngToPoint(ne, zoom); + // assume no padding around bounds + final projectedCenter = _crs.pointToLatLng((swPoint + nePoint) / 2, zoom); + return projectedCenter ?? GeoUtils.getLatLngCenter([sw, ne]); + } @override List get props => [sw, ne, zoom, rotation]; diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart similarity index 98% rename from lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart rename to lib/widgets/dialogs/entry_editors/edit_date_dialog.dart index cb4e5657b..95145b337 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/metadata/fields.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; @@ -304,10 +305,12 @@ class _EditEntryDateDialogState extends State { return 'Exif original date'; case MetadataField.exifDateDigitized: return 'Exif digitized date'; - case MetadataField.exifGpsDate: + case MetadataField.exifGpsDatestamp: return 'Exif GPS date'; case MetadataField.xmpCreateDate: return 'XMP xmp:CreateDate'; + default: + return field.name; } } diff --git a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart new file mode 100644 index 000000000..f7fdcc1ab --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart @@ -0,0 +1,231 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/location_pick_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:latlong2/latlong.dart'; + +class EditEntryLocationDialog extends StatefulWidget { + final AvesEntry entry; + final CollectionLens? collection; + + const EditEntryLocationDialog({ + Key? key, + required this.entry, + this.collection, + }) : super(key: key); + + @override + _EditEntryLocationDialogState createState() => _EditEntryLocationDialogState(); +} + +class _EditEntryLocationDialogState extends State { + _LocationAction _action = _LocationAction.set; + final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController(); + final FocusNode _latitudeFocusNode = FocusNode(), _longitudeFocusNode = FocusNode(); + final ValueNotifier _isValidNotifier = ValueNotifier(false); + + static const _coordinateFormat = '0.000000'; + + @override + void initState() { + super.initState(); + _latitudeFocusNode.addListener(_onLatLngFocusChange); + _longitudeFocusNode.addListener(_onLatLngFocusChange); + WidgetsBinding.instance!.addPostFrameCallback((_) => _setLocation(context, widget.entry.latLng)); + } + + @override + void dispose() { + _latitudeFocusNode.removeListener(_onLatLngFocusChange); + _longitudeFocusNode.removeListener(_onLatLngFocusChange); + _latitudeController.dispose(); + _longitudeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Builder(builder: (context) { + final l10n = context.l10n; + + return AvesDialog( + title: l10n.editEntryLocationDialogTitle, + scrollableContent: [ + RadioListTile<_LocationAction>( + value: _LocationAction.set, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _validate(); + }), + title: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + TextField( + controller: _latitudeController, + focusNode: _latitudeFocusNode, + decoration: InputDecoration( + labelText: context.l10n.editEntryLocationDialogLatitude, + ), + onChanged: (_) => _validate(), + ), + TextField( + controller: _longitudeController, + focusNode: _longitudeFocusNode, + decoration: InputDecoration( + labelText: context.l10n.editEntryLocationDialogLongitude, + ), + onChanged: (_) => _validate(), + ), + ], + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: IconButton( + icon: const Icon(AIcons.map), + onPressed: _pickLocation, + tooltip: l10n.editEntryLocationDialogChooseOnMapTooltip, + ), + ), + ], + ), + contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 8), + ), + RadioListTile<_LocationAction>( + value: _LocationAction.remove, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _latitudeFocusNode.unfocus(); + _longitudeFocusNode.unfocus(); + _validate(); + }), + title: Text(l10n.actionRemove), + ), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + ValueListenableBuilder( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return TextButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text(context.l10n.applyButtonLabel), + ); + }, + ), + ], + ); + }), + ), + ); + } + + void _onLatLngFocusChange() { + if (_latitudeFocusNode.hasFocus || _longitudeFocusNode.hasFocus) { + setState(() { + _action = _LocationAction.set; + _validate(); + }); + } + } + + void _setLocation(BuildContext context, LatLng? latLng) { + final locale = context.l10n.localeName; + final formatter = NumberFormat(_coordinateFormat, locale); + _latitudeController.text = latLng != null ? formatter.format(latLng.latitude) : ''; + _longitudeController.text = latLng != null ? formatter.format(latLng.longitude) : ''; + setState(() { + _action = _LocationAction.set; + _validate(); + }); + } + + Future _pickLocation() async { + final latLng = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: LocationPickDialog.routeName), + builder: (context) { + final baseCollection = widget.collection; + final mapCollection = baseCollection != null + ? CollectionLens( + source: baseCollection.source, + filters: baseCollection.filters, + fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(), + ) + : null; + return LocationPickDialog( + collection: mapCollection, + initialLocation: _parseLatLng(), + ); + }, + fullscreenDialog: true, + ), + ); + if (latLng != null) { + _setLocation(context, latLng); + } + } + + LatLng? _parseLatLng() { + final locale = context.l10n.localeName; + final formatter = NumberFormat(_coordinateFormat, locale); + double? tryParse(String text) { + try { + return double.tryParse(text) ?? (formatter.parse(text).toDouble()); + } catch (e) { + // ignore + return null; + } + } + + final lat = tryParse(_latitudeController.text); + final lng = tryParse(_longitudeController.text); + if (lat == null || lng == null) return null; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; + return LatLng(lat, lng); + } + + Future _validate() async { + switch (_action) { + case _LocationAction.set: + _isValidNotifier.value = _parseLatLng() != null; + break; + case _LocationAction.remove: + _isValidNotifier.value = true; + break; + } + } + + void _submit(BuildContext context) { + switch (_action) { + case _LocationAction.set: + Navigator.pop(context, _parseLatLng()); + break; + case _LocationAction.remove: + Navigator.pop(context, LatLng(0, 0)); + break; + } + } +} + +enum _LocationAction { set, remove } diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_rating_dialog.dart similarity index 100% rename from lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart rename to lib/widgets/dialogs/entry_editors/edit_rating_dialog.dart diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_tags_dialog.dart similarity index 100% rename from lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart rename to lib/widgets/dialogs/entry_editors/edit_tags_dialog.dart diff --git a/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart similarity index 100% rename from lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart rename to lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_dialog.dart similarity index 99% rename from lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart rename to lib/widgets/dialogs/entry_editors/rename_dialog.dart index 03c72304f..f50b32e13 100644 --- a/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/rename_dialog.dart @@ -70,7 +70,7 @@ class _RenameEntryDialogState extends State { child: Text(context.l10n.applyButtonLabel), ); }, - ) + ), ], ); } diff --git a/lib/widgets/dialogs/location_pick_dialog.dart b/lib/widgets/dialogs/location_pick_dialog.dart new file mode 100644 index 000000000..ecdd98f5d --- /dev/null +++ b/lib/widgets/dialogs/location_pick_dialog.dart @@ -0,0 +1,335 @@ +import 'dart:async'; + +import 'package:aves/model/settings/coordinate_format.dart'; +import 'package:aves/model/settings/map_style.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/geocoding_service.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/buttons.dart'; +import 'package:aves/widgets/common/map/controller.dart'; +import 'package:aves/widgets/common/map/geo_map.dart'; +import 'package:aves/widgets/common/map/marker.dart'; +import 'package:aves/widgets/common/map/theme.dart'; +import 'package:aves/widgets/common/map/zoomed_bounds.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:decorated_icon/decorated_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +class LocationPickDialog extends StatelessWidget { + static const routeName = '/location_pick'; + + final CollectionLens? collection; + final LatLng? initialLocation; + + const LocationPickDialog({ + Key? key, + required this.collection, + required this.initialLocation, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: Scaffold( + body: SafeArea( + left: false, + top: false, + right: false, + bottom: true, + child: _Content( + collection: collection, + initialLocation: initialLocation, + ), + ), + ), + ); + } +} + +class _Content extends StatefulWidget { + final CollectionLens? collection; + final LatLng? initialLocation; + + const _Content({ + Key? key, + required this.collection, + required this.initialLocation, + }) : super(key: key); + + @override + _ContentState createState() => _ContentState(); +} + +class _ContentState extends State<_Content> with SingleTickerProviderStateMixin { + final List _subscriptions = []; + final AvesMapController _mapController = AvesMapController(); + late final ValueNotifier _isPageAnimatingNotifier; + final ValueNotifier _dotLocationNotifier = ValueNotifier(null), _infoLocationNotifier = ValueNotifier(null); + final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); + + CollectionLens? get openingCollection => widget.collection; + + @override + void initState() { + super.initState(); + + if (settings.infoMapStyle.isGoogleMaps) { + _isPageAnimatingNotifier = ValueNotifier(true); + Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) { + if (!mounted) return; + _isPageAnimatingNotifier.value = false; + }); + } else { + _isPageAnimatingNotifier = ValueNotifier(false); + } + + _dotLocationNotifier.addListener(_updateLocationInfo); + + _subscriptions.add(_mapController.idleUpdates.listen((event) => _onIdle(event.bounds))); + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + _dotLocationNotifier.removeListener(_updateLocationInfo); + _mapController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded(child: _buildMap()), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + const Divider(height: 0), + SafeArea( + top: false, + bottom: false, + child: _LocationInfo(locationNotifier: _infoLocationNotifier), + ), + const SizedBox(height: 8), + AvesOutlinedButton( + label: context.l10n.locationPickerUseThisLocationButton, + onPressed: () => Navigator.pop(context, _dotLocationNotifier.value), + ), + ], + ), + ], + ); + } + + Widget _buildMap() { + return MapTheme( + interactive: true, + showCoordinateFilter: false, + navigationButton: MapNavigationButton.back, + child: GeoMap( + controller: _mapController, + collectionListenable: openingCollection, + entries: openingCollection?.sortedEntries ?? [], + initialCenter: widget.initialLocation, + isAnimatingNotifier: _isPageAnimatingNotifier, + dotLocationNotifier: _dotLocationNotifier, + onMapTap: _setLocation, + onMarkerTap: (averageLocation, markerEntry, getClusterEntries) { + _setLocation(averageLocation); + }, + ), + ); + } + + void _setLocation(LatLng location) { + _dotLocationNotifier.value = location; + _mapController.moveTo(location); + } + + void _onIdle(ZoomedBounds bounds) { + _dotLocationNotifier.value = bounds.projectedCenter; + } + + void _updateLocationInfo() { + final selectedLocation = _dotLocationNotifier.value; + if (_infoLocationNotifier.value == null || selectedLocation == null) { + _infoLocationNotifier.value = selectedLocation; + } else { + _infoDebouncer(() => _infoLocationNotifier.value = selectedLocation); + } + } +} + +class _LocationInfo extends StatelessWidget { + final ValueNotifier locationNotifier; + + static const double iconPadding = 8.0; + static const double iconSize = 16.0; + static const double _interRowPadding = 2.0; + + const _LocationInfo({ + Key? key, + required this.locationNotifier, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final orientation = context.select((v) => v.orientation); + + return ValueListenableBuilder( + valueListenable: locationNotifier, + builder: (context, location, child) { + final content = orientation == Orientation.portrait + ? [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AddressRow(location: location), + const SizedBox(height: _interRowPadding), + _CoordinateRow(location: location), + ], + ), + ), + ] + : [ + _CoordinateRow(location: location), + Expanded( + child: _AddressRow(location: location), + ), + ]; + + return Opacity( + opacity: location != null ? 1 : 0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: iconPadding), + const DotMarker(), + ...content, + ], + ), + ); + }, + ); + } +} + +class _AddressRow extends StatefulWidget { + final LatLng? location; + + const _AddressRow({ + Key? key, + required this.location, + }) : super(key: key); + + @override + _AddressRowState createState() => _AddressRowState(); +} + +class _AddressRowState extends State<_AddressRow> { + final ValueNotifier _addressLineNotifier = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _updateAddress(); + } + + @override + void didUpdateWidget(covariant _AddressRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.location != widget.location) { + _updateAddress(); + } + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: _LocationInfo.iconPadding), + const DecoratedIcon(AIcons.location, size: _LocationInfo.iconSize), + const SizedBox(width: _LocationInfo.iconPadding), + Expanded( + child: Container( + alignment: AlignmentDirectional.centerStart, + // addresses can include non-latin scripts with inconsistent line height, + // which is especially an issue for relayout/painting of heavy Google map, + // so we give extra height to give breathing room to the text and stabilize layout + height: Theme.of(context).textTheme.bodyText2!.fontSize! * context.select((mq) => mq.textScaleFactor) * 2, + child: ValueListenableBuilder( + valueListenable: _addressLineNotifier, + builder: (context, addressLine, child) { + return Text( + addressLine ?? Constants.overlayUnknown, + strutStyle: Constants.overflowStrutStyle, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + }, + ), + ), + ), + ], + ); + } + + Future _updateAddress() async { + final location = widget.location; + final addressLine = await _getAddressLine(location); + if (mounted && location == widget.location) { + _addressLineNotifier.value = addressLine; + } + } + + Future _getAddressLine(LatLng? location) async { + if (location != null && await availability.canLocatePlaces) { + final addresses = await GeocodingService.getAddress(location, settings.appliedLocale); + if (addresses.isNotEmpty) { + final address = addresses.first; + return address.addressLine; + } + } + return null; + } +} + +class _CoordinateRow extends StatelessWidget { + final LatLng? location; + + const _CoordinateRow({ + Key? key, + required this.location, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const SizedBox(width: _LocationInfo.iconPadding), + const DecoratedIcon(AIcons.geoBounds, size: _LocationInfo.iconSize), + const SizedBox(width: _LocationInfo.iconPadding), + Text( + location != null ? settings.coordinateFormat.format(context.l10n, location!) : Constants.overlayUnknown, + strutStyle: Constants.overflowStrutStyle, + ), + ], + ); + } +} diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index f81b578c0..171eafe28 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -27,6 +27,7 @@ import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; class MapPage extends StatelessWidget { @@ -54,7 +55,7 @@ class MapPage extends StatelessWidget { top: false, right: false, bottom: true, - child: MapPageContent( + child: _Content( collection: collection, initialEntry: initialEntry, ), @@ -65,26 +66,27 @@ class MapPage extends StatelessWidget { } } -class MapPageContent extends StatefulWidget { +class _Content extends StatefulWidget { final CollectionLens collection; final AvesEntry? initialEntry; - const MapPageContent({ + const _Content({ Key? key, required this.collection, this.initialEntry, }) : super(key: key); @override - _MapPageContentState createState() => _MapPageContentState(); + _ContentState createState() => _ContentState(); } -class _MapPageContentState extends State with SingleTickerProviderStateMixin { +class _ContentState extends State<_Content> with SingleTickerProviderStateMixin { final List _subscriptions = []; final AvesMapController _mapController = AvesMapController(); late final ValueNotifier _isPageAnimatingNotifier; final ValueNotifier _selectedIndexNotifier = ValueNotifier(0); final ValueNotifier _regionCollectionNotifier = ValueNotifier(null); + final ValueNotifier _dotLocationNotifier = ValueNotifier(null); final ValueNotifier _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null); final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); final ValueNotifier _overlayVisible = ValueNotifier(true); @@ -223,11 +225,11 @@ class _MapPageContentState extends State with SingleTickerProvid controller: _mapController, collectionListenable: openingCollection, entries: openingCollection.sortedEntries, - initialEntry: widget.initialEntry, + initialCenter: widget.initialEntry?.latLng, isAnimatingNotifier: _isPageAnimatingNotifier, - dotEntryNotifier: _dotEntryNotifier, - onMapTap: _toggleOverlay, - onMarkerTap: (markerEntry, getClusterEntries) async { + dotLocationNotifier: _dotLocationNotifier, + onMapTap: (_) => _toggleOverlay(), + onMarkerTap: (averageLocation, markerEntry, getClusterEntries) async { final index = regionCollection?.sortedEntries.indexOf(markerEntry); if (index != null && _selectedIndexNotifier.value != index) { _selectedIndexNotifier.value = index; @@ -337,7 +339,10 @@ class _MapPageContentState extends State with SingleTickerProvid void _onThumbnailIndexChange() => _onEntrySelected(_getRegionEntry(_selectedIndexNotifier.value)); - void _onEntrySelected(AvesEntry? selectedEntry) => _dotEntryNotifier.value = selectedEntry; + void _onEntrySelected(AvesEntry? selectedEntry) { + _dotLocationNotifier.value = selectedEntry?.latLng; + _dotEntryNotifier.value = selectedEntry; + } void _updateInfoEntry() { final selectedEntry = _dotEntryNotifier.value; diff --git a/lib/widgets/settings/navigation/drawer_tab_albums.dart b/lib/widgets/settings/navigation/drawer_tab_albums.dart index a0dbad668..d76bd2aa4 100644 --- a/lib/widgets/settings/navigation/drawer_tab_albums.dart +++ b/lib/widgets/settings/navigation/drawer_tab_albums.dart @@ -67,7 +67,7 @@ class _DrawerAlbumTabState extends State { if (album == null) return; setState(() => widget.items.add(album)); }, - ) + ), ], ); } diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 2611acc76..f0e1aab86 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -23,7 +23,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/rename_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/action/printer.dart'; diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 1c20cba58..77007ede6 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -4,6 +4,7 @@ import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/events.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_metadata_edition.dart'; +import 'package:aves/model/source/collection_lens.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/permission_aware.dart'; @@ -14,17 +15,19 @@ import 'package:flutter/material.dart'; class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin { @override final AvesEntry entry; + final CollectionLens? collection; final StreamController> _eventStreamController = StreamController>.broadcast(); Stream> get eventStream => _eventStreamController.stream; - EntryInfoActionDelegate(this.entry); + EntryInfoActionDelegate(this.entry, this.collection); bool isVisible(EntryInfoAction action) { switch (action) { // general case EntryInfoAction.editDate: + case EntryInfoAction.editLocation: case EntryInfoAction.editRating: case EntryInfoAction.editTags: case EntryInfoAction.removeMetadata: @@ -40,6 +43,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi // general case EntryInfoAction.editDate: return entry.canEditDate; + case EntryInfoAction.editLocation: + return entry.canEditLocation; case EntryInfoAction.editRating: return entry.canEditRating; case EntryInfoAction.editTags: @@ -59,6 +64,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.editDate: await _editDate(context); break; + case EntryInfoAction.editLocation: + await _editLocation(context); + break; case EntryInfoAction.editRating: await _editRating(context); break; @@ -83,6 +91,13 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi await edit(context, () => entry.editDate(modifier)); } + Future _editLocation(BuildContext context) async { + final location = await selectLocation(context, {entry}, collection); + if (location == null) return; + + await edit(context, () => entry.editLocation(location)); + } + Future _editRating(BuildContext context) async { final rating = await selectRating(context, {entry}); if (rating == null) return; diff --git a/lib/widgets/viewer/action/single_entry_editor.dart b/lib/widgets/viewer/action/single_entry_editor.dart index f47d31314..5cdfdadc4 100644 --- a/lib/widgets/viewer/action/single_entry_editor.dart +++ b/lib/widgets/viewer/action/single_entry_editor.dart @@ -32,7 +32,20 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin { try { if (success) { if (isMainMode && source != null) { + Set obsoleteTags = entry.tags; + String? obsoleteCountryCode = entry.addressDetails?.countryCode; + await source.refreshEntry(entry, dataTypes); + + // invalidate filters derived from values before edition + // this invalidation must happen after the source is refreshed, + // otherwise filter chips may eagerly rebuild in between with the old state + if (obsoleteCountryCode != null) { + source.invalidateCountryFilterSummary(countryCodes: {obsoleteCountryCode}); + } + if (obsoleteTags.isNotEmpty) { + source.invalidateTagFilterSummary(tags: obsoleteTags); + } } else { await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); } diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index ad14c8ee6..2f370f5f3 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -181,7 +181,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { } void _registerWidget(_InfoPageContent widget) { - _actionDelegate = EntryInfoActionDelegate(widget.entry); + _actionDelegate = EntryInfoActionDelegate(widget.entry, collection); _subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent)); } diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index fa78e6da9..d216417ff 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -7,6 +7,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/theme/icons.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/map/controller.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/map/map_page.dart'; @@ -34,6 +35,8 @@ class LocationSection extends StatefulWidget { } class _LocationSectionState extends State { + final AvesMapController _mapController = AvesMapController(); + CollectionLens? get collection => widget.collection; AvesEntry get entry => widget.entry; @@ -58,28 +61,17 @@ class _LocationSectionState extends State { } void _registerWidget(LocationSection widget) { - widget.entry.metadataChangeNotifier.addListener(_handleChange); - widget.entry.addressChangeNotifier.addListener(_handleChange); + widget.entry.metadataChangeNotifier.addListener(_onMetadataChange); } void _unregisterWidget(LocationSection widget) { - widget.entry.metadataChangeNotifier.removeListener(_handleChange); - widget.entry.addressChangeNotifier.removeListener(_handleChange); + widget.entry.metadataChangeNotifier.removeListener(_onMetadataChange); } @override Widget build(BuildContext context) { if (!entry.hasGps) return const SizedBox(); - final filters = []; - if (entry.hasAddress) { - final address = entry.addressDetails!; - final country = address.countryName; - if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}')); - final place = address.place; - if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); - } - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -91,28 +83,48 @@ class _LocationSectionState extends State { visualDensity: VisualDensity.compact, mapHeight: 200, child: GeoMap( + controller: _mapController, entries: [entry], isAnimatingNotifier: widget.isScrollingNotifier, onUserZoomChange: (zoom) => settings.infoMapZoom = zoom, - onMarkerTap: collection != null ? (_, __) => _openMapPage(context) : null, + onMarkerTap: collection != null ? (_, __, ___) => _openMapPage(context) : null, openMapPage: collection != null ? _openMapPage : null, ), ), - _AddressInfoGroup(entry: entry), - if (filters.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: filters - .map((filter) => AvesFilterChip( - filter: filter, - onTap: widget.onFilter, - )) - .toList(), - ), - ), + AnimatedBuilder( + animation: entry.addressChangeNotifier, + builder: (context, child) { + final filters = []; + if (entry.hasAddress) { + final address = entry.addressDetails!; + final country = address.countryName; + if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}')); + final place = address.place; + if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AddressInfoGroup(entry: entry), + if (filters.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: filters + .map((filter) => AvesFilterChip( + filter: filter, + onTap: widget.onFilter, + )) + .toList(), + ), + ), + ], + ); + }, + ), ], ); } @@ -136,7 +148,15 @@ class _LocationSectionState extends State { ); } - void _handleChange() => setState(() {}); + void _onMetadataChange() { + setState(() {}); + + final location = entry.latLng; + if (location != null) { + _mapController.notifyMarkerLocationChange(); + _mapController.moveTo(location); + } + } } class _AddressInfoGroup extends StatefulWidget { diff --git a/untranslated.json b/untranslated.json index 09080dbd1..4c6c626d6 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,22 +1,64 @@ { "de": [ + "entryInfoActionEditLocation", "exportEntryDialogWidth", - "exportEntryDialogHeight" + "exportEntryDialogHeight", + "editEntryLocationDialogTitle", + "editEntryLocationDialogChooseOnMapTooltip", + "editEntryLocationDialogLatitude", + "editEntryLocationDialogLongitude", + "locationPickerUseThisLocationButton" ], "es": [ + "entryInfoActionEditLocation", "exportEntryDialogWidth", - "exportEntryDialogHeight" + "exportEntryDialogHeight", + "editEntryLocationDialogTitle", + "editEntryLocationDialogChooseOnMapTooltip", + "editEntryLocationDialogLatitude", + "editEntryLocationDialogLongitude", + "locationPickerUseThisLocationButton" + ], + + "fr": [ + "entryInfoActionEditLocation", + "editEntryLocationDialogTitle", + "editEntryLocationDialogChooseOnMapTooltip", + "editEntryLocationDialogLatitude", + "editEntryLocationDialogLongitude", + "locationPickerUseThisLocationButton" + ], + + "ko": [ + "entryInfoActionEditLocation", + "editEntryLocationDialogTitle", + "editEntryLocationDialogChooseOnMapTooltip", + "editEntryLocationDialogLatitude", + "editEntryLocationDialogLongitude", + "locationPickerUseThisLocationButton" ], "pt": [ + "entryInfoActionEditLocation", "exportEntryDialogWidth", - "exportEntryDialogHeight" + "exportEntryDialogHeight", + "editEntryLocationDialogTitle", + "editEntryLocationDialogChooseOnMapTooltip", + "editEntryLocationDialogLatitude", + "editEntryLocationDialogLongitude", + "locationPickerUseThisLocationButton" ], "ru": [ + "entryInfoActionEditLocation", "exportEntryDialogWidth", "exportEntryDialogHeight", + "editEntryLocationDialogTitle", + "editEntryLocationDialogChooseOnMapTooltip", + "editEntryLocationDialogLatitude", + "editEntryLocationDialogLongitude", + "locationPickerUseThisLocationButton", "appExportCovers", "settingsThumbnailShowFavouriteIcon" ]