import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:dart_geohash/dart_geohash.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 RugReefer { final String apiUrl; final bool enableCacheExpiration; final Duration cacheExpirationDuration; final http.Client httpClient; Directory? cacheDir; RugReefer({ 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); return kIsWeb ? await _lookupWeb(geohash, latitude, longitude, distanceThreshold) : await _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'); if (!cacheFile.existsSync() || await _isCacheExpired(geohash)) { await _downloadGeoJson(geohash, cacheFile); } else { await _updateLastAccessTime(geohash); } final String geoJsonStr = utf8.decode(GZipCodec().decode(cacheFile.readAsBytesSync())); return _findMetadata(latitude, longitude, jsonDecode(geoJsonStr), distanceThreshold); } Future>> _lookupWeb(String geohash, double latitude, double longitude, double distanceThreshold) async { final prefs = await SharedPreferences.getInstance(); final String? cachedGeoJson = prefs.getString('geojson_$geohash'); if (cachedGeoJson == null || await _isCacheExpired(geohash)) { final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson')); if (response.statusCode == 200) { final geoJsonStr = utf8.decode(response.bodyBytes); await prefs.setString('geojson_$geohash', geoJsonStr); await _updateLastAccessTime(geohash); return _findMetadata(latitude, longitude, jsonDecode(geoJsonStr), distanceThreshold); } throw Exception('Failed to download GeoJSON for geohash: $geohash'); } await _updateLastAccessTime(geohash); return _findMetadata(latitude, longitude, jsonDecode(cachedGeoJson), distanceThreshold); } 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'); return lastAccess == null || 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); final List> results = []; for (var feature in geoJson['features']) { final geometry = feature['geometry']; final metadata = Map.from(feature['properties']['tags'] ?? {})..removeWhere((_, v) => v == null); switch (geometry['type']) { case 'Polygon': final polygon = Polygon( coordinates: (geometry['coordinates'] as List) .map((ring) => (ring as List) .map((p) => Position(p[0].toDouble(), p[1].toDouble())) .toList()) .toList(), ); if (booleanPointInPolygon(point, polygon)) results.add({"type": "polygon", ...metadata}); break; case 'LineString': final line = LineString( coordinates: (geometry['coordinates'] as List) .map((p) => Position(p[0].toDouble(), p[1].toDouble())) .toList(), ); if (_isPointNearLine(Point(coordinates: point), line, distanceThreshold)) results.add({"type": "line", ...metadata}); break; } } return results; } bool _isPointNearLine(Point point, LineString line, double distanceThreshold) { for (final Position p in line.coordinates) { if (distance(point, Point(coordinates: p), Unit.meters) <= distanceThreshold) { return true; } } return false; } }