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; import 'package:flutter/services.dart' show rootBundle; class RugReefer { final String apiUrl; final bool enableCacheExpiration; final Duration cacheExpirationDuration; final http.Client httpClient; final String categoryMap; Directory? cacheDir; RugReefer({ required this.apiUrl, required this.categoryMap, 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); final jsonMap = await loadJsonFromAssets(categoryMap); List> rawData = kIsWeb ? await _lookupWeb(geohash, latitude, longitude, distanceThreshold) : await _lookupFile(geohash, latitude, longitude, distanceThreshold); return _processMetadata(rawData, jsonMap, 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 jsonDecode(geoJsonStr)['features']; } 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 jsonDecode(geoJsonStr)['features']; } throw Exception('Failed to download GeoJSON for geohash: $geohash'); } await _updateLastAccessTime(geohash); return jsonDecode(cachedGeoJson)['features']; } 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); } } } Future> loadJsonFromAssets(String assetPath) async { final jsonString = await rootBundle.loadString(assetPath); return jsonDecode(jsonString); } List> _processMetadata(List> features, Map jsonMap, double latitude, double longitude, double distanceThreshold) { final Position point = Position(longitude, latitude); Map>> groupedObjects = {}; for (var feature in features) { final geometry = feature['geometry']; final metadata = Map.from(feature['properties']['tags'] ?? {})..removeWhere((_, v) => v == null); String? mainKey; 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)) mainKey = 'in'; 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)) mainKey = 'by'; break; } if (mainKey == null) continue; groupedObjects.putIfAbsent(mainKey, () => {}); for (var key in metadata.keys) { for (var category in jsonMap.keys) { var categoryMap = jsonMap[category]; if (categoryMap is Map && categoryMap.containsKey(key)) { var rule = categoryMap[key]; if (rule is String || (rule is List && rule.contains(metadata[key])) || rule == true) { groupedObjects[mainKey]!.putIfAbsent(category, () => []); groupedObjects[mainKey]![category]!.add(metadata[key]); } } } } } return groupedObjects.entries.map((entry) => {entry.key: entry.value}).toList(); } 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; } }