quick country reverse geolocation w/o play services

This commit is contained in:
Thibault Deckers 2021-02-19 18:55:15 +09:00
parent d5cfab6236
commit c34faa1568
28 changed files with 616 additions and 62 deletions

File diff suppressed because one or more lines are too long

95
lib/geo/countries.dart Normal file
View file

@ -0,0 +1,95 @@
import 'dart:async';
import 'package:aves/geo/topojson.dart';
import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong/latlong.dart';
final CountryTopology countryTopology = CountryTopology._private();
class CountryTopology {
static const topoJsonAsset = 'assets/countries-50m.json';
CountryTopology._private();
Topology _topology;
Future<Topology> getTopology() => _topology != null ? SynchronousFuture(_topology) : rootBundle.loadString(topoJsonAsset).then(TopoJson().parse);
// return the country containing given coordinates
Future<CountryCode> countryCode(LatLng position) async {
return _countryOfNumeric(await numericCode(position));
}
// return the ISO 3166-1 numeric code of the country containing given coordinates
Future<int> numericCode(LatLng position) async {
final topology = await getTopology();
if (topology == null) return null;
final countries = (topology.objects['countries'] as GeometryCollection).geometries;
return _getNumeric(topology, countries, position);
}
// return a map of the given positions by country
Future<Map<CountryCode, Set<LatLng>>> countryCodeMap(Set<LatLng> positions) async {
final numericMap = await numericCodeMap(positions);
numericMap.remove(null);
final codeMap = numericMap.map((key, value) {
final code = _countryOfNumeric(key);
return code == null ? null : MapEntry(code, value);
});
codeMap.remove(null);
return codeMap;
}
// return a map of the given positions by the ISO 3166-1 numeric code of the country containing them
Future<Map<int, Set<LatLng>>> numericCodeMap(Set<LatLng> positions) async {
final topology = await getTopology();
if (topology == null) return null;
return compute(_isoNumericCodeMap, _IsoNumericCodeMapData(topology, positions));
}
static Future<Map<int, Set<LatLng>>> _isoNumericCodeMap(_IsoNumericCodeMapData data) async {
try {
final topology = data.topology;
final countries = (topology.objects['countries'] as GeometryCollection).geometries;
final byCode = <int, Set<LatLng>>{};
for (final position in data.positions) {
final code = _getNumeric(topology, countries, position);
byCode[code] = (byCode[code] ?? {})..add(position);
}
return byCode;
} catch (error, stack) {
// an unhandled error in a spawn isolate would make the app crash
debugPrint('failed to get country codes with error=$error\n$stack');
}
return null;
}
static int _getNumeric(Topology topology, List<Geometry> countries, LatLng position) {
final point = [position.longitude, position.latitude];
final hit = countries.firstWhere((country) => country.containsPoint(topology, point), orElse: () => null);
final idString = (hit?.id as String);
final code = idString == null ? null : int.tryParse(idString);
return code;
}
static CountryCode _countryOfNumeric(int numeric) {
if (numeric == null) return null;
try {
return CountryCode.ofNumeric(numeric);
} catch (error) {
debugPrint('failed to find country for numeric=$numeric with error=$error');
}
return null;
}
}
class _IsoNumericCodeMapData {
Topology topology;
Set<LatLng> positions;
_IsoNumericCodeMapData(this.topology, this.positions);
}

245
lib/geo/topojson.dart Normal file
View file

@ -0,0 +1,245 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
// cf https://github.com/topojson/topojson-specification
class TopoJson {
Future<Topology> parse(String data) async {
return compute(_isoParse, data);
}
static Topology _isoParse(String jsonData) {
try {
final data = json.decode(jsonData) as Map<String, dynamic>;
return Topology.parse(data);
} catch (error, stack) {
// an unhandled error in a spawn isolate would make the app crash
debugPrint('failed to parse TopoJSON with error=$error\n$stack');
}
return null;
}
}
enum TopoJsonObjectType { topology, point, multipoint, linestring, multilinestring, polygon, multipolygon, geometrycollection }
TopoJsonObjectType _parseTopoJsonObjectType(String data) {
switch (data) {
case 'Topology':
return TopoJsonObjectType.topology;
case 'Point':
return TopoJsonObjectType.point;
case 'MultiPoint':
return TopoJsonObjectType.multipoint;
case 'LineString':
return TopoJsonObjectType.linestring;
case 'MultiLineString':
return TopoJsonObjectType.multilinestring;
case 'Polygon':
return TopoJsonObjectType.polygon;
case 'MultiPolygon':
return TopoJsonObjectType.multipolygon;
case 'GeometryCollection':
return TopoJsonObjectType.geometrycollection;
}
return null;
}
class TopologyJsonObject {
final List<num> bbox;
TopologyJsonObject.parse(Map<String, dynamic> data) : bbox = data.containsKey('bbox') ? (data['bbox'] as List).cast<num>().toList() : null;
}
class Topology extends TopologyJsonObject {
final Map<String, Geometry> objects;
final List<List<List<num>>> arcs;
final Transform transform;
Topology.parse(Map<String, dynamic> data)
: objects = (data['objects'] as Map).cast<String, dynamic>().map<String, Geometry>((name, geometry) => MapEntry(name, Geometry.build(geometry))),
arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<List>().map((position) => position.cast<num>()).toList()).toList(),
transform = data.containsKey('transform') ? Transform.parse((data['transform'] as Map).cast<String, dynamic>()) : null,
super.parse(data);
List<List<num>> _arcAt(int index) {
var arc = arcs[index < 0 ? ~index : index];
if (transform != null) {
var x = 0, y = 0;
arc = arc.map((quantized) {
final absolute = List.of(quantized);
absolute[0] = (x += quantized[0]) * transform.scale[0] + transform.translate[0];
absolute[1] = (y += quantized[1]) * transform.scale[1] + transform.translate[1];
return absolute;
}).toList();
}
return index < 0 ? arc.reversed.toList() : arc;
}
List<List<num>> _toLine(List<List<List<num>>> arcs) {
return arcs.fold(<List<num>>[], (prev, arc) => [...prev, ...prev.isEmpty ? arc : arc.skip(1)]);
}
List<List<num>> _decodeRingArcs(List<int> ringArcs) {
return _toLine(ringArcs.map(_arcAt).toList());
}
List<List<List<num>>> _decodePolygonArcs(List<List<int>> polyArcs) {
return polyArcs.map(_decodeRingArcs).toList();
}
List<List<List<List<num>>>> _decodeMultiPolygonArcs(List<List<List<int>>> multiPolyArcs) {
return multiPolyArcs.map(_decodePolygonArcs).toList();
}
// cf https://en.wikipedia.org/wiki/Even%E2%80%93odd_rule
bool _pointInRing(List<num> point, List<List<num>> poly) {
final x = point[0];
final y = point[1];
final length = poly.length;
var j = length - 1;
var c = false;
for (var i = 0; i < length; i++) {
if (((poly[i][1] > y) != (poly[j][1] > y)) && (x < poly[i][0] + (poly[j][0] - poly[i][0]) * (y - poly[i][1]) / (poly[j][1] - poly[i][1]))) {
c = !c;
}
j = i;
}
return c;
}
bool _pointInRings(List<num> point, List<List<List<num>>> rings) {
return rings.any((ring) => _pointInRing(point, ring));
}
}
class Transform {
final List<num> scale;
final List<num> translate;
Transform.parse(Map<String, dynamic> data)
: scale = (data['scale'] as List).cast<num>(),
translate = (data['translate'] as List).cast<num>();
}
abstract class Geometry extends TopologyJsonObject {
final dynamic id;
final Map<String, dynamic> properties;
Geometry.parse(Map<String, dynamic> data)
: id = data.containsKey('id') ? data['id'] : null,
properties = data.containsKey('properties') ? data['properties'] as Map<String, dynamic> : null,
super.parse(data);
static Geometry build(Map<String, dynamic> data) {
final type = _parseTopoJsonObjectType(data['type'] as String);
switch (type) {
case TopoJsonObjectType.topology:
return null;
case TopoJsonObjectType.point:
return Point.parse(data);
case TopoJsonObjectType.multipoint:
return MultiPoint.parse(data);
case TopoJsonObjectType.linestring:
return LineString.parse(data);
case TopoJsonObjectType.multilinestring:
return MultiLineString.parse(data);
case TopoJsonObjectType.polygon:
return Polygon.parse(data);
case TopoJsonObjectType.multipolygon:
return MultiPolygon.parse(data);
case TopoJsonObjectType.geometrycollection:
return GeometryCollection.parse(data);
}
return null;
}
bool containsPoint(Topology topology, List<num> point) => false;
}
class Point extends Geometry {
final List<num> coordinates;
Point.parse(Map<String, dynamic> data)
: coordinates = (data['coordinates'] as List).cast<num>(),
super.parse(data);
}
class MultiPoint extends Geometry {
final List<List<num>> coordinates;
MultiPoint.parse(Map<String, dynamic> data)
: coordinates = (data['coordinates'] as List).cast<List>().map((position) => position.cast<num>()).toList(),
super.parse(data);
}
class LineString extends Geometry {
final List<int> arcs;
LineString.parse(Map<String, dynamic> data)
: arcs = (data['arcs'] as List).cast<int>(),
super.parse(data);
}
class MultiLineString extends Geometry {
final List<List<int>> arcs;
MultiLineString.parse(Map<String, dynamic> data)
: arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<int>()).toList(),
super.parse(data);
}
class Polygon extends Geometry {
final List<List<int>> arcs;
Polygon.parse(Map<String, dynamic> data)
: arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<int>()).toList(),
super.parse(data);
List<List<List<num>>> _rings;
List<List<List<num>>> rings(Topology topology) {
_rings ??= topology._decodePolygonArcs(arcs);
return _rings;
}
@override
bool containsPoint(Topology topology, List<num> point) {
return topology._pointInRings(point, rings(topology));
}
}
class MultiPolygon extends Geometry {
final List<List<List<int>>> arcs;
MultiPolygon.parse(Map<String, dynamic> data)
: arcs = (data['arcs'] as List).cast<List>().map((polygon) => polygon.cast<List>().map((arc) => arc.cast<int>()).toList()).toList(),
super.parse(data);
List<List<List<List<num>>>> _polygons;
List<List<List<List<num>>>> polygons(Topology topology) {
_polygons ??= topology._decodeMultiPolygonArcs(arcs);
return _polygons;
}
@override
bool containsPoint(Topology topology, List<num> point) {
return polygons(topology).any((polygon) => topology._pointInRings(point, polygon));
}
}
class GeometryCollection extends Geometry {
final List<Geometry> geometries;
GeometryCollection.parse(Map<String, dynamic> data)
: geometries = (data['geometries'] as List).cast<Map<String, dynamic>>().map(Geometry.build).toList(),
super.parse(data);
@override
bool containsPoint(Topology topology, List<num> point) {
return geometries.any((geometry) => geometry.containsPoint(topology, point));
}
}

View file

@ -43,7 +43,7 @@ class AvesAvailability {
} }
// local geolocation with `geocoder` requires Play Services // local geolocation with `geocoder` requires Play Services
Future<bool> get canGeolocate => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
Future<bool> get isNewVersionAvailable async { Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable); if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);

View file

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/geo/countries.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
@ -13,6 +15,7 @@ import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart'; import 'package:geocoder/geocoder.dart';
@ -366,9 +369,11 @@ class AvesEntry {
return _durationText; return _durationText;
} }
bool get hasGps => isCatalogued && _catalogMetadata.latitude != null; bool get hasGps => _catalogMetadata?.latitude != null;
bool get isLocated => _addressDetails != null; bool get hasAddress => _addressDetails != null;
bool get hasPlace => _addressDetails?.place?.isNotEmpty == true;
LatLng get latLng => hasGps ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null; LatLng get latLng => hasGps ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
@ -389,7 +394,7 @@ class AvesEntry {
String _bestTitle; String _bestTitle;
String get bestTitle { String get bestTitle {
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle; _bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata.xmpTitleDescription : sourceTitle;
return _bestTitle; return _bestTitle;
} }
@ -444,8 +449,32 @@ class AvesEntry {
addressChangeNotifier.notifyListeners(); addressChangeNotifier.notifyListeners();
} }
Future<void> locate({bool background = false}) async { Future<void> locate() async {
if (isLocated) return; await _locateCountry();
if (await availability.canLocatePlaces) {
await locatePlace(background: false);
}
}
// quick reverse geolocation to find the country, using an offline asset
Future<void> _locateCountry() async {
if (hasAddress) return;
final countryCode = await countryTopology.countryCode(latLng);
setCountry(countryCode);
}
void setCountry(CountryCode countryCode) {
if (hasPlace || countryCode == null) return;
addressDetails = AddressDetails(
contentId: contentId,
countryCode: countryCode.alpha2,
countryName: countryCode.alpha3,
);
}
// full reverse geolocation, requiring Play Services and some connectivity
Future<void> locatePlace({@required bool background}) async {
if (hasPlace) return;
await catalog(background: background); await catalog(background: background);
final latitude = _catalogMetadata?.latitude; final latitude = _catalogMetadata?.latitude;
@ -476,8 +505,8 @@ class AvesEntry {
locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null), locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
); );
} }
} catch (error, stackTrace) { } catch (error, stack) {
debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stackTrace'); debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stack');
} }
} }
@ -493,21 +522,19 @@ class AvesEntry {
final address = addresses.first; final address = addresses.first;
return address.addressLine; return address.addressLine;
} }
} catch (error, stackTrace) { } catch (error, stack) {
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stackTrace'); debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stack');
} }
return null; return null;
} }
String get shortAddress { String get shortAddress {
if (!isLocated) return '';
// `admin area` examples: Seoul, Geneva, null // `admin area` examples: Seoul, Geneva, null
// `locality` examples: Mapo-gu, Geneva, Annecy // `locality` examples: Mapo-gu, Geneva, Annecy
return { return {
_addressDetails.countryName, _addressDetails?.countryName,
_addressDetails.adminArea, _addressDetails?.adminArea,
_addressDetails.locality, _addressDetails?.locality,
}.where((part) => part != null && part.isNotEmpty).join(', '); }.where((part) => part != null && part.isNotEmpty).join(', ');
} }

View file

@ -19,7 +19,7 @@ class LocationFilter extends CollectionFilter {
if (split.length > 1) _countryCode = split[1]; if (split.length > 1) _countryCode = split[1];
if (_location.isEmpty) { if (_location.isEmpty) {
_test = (entry) => !entry.isLocated; _test = (entry) => !entry.hasGps;
} else if (level == LocationLevel.country) { } else if (level == LocationLevel.country) {
_test = (entry) => entry.addressDetails?.countryCode == _countryCode; _test = (entry) => entry.addressDetails?.countryCode == _countryCode;
} else if (level == LocationLevel.place) { } else if (level == LocationLevel.place) {

View file

@ -156,13 +156,14 @@ class OverlayMetadata {
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}'; String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
} }
@immutable
class AddressDetails { class AddressDetails {
final int contentId; final int contentId;
final String countryCode, countryName, adminArea, locality; final String countryCode, countryName, adminArea, locality;
String get place => locality != null && locality.isNotEmpty ? locality : adminArea; String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
AddressDetails({ const AddressDetails({
this.contentId, this.contentId,
this.countryCode, this.countryCode,
this.countryName, this.countryName,

View file

@ -195,8 +195,8 @@ class MetadataDb {
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata)); metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
await batch.commit(noResult: true); await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
} catch (exception, stack) { } catch (error, stack) {
debugPrint('$runtimeType failed to save metadata with exception=$exception\n$stack'); debugPrint('$runtimeType failed to save metadata with exception=$error\n$stack');
} }
} }

View file

@ -1,4 +1,4 @@
import 'package:aves/utils/geo_utils.dart'; import 'package:aves/geo/format.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong/latlong.dart';
enum CoordinateFormat { dms, decimal } enum CoordinateFormat { dms, decimal }

View file

@ -1,5 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/geo/countries.dart';
import 'package:aves/model/availability.dart'; import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
@ -28,10 +29,39 @@ mixin LocationMixin on SourceBase {
} }
Future<void> locateEntries() async { Future<void> locateEntries() async {
if (!(await availability.canGeolocate)) return; await _locateCountries();
await _locatePlaces();
}
// final stopwatch = Stopwatch()..start(); // quick reverse geolocation to find the countries, using an offline asset
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated); Future<void> _locateCountries() async {
final todo = visibleEntries.where((entry) => entry.hasGps && entry.addressDetails?.countryCode == null).toSet();
if (todo.isEmpty) return;
// final stopwatch = Stopwatch()..start();
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng).toSet());
final newAddresses = <AddressDetails>[];
todo.forEach((entry) {
final position = entry.latLng;
final countryCode = countryCodeMap.entries.firstWhere((kv) => kv.value.contains(position), orElse: () => null)?.key;
entry.setCountry(countryCode);
if (entry.hasAddress) {
newAddresses.add(entry.addressDetails);
}
});
if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
onAddressMetadataChanged();
}
// debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inSeconds}s');
}
// full reverse geolocation, requiring Play Services and some connectivity
Future<void> _locatePlaces() async {
if (!(await availability.canLocatePlaces)) return;
// final stopwatch = Stopwatch()..start();
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.hasPlace);
final todo = byLocated[false] ?? []; final todo = byLocated[false] ?? [];
if (todo.isEmpty) return; if (todo.isEmpty) return;
@ -65,12 +95,12 @@ mixin LocationMixin on SourceBase {
if (knownLocations.containsKey(latLng)) { if (knownLocations.containsKey(latLng)) {
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
} else { } else {
await entry.locate(background: true); await entry.locatePlace(background: true);
// it is intended to insert `null` if the geocoder failed, // it is intended to insert `null` if the geocoder failed,
// so that we skip geocoding of following entries with the same coordinates // so that we skip geocoding of following entries with the same coordinates
knownLocations[latLng] = entry.addressDetails; knownLocations[latLng] = entry.addressDetails;
} }
if (entry.isLocated) { if (entry.hasPlace) {
newAddresses.add(entry.addressDetails); newAddresses.add(entry.addressDetails);
if (newAddresses.length >= _commitCountThreshold) { if (newAddresses.length >= _commitCountThreshold) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
@ -80,9 +110,11 @@ mixin LocationMixin on SourceBase {
} }
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); });
if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
// debugPrint('$runtimeType locateEntries complete in ${stopwatch.elapsed.inSeconds}s'); }
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
} }
void onAddressMetadataChanged() { void onAddressMetadataChanged() {
@ -91,7 +123,7 @@ mixin LocationMixin on SourceBase {
} }
void updateLocations() { void updateLocations() {
final locations = visibleEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList(); final locations = visibleEntries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails).toList();
sortedPlaces = List<String>.unmodifiable(locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase)); sortedPlaces = List<String>.unmodifiable(locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
// the same country code could be found with different country names // the same country code could be found with different country names
@ -115,7 +147,7 @@ mixin LocationMixin on SourceBase {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();
_filterRecentEntryMap.clear(); _filterRecentEntryMap.clear();
} else { } else {
final countryCodes = entries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails.countryCode).toSet(); final countryCodes = entries.where((entry) => entry.hasPlace).map((entry) => entry.addressDetails.countryCode).toSet();
countryCodes.forEach(_filterEntryCountMap.remove); countryCodes.forEach(_filterEntryCountMap.remove);
} }
} }

View file

@ -86,8 +86,8 @@ class ImageFileService {
bytesReceived += chunk.length; bytesReceived += chunk.length;
try { try {
onBytesReceived(bytesReceived, expectedContentLength); onBytesReceived(bytesReceived, expectedContentLength);
} catch (error, stackTrace) { } catch (error, stack) {
completer.completeError(error, stackTrace); completer.completeError(error, stack);
return; return;
} }
} }

View file

@ -38,8 +38,8 @@ class ServicePolicy {
() async { () async {
try { try {
completer.complete(await platformCall()); completer.complete(await platformCall());
} catch (error, stackTrace) { } catch (error, stack) {
completer.completeError(error, stackTrace); completer.completeError(error, stack);
} }
_runningQueue.remove(key); _runningQueue.remove(key);
_pickNext(); _pickNext();

View file

@ -42,8 +42,8 @@ class SvgMetadataService {
} }
} }
} }
} catch (exception, stack) { } catch (error, stack) {
debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); debugPrint('failed to parse XML from SVG with error=$error\n$stack');
} }
return null; return null;
} }
@ -78,8 +78,8 @@ class SvgMetadataService {
if (docDir.isNotEmpty) docDirectory: docDir, if (docDir.isNotEmpty) docDirectory: docDir,
if (metadataDir.isNotEmpty) metadataDirectory: metadataDir, if (metadataDir.isNotEmpty) metadataDirectory: metadataDir,
}; };
} catch (exception, stack) { } catch (error, stack) {
debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); debugPrint('failed to parse XML from SVG with error=$error\n$stack');
return null; return null;
} }
} }

View file

@ -18,8 +18,8 @@ class AChangeNotifier implements Listenable {
for (final listener in localListeners) { for (final listener in localListeners) {
try { try {
if (_listeners.contains(listener)) listener(); if (_listeners.contains(listener)) listener();
} catch (exception, stack) { } catch (error, stack) {
debugPrint('$runtimeType failed to notify listeners with exception=$exception\n$stack'); debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack');
} }
} }
} }

View file

@ -89,6 +89,12 @@ class Constants {
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE', licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity', sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity',
), ),
Dependency(
name: 'Country Code',
license: 'MIT',
licenseUrl: 'https://github.com/denixport/dart.country/blob/master/LICENSE',
sourceUrl: 'https://github.com/denixport/dart.country',
),
Dependency( Dependency(
name: 'Decorated Icon', name: 'Decorated Icon',
license: 'MIT', license: 'MIT',

View file

@ -63,7 +63,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
Widget _buildGeneralTabView() { Widget _buildGeneralTabView() {
final catalogued = visibleEntries.where((entry) => entry.isCatalogued); final catalogued = visibleEntries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps); final withGps = catalogued.where((entry) => entry.hasGps);
final located = withGps.where((entry) => entry.isLocated); final withAddress = withGps.where((entry) => entry.hasAddress);
final withPlace = withGps.where((entry) => entry.hasPlace);
return AvesExpansionTile( return AvesExpansionTile(
title: 'General', title: 'General',
children: [ children: [
@ -104,7 +105,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
'Visible entries': '${visibleEntries.length}', 'Visible entries': '${visibleEntries.length}',
'Catalogued': '${catalogued.length}', 'Catalogued': '${catalogued.length}',
'With GPS': '${withGps.length}', 'With GPS': '${withGps.length}',
'With address': '${located.length}', 'With address': '${withAddress.length}',
'With place': '${withPlace.length}',
}, },
), ),
), ),

View file

@ -1,5 +1,4 @@
import 'package:aves/main.dart'; import 'package:aves/main.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/home_page.dart';
@ -114,12 +113,6 @@ class _HomePageState extends State<HomePage> {
if (entry != null) { if (entry != null) {
// cataloguing is essential for coordinates and video rotation // cataloguing is essential for coordinates and video rotation
await entry.catalog(); await entry.catalog();
// locating is fine in the background
unawaited(availability.canGeolocate.then((connected) {
if (connected) {
entry.locate();
}
}));
} }
return entry; return entry;
} }

View file

@ -38,7 +38,7 @@ class StatsPage extends StatelessWidget {
this.parentCollection, this.parentCollection,
}) : assert(source != null) { }) : assert(source != null) {
entries.forEach((entry) { entries.forEach((entry) {
if (entry.isLocated) { if (entry.hasAddress) {
final address = entry.addressDetails; final address = entry.addressDetails;
var country = address.countryName; var country = address.countryName;
if (country != null && country.isNotEmpty) { if (country != null && country.isNotEmpty) {

View file

@ -1,7 +1,7 @@
import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/main.dart'; import 'package:aves/main.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/viewer/debug/db.dart'; import 'package:aves/widgets/viewer/debug/db.dart';
import 'package:aves/widgets/viewer/debug/metadata.dart'; import 'package:aves/widgets/viewer/debug/metadata.dart';
@ -106,7 +106,8 @@ class ViewerDebugPage extends StatelessWidget {
Divider(), Divider(),
InfoRowGroup({ InfoRowGroup({
'hasGps': '${entry.hasGps}', 'hasGps': '${entry.hasGps}',
'isLocated': '${entry.isLocated}', 'hasAddress': '${entry.hasAddress}',
'hasPlace': '${entry.hasPlace}',
'latLng': '${entry.latLng}', 'latLng': '${entry.latLng}',
'geoUri': '${entry.geoUri}', 'geoUri': '${entry.geoUri}',
}), }),

View file

@ -1,6 +1,5 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
@ -152,11 +151,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
// make sure to locate the entry, // make sure to locate the entry,
// so that we can display the address instead of coordinates // so that we can display the address instead of coordinates
// even when initial collection locating has not reached this entry yet // even when initial collection locating has not reached this entry yet
availability.canGeolocate.then((connected) {
if (connected) {
entry.locate(); entry.locate();
}
});
} else { } else {
Navigator.pop(context); Navigator.pop(context);
} }

View file

@ -83,7 +83,7 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
if (showMap) { if (showMap) {
_loadedUri = entry.uri; _loadedUri = entry.uri;
final filters = <LocationFilter>[]; final filters = <LocationFilter>[];
if (entry.isLocated) { if (entry.hasAddress) {
final address = entry.addressDetails; final address = entry.addressDetails;
final country = address.countryName; final country = address.countryName;
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}')); if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}'));
@ -181,7 +181,7 @@ class _AddressInfoGroupState extends State<_AddressInfoGroup> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_addressLineLoader = availability.canGeolocate.then((connected) { _addressLineLoader = availability.canLocatePlaces.then((connected) {
if (connected) { if (connected) {
return entry.findAddressLine(); return entry.findAddressLine();
} }

View file

@ -309,7 +309,7 @@ class _LocationRow extends AnimatedWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
String location; String location;
if (entry.isLocated) { if (entry.hasAddress) {
location = entry.shortAddress; location = entry.shortAddress;
} else if (entry.hasGps) { } else if (entry.hasGps) {
location = settings.coordinateFormat.format(entry.latLng); location = settings.coordinateFormat.format(entry.latLng);

View file

@ -148,6 +148,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
country_code:
dependency: "direct main"
description:
name: country_code
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.1"
coverage: coverage:
dependency: transitive dependency: transitive
description: description:

View file

@ -34,6 +34,7 @@ dependencies:
charts_flutter: charts_flutter:
collection: collection:
connectivity: connectivity:
country_code:
decorated_icon: decorated_icon:
event_bus: event_bus:
expansion_tile_card: expansion_tile_card:

View file

@ -0,0 +1,32 @@
import 'package:aves/geo/countries.dart';
import 'package:aves/geo/topojson.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong/latlong.dart';
void main() {
// [lng, lat, z]
const buenosAires = [-58.381667, -34.603333];
const paris = [2.348777, 48.875683];
const seoul = [126.99, 37.56, 42];
const argentinaN3String = '032';
const franceN3String = '250';
const southKoreaN3String = '410';
test('Parse countries', () async {
TestWidgetsFlutterBinding.ensureInitialized();
final topo = await countryTopology.getTopology();
final countries = topo.objects['countries'] as GeometryCollection;
final argentina = countries.geometries.firstWhere((geometry) => geometry.id == argentinaN3String);
expect(argentina.properties['name'], 'Argentina');
expect(argentina.containsPoint(topo, buenosAires), true);
expect(argentina.containsPoint(topo, seoul), false);
});
test('Get country id', () async {
TestWidgetsFlutterBinding.ensureInitialized();
expect(await countryTopology.numericCode(LatLng(buenosAires[1], buenosAires[0])), int.parse(argentinaN3String));
expect(await countryTopology.numericCode(LatLng(seoul[1], seoul[0])), int.parse(southKoreaN3String));
expect(await countryTopology.numericCode(LatLng(paris[1], paris[0])), int.parse(franceN3String));
});
}

View file

@ -1,4 +1,4 @@
import 'package:aves/utils/geo_utils.dart'; import 'package:aves/geo/format.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong/latlong.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';

116
test/geo/topojson_test.dart Normal file
View file

@ -0,0 +1,116 @@
import 'package:aves/geo/topojson.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const example1 = '''
{
"type": "Topology",
"objects": {
"example": {
"type": "GeometryCollection",
"geometries": [
{
"type": "Point",
"properties": {
"prop0": "value0"
},
"coordinates": [102, 0.5]
},
{
"type": "LineString",
"properties": {
"prop0": "value0",
"prop1": 0
},
"arcs": [0]
},
{
"type": "Polygon",
"properties": {
"prop0": "value0",
"prop1": {
"this": "that"
}
},
"arcs": [[-2]]
}
]
}
},
"arcs": [
[[102, 0], [103, 1], [104, 0], [105, 1]],
[[100, 0], [101, 0], [101, 1], [100, 1], [100, 0]]
]
}
''';
const example1Quantized = '''
{
"type": "Topology",
"transform": {
"scale": [0.0005000500050005, 0.00010001000100010001],
"translate": [100, 0]
},
"objects": {
"example": {
"type": "GeometryCollection",
"geometries": [
{
"type": "Point",
"properties": {
"prop0": "value0"
},
"coordinates": [4000, 5000]
},
{
"type": "LineString",
"properties": {
"prop0": "value0",
"prop1": 0
},
"arcs": [0]
},
{
"type": "Polygon",
"properties": {
"prop0": "value0",
"prop1": {
"this": "that"
}
},
"arcs": [[1]]
}
]
}
},
"arcs": [
[[4000, 0], [1999, 9999], [2000, -9999], [2000, 9999]],
[[0, 0], [0, 9999], [2000, 0], [0, -9999], [-2000, 0]]
]
}
''';
test('parse example', () async {
final topo = await TopoJson().parse(example1);
expect(topo.objects.containsKey('example'), true);
final exampleObj = topo.objects['example'] as GeometryCollection;
expect(exampleObj.geometries.length, 3);
final point = exampleObj.geometries[0] as Point;
expect(point.coordinates, [102, 0.5]);
final lineString = exampleObj.geometries[1] as LineString;
expect(lineString.arcs, [0]);
final polygon = exampleObj.geometries[2] as Polygon;
expect(polygon.arcs.first, [-2]);
expect(polygon.properties.containsKey('prop0'), true);
});
test('parse quantized example', () async {
final topo = await TopoJson().parse(example1Quantized);
expect(topo.arcs.first.first, [4000, 0]);
expect(topo.transform.scale, [0.0005000500050005, 0.00010001000100010001]);
});
}