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; final int cacheSizeThreshold; // Cache size threshold in bytes (default: 20MB) Directory? cacheDir; RugReefer({ required this.apiUrl, required this.categoryMap, this.enableCacheExpiration = false, this.cacheExpirationDuration = const Duration(days: 14), this.cacheSizeThreshold = 20 * 1024 * 1024, // 20MB in bytes 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 geohash = GeoHasher().encode(longitude, latitude, precision: 4); final jsonMap = await loadJsonFromAssets(categoryMap); try { final rawData = kIsWeb ? await _lookupWeb(geohash) : await _lookupFile(geohash); return _processMetadata(rawData, jsonMap, Position(longitude, latitude), distanceThreshold); } catch (e, stackTrace) { print("Error in lookup: $e\nStackTrace: $stackTrace"); return {}; } } Future>> _lookupFile(String geohash) async { await _initCacheDir(); final cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz'); if (!cacheFile.existsSync() || await _isCacheExpired(geohash)) { await _downloadGeoJson(geohash, cacheFile); } else { await _updateLastAccessTime(geohash); } final geoJsonStr = utf8.decode(GZipCodec().decode(cacheFile.readAsBytesSync())); return (jsonDecode(geoJsonStr)['features'] as List).cast>(); } Future>> _lookupWeb(String geohash) async { final prefs = await SharedPreferences.getInstance(); final 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'] as List).cast>(); } throw Exception('Failed to download GeoJSON for geohash: $geohash'); } await _updateLastAccessTime(geohash); return (jsonDecode(cachedGeoJson)['features'] as List).cast>(); } Future _downloadGeoJson(String geohash, File cacheFile) async { final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson')); if (response.statusCode == 200) { final bytes = response.bodyBytes; cacheFile.writeAsBytesSync(GZipCodec().encode(bytes)); await _updateLastAccessTime(geohash); await _ensureCacheSizeWithinThreshold(bytes.length); // Check and manage cache size } else { throw Exception('Failed to download GeoJSON for geohash: $geohash'); } } Future _ensureCacheSizeWithinThreshold(int newFileSize) async { if (kIsWeb || cacheDir == null) return; // Skip for web or if cacheDir is not initialized final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList(); final fileSizes = await Future.wait(files.map((file) => file.stat().then((stat) => stat.size))); int totalSize = fileSizes.fold(0, (sum, size) => sum + size); print("Current cache size: ${totalSize / 1024 / 1024} MB"); if (totalSize + newFileSize <= cacheSizeThreshold) return; // Within threshold // Sort files by last access time (oldest first) final prefs = await SharedPreferences.getInstance(); final sortedFiles = await Future.wait(files.map((file) async { final geohash = file.path.split('/').last.replaceAll('.geojson.gz', ''); final lastAccess = prefs.getInt('cache_last_access_$geohash') ?? 0; return {'file': file, 'lastAccess': lastAccess}; })); sortedFiles.sort((a, b) => a['lastAccess'].compareTo(b['lastAccess'])); // Delete oldest files until we're within the threshold for (var entry in sortedFiles) { final file = entry['file'] as File; final size = await file.length(); file.deleteSync(); totalSize -= size; print("Deleted file: ${file.path} (${size / 1024 / 1024} MB)"); if (totalSize + newFileSize <= cacheSizeThreshold) break; } print("New cache size: ${totalSize / 1024 / 1024} MB"); } 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 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 cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz'); if (cacheFile.existsSync()) cacheFile.deleteSync(); await prefs.remove(key); } } } Future clearCache() async { if (kIsWeb || cacheDir == null) return; // Skip for web or if cacheDir is not initialized final prefs = await SharedPreferences.getInstance(); // Delete all cached files final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList(); for (var file in files) { if (file is File) { file.deleteSync(); print("Deleted file: ${file.path}"); } } // Remove all last access times from SharedPreferences final keys = prefs.getKeys().where((key) => key.startsWith('cache_last_access_')).toList(); for (var key in keys) { await prefs.remove(key); print("Removed cache access time for key: $key"); } print("Cache cleared successfully."); } /// Returns the total size of the cache in bytes. Future getCurrentCacheSize() async { if (kIsWeb || cacheDir == null) return 0; // Skip for web or if cacheDir is not initialized final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList(); final fileSizes = await Future.wait(files.map((file) => file.stat().then((stat) => stat.size))); return fileSizes.fold(0, (sum, size) => sum + size); } /// Returns a list of currently cached files with their sizes and last access times. Future>> listCachedFiles() async { if (kIsWeb || cacheDir == null) return []; // Skip for web or if cacheDir is not initialized final prefs = await SharedPreferences.getInstance(); final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList(); final fileDetails = await Future.wait(files.map((file) async { final geohash = file.path.split('/').last.replaceAll('.geojson.gz', ''); final lastAccess = prefs.getInt('cache_last_access_$geohash') ?? 0; final size = await file.stat().then((stat) => stat.size); return { 'file': file.path, 'size': size, 'lastAccess': DateTime.fromMillisecondsSinceEpoch(lastAccess), }; })); return fileDetails; } /// Deletes a specific cached file by its geohash. Future deleteCachedFile(String geohash) async { if (kIsWeb || cacheDir == null) return; // Skip for web or if cacheDir is not initialized final prefs = await SharedPreferences.getInstance(); final cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz'); if (cacheFile.existsSync()) { cacheFile.deleteSync(); print("Deleted file: ${cacheFile.path}"); } // Remove the last access time for this geohash await prefs.remove('cache_last_access_$geohash'); print("Removed cache access time for geohash: $geohash"); } Future> loadJsonFromAssets(String assetPath) async { return jsonDecode(await rootBundle.loadString(assetPath)); } Map>> _processMetadata( List> features, Map jsonMap, Position point, double distanceThreshold) { final groupedObjects = {"in": >{}, "by": >{}}; for (var feature in features) { final geometry = feature['geometry']; final metadata = Map.from(feature['properties']['tags'] ?? {})..removeWhere((_, v) => v == null); final mainKey = _determineSpatialCategory(geometry, point, distanceThreshold); if (mainKey == null) continue; for (var key in metadata.keys) { final value = metadata[key]; for (var category in jsonMap.keys) { final rule = jsonMap[category] is Map ? jsonMap[category][key] : null; if (_matchesRule(rule, value)) { groupedObjects[mainKey]!.putIfAbsent(category, () => []).add(value); } } } } return groupedObjects; } String? _determineSpatialCategory(Map geometry, Position point, double distanceThreshold) { 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(), ); return booleanPointInPolygon(point, polygon) ? 'in' : null; case 'LineString': final line = LineString( coordinates: (geometry['coordinates'] as List) .map((p) => Position(p[0].toDouble(), p[1].toDouble())) .toList(), ); return _isPointNearLine(Point(coordinates: point), line, distanceThreshold) ? 'by' : null; default: return null; } } bool _matchesRule(dynamic rule, dynamic value) { if (rule == true) return true; if (rule is String && rule == value) return true; if (rule is List && rule.contains(value)) return true; return false; } bool _isPointNearLine(Point point, LineString line, double distanceThreshold) { for (int i = 0; i < line.coordinates.length - 1; i++) { final p1 = line.coordinates[i]; final p2 = line.coordinates[i + 1]; final dist = distance(p1, point, Unit.meter); if (dist <= distanceThreshold) return true; } return false; } }