rugreefer/lib/rugreefer.dart
2025-03-04 20:50:16 +02:00

304 lines
No EOL
11 KiB
Dart

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<void> _initCacheDir() async {
if (!kIsWeb) {
cacheDir = await getApplicationDocumentsDirectory();
if (enableCacheExpiration) await _cleanExpiredCache();
}
}
Future<Map<String, Map<String, List<String>>>> 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<List<Map<String, dynamic>>> _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<Map<String, dynamic>>();
}
Future<List<Map<String, dynamic>>> _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<Map<String, dynamic>>();
}
throw Exception('Failed to download GeoJSON for geohash: $geohash');
}
await _updateLastAccessTime(geohash);
return (jsonDecode(cachedGeoJson)['features'] as List).cast<Map<String, dynamic>>();
}
Future<void> _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<void> _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<void> _updateLastAccessTime(String geohash) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('cache_last_access_$geohash', DateTime.now().millisecondsSinceEpoch);
}
Future<bool> _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<void> _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<void> 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<int> 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<List<Map<String, dynamic>>> 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<void> 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<Map<String, dynamic>> loadJsonFromAssets(String assetPath) async {
return jsonDecode(await rootBundle.loadString(assetPath));
}
Map<String, Map<String, List<String>>> _processMetadata(
List<Map<String, dynamic>> features,
Map<String, dynamic> jsonMap,
Position point,
double distanceThreshold) {
final groupedObjects = {"in": <String, List<String>>{}, "by": <String, List<String>>{}};
for (var feature in features) {
final geometry = feature['geometry'];
final metadata = Map<String, dynamic>.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<String, dynamic> 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;
}
}