import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:dart_geohash/dart_geohash.dart'; import 'package:latlong2/latlong.dart'; import 'package:turf/turf.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/foundation.dart' show kIsWeb; class GeoMetadataLookup { final String apiUrl; final bool enableCacheExpiration; final Duration cacheExpirationDuration; final Distance distance = const Distance(); final http.Client httpClient; Directory? cacheDir; GeoMetadataLookup({ required this.apiUrl, this.enableCacheExpiration = false, this.cacheExpirationDuration = const Duration(days: 14), http.Client? client, }) : httpClient = client ?? http.Client() { _initCacheDir(); } Future _initCacheDir() async { if (!kIsWeb) { cacheDir = await getApplicationDocumentsDirectory(); if (enableCacheExpiration) { await _cleanExpiredCache(); } } } Future>> lookup(double latitude, double longitude, {double distanceThreshold = 10.0}) async { final String geohash = GeoHasher().encode(latitude, longitude, precision: 4); if (kIsWeb) { return _lookupWeb(geohash); } else { return _lookupFile(geohash, latitude, longitude, distanceThreshold); } } Future>> _lookupFile(String geohash, double latitude, double longitude, double distanceThreshold) async { final File cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz'); await _updateLastAccessTime(geohash); final bool isExpired = await _isCacheExpired(geohash); if (!cacheFile.existsSync() || isExpired) { await _downloadGeoJson(geohash, cacheFile); } final String geoJsonStr = utf8.decode(GZipCodec().decode(cacheFile.readAsBytesSync())); final Map geoJson = jsonDecode(geoJsonStr); return _findMetadata(latitude, longitude, geoJson, distanceThreshold); } Future>> _lookupWeb(String geohash) async { final prefs = await SharedPreferences.getInstance(); final String? cachedGeoJson = prefs.getString('geojson_$geohash'); if (cachedGeoJson == null || await _isCacheExpired(geohash)) { final response = await http.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson')); if (response.statusCode == 200) { await prefs.setString('geojson_$geohash', utf8.decode(response.bodyBytes)); await _updateLastAccessTime(geohash); return _findMetadata(0, 0, jsonDecode(utf8.decode(response.bodyBytes)), 10.0); } else { throw Exception('Failed to download GeoJSON for geohash: $geohash'); } } return _findMetadata(0, 0, jsonDecode(cachedGeoJson), 10.0); } Future _downloadGeoJson(String geohash, File cacheFile) async { final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson')); if (response.statusCode == 200) { cacheFile.writeAsBytesSync(GZipCodec().encode(response.bodyBytes)); await _updateLastAccessTime(geohash); } else { throw Exception('Failed to download GeoJSON for geohash: $geohash'); } } Future _updateLastAccessTime(String geohash) async { final prefs = await SharedPreferences.getInstance(); await prefs.setInt('cache_last_access_$geohash', DateTime.now().millisecondsSinceEpoch); } Future _isCacheExpired(String geohash) async { if (!enableCacheExpiration) return false; final prefs = await SharedPreferences.getInstance(); final int? lastAccess = prefs.getInt('cache_last_access_$geohash'); if (lastAccess == null) return true; return DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(lastAccess)) > cacheExpirationDuration; } Future _cleanExpiredCache() async { final prefs = await SharedPreferences.getInstance(); final keys = prefs.getKeys().where((key) => key.startsWith('cache_last_access_')).toList(); for (var key in keys) { final geohash = key.replaceFirst('cache_last_access_', ''); if (await _isCacheExpired(geohash)) { final File cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz'); if (cacheFile.existsSync()) { cacheFile.deleteSync(); } await prefs.remove(key); } } } List> _findMetadata(double latitude, double longitude, Map geoJson, double distanceThreshold) { final Position point = Position(longitude, latitude); List> results = []; for (var feature in geoJson['features']) { final geometry = feature['geometry']; final properties = feature['properties'] ?? {}; final metadata = properties['tags']?.cast() ?? {}; metadata.removeWhere((key, value) => value == null); if (geometry['type'] == 'Polygon') { final polygon = Polygon( coordinates: (geometry['coordinates'] as List) .map((ring) => (ring as List) .map((p) => Position(p[0] as double, p[1] as double)) .toList()) .toList(), ); if (booleanPointInPolygon(point, polygon)) { results.add({"type": "polygon", ...metadata}); } } else if (geometry['type'] == 'LineString') { final line = LineString( coordinates: (geometry['coordinates'] as List) .map((p) => Position(p[0] as double, p[1] as double)) .toList(), ); if (_isPointNearLine(point, line, distanceThreshold)) { results.add({"type": "line", ...metadata}); } } } return results; } bool _isPointNearLine(Position point, LineString line, double distanceThreshold) { for (var p1 in line.coordinates) { final double dist = distance.distance( LatLng(point.lat.toDouble(), point.lng.toDouble()), LatLng(p1.lat.toDouble(), p1.lng.toDouble()), ); if (dist <= distanceThreshold) return true; } return false; } }