#2 info: edit location;
fixes for map center computation, DB & filter chip update on metadata changes, offscreen marker generation
This commit is contained in:
parent
d44b001bb7
commit
e0f45f03c1
38 changed files with 1206 additions and 176 deletions
|
@ -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<FieldMap>()
|
||||
if (!editIptc(
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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<void> 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Set<EntryDataType>> editLocation(LatLng? latLng) async {
|
||||
final Set<EntryDataType> dataTypes = {};
|
||||
|
||||
await _missingDateCheckAndExifEdit(dataTypes);
|
||||
|
||||
// clear every GPS field
|
||||
final exifFields = Map<MetadataField, dynamic>.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<String, dynamic>.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<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async {
|
||||
final Set<EntryDataType> dataTypes = {};
|
||||
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
199
lib/model/metadata/fields.dart
Normal file
199
lib/model/metadata/fields.dart
Normal file
|
@ -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<MetadataField> 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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<AvesEntry>? entries]) {
|
||||
|
@ -292,6 +292,18 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> 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}));
|
||||
}
|
||||
|
|
|
@ -176,16 +176,21 @@ mixin LocationMixin on SourceBase {
|
|||
final Map<String, int> _filterEntryCountMap = {};
|
||||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateCountryFilterSummary([Set<AvesEntry>? entries]) {
|
||||
void invalidateCountryFilterSummary({Set<AvesEntry>? entries, Set<String>? countryCodes}) {
|
||||
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
|
||||
|
||||
Set<String>? 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));
|
||||
}
|
||||
|
|
|
@ -77,16 +77,21 @@ mixin TagMixin on SourceBase {
|
|||
final Map<String, int> _filterEntryCountMap = {};
|
||||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateTagFilterSummary([Set<AvesEntry>? entries]) {
|
||||
void invalidateTagFilterSummary({Set<AvesEntry>? entries, Set<String>? tags}) {
|
||||
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
|
||||
|
||||
Set<String>? 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));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -29,6 +29,7 @@ class BugReport extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _BugReportState extends State<BugReport> with FeedbackMixin {
|
||||
final ScrollController _infoScrollController = ScrollController();
|
||||
late Future<String> _infoLoader;
|
||||
bool _showInstructions = false;
|
||||
|
||||
|
@ -92,8 +93,14 @@ class _BugReportState extends State<BugReport> 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<BugReport> with FeedbackMixin {
|
|||
AvesOutlinedButton(
|
||||
label: buttonText,
|
||||
onPressed: onPressed,
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries) async {
|
||||
|
@ -23,10 +26,23 @@ mixin EntryEditorMixin {
|
|||
return modifier;
|
||||
}
|
||||
|
||||
Future<LatLng?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
final location = await showDialog<LatLng>(
|
||||
context: context,
|
||||
builder: (context) => EditEntryLocationDialog(
|
||||
entry: entries.first,
|
||||
collection: collection,
|
||||
),
|
||||
);
|
||||
return location;
|
||||
}
|
||||
|
||||
Future<int?> selectRating(BuildContext context, Set<AvesEntry> entries) async {
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
final rating = await showDialog<int?>(
|
||||
final rating = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => EditEntryRatingDialog(
|
||||
entry: entries.first,
|
||||
|
|
|
@ -12,6 +12,8 @@ class AvesMapController {
|
|||
|
||||
Stream<MapIdleUpdate> get idleUpdates => _events.where((event) => event is MapIdleUpdate).cast<MapIdleUpdate>();
|
||||
|
||||
Stream<MapMarkerLocationChangeEvent> get markerLocationChanges => _events.where((event) => event is MapMarkerLocationChangeEvent).cast<MapMarkerLocationChangeEvent>();
|
||||
|
||||
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 {}
|
||||
|
|
|
@ -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<AvesEntry> entries;
|
||||
final AvesEntry? initialEntry;
|
||||
final LatLng? initialCenter;
|
||||
final ValueNotifier<bool> isAnimatingNotifier;
|
||||
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
|
||||
final ValueNotifier<LatLng?>? 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<GeoMap> {
|
||||
final List<StreamSubscription> _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<GeoMap> {
|
|||
@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<GeoMap> {
|
|||
|
||||
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<GeoMap> {
|
|||
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<AvesEntry> getClusterEntries() {
|
||||
if (clusterId == null) {
|
||||
return {geoEntry.entry!};
|
||||
|
@ -135,17 +148,8 @@ class _GeoMapState extends State<GeoMap> {
|
|||
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<bool>(
|
||||
|
@ -176,7 +180,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
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<GeoMap> {
|
|||
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<GeoMap> {
|
|||
);
|
||||
}
|
||||
|
||||
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<MarkerKey, GeoEntry> Function();
|
||||
typedef MarkerWidgetBuilder = Widget Function(MarkerKey key);
|
||||
typedef UserZoomChangeCallback = void Function(double zoom);
|
||||
typedef MarkerTapCallback = void Function(AvesEntry markerEntry, Set<AvesEntry> Function() getClusterEntries);
|
||||
typedef MarkerTapCallback = void Function(LatLng averageLocation, AvesEntry markerEntry, Set<AvesEntry> Function() getClusterEntries);
|
||||
|
|
|
@ -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<AvesEntry?>? dotEntryNotifier;
|
||||
final ValueNotifier<ll.LatLng?>? 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<EntryGoogleMap> with WidgetsBindingObse
|
|||
});
|
||||
|
||||
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
|
||||
return ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
||||
builder: (context, dotEntry, child) {
|
||||
return ValueListenableBuilder<ll.LatLng?>(
|
||||
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<EntryGoogleMap> 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<EntryGoogleMap> 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<EntryGoogleMap> 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<EntryGoogleMap> 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) {
|
||||
|
|
|
@ -107,9 +107,14 @@ class _MarkerGeneratorItem<T extends Key> {
|
|||
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;
|
||||
}
|
||||
|
|
|
@ -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<AvesEntry?>? dotEntryNotifier;
|
||||
final ValueNotifier<LatLng?>? 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<EntryLeafletMap> 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<EntryLeafletMap> 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<EntryLeafletMap> with TickerProviderSt
|
|||
rotateAlignment: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
||||
builder: (context, dotEntry, child) => MarkerLayerWidget(
|
||||
ValueListenableBuilder<LatLng?>(
|
||||
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,
|
||||
|
|
|
@ -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<double> 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<Object?> get props => [sw, ne, zoom, rotation];
|
||||
|
|
|
@ -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<EditEntryDateDialog> {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
231
lib/widgets/dialogs/entry_editors/edit_location_dialog.dart
Normal file
231
lib/widgets/dialogs/entry_editors/edit_location_dialog.dart
Normal file
|
@ -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<EditEntryLocationDialog> {
|
||||
_LocationAction _action = _LocationAction.set;
|
||||
final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController();
|
||||
final FocusNode _latitudeFocusNode = FocusNode(), _longitudeFocusNode = FocusNode();
|
||||
final ValueNotifier<bool> _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<bool>(
|
||||
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<void> _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<void> _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 }
|
|
@ -70,7 +70,7 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
|||
child: Text(context.l10n.applyButtonLabel),
|
||||
);
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
335
lib/widgets/dialogs/location_pick_dialog.dart
Normal file
335
lib/widgets/dialogs/location_pick_dialog.dart
Normal file
|
@ -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<StreamSubscription> _subscriptions = [];
|
||||
final AvesMapController _mapController = AvesMapController();
|
||||
late final ValueNotifier<bool> _isPageAnimatingNotifier;
|
||||
final ValueNotifier<LatLng?> _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<LatLng?> 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<MediaQueryData, Orientation>((v) => v.orientation);
|
||||
|
||||
return ValueListenableBuilder<LatLng?>(
|
||||
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<String?> _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<MediaQueryData, double>((mq) => mq.textScaleFactor) * 2,
|
||||
child: ValueListenableBuilder<String?>(
|
||||
valueListenable: _addressLineNotifier,
|
||||
builder: (context, addressLine, child) {
|
||||
return Text(
|
||||
addressLine ?? Constants.overlayUnknown,
|
||||
strutStyle: Constants.overflowStrutStyle,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateAddress() async {
|
||||
final location = widget.location;
|
||||
final addressLine = await _getAddressLine(location);
|
||||
if (mounted && location == widget.location) {
|
||||
_addressLineNotifier.value = addressLine;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<MapPageContent> with SingleTickerProviderStateMixin {
|
||||
class _ContentState extends State<_Content> with SingleTickerProviderStateMixin {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
final AvesMapController _mapController = AvesMapController();
|
||||
late final ValueNotifier<bool> _isPageAnimatingNotifier;
|
||||
final ValueNotifier<int?> _selectedIndexNotifier = ValueNotifier(0);
|
||||
final ValueNotifier<CollectionLens?> _regionCollectionNotifier = ValueNotifier(null);
|
||||
final ValueNotifier<LatLng?> _dotLocationNotifier = ValueNotifier(null);
|
||||
final ValueNotifier<AvesEntry?> _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null);
|
||||
final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay);
|
||||
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
|
||||
|
@ -223,11 +225,11 @@ class _MapPageContentState extends State<MapPageContent> 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<MapPageContent> 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;
|
||||
|
|
|
@ -67,7 +67,7 @@ class _DrawerAlbumTabState extends State<DrawerAlbumTab> {
|
|||
if (album == null) return;
|
||||
setState(() => widget.items.add(album));
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController<ActionEvent<EntryInfoAction>>.broadcast();
|
||||
|
||||
Stream<ActionEvent<EntryInfoAction>> 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<void> _editLocation(BuildContext context) async {
|
||||
final location = await selectLocation(context, {entry}, collection);
|
||||
if (location == null) return;
|
||||
|
||||
await edit(context, () => entry.editLocation(location));
|
||||
}
|
||||
|
||||
Future<void> _editRating(BuildContext context) async {
|
||||
final rating = await selectRating(context, {entry});
|
||||
if (rating == null) return;
|
||||
|
|
|
@ -32,7 +32,20 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
|
|||
try {
|
||||
if (success) {
|
||||
if (isMainMode && source != null) {
|
||||
Set<String> 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);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -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<LocationSection> {
|
||||
final AvesMapController _mapController = AvesMapController();
|
||||
|
||||
CollectionLens? get collection => widget.collection;
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
@ -58,28 +61,17 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
}
|
||||
|
||||
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 = <LocationFilter>[];
|
||||
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<LocationSection> {
|
|||
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 = <LocationFilter>[];
|
||||
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<LocationSection> {
|
|||
);
|
||||
}
|
||||
|
||||
void _handleChange() => setState(() {});
|
||||
void _onMetadataChange() {
|
||||
setState(() {});
|
||||
|
||||
final location = entry.latLng;
|
||||
if (location != null) {
|
||||
_mapController.notifyMarkerLocationChange();
|
||||
_mapController.moveTo(location);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressInfoGroup extends StatefulWidget {
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
|
|
Loading…
Reference in a new issue