304 lines
No EOL
11 KiB
Dart
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;
|
|
}
|
|
} |