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(longitude, latitude, precision: 4); print("Generated geohash: $geohash"); final jsonMap = await loadJsonFromAssets(categoryMap); print("Loaded JSON Map from Assets"); try { List> rawData = kIsWeb ? await _lookupWeb(geohash, latitude, longitude, distanceThreshold) : await _lookupFile(geohash, latitude, longitude, distanceThreshold); print("Raw Data from lookup: ${jsonEncode(rawData)}"); final processedData = _processMetadata(rawData, jsonMap, latitude, longitude, distanceThreshold); print("Processed Data: ${jsonEncode(processedData)}"); return processedData; } catch (e, stackTrace) { print("Error in lookup: $e"); print("StackTrace: $stackTrace"); return {}; } } Future>> _lookupFile(String geohash, double latitude, double longitude, double distanceThreshold) async { await _initCacheDir(); final File cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz'); print("Checking cache file: ${cacheFile.path}"); if (!cacheFile.existsSync() || await _isCacheExpired(geohash)) { print("Cache file missing or expired, downloading..."); await _downloadGeoJson(geohash, cacheFile); } else { print("Cache file found, updating last access time"); await _updateLastAccessTime(geohash); } final String geoJsonStr = utf8.decode(GZipCodec().decode(cacheFile.readAsBytesSync())); print("Read from cache: ${geoJsonStr.length} characters"); // ✅ Explicitly cast to List> final List decodedJson = jsonDecode(geoJsonStr)['features']; return decodedJson.cast>(); } 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); } Map>> _processMetadata( List> features, Map jsonMap, double latitude, double longitude, double distanceThreshold) { final Position point = Position(longitude, latitude); Map>> groupedObjects = {"in": {}, "by": {}}; for (var feature in features) { final geometry = feature['geometry']; final metadata = Map.from(feature['properties']['tags'] ?? {})..removeWhere((_, v) => v == null); String? mainKey; // Determine spatial category: "in" (Polygon) or "by" (LineString) 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; for (var key in metadata.keys) { var value = metadata[key]; 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 == value) { groupedObjects[mainKey]!.putIfAbsent(category, () => []); groupedObjects[mainKey]![category]!.add(value); } else if (rule is List && rule.contains(value)) { groupedObjects[mainKey]!.putIfAbsent(category, () => []); groupedObjects[mainKey]![category]!.add(value); } else if (rule == true) { groupedObjects[mainKey]!.putIfAbsent(category, () => []); groupedObjects[mainKey]![category]!.add(value); } } } } } return groupedObjects; } 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; } }