#2 info: edit location;

fixes for map center computation, DB & filter chip update on metadata changes, offscreen marker generation
This commit is contained in:
Thibault Deckers 2022-01-26 17:47:23 +09:00
parent d44b001bb7
commit e0f45f03c1
38 changed files with 1206 additions and 176 deletions

View file

@ -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(

View file

@ -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",

View file

@ -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:

View file

@ -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

View file

@ -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 = {};

View file

@ -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,
]; ];

View file

@ -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;
} }
} }
} }

View 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;
}
}
}

View file

@ -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));
} }

View file

@ -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}));
} }

View file

@ -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));
} }

View file

@ -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));
} }

View file

@ -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;

View file

@ -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) {

View file

@ -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);

View file

@ -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,
) ),
], ],
), ),
); );

View file

@ -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,

View file

@ -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 {}

View file

@ -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);

View file

@ -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) {

View file

@ -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;
} }

View file

@ -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,

View file

@ -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];

View file

@ -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;
} }
} }

View 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 }

View file

@ -70,7 +70,7 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
child: Text(context.l10n.applyButtonLabel), child: Text(context.l10n.applyButtonLabel),
); );
}, },
) ),
], ],
); );
} }

View 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,
),
],
);
}
}

View file

@ -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;

View file

@ -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));
}, },
) ),
], ],
); );
} }

View file

@ -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';

View file

@ -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;

View file

@ -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);
} }

View file

@ -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));
} }

View file

@ -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 {

View file

@ -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"
] ]