import 'dart:math'; import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:tuple/tuple.dart'; mixin LocationMixin on SourceBase { static const commitCountThreshold = 200; static const _stopCheckCountThreshold = 50; List sortedCountries = List.unmodifiable([]); List sortedPlaces = List.unmodifiable([]); Future loadAddresses({Set? ids}) async { final saved = await (ids != null ? metadataDb.loadAddressesById(ids) : metadataDb.loadAddresses()); final idMap = entryById; saved.forEach((metadata) => idMap[metadata.id]?.addressDetails = metadata); onAddressMetadataChanged(); } Future locateEntries(AnalysisController controller, Set candidateEntries) async { await _locateCountries(controller, candidateEntries); await _locatePlaces(controller, candidateEntries); final unlocatedIds = candidateEntries.where((entry) => !entry.hasGps).map((entry) => entry.id).toSet(); if (unlocatedIds.isNotEmpty) { await metadataDb.removeIds(unlocatedIds, dataTypes: {EntryDataType.address}); onAddressMetadataChanged(); } } static bool locateCountriesTest(AvesEntry entry) => entry.hasGps && !entry.hasAddress; static bool locatePlacesTest(AvesEntry entry) => entry.hasGps && !entry.hasFineAddress; // quick reverse geocoding to find the countries, using an offline asset Future _locateCountries(AnalysisController controller, Set candidateEntries) async { if (controller.isStopping) return; final force = controller.force; final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locateCountriesTest)).toSet(); if (todo.isEmpty) return; stateNotifier.value = SourceState.locatingCountries; var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet()); final newAddresses = {}; todo.forEach((entry) { final position = entry.latLng; final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key; entry.setCountry(countryCode); if (entry.hasAddress) { newAddresses.add(entry.addressDetails!); } setProgress(done: ++progressDone, total: progressTotal); }); if (newAddresses.isNotEmpty) { await metadataDb.saveAddresses(Set.unmodifiable(newAddresses)); onAddressMetadataChanged(); } } // full reverse geocoding, requiring Play Services and some connectivity Future _locatePlaces(AnalysisController controller, Set candidateEntries) async { if (controller.isStopping) return; if (!(await availability.canLocatePlaces)) return; final force = controller.force; final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locatePlacesTest)).toSet(); if (todo.isEmpty) return; // 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(AvesEntry entry) { // entry has coordinates final catalogMetadata = entry.catalogMetadata!; final lat = catalogMetadata.latitude!; final lng = catalogMetadata.longitude!; return Tuple2((lat * latLngFactor).round(), (lng * latLngFactor).round()); } final located = visibleEntries.where((entry) => entry.hasGps).toSet().difference(todo); final knownLocations = , AddressDetails?>{}; located.forEach((entry) { knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails); }); stateNotifier.value = SourceState.locatingPlaces; var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); var stopCheckCount = 0; final newAddresses = {}; for (final entry in todo) { final latLng = approximateLatLng(entry); if (knownLocations.containsKey(latLng)) { entry.addressDetails = knownLocations[latLng]?.copyWith(id: entry.id); } else { await entry.locatePlace(background: true, force: force, geocoderLocale: settings.appliedLocale); // it is intended to insert `null` if the geocoder failed, // so that we skip geocoding of following entries with the same coordinates knownLocations[latLng] = entry.addressDetails; } if (entry.hasFineAddress) { newAddresses.add(entry.addressDetails!); if (newAddresses.length >= commitCountThreshold) { await metadataDb.saveAddresses(Set.unmodifiable(newAddresses)); onAddressMetadataChanged(); newAddresses.clear(); } if (++stopCheckCount >= _stopCheckCountThreshold) { stopCheckCount = 0; if (controller.isStopping) return; } } setProgress(done: ++progressDone, total: progressTotal); } if (newAddresses.isNotEmpty) { await metadataDb.saveAddresses(Set.unmodifiable(newAddresses)); onAddressMetadataChanged(); } } void onAddressMetadataChanged() { updateLocations(); eventBus.fire(AddressMetadataChangedEvent()); } void updateLocations() { final locations = visibleEntries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails).whereNotNull().toList(); final updatedPlaces = locations.map((address) => address.place).whereNotNull().where((v) => v.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase); if (!listEquals(updatedPlaces, sortedPlaces)) { sortedPlaces = List.unmodifiable(updatedPlaces); eventBus.fire(PlacesChangedEvent()); } // the same country code could be found with different country names // e.g. if the locale changed between geocoding calls // so we merge countries by code, keeping only one name for each code final countriesByCode = Map.fromEntries(locations.map((address) { final code = address.countryCode; if (code == null || code.isEmpty) return null; return MapEntry(code, address.countryName); }).whereNotNull()); final updatedCountries = countriesByCode.entries.map((kv) { final code = kv.key; final name = kv.value; return '${name != null && name.isNotEmpty ? name : code}${LocationFilter.locationSeparator}$code'; }).toList() ..sort(compareAsciiUpperCase); if (!listEquals(updatedCountries, sortedCountries)) { sortedCountries = List.unmodifiable(updatedCountries); invalidateCountryFilterSummary(); eventBus.fire(CountriesChangedEvent()); } } // filter summary // by country code final Map _filterEntryCountMap = {}; final Map _filterRecentEntryMap = {}; void invalidateCountryFilterSummary({Set? entries, Set? countryCodes}) { if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return; if (entries == null && countryCodes == null) { _filterEntryCountMap.clear(); _filterRecentEntryMap.clear(); } else { countryCodes ??= {}; if (entries != null) { countryCodes.addAll(entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull()); } countryCodes.forEach((countryCode) { _filterEntryCountMap.remove(countryCode); _filterRecentEntryMap.remove(countryCode); }); } eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes)); } int countryEntryCount(LocationFilter filter) { final countryCode = filter.countryCode; if (countryCode == null) return 0; return _filterEntryCountMap.putIfAbsent(countryCode, () => visibleEntries.where(filter.test).length); } AvesEntry? countryRecentEntry(LocationFilter filter) { final countryCode = filter.countryCode; if (countryCode == null) return null; return _filterRecentEntryMap.putIfAbsent(countryCode, () => sortedEntriesByDate.firstWhereOrNull(filter.test)); } } class AddressMetadataChangedEvent {} class PlacesChangedEvent {} class CountriesChangedEvent {} class CountrySummaryInvalidatedEvent { final Set? countryCodes; const CountrySummaryInvalidatedEvent(this.countryCodes); }