diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index ba7d520cc..340a69dca 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -8,13 +8,14 @@ import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/service_policy.dart'; import 'package:aves/utils/change_notifier.dart'; +import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:geocoder/geocoder.dart'; +import 'package:latlong/latlong.dart'; import 'package:path/path.dart' as ppath; -import 'package:tuple/tuple.dart'; import 'mime_types.dart'; @@ -295,9 +296,14 @@ class ImageEntry { bool get isLocated => _addressDetails != null; - Tuple2 get latLng => isCatalogued ? Tuple2(_catalogMetadata.latitude, _catalogMetadata.longitude) : null; + LatLng get latLng => isCatalogued ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null; - String get geoUri => hasGps ? 'geo:${_catalogMetadata.latitude},${_catalogMetadata.longitude}?q=${_catalogMetadata.latitude},${_catalogMetadata.longitude}' : null; + String get geoUri { + if (!hasGps) return null; + final latitude = roundToPrecision(_catalogMetadata.latitude, decimals: 6); + final longitude = roundToPrecision(_catalogMetadata.longitude, decimals: 6); + return 'geo:$latitude,$longitude?q=$latitude,$longitude'; + } List get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? []; @@ -366,7 +372,6 @@ class ImageEntry { final address = addresses.first; addressDetails = AddressDetails( contentId: contentId, - addressLine: address.addressLine, countryCode: address.countryCode, countryName: address.countryName, adminArea: address.adminArea, @@ -378,11 +383,29 @@ class ImageEntry { } } + Future findAddressLine() async { + final latitude = _catalogMetadata?.latitude; + final longitude = _catalogMetadata?.longitude; + if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return null; + + final coordinates = Coordinates(latitude, longitude); + try { + final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates); + if (addresses != null && addresses.isNotEmpty) { + final address = addresses.first; + return address.addressLine; + } + } catch (error, stackTrace) { + debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stackTrace'); + } + return null; + } + String get shortAddress { if (!isLocated) return ''; - // admin area examples: Seoul, Geneva, null - // locality examples: Mapo-gu, Geneva, Annecy + // `admin area` examples: Seoul, Geneva, null + // `locality` examples: Mapo-gu, Geneva, Annecy return { _addressDetails.countryName, _addressDetails.adminArea, @@ -390,12 +413,13 @@ class ImageEntry { }.where((part) => part != null && part.isNotEmpty).join(', '); } - bool search(String query) { - if (bestTitle?.toUpperCase()?.contains(query) ?? false) return true; - if (_catalogMetadata?.xmpSubjects?.toUpperCase()?.contains(query) ?? false) return true; - if (_addressDetails?.addressLine?.toUpperCase()?.contains(query) ?? false) return true; - return false; - } + bool search(String query) => { + bestTitle, + _catalogMetadata?.xmpSubjects, + _addressDetails?.countryName, + _addressDetails?.adminArea, + _addressDetails?.locality, + }.any((s) => s != null && s.toUpperCase().contains(query)); Future _applyNewFields(Map newFields) async { final uri = newFields['uri']; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index 42ec4f5fd..57f98a0eb 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -142,13 +142,12 @@ class OverlayMetadata { class AddressDetails { final int contentId; - final String addressLine, countryCode, countryName, adminArea, locality; + final String countryCode, countryName, adminArea, locality; String get place => locality != null && locality.isNotEmpty ? locality : adminArea; AddressDetails({ this.contentId, - this.addressLine, this.countryCode, this.countryName, this.adminArea, @@ -160,7 +159,6 @@ class AddressDetails { }) { return AddressDetails( contentId: contentId ?? this.contentId, - addressLine: addressLine, countryCode: countryCode, countryName: countryName, adminArea: adminArea, @@ -171,7 +169,6 @@ class AddressDetails { factory AddressDetails.fromMap(Map map) { return AddressDetails( contentId: map['contentId'], - addressLine: map['addressLine'] ?? '', countryCode: map['countryCode'] ?? '', countryName: map['countryName'] ?? '', adminArea: map['adminArea'] ?? '', @@ -181,7 +178,6 @@ class AddressDetails { Map toMap() => { 'contentId': contentId, - 'addressLine': addressLine, 'countryCode': countryCode, 'countryName': countryName, 'adminArea': adminArea, @@ -190,7 +186,7 @@ class AddressDetails { @override String toString() { - return 'AddressDetails{contentId=$contentId, addressLine=$addressLine, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; + return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; } } diff --git a/lib/model/settings/coordinate_format.dart b/lib/model/settings/coordinate_format.dart index be88e0ca1..7b968ba88 100644 --- a/lib/model/settings/coordinate_format.dart +++ b/lib/model/settings/coordinate_format.dart @@ -1,4 +1,5 @@ import 'package:aves/utils/geo_utils.dart'; +import 'package:latlong/latlong.dart'; import 'package:tuple/tuple.dart'; enum CoordinateFormat { dms, decimal } @@ -15,12 +16,12 @@ extension ExtraCoordinateFormat on CoordinateFormat { } } - String format(Tuple2 latLng) { + String format(LatLng latLng) { switch (this) { case CoordinateFormat.dms: return toDMS(latLng).join(', '); case CoordinateFormat.decimal: - return [latLng.item1, latLng.item2].map((n) => n.toStringAsFixed(6)).join(', '); + return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', '); default: return toString(); } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 789a1e1eb..6312bffeb 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:aves/model/filters/location.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; @@ -30,15 +32,25 @@ mixin LocationMixin on SourceBase { final todo = byLocated[false] ?? []; if (todo.isEmpty) return; - // cache known locations to avoid querying the geocoder unless necessary - // measuring the time it takes to process ~3000 coordinates (with ~10% of duplicates) - // does not clearly show whether it is an actual optimization, - // as results vary wildly (durations in "min:sec"): - // - with no cache: 06:17, 08:36, 08:34 - // - with cache: 08:28, 05:42, 08:03, 05:58 - // anyway, in theory it should help! - final knownLocations = , AddressDetails>{}; - byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(entry.latLng, () => entry.addressDetails)); + // geocoder calls take between 150ms and 250ms + // approximation and caching can reduce geocoder usage + // for example, for a set of 2932 entries: + // - 2476 calls (84%) when approximating to 6 decimal places (~10cm - individual humans) + // - 2433 calls (83%) when approximating to 5 decimal places (~1m - individual trees, houses) + // - 2277 calls (78%) when approximating to 4 decimal places (~10m - individual street, large buildings) + // - 1521 calls (52%) when approximating to 3 decimal places (~100m - neighborhood, street) + // - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village) + // cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision + final latLngFactor = pow(10, 2); + Tuple2 approximateLatLng(ImageEntry entry) { + final lat = entry.catalogMetadata?.latitude; + final lng = entry.catalogMetadata?.longitude; + if (lat == null || lng == null) return null; + return Tuple2((lat * latLngFactor).round(), (lng * latLngFactor).round()); + } + + final knownLocations = {}; + byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails)); var progressDone = 0; final progressTotal = todo.length; @@ -46,13 +58,14 @@ mixin LocationMixin on SourceBase { final newAddresses = []; await Future.forEach(todo, (entry) async { - if (knownLocations.containsKey(entry.latLng)) { - entry.addressDetails = knownLocations[entry.latLng]?.copyWith(contentId: entry.contentId); + final latLng = approximateLatLng(entry); + if (knownLocations.containsKey(latLng)) { + entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); } else { await entry.locate(background: true); // it is intended to insert `null` if the geocoder failed, // so that we skip geocoding of following entries with the same coordinates - knownLocations[entry.latLng] = entry.addressDetails; + knownLocations[latLng] = entry.addressDetails; } if (entry.isLocated) { newAddresses.add(entry.addressDetails); diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 9c8682314..59d567b65 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/painting.dart'; -import 'package:tuple/tuple.dart'; +import 'package:latlong/latlong.dart'; class Constants { // as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped @@ -21,7 +21,7 @@ class Constants { static const String overlayUnknown = '—'; // em dash static const String infoUnknown = 'unknown'; - static const pointNemo = Tuple2(-48.876667, -123.393333); + static final pointNemo = LatLng(-48.876667, -123.393333); static const int infoGroupMaxValueLength = 140; diff --git a/lib/utils/geo_utils.dart b/lib/utils/geo_utils.dart index 226eaa95b..62a942c2a 100644 --- a/lib/utils/geo_utils.dart +++ b/lib/utils/geo_utils.dart @@ -1,15 +1,12 @@ -import 'dart:math'; - +import 'package:aves/utils/math_utils.dart'; import 'package:intl/intl.dart'; -import 'package:tuple/tuple.dart'; +import 'package:latlong/latlong.dart'; String _decimal2sexagesimal(final double degDecimal) { - double _round(final double value, {final int decimals = 6}) => (value * pow(10, decimals)).round() / pow(10, decimals); - List _split(final double value) { // NumberFormat is necessary to create digit after comma if the value // has no decimal point (only necessary for browser) - final tmp = NumberFormat('0.0#####').format(_round(value, decimals: 10)).split('.'); + final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.'); return [ int.parse(tmp[0]).abs(), int.parse(tmp[1]), @@ -21,14 +18,14 @@ String _decimal2sexagesimal(final double degDecimal) { final min = _split(minDecimal)[0]; final sec = (minDecimal - min) * 60; - return '$deg° $min′ ${_round(sec, decimals: 2).toStringAsFixed(2)}″'; + return '$deg° $min′ ${roundToPrecision(sec, decimals: 2).toStringAsFixed(2)}″'; } // return coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E'] -List toDMS(Tuple2 latLng) { +List toDMS(LatLng latLng) { if (latLng == null) return []; - final lat = latLng.item1; - final lng = latLng.item2; + final lat = latLng.latitude; + final lng = latLng.longitude; return [ '${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}', '${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}', diff --git a/lib/utils/math_utils.dart b/lib/utils/math_utils.dart index 299404753..541ebb5c5 100644 --- a/lib/utils/math_utils.dart +++ b/lib/utils/math_utils.dart @@ -1,5 +1,7 @@ import 'dart:math'; +import 'package:flutter/foundation.dart'; + final double _log2 = log(2); const double _piOver180 = pi / 180.0; @@ -9,6 +11,8 @@ double toRadians(num degrees) => degrees * _piOver180; int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor()); +double roundToPrecision(final double value, {@required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals); + // e.g. x=12345, precision=3 should return 13000 int ceilBy(num x, int precision) { final factor = pow(10, precision); diff --git a/lib/widgets/fullscreen/debug/db.dart b/lib/widgets/fullscreen/debug/db.dart index 4abe39a68..745368d1d 100644 --- a/lib/widgets/fullscreen/debug/db.dart +++ b/lib/widgets/fullscreen/debug/db.dart @@ -128,7 +128,6 @@ class _DbTabState extends State { Text('DB address:${data == null ? ' no row' : ''}'), if (data != null) InfoRowGroup({ - 'addressLine': '${data.addressLine}', 'countryCode': '${data.countryCode}', 'countryName': '${data.countryName}', 'adminArea': '${data.adminArea}', diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index c6a1e3db2..d80b1ab80 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -12,6 +12,7 @@ import 'package:aves/widgets/fullscreen/info/maps/google_map.dart'; import 'package:aves/widgets/fullscreen/info/maps/leaflet_map.dart'; import 'package:aves/widgets/fullscreen/info/maps/marker.dart'; import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; class LocationSection extends StatefulWidget { final CollectionLens collection; @@ -79,11 +80,9 @@ class _LocationSectionState extends State with TickerProviderSt final showMap = (_loadedUri == entry.uri) || (entry.hasGps && widget.visibleNotifier.value); if (showMap) { _loadedUri = entry.uri; - var location = ''; final filters = []; if (entry.isLocated) { final address = entry.addressDetails; - location = address.addressLine; final country = address.countryName; if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}')); final place = address.place; @@ -114,7 +113,8 @@ class _LocationSectionState extends State with TickerProviderSt vsync: this, child: settings.infoMapStyle.isGoogleMaps ? EntryGoogleMap( - latLng: entry.latLng, + // `LatLng` used by `google_maps_flutter` is not the one from `latlong` package + latLng: Tuple2(entry.latLng.latitude, entry.latLng.longitude), geoUri: entry.geoUri, initialZoom: settings.infoMapZoom, markerId: entry.uri ?? entry.path, @@ -130,11 +130,7 @@ class _LocationSectionState extends State with TickerProviderSt ), ), ), - if (entry.hasGps) - InfoRowGroup(Map.fromEntries([ - MapEntry('Coordinates', settings.coordinateFormat.format(entry.latLng)), - if (location.isNotEmpty) MapEntry('Address', location), - ])), + if (entry.hasGps) _AddressInfoGroup(entry: entry), if (filters.isNotEmpty) Padding( padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8), @@ -160,6 +156,41 @@ class _LocationSectionState extends State with TickerProviderSt void _handleChange() => setState(() {}); } +class _AddressInfoGroup extends StatefulWidget { + final ImageEntry entry; + + const _AddressInfoGroup({@required this.entry}); + + @override + _AddressInfoGroupState createState() => _AddressInfoGroupState(); +} + +class _AddressInfoGroupState extends State<_AddressInfoGroup> { + Future _addressLineLoader; + + ImageEntry get entry => widget.entry; + + @override + void initState() { + super.initState(); + _addressLineLoader = entry.findAddressLine(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _addressLineLoader, + builder: (context, snapshot) { + final address = !snapshot.hasError && snapshot.connectionState == ConnectionState.done ? snapshot.data : ''; + return InfoRowGroup({ + 'Coordinates': settings.coordinateFormat.format(entry.latLng), + if (address.isNotEmpty) 'Address': address, + }); + }, + ); + } +} + // browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/ enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor } diff --git a/lib/widgets/fullscreen/info/maps/leaflet_map.dart b/lib/widgets/fullscreen/info/maps/leaflet_map.dart index cc6fd5fcd..84c8bc203 100644 --- a/lib/widgets/fullscreen/info/maps/leaflet_map.dart +++ b/lib/widgets/fullscreen/info/maps/leaflet_map.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:latlong/latlong.dart'; -import 'package:tuple/tuple.dart'; import 'package:url_launcher/url_launcher.dart'; import '../location_section.dart'; @@ -18,16 +17,15 @@ class EntryLeafletMap extends StatefulWidget { final Size markerSize; final WidgetBuilder markerBuilder; - EntryLeafletMap({ + const EntryLeafletMap({ Key key, - Tuple2 latLng, + this.latLng, this.geoUri, this.initialZoom, this.style, this.markerBuilder, this.markerSize, - }) : latLng = LatLng(latLng.item1, latLng.item2), - super(key: key); + }) : super(key: key); @override State createState() => EntryLeafletMapState(); diff --git a/test/utils/geo_utils_test.dart b/test/utils/geo_utils_test.dart index 0057dfb10..c728d71c2 100644 --- a/test/utils/geo_utils_test.dart +++ b/test/utils/geo_utils_test.dart @@ -1,12 +1,13 @@ import 'package:aves/utils/geo_utils.dart'; +import 'package:latlong/latlong.dart'; import 'package:test/test.dart'; -import 'package:tuple/tuple.dart'; void main() { test('Decimal degrees to DMS (sexagesimal)', () { - expect(toDMS(Tuple2(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam - expect(toDMS(Tuple2(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund - expect(toDMS(Tuple2(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo - expect(toDMS(Tuple2(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio + expect(toDMS(LatLng(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam + expect(toDMS(LatLng(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund + expect(toDMS(LatLng(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo + expect(toDMS(LatLng(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio + expect(toDMS(LatLng(0, 0)), ['0° 0′ 0.00″ N', '0° 0′ 0.00″ E']); }); } diff --git a/test/utils/math_utils_test.dart b/test/utils/math_utils_test.dart index 13eed0641..a0440626f 100644 --- a/test/utils/math_utils_test.dart +++ b/test/utils/math_utils_test.dart @@ -21,6 +21,11 @@ void main() { expect(highestPowerOf2(-42), 0); }); + test('rounding to a given precision after the decimal', () { + expect(roundToPrecision(1.2345678, decimals: 3), 1.235); + expect(roundToPrecision(0, decimals: 3), 0); + }); + test('rounding up to a given precision before the decimal', () { expect(ceilBy(12345.678, 3), 13000); expect(ceilBy(42, 3), 1000);