#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,
|
modifier: FieldMap,
|
||||||
callback: ImageOpCallback,
|
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")) {
|
if (modifier.containsKey("iptc")) {
|
||||||
val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>()
|
val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>()
|
||||||
if (!editIptc(
|
if (!editIptc(
|
||||||
|
|
|
@ -97,6 +97,7 @@
|
||||||
"videoActionSettings": "Settings",
|
"videoActionSettings": "Settings",
|
||||||
|
|
||||||
"entryInfoActionEditDate": "Edit date & time",
|
"entryInfoActionEditDate": "Edit date & time",
|
||||||
|
"entryInfoActionEditLocation": "Edit location",
|
||||||
"entryInfoActionEditRating": "Edit rating",
|
"entryInfoActionEditRating": "Edit rating",
|
||||||
"entryInfoActionEditTags": "Edit tags",
|
"entryInfoActionEditTags": "Edit tags",
|
||||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||||
|
@ -315,6 +316,13 @@
|
||||||
"editEntryDateDialogHours": "Hours",
|
"editEntryDateDialogHours": "Hours",
|
||||||
"editEntryDateDialogMinutes": "Minutes",
|
"editEntryDateDialogMinutes": "Minutes",
|
||||||
|
|
||||||
|
"editEntryLocationDialogTitle": "Location",
|
||||||
|
"editEntryLocationDialogChooseOnMapTooltip": "Choose on map",
|
||||||
|
"editEntryLocationDialogLatitude": "Latitude",
|
||||||
|
"editEntryLocationDialogLongitude": "Longitude",
|
||||||
|
|
||||||
|
"locationPickerUseThisLocationButton": "Use this location",
|
||||||
|
|
||||||
"editEntryRatingDialogTitle": "Rating",
|
"editEntryRatingDialogTitle": "Rating",
|
||||||
|
|
||||||
"removeEntryMetadataDialogTitle": "Metadata Removal",
|
"removeEntryMetadataDialogTitle": "Metadata Removal",
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
|
||||||
enum EntryInfoAction {
|
enum EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
editDate,
|
editDate,
|
||||||
|
editLocation,
|
||||||
editRating,
|
editRating,
|
||||||
editTags,
|
editTags,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
|
@ -15,6 +16,7 @@ enum EntryInfoAction {
|
||||||
class EntryInfoActions {
|
class EntryInfoActions {
|
||||||
static const all = [
|
static const all = [
|
||||||
EntryInfoAction.editDate,
|
EntryInfoAction.editDate,
|
||||||
|
EntryInfoAction.editLocation,
|
||||||
EntryInfoAction.editRating,
|
EntryInfoAction.editRating,
|
||||||
EntryInfoAction.editTags,
|
EntryInfoAction.editTags,
|
||||||
EntryInfoAction.removeMetadata,
|
EntryInfoAction.removeMetadata,
|
||||||
|
@ -28,6 +30,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return context.l10n.entryInfoActionEditDate;
|
return context.l10n.entryInfoActionEditDate;
|
||||||
|
case EntryInfoAction.editLocation:
|
||||||
|
return context.l10n.entryInfoActionEditLocation;
|
||||||
case EntryInfoAction.editRating:
|
case EntryInfoAction.editRating:
|
||||||
return context.l10n.entryInfoActionEditRating;
|
return context.l10n.entryInfoActionEditRating;
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
|
@ -49,6 +53,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return AIcons.date;
|
return AIcons.date;
|
||||||
|
case EntryInfoAction.editLocation:
|
||||||
|
return AIcons.location;
|
||||||
case EntryInfoAction.editRating:
|
case EntryInfoAction.editRating:
|
||||||
return AIcons.editRating;
|
return AIcons.editRating;
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
|
|
|
@ -237,6 +237,8 @@ class AvesEntry {
|
||||||
|
|
||||||
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
|
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
|
||||||
|
|
||||||
|
bool get canEditLocation => canEdit && canEditExif;
|
||||||
|
|
||||||
bool get canEditRating => canEdit && canEditXmp;
|
bool get canEditRating => canEdit && canEditXmp;
|
||||||
|
|
||||||
bool get canEditTags => canEdit && canEditXmp;
|
bool get canEditTags => canEdit && canEditXmp;
|
||||||
|
@ -497,11 +499,14 @@ class AvesEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
|
Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
|
||||||
if (!hasGps) return;
|
if (hasGps) {
|
||||||
await _locateCountry(force: force);
|
await _locateCountry(force: force);
|
||||||
if (await availability.canLocatePlaces) {
|
if (await availability.canLocatePlaces) {
|
||||||
await locatePlace(background: background, force: force, geocoderLocale: geocoderLocale);
|
await locatePlace(background: background, force: force, geocoderLocale: geocoderLocale);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
addressDetails = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// quick reverse geocoding to find the country, using an offline asset
|
// quick reverse geocoding to find the country, using an offline asset
|
||||||
|
|
|
@ -4,12 +4,15 @@ import 'dart:io';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata/date_modifier.dart';
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
import 'package:aves/model/metadata/enums.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/ref/iptc.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:aves/services/metadata/xmp.dart';
|
import 'package:aves/services/metadata/xmp.dart';
|
||||||
import 'package:aves/utils/time_utils.dart';
|
import 'package:aves/utils/time_utils.dart';
|
||||||
import 'package:aves/utils/xmp_utils.dart';
|
import 'package:aves/utils/xmp_utils.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:xml/xml.dart';
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
@ -73,6 +76,40 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
return dataTypes;
|
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 {
|
Future<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async {
|
||||||
final Set<EntryDataType> dataTypes = {};
|
final Set<EntryDataType> dataTypes = {};
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:aves/model/metadata/enums.dart';
|
import 'package:aves/model/metadata/enums.dart';
|
||||||
|
import 'package:aves/model/metadata/fields.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
@ -9,7 +10,7 @@ class DateModifier extends Equatable {
|
||||||
MetadataField.exifDate,
|
MetadataField.exifDate,
|
||||||
MetadataField.exifDateOriginal,
|
MetadataField.exifDateOriginal,
|
||||||
MetadataField.exifDateDigitized,
|
MetadataField.exifDateDigitized,
|
||||||
MetadataField.exifGpsDate,
|
MetadataField.exifGpsDatestamp,
|
||||||
MetadataField.xmpCreateDate,
|
MetadataField.xmpCreateDate,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,4 @@
|
||||||
enum MetadataField {
|
import 'package:aves/model/metadata/fields.dart';
|
||||||
exifDate,
|
|
||||||
exifDateOriginal,
|
|
||||||
exifDateDigitized,
|
|
||||||
exifGpsDate,
|
|
||||||
xmpCreateDate,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum DateEditAction {
|
enum DateEditAction {
|
||||||
setCustom,
|
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 {
|
extension ExtraDateFieldSource on DateFieldSource {
|
||||||
MetadataField? toMetadataField() {
|
MetadataField? toMetadataField() {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
|
@ -132,7 +97,7 @@ extension ExtraDateFieldSource on DateFieldSource {
|
||||||
case DateFieldSource.exifDateDigitized:
|
case DateFieldSource.exifDateDigitized:
|
||||||
return MetadataField.exifDateDigitized;
|
return MetadataField.exifDateDigitized;
|
||||||
case DateFieldSource.exifGpsDate:
|
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();
|
_filterEntryCountMap.clear();
|
||||||
_filterRecentEntryMap.clear();
|
_filterRecentEntryMap.clear();
|
||||||
} else {
|
} else {
|
||||||
directories ??= entries!.map((entry) => entry.directory).toSet();
|
directories ??= {};
|
||||||
directories.forEach(_filterEntryCountMap.remove);
|
if (entries != null) {
|
||||||
directories.forEach(_filterRecentEntryMap.remove);
|
directories.addAll(entries.map((entry) => entry.directory).whereNotNull());
|
||||||
|
}
|
||||||
|
directories.forEach((directory) {
|
||||||
|
_filterEntryCountMap.remove(directory);
|
||||||
|
_filterRecentEntryMap.remove(directory);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
eventBus.fire(AlbumSummaryInvalidatedEvent(directories));
|
eventBus.fire(AlbumSummaryInvalidatedEvent(directories));
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,8 +84,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
_visibleEntries = null;
|
_visibleEntries = null;
|
||||||
_sortedEntriesByDate = null;
|
_sortedEntriesByDate = null;
|
||||||
invalidateAlbumFilterSummary(entries: entries);
|
invalidateAlbumFilterSummary(entries: entries);
|
||||||
invalidateCountryFilterSummary(entries);
|
invalidateCountryFilterSummary(entries: entries);
|
||||||
invalidateTagFilterSummary(entries);
|
invalidateTagFilterSummary(entries: entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateDerivedFilters([Set<AvesEntry>? 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 {
|
Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> dataTypes) async {
|
||||||
await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
|
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});
|
updateDerivedFilters({entry});
|
||||||
eventBus.fire(EntryRefreshedEvent({entry}));
|
eventBus.fire(EntryRefreshedEvent({entry}));
|
||||||
}
|
}
|
||||||
|
|
|
@ -176,16 +176,21 @@ mixin LocationMixin on SourceBase {
|
||||||
final Map<String, int> _filterEntryCountMap = {};
|
final Map<String, int> _filterEntryCountMap = {};
|
||||||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||||
|
|
||||||
void invalidateCountryFilterSummary([Set<AvesEntry>? entries]) {
|
void invalidateCountryFilterSummary({Set<AvesEntry>? entries, Set<String>? countryCodes}) {
|
||||||
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
|
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
|
||||||
|
|
||||||
Set<String>? countryCodes;
|
if (entries == null && countryCodes == null) {
|
||||||
if (entries == null) {
|
|
||||||
_filterEntryCountMap.clear();
|
_filterEntryCountMap.clear();
|
||||||
_filterRecentEntryMap.clear();
|
_filterRecentEntryMap.clear();
|
||||||
} else {
|
} else {
|
||||||
countryCodes = entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull().toSet();
|
countryCodes ??= {};
|
||||||
countryCodes.forEach(_filterEntryCountMap.remove);
|
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));
|
eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes));
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,16 +77,21 @@ mixin TagMixin on SourceBase {
|
||||||
final Map<String, int> _filterEntryCountMap = {};
|
final Map<String, int> _filterEntryCountMap = {};
|
||||||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||||
|
|
||||||
void invalidateTagFilterSummary([Set<AvesEntry>? entries]) {
|
void invalidateTagFilterSummary({Set<AvesEntry>? entries, Set<String>? tags}) {
|
||||||
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
|
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
|
||||||
|
|
||||||
Set<String>? tags;
|
if (entries == null && tags == null) {
|
||||||
if (entries == null) {
|
|
||||||
_filterEntryCountMap.clear();
|
_filterEntryCountMap.clear();
|
||||||
_filterRecentEntryMap.clear();
|
_filterRecentEntryMap.clear();
|
||||||
} else {
|
} else {
|
||||||
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags).toSet();
|
tags ??= {};
|
||||||
tags.forEach(_filterEntryCountMap.remove);
|
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));
|
eventBus.fire(TagSummaryInvalidatedEvent(tags));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
class Exif {
|
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) {
|
static String getColorSpaceDescription(String valueString) {
|
||||||
final value = int.tryParse(valueString);
|
final value = int.tryParse(valueString);
|
||||||
if (value == null) return valueString;
|
if (value == null) return valueString;
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata/date_modifier.dart';
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
import 'package:aves/model/metadata/enums.dart';
|
import 'package:aves/model/metadata/enums.dart';
|
||||||
|
import 'package:aves/model/metadata/fields.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
@ -77,7 +78,7 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
'entry': _toPlatformEntryMap(entry),
|
'entry': _toPlatformEntryMap(entry),
|
||||||
'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch,
|
'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch,
|
||||||
'shiftMinutes': modifier.shiftMinutes,
|
'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>();
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata/catalog.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/metadata/overlay.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/model/panorama.dart';
|
import 'package:aves/model/panorama.dart';
|
||||||
|
@ -236,7 +236,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
||||||
'mimeType': entry.mimeType,
|
'mimeType': entry.mimeType,
|
||||||
'uri': entry.uri,
|
'uri': entry.uri,
|
||||||
'sizeBytes': entry.sizeBytes,
|
'sizeBytes': entry.sizeBytes,
|
||||||
'field': field.toExifInterfaceTag(),
|
'field': field.exifInterfaceTag,
|
||||||
});
|
});
|
||||||
if (result is int) {
|
if (result is int) {
|
||||||
return dateTimeFromMillis(result, isUtc: false);
|
return dateTimeFromMillis(result, isUtc: false);
|
||||||
|
|
|
@ -29,6 +29,7 @@ class BugReport extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BugReportState extends State<BugReport> with FeedbackMixin {
|
class _BugReportState extends State<BugReport> with FeedbackMixin {
|
||||||
|
final ScrollController _infoScrollController = ScrollController();
|
||||||
late Future<String> _infoLoader;
|
late Future<String> _infoLoader;
|
||||||
bool _showInstructions = false;
|
bool _showInstructions = false;
|
||||||
|
|
||||||
|
@ -92,8 +93,14 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Scrollbar(
|
child: Scrollbar(
|
||||||
child: Padding(
|
// when using `Scrollbar.isAlwaysShown`, a controller must be provided
|
||||||
padding: const EdgeInsetsDirectional.only(start: 8, end: 16),
|
// 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),
|
child: SelectableText(info),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -136,7 +143,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
||||||
AvesOutlinedButton(
|
AvesOutlinedButton(
|
||||||
label: buttonText,
|
label: buttonText,
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata/date_modifier.dart';
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
import 'package:aves/model/metadata/enums.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/ref/mime_types.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.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_date_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart';
|
import 'package:aves/widgets/dialogs/entry_editors/edit_location_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart';
|
import 'package:aves/widgets/dialogs/entry_editors/edit_rating_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_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:flutter/material.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
mixin EntryEditorMixin {
|
mixin EntryEditorMixin {
|
||||||
Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries) async {
|
Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries) async {
|
||||||
|
@ -23,10 +26,23 @@ mixin EntryEditorMixin {
|
||||||
return modifier;
|
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 {
|
Future<int?> selectRating(BuildContext context, Set<AvesEntry> entries) async {
|
||||||
if (entries.isEmpty) return null;
|
if (entries.isEmpty) return null;
|
||||||
|
|
||||||
final rating = await showDialog<int?>(
|
final rating = await showDialog<int>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => EditEntryRatingDialog(
|
builder: (context) => EditEntryRatingDialog(
|
||||||
entry: entries.first,
|
entry: entries.first,
|
||||||
|
|
|
@ -12,6 +12,8 @@ class AvesMapController {
|
||||||
|
|
||||||
Stream<MapIdleUpdate> get idleUpdates => _events.where((event) => event is MapIdleUpdate).cast<MapIdleUpdate>();
|
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() {
|
void dispose() {
|
||||||
_streamController.close();
|
_streamController.close();
|
||||||
}
|
}
|
||||||
|
@ -19,6 +21,8 @@ class AvesMapController {
|
||||||
void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng));
|
void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng));
|
||||||
|
|
||||||
void notifyIdle(ZoomedBounds bounds) => _streamController.add(MapIdleUpdate(bounds));
|
void notifyIdle(ZoomedBounds bounds) => _streamController.add(MapIdleUpdate(bounds));
|
||||||
|
|
||||||
|
void notifyMarkerLocationChange() => _streamController.add(MapMarkerLocationChangeEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MapControllerMoveEvent {
|
class MapControllerMoveEvent {
|
||||||
|
@ -32,3 +36,5 @@ class MapIdleUpdate {
|
||||||
|
|
||||||
MapIdleUpdate(this.bounds);
|
MapIdleUpdate(this.bounds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MapMarkerLocationChangeEvent {}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
@ -23,17 +24,18 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:fluster/fluster.dart';
|
import 'package:fluster/fluster.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class GeoMap extends StatefulWidget {
|
class GeoMap extends StatefulWidget {
|
||||||
final AvesMapController? controller;
|
final AvesMapController? controller;
|
||||||
final Listenable? collectionListenable;
|
final Listenable? collectionListenable;
|
||||||
final List<AvesEntry> entries;
|
final List<AvesEntry> entries;
|
||||||
final AvesEntry? initialEntry;
|
final LatLng? initialCenter;
|
||||||
final ValueNotifier<bool> isAnimatingNotifier;
|
final ValueNotifier<bool> isAnimatingNotifier;
|
||||||
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
|
final ValueNotifier<LatLng?>? dotLocationNotifier;
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
final VoidCallback? onMapTap;
|
final void Function(LatLng location)? onMapTap;
|
||||||
final MarkerTapCallback? onMarkerTap;
|
final MarkerTapCallback? onMarkerTap;
|
||||||
final MapOpener? openMapPage;
|
final MapOpener? openMapPage;
|
||||||
|
|
||||||
|
@ -45,9 +47,9 @@ class GeoMap extends StatefulWidget {
|
||||||
this.controller,
|
this.controller,
|
||||||
this.collectionListenable,
|
this.collectionListenable,
|
||||||
required this.entries,
|
required this.entries,
|
||||||
this.initialEntry,
|
this.initialCenter,
|
||||||
required this.isAnimatingNotifier,
|
required this.isAnimatingNotifier,
|
||||||
this.dotEntryNotifier,
|
this.dotLocationNotifier,
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
this.onMapTap,
|
this.onMapTap,
|
||||||
this.onMarkerTap,
|
this.onMarkerTap,
|
||||||
|
@ -59,6 +61,8 @@ class GeoMap extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GeoMapState extends State<GeoMap> {
|
class _GeoMapState extends State<GeoMap> {
|
||||||
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
|
|
||||||
// as of google_maps_flutter v2.0.6, Google map initialization is blocking
|
// as of google_maps_flutter v2.0.6, Google map initialization is blocking
|
||||||
// cf https://github.com/flutter/flutter/issues/28493
|
// cf https://github.com/flutter/flutter/issues/28493
|
||||||
// it is especially severe the first time, but still significant afterwards
|
// it is especially severe the first time, but still significant afterwards
|
||||||
|
@ -78,15 +82,7 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final initialEntry = widget.initialEntry;
|
_boundsNotifier = ValueNotifier(_initBounds());
|
||||||
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),
|
|
||||||
));
|
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
_onCollectionChanged();
|
_onCollectionChanged();
|
||||||
}
|
}
|
||||||
|
@ -106,10 +102,17 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
|
|
||||||
void _registerWidget(GeoMap widget) {
|
void _registerWidget(GeoMap widget) {
|
||||||
widget.collectionListenable?.addListener(_onCollectionChanged);
|
widget.collectionListenable?.addListener(_onCollectionChanged);
|
||||||
|
final controller = widget.controller;
|
||||||
|
if (controller != null) {
|
||||||
|
_subscriptions.add(controller.markerLocationChanges.listen((event) => _onCollectionChanged()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unregisterWidget(GeoMap widget) {
|
void _unregisterWidget(GeoMap widget) {
|
||||||
widget.collectionListenable?.removeListener(_onCollectionChanged);
|
widget.collectionListenable?.removeListener(_onCollectionChanged);
|
||||||
|
_subscriptions
|
||||||
|
..forEach((sub) => sub.cancel())
|
||||||
|
..clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -119,6 +122,16 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
if (onTap == null) return;
|
if (onTap == null) return;
|
||||||
|
|
||||||
final clusterId = geoEntry.clusterId;
|
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() {
|
Set<AvesEntry> getClusterEntries() {
|
||||||
if (clusterId == null) {
|
if (clusterId == null) {
|
||||||
return {geoEntry.entry!};
|
return {geoEntry.entry!};
|
||||||
|
@ -135,17 +148,8 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
return points.map((geoEntry) => geoEntry.entry!).toSet();
|
return points.map((geoEntry) => geoEntry.entry!).toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
AvesEntry? markerEntry;
|
final clusterAverageLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!);
|
||||||
if (clusterId != null) {
|
onTap(clusterAverageLocation, markerEntry, getClusterEntries);
|
||||||
final uri = geoEntry.childMarkerId;
|
|
||||||
markerEntry = entries.firstWhereOrNull((v) => v.uri == uri);
|
|
||||||
} else {
|
|
||||||
markerEntry = geoEntry.entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (markerEntry != null) {
|
|
||||||
onTap(markerEntry, getClusterEntries);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return FutureBuilder<bool>(
|
return FutureBuilder<bool>(
|
||||||
|
@ -176,7 +180,7 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
style: mapStyle,
|
style: mapStyle,
|
||||||
markerClusterBuilder: _buildMarkerClusters,
|
markerClusterBuilder: _buildMarkerClusters,
|
||||||
markerWidgetBuilder: _buildMarkerWidget,
|
markerWidgetBuilder: _buildMarkerWidget,
|
||||||
dotEntryNotifier: widget.dotEntryNotifier,
|
dotLocationNotifier: widget.dotLocationNotifier,
|
||||||
onUserZoomChange: widget.onUserZoomChange,
|
onUserZoomChange: widget.onUserZoomChange,
|
||||||
onMapTap: widget.onMapTap,
|
onMapTap: widget.onMapTap,
|
||||||
onMarkerTap: _onMarkerTap,
|
onMarkerTap: _onMarkerTap,
|
||||||
|
@ -191,7 +195,7 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
style: mapStyle,
|
style: mapStyle,
|
||||||
markerClusterBuilder: _buildMarkerClusters,
|
markerClusterBuilder: _buildMarkerClusters,
|
||||||
markerWidgetBuilder: _buildMarkerWidget,
|
markerWidgetBuilder: _buildMarkerWidget,
|
||||||
dotEntryNotifier: widget.dotEntryNotifier,
|
dotLocationNotifier: widget.dotLocationNotifier,
|
||||||
markerSize: Size(
|
markerSize: Size(
|
||||||
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2,
|
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2,
|
||||||
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.markerArrowSize.height,
|
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() {
|
void _onCollectionChanged() {
|
||||||
_defaultMarkerCluster = _buildFluster();
|
_defaultMarkerCluster = _buildFluster();
|
||||||
_slowMarkerCluster = null;
|
_slowMarkerCluster = null;
|
||||||
|
@ -328,4 +344,4 @@ class MarkerKey extends LocalKey with EquatableMixin {
|
||||||
typedef MarkerClusterBuilder = Map<MarkerKey, GeoEntry> Function();
|
typedef MarkerClusterBuilder = Map<MarkerKey, GeoEntry> Function();
|
||||||
typedef MarkerWidgetBuilder = Widget Function(MarkerKey key);
|
typedef MarkerWidgetBuilder = Widget Function(MarkerKey key);
|
||||||
typedef UserZoomChangeCallback = void Function(double zoom);
|
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:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
|
||||||
import 'package:aves/model/entry_images.dart';
|
import 'package:aves/model/entry_images.dart';
|
||||||
import 'package:aves/model/settings/enums.dart';
|
import 'package:aves/model/settings/enums.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
|
@ -27,9 +26,9 @@ class EntryGoogleMap extends StatefulWidget {
|
||||||
final EntryMapStyle style;
|
final EntryMapStyle style;
|
||||||
final MarkerClusterBuilder markerClusterBuilder;
|
final MarkerClusterBuilder markerClusterBuilder;
|
||||||
final MarkerWidgetBuilder markerWidgetBuilder;
|
final MarkerWidgetBuilder markerWidgetBuilder;
|
||||||
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
|
final ValueNotifier<ll.LatLng?>? dotLocationNotifier;
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
final VoidCallback? onMapTap;
|
final void Function(ll.LatLng location)? onMapTap;
|
||||||
final void Function(GeoEntry geoEntry)? onMarkerTap;
|
final void Function(GeoEntry geoEntry)? onMarkerTap;
|
||||||
final MapOpener? openMapPage;
|
final MapOpener? openMapPage;
|
||||||
|
|
||||||
|
@ -43,7 +42,7 @@ class EntryGoogleMap extends StatefulWidget {
|
||||||
required this.style,
|
required this.style,
|
||||||
required this.markerClusterBuilder,
|
required this.markerClusterBuilder,
|
||||||
required this.markerWidgetBuilder,
|
required this.markerWidgetBuilder,
|
||||||
required this.dotEntryNotifier,
|
required this.dotLocationNotifier,
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
this.onMapTap,
|
this.onMapTap,
|
||||||
this.onMarkerTap,
|
this.onMarkerTap,
|
||||||
|
@ -170,13 +169,13 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
||||||
});
|
});
|
||||||
|
|
||||||
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
|
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
|
||||||
return ValueListenableBuilder<AvesEntry?>(
|
return ValueListenableBuilder<ll.LatLng?>(
|
||||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
valueListenable: widget.dotLocationNotifier ?? ValueNotifier(null),
|
||||||
builder: (context, dotEntry, child) {
|
builder: (context, dotLocation, child) {
|
||||||
return GoogleMap(
|
return GoogleMap(
|
||||||
initialCameraPosition: CameraPosition(
|
initialCameraPosition: CameraPosition(
|
||||||
bearing: -bounds.rotation,
|
bearing: -bounds.rotation,
|
||||||
target: _toGoogleLatLng(bounds.center),
|
target: _toGoogleLatLng(bounds.projectedCenter),
|
||||||
zoom: bounds.zoom,
|
zoom: bounds.zoom,
|
||||||
),
|
),
|
||||||
onMapCreated: (controller) async {
|
onMapCreated: (controller) async {
|
||||||
|
@ -205,19 +204,19 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
||||||
myLocationButtonEnabled: false,
|
myLocationButtonEnabled: false,
|
||||||
markers: {
|
markers: {
|
||||||
...markers,
|
...markers,
|
||||||
if (dotEntry != null && _dotMarkerBitmap != null)
|
if (dotLocation != null && _dotMarkerBitmap != null)
|
||||||
Marker(
|
Marker(
|
||||||
markerId: const MarkerId('dot'),
|
markerId: const MarkerId('dot'),
|
||||||
anchor: const Offset(.5, .5),
|
anchor: const Offset(.5, .5),
|
||||||
consumeTapEvents: true,
|
consumeTapEvents: true,
|
||||||
icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!),
|
icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!),
|
||||||
position: _toGoogleLatLng(dotEntry.latLng!),
|
position: _toGoogleLatLng(dotLocation),
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
|
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
|
||||||
onCameraIdle: _onIdle,
|
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 sw = bounds.southwest;
|
||||||
final ne = bounds.northeast;
|
final ne = bounds.northeast;
|
||||||
boundsNotifier.value = ZoomedBounds(
|
boundsNotifier.value = ZoomedBounds(
|
||||||
sw: ll.LatLng(sw.latitude, sw.longitude),
|
sw: _fromGoogleLatLng(sw),
|
||||||
ne: ll.LatLng(ne.latitude, ne.longitude),
|
ne: _fromGoogleLatLng(ne),
|
||||||
zoom: zoom,
|
zoom: zoom,
|
||||||
rotation: rotation,
|
rotation: rotation,
|
||||||
);
|
);
|
||||||
|
@ -262,7 +261,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
||||||
if (controller == null) return;
|
if (controller == null) return;
|
||||||
|
|
||||||
await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition(
|
await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition(
|
||||||
target: _toGoogleLatLng(bounds.center),
|
target: _toGoogleLatLng(bounds.projectedCenter),
|
||||||
zoom: bounds.zoom,
|
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` 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) {
|
MapType _toMapType(EntryMapStyle style) {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
|
|
|
@ -107,9 +107,14 @@ class _MarkerGeneratorItem<T extends Key> {
|
||||||
state = MarkerGeneratorItemState.rendering;
|
state = MarkerGeneratorItemState.rendering;
|
||||||
final boundary = _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
final boundary = _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||||
if (boundary.hasSize && boundary.size != Size.zero) {
|
if (boundary.hasSize && boundary.size != Size.zero) {
|
||||||
|
try {
|
||||||
final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio);
|
final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio);
|
||||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
bytes = byteData?.buffer.asUint8List();
|
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;
|
state = bytes != null ? MarkerGeneratorItemState.done : MarkerGeneratorItemState.waiting;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
|
||||||
import 'package:aves/model/settings/enums.dart';
|
import 'package:aves/model/settings/enums.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
|
@ -29,10 +28,10 @@ class EntryLeafletMap extends StatefulWidget {
|
||||||
final EntryMapStyle style;
|
final EntryMapStyle style;
|
||||||
final MarkerClusterBuilder markerClusterBuilder;
|
final MarkerClusterBuilder markerClusterBuilder;
|
||||||
final MarkerWidgetBuilder markerWidgetBuilder;
|
final MarkerWidgetBuilder markerWidgetBuilder;
|
||||||
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
|
final ValueNotifier<LatLng?>? dotLocationNotifier;
|
||||||
final Size markerSize, dotMarkerSize;
|
final Size markerSize, dotMarkerSize;
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
final VoidCallback? onMapTap;
|
final void Function(LatLng location)? onMapTap;
|
||||||
final void Function(GeoEntry geoEntry)? onMarkerTap;
|
final void Function(GeoEntry geoEntry)? onMarkerTap;
|
||||||
final MapOpener? openMapPage;
|
final MapOpener? openMapPage;
|
||||||
|
|
||||||
|
@ -46,7 +45,7 @@ class EntryLeafletMap extends StatefulWidget {
|
||||||
required this.style,
|
required this.style,
|
||||||
required this.markerClusterBuilder,
|
required this.markerClusterBuilder,
|
||||||
required this.markerWidgetBuilder,
|
required this.markerWidgetBuilder,
|
||||||
required this.dotEntryNotifier,
|
required this.dotLocationNotifier,
|
||||||
required this.markerSize,
|
required this.markerSize,
|
||||||
required this.dotMarkerSize,
|
required this.dotMarkerSize,
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
|
@ -154,7 +153,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
||||||
|
|
||||||
return FlutterMap(
|
return FlutterMap(
|
||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
center: bounds.center,
|
center: bounds.projectedCenter,
|
||||||
zoom: bounds.zoom,
|
zoom: bounds.zoom,
|
||||||
rotation: bounds.rotation,
|
rotation: bounds.rotation,
|
||||||
minZoom: widget.minZoom,
|
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
|
// 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
|
// this could be worked around with https://github.com/fleaflet/flutter_map/pull/960
|
||||||
interactiveFlags: interactive ? InteractiveFlag.all : InteractiveFlag.none,
|
interactiveFlags: interactive ? InteractiveFlag.all : InteractiveFlag.none,
|
||||||
onTap: (tapPosition, point) => widget.onMapTap?.call(),
|
onTap: (tapPosition, point) => widget.onMapTap?.call(point),
|
||||||
controller: _leafletMapController,
|
controller: _leafletMapController,
|
||||||
),
|
),
|
||||||
mapController: _leafletMapController,
|
mapController: _leafletMapController,
|
||||||
|
@ -182,14 +181,14 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
||||||
rotateAlignment: Alignment.bottomCenter,
|
rotateAlignment: Alignment.bottomCenter,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ValueListenableBuilder<AvesEntry?>(
|
ValueListenableBuilder<LatLng?>(
|
||||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
valueListenable: widget.dotLocationNotifier ?? ValueNotifier(null),
|
||||||
builder: (context, dotEntry, child) => MarkerLayerWidget(
|
builder: (context, dotLocation, child) => MarkerLayerWidget(
|
||||||
options: MarkerLayerOptions(
|
options: MarkerLayerOptions(
|
||||||
markers: [
|
markers: [
|
||||||
if (dotEntry != null)
|
if (dotLocation != null)
|
||||||
Marker(
|
Marker(
|
||||||
point: dotEntry.latLng!,
|
point: dotLocation,
|
||||||
builder: (context) => const DotMarker(),
|
builder: (context) => const DotMarker(),
|
||||||
width: dotMarkerSize.width,
|
width: dotMarkerSize.width,
|
||||||
height: dotMarkerSize.height,
|
height: dotMarkerSize.height,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
||||||
import 'package:aves/utils/geo_utils.dart';
|
import 'package:aves/utils/geo_utils.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
|
@ -13,7 +14,17 @@ class ZoomedBounds extends Equatable {
|
||||||
// returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster
|
// returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster
|
||||||
List<double> get boundingBox => [sw.longitude, sw.latitude, ne.longitude, ne.latitude];
|
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
|
@override
|
||||||
List<Object?> get props => [sw, ne, zoom, rotation];
|
List<Object?> get props => [sw, ne, zoom, rotation];
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata/date_modifier.dart';
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
import 'package:aves/model/metadata/enums.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/durations.dart';
|
||||||
import 'package:aves/theme/format.dart';
|
import 'package:aves/theme/format.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
|
@ -304,10 +305,12 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
return 'Exif original date';
|
return 'Exif original date';
|
||||||
case MetadataField.exifDateDigitized:
|
case MetadataField.exifDateDigitized:
|
||||||
return 'Exif digitized date';
|
return 'Exif digitized date';
|
||||||
case MetadataField.exifGpsDate:
|
case MetadataField.exifGpsDatestamp:
|
||||||
return 'Exif GPS date';
|
return 'Exif GPS date';
|
||||||
case MetadataField.xmpCreateDate:
|
case MetadataField.xmpCreateDate:
|
||||||
return 'XMP xmp:CreateDate';
|
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),
|
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:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class MapPage extends StatelessWidget {
|
class MapPage extends StatelessWidget {
|
||||||
|
@ -54,7 +55,7 @@ class MapPage extends StatelessWidget {
|
||||||
top: false,
|
top: false,
|
||||||
right: false,
|
right: false,
|
||||||
bottom: true,
|
bottom: true,
|
||||||
child: MapPageContent(
|
child: _Content(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
initialEntry: initialEntry,
|
initialEntry: initialEntry,
|
||||||
),
|
),
|
||||||
|
@ -65,26 +66,27 @@ class MapPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MapPageContent extends StatefulWidget {
|
class _Content extends StatefulWidget {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
final AvesEntry? initialEntry;
|
final AvesEntry? initialEntry;
|
||||||
|
|
||||||
const MapPageContent({
|
const _Content({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.collection,
|
required this.collection,
|
||||||
this.initialEntry,
|
this.initialEntry,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@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 List<StreamSubscription> _subscriptions = [];
|
||||||
final AvesMapController _mapController = AvesMapController();
|
final AvesMapController _mapController = AvesMapController();
|
||||||
late final ValueNotifier<bool> _isPageAnimatingNotifier;
|
late final ValueNotifier<bool> _isPageAnimatingNotifier;
|
||||||
final ValueNotifier<int?> _selectedIndexNotifier = ValueNotifier(0);
|
final ValueNotifier<int?> _selectedIndexNotifier = ValueNotifier(0);
|
||||||
final ValueNotifier<CollectionLens?> _regionCollectionNotifier = ValueNotifier(null);
|
final ValueNotifier<CollectionLens?> _regionCollectionNotifier = ValueNotifier(null);
|
||||||
|
final ValueNotifier<LatLng?> _dotLocationNotifier = ValueNotifier(null);
|
||||||
final ValueNotifier<AvesEntry?> _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null);
|
final ValueNotifier<AvesEntry?> _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null);
|
||||||
final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay);
|
final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay);
|
||||||
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
|
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
|
||||||
|
@ -223,11 +225,11 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
|
||||||
controller: _mapController,
|
controller: _mapController,
|
||||||
collectionListenable: openingCollection,
|
collectionListenable: openingCollection,
|
||||||
entries: openingCollection.sortedEntries,
|
entries: openingCollection.sortedEntries,
|
||||||
initialEntry: widget.initialEntry,
|
initialCenter: widget.initialEntry?.latLng,
|
||||||
isAnimatingNotifier: _isPageAnimatingNotifier,
|
isAnimatingNotifier: _isPageAnimatingNotifier,
|
||||||
dotEntryNotifier: _dotEntryNotifier,
|
dotLocationNotifier: _dotLocationNotifier,
|
||||||
onMapTap: _toggleOverlay,
|
onMapTap: (_) => _toggleOverlay(),
|
||||||
onMarkerTap: (markerEntry, getClusterEntries) async {
|
onMarkerTap: (averageLocation, markerEntry, getClusterEntries) async {
|
||||||
final index = regionCollection?.sortedEntries.indexOf(markerEntry);
|
final index = regionCollection?.sortedEntries.indexOf(markerEntry);
|
||||||
if (index != null && _selectedIndexNotifier.value != index) {
|
if (index != null && _selectedIndexNotifier.value != index) {
|
||||||
_selectedIndexNotifier.value = index;
|
_selectedIndexNotifier.value = index;
|
||||||
|
@ -337,7 +339,10 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
|
||||||
|
|
||||||
void _onThumbnailIndexChange() => _onEntrySelected(_getRegionEntry(_selectedIndexNotifier.value));
|
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() {
|
void _updateInfoEntry() {
|
||||||
final selectedEntry = _dotEntryNotifier.value;
|
final selectedEntry = _dotEntryNotifier.value;
|
||||||
|
|
|
@ -67,7 +67,7 @@ class _DrawerAlbumTabState extends State<DrawerAlbumTab> {
|
||||||
if (album == null) return;
|
if (album == null) return;
|
||||||
setState(() => widget.items.add(album));
|
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/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_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/dialogs/export_entry_dialog.dart';
|
||||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||||
import 'package:aves/widgets/viewer/action/printer.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/actions/events.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/entry_metadata_edition.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/entry_editor.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.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 {
|
class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin {
|
||||||
@override
|
@override
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
final CollectionLens? collection;
|
||||||
|
|
||||||
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController<ActionEvent<EntryInfoAction>>.broadcast();
|
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController<ActionEvent<EntryInfoAction>>.broadcast();
|
||||||
|
|
||||||
Stream<ActionEvent<EntryInfoAction>> get eventStream => _eventStreamController.stream;
|
Stream<ActionEvent<EntryInfoAction>> get eventStream => _eventStreamController.stream;
|
||||||
|
|
||||||
EntryInfoActionDelegate(this.entry);
|
EntryInfoActionDelegate(this.entry, this.collection);
|
||||||
|
|
||||||
bool isVisible(EntryInfoAction action) {
|
bool isVisible(EntryInfoAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
|
case EntryInfoAction.editLocation:
|
||||||
case EntryInfoAction.editRating:
|
case EntryInfoAction.editRating:
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
|
@ -40,6 +43,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return entry.canEditDate;
|
return entry.canEditDate;
|
||||||
|
case EntryInfoAction.editLocation:
|
||||||
|
return entry.canEditLocation;
|
||||||
case EntryInfoAction.editRating:
|
case EntryInfoAction.editRating:
|
||||||
return entry.canEditRating;
|
return entry.canEditRating;
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
|
@ -59,6 +64,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
await _editDate(context);
|
await _editDate(context);
|
||||||
break;
|
break;
|
||||||
|
case EntryInfoAction.editLocation:
|
||||||
|
await _editLocation(context);
|
||||||
|
break;
|
||||||
case EntryInfoAction.editRating:
|
case EntryInfoAction.editRating:
|
||||||
await _editRating(context);
|
await _editRating(context);
|
||||||
break;
|
break;
|
||||||
|
@ -83,6 +91,13 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
await edit(context, () => entry.editDate(modifier));
|
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 {
|
Future<void> _editRating(BuildContext context) async {
|
||||||
final rating = await selectRating(context, {entry});
|
final rating = await selectRating(context, {entry});
|
||||||
if (rating == null) return;
|
if (rating == null) return;
|
||||||
|
|
|
@ -32,7 +32,20 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
|
||||||
try {
|
try {
|
||||||
if (success) {
|
if (success) {
|
||||||
if (isMainMode && source != null) {
|
if (isMainMode && source != null) {
|
||||||
|
Set<String> obsoleteTags = entry.tags;
|
||||||
|
String? obsoleteCountryCode = entry.addressDetails?.countryCode;
|
||||||
|
|
||||||
await source.refreshEntry(entry, dataTypes);
|
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 {
|
} else {
|
||||||
await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
|
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) {
|
void _registerWidget(_InfoPageContent widget) {
|
||||||
_actionDelegate = EntryInfoActionDelegate(widget.entry);
|
_actionDelegate = EntryInfoActionDelegate(widget.entry, collection);
|
||||||
_subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent));
|
_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/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.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/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/geo_map.dart';
|
||||||
import 'package:aves/widgets/common/map/theme.dart';
|
import 'package:aves/widgets/common/map/theme.dart';
|
||||||
import 'package:aves/widgets/map/map_page.dart';
|
import 'package:aves/widgets/map/map_page.dart';
|
||||||
|
@ -34,6 +35,8 @@ class LocationSection extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LocationSectionState extends State<LocationSection> {
|
class _LocationSectionState extends State<LocationSection> {
|
||||||
|
final AvesMapController _mapController = AvesMapController();
|
||||||
|
|
||||||
CollectionLens? get collection => widget.collection;
|
CollectionLens? get collection => widget.collection;
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
@ -58,28 +61,17 @@ class _LocationSectionState extends State<LocationSection> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerWidget(LocationSection widget) {
|
void _registerWidget(LocationSection widget) {
|
||||||
widget.entry.metadataChangeNotifier.addListener(_handleChange);
|
widget.entry.metadataChangeNotifier.addListener(_onMetadataChange);
|
||||||
widget.entry.addressChangeNotifier.addListener(_handleChange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unregisterWidget(LocationSection widget) {
|
void _unregisterWidget(LocationSection widget) {
|
||||||
widget.entry.metadataChangeNotifier.removeListener(_handleChange);
|
widget.entry.metadataChangeNotifier.removeListener(_onMetadataChange);
|
||||||
widget.entry.addressChangeNotifier.removeListener(_handleChange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!entry.hasGps) return const SizedBox();
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -91,13 +83,29 @@ class _LocationSectionState extends State<LocationSection> {
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
mapHeight: 200,
|
mapHeight: 200,
|
||||||
child: GeoMap(
|
child: GeoMap(
|
||||||
|
controller: _mapController,
|
||||||
entries: [entry],
|
entries: [entry],
|
||||||
isAnimatingNotifier: widget.isScrollingNotifier,
|
isAnimatingNotifier: widget.isScrollingNotifier,
|
||||||
onUserZoomChange: (zoom) => settings.infoMapZoom = zoom,
|
onUserZoomChange: (zoom) => settings.infoMapZoom = zoom,
|
||||||
onMarkerTap: collection != null ? (_, __) => _openMapPage(context) : null,
|
onMarkerTap: collection != null ? (_, __, ___) => _openMapPage(context) : null,
|
||||||
openMapPage: collection != null ? _openMapPage : null,
|
openMapPage: collection != null ? _openMapPage : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
_AddressInfoGroup(entry: entry),
|
||||||
if (filters.isNotEmpty)
|
if (filters.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
|
@ -115,6 +123,10 @@ class _LocationSectionState extends State<LocationSection> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openMapPage(BuildContext context) {
|
void _openMapPage(BuildContext context) {
|
||||||
|
@ -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 {
|
class _AddressInfoGroup extends StatefulWidget {
|
||||||
|
|
|
@ -1,22 +1,64 @@
|
||||||
{
|
{
|
||||||
"de": [
|
"de": [
|
||||||
|
"entryInfoActionEditLocation",
|
||||||
"exportEntryDialogWidth",
|
"exportEntryDialogWidth",
|
||||||
"exportEntryDialogHeight"
|
"exportEntryDialogHeight",
|
||||||
|
"editEntryLocationDialogTitle",
|
||||||
|
"editEntryLocationDialogChooseOnMapTooltip",
|
||||||
|
"editEntryLocationDialogLatitude",
|
||||||
|
"editEntryLocationDialogLongitude",
|
||||||
|
"locationPickerUseThisLocationButton"
|
||||||
],
|
],
|
||||||
|
|
||||||
"es": [
|
"es": [
|
||||||
|
"entryInfoActionEditLocation",
|
||||||
"exportEntryDialogWidth",
|
"exportEntryDialogWidth",
|
||||||
"exportEntryDialogHeight"
|
"exportEntryDialogHeight",
|
||||||
|
"editEntryLocationDialogTitle",
|
||||||
|
"editEntryLocationDialogChooseOnMapTooltip",
|
||||||
|
"editEntryLocationDialogLatitude",
|
||||||
|
"editEntryLocationDialogLongitude",
|
||||||
|
"locationPickerUseThisLocationButton"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fr": [
|
||||||
|
"entryInfoActionEditLocation",
|
||||||
|
"editEntryLocationDialogTitle",
|
||||||
|
"editEntryLocationDialogChooseOnMapTooltip",
|
||||||
|
"editEntryLocationDialogLatitude",
|
||||||
|
"editEntryLocationDialogLongitude",
|
||||||
|
"locationPickerUseThisLocationButton"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ko": [
|
||||||
|
"entryInfoActionEditLocation",
|
||||||
|
"editEntryLocationDialogTitle",
|
||||||
|
"editEntryLocationDialogChooseOnMapTooltip",
|
||||||
|
"editEntryLocationDialogLatitude",
|
||||||
|
"editEntryLocationDialogLongitude",
|
||||||
|
"locationPickerUseThisLocationButton"
|
||||||
],
|
],
|
||||||
|
|
||||||
"pt": [
|
"pt": [
|
||||||
|
"entryInfoActionEditLocation",
|
||||||
"exportEntryDialogWidth",
|
"exportEntryDialogWidth",
|
||||||
"exportEntryDialogHeight"
|
"exportEntryDialogHeight",
|
||||||
|
"editEntryLocationDialogTitle",
|
||||||
|
"editEntryLocationDialogChooseOnMapTooltip",
|
||||||
|
"editEntryLocationDialogLatitude",
|
||||||
|
"editEntryLocationDialogLongitude",
|
||||||
|
"locationPickerUseThisLocationButton"
|
||||||
],
|
],
|
||||||
|
|
||||||
"ru": [
|
"ru": [
|
||||||
|
"entryInfoActionEditLocation",
|
||||||
"exportEntryDialogWidth",
|
"exportEntryDialogWidth",
|
||||||
"exportEntryDialogHeight",
|
"exportEntryDialogHeight",
|
||||||
|
"editEntryLocationDialogTitle",
|
||||||
|
"editEntryLocationDialogChooseOnMapTooltip",
|
||||||
|
"editEntryLocationDialogLatitude",
|
||||||
|
"editEntryLocationDialogLongitude",
|
||||||
|
"locationPickerUseThisLocationButton",
|
||||||
"appExportCovers",
|
"appExportCovers",
|
||||||
"settingsThumbnailShowFavouriteIcon"
|
"settingsThumbnailShowFavouriteIcon"
|
||||||
]
|
]
|
||||||
|
|
Loading…
Reference in a new issue