quick country reverse geolocation w/o play services
This commit is contained in:
parent
d5cfab6236
commit
c34faa1568
28 changed files with 616 additions and 62 deletions
1
assets/countries-50m.json
Normal file
1
assets/countries-50m.json
Normal file
File diff suppressed because one or more lines are too long
95
lib/geo/countries.dart
Normal file
95
lib/geo/countries.dart
Normal 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
245
lib/geo/topojson.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
if (newAddresses.isNotEmpty) {
|
||||||
onAddressMetadataChanged();
|
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
||||||
// debugPrint('$runtimeType locateEntries complete in ${stopwatch.elapsed.inSeconds}s');
|
onAddressMetadataChanged();
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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}',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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}',
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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) {
|
entry.locate();
|
||||||
if (connected) {
|
|
||||||
entry.locate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
32
test/geo/countries_test.dart
Normal file
32
test/geo/countries_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
|
@ -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
116
test/geo/topojson_test.dart
Normal 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]);
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue