2025-02-27 13:43:31 +02:00
|
|
|
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';
|
2025-02-27 15:24:16 +02:00
|
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
2025-02-27 13:43:31 +02:00
|
|
|
|
2025-02-27 23:14:03 +02:00
|
|
|
class RugReefer {
|
2025-02-27 13:43:31 +02:00
|
|
|
final String apiUrl;
|
2025-02-27 15:24:16 +02:00
|
|
|
final bool enableCacheExpiration;
|
|
|
|
|
final Duration cacheExpirationDuration;
|
|
|
|
|
final http.Client httpClient;
|
|
|
|
|
Directory? cacheDir;
|
2025-02-27 13:43:31 +02:00
|
|
|
|
2025-02-27 23:14:03 +02:00
|
|
|
RugReefer({
|
2025-02-27 13:43:31 +02:00
|
|
|
required this.apiUrl,
|
2025-02-27 15:24:16 +02:00
|
|
|
this.enableCacheExpiration = false,
|
|
|
|
|
this.cacheExpirationDuration = const Duration(days: 14),
|
|
|
|
|
http.Client? client,
|
|
|
|
|
}) : httpClient = client ?? http.Client() {
|
|
|
|
|
_initCacheDir();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _initCacheDir() async {
|
|
|
|
|
if (!kIsWeb) {
|
|
|
|
|
cacheDir = await getApplicationDocumentsDirectory();
|
|
|
|
|
if (enableCacheExpiration) {
|
|
|
|
|
await _cleanExpiredCache();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> lookup(double latitude, double longitude, {double distanceThreshold = 10.0}) async {
|
|
|
|
|
final String geohash = GeoHasher().encode(latitude, longitude, precision: 4);
|
2025-02-27 15:38:39 +02:00
|
|
|
return kIsWeb
|
|
|
|
|
? await _lookupWeb(geohash, latitude, longitude, distanceThreshold)
|
|
|
|
|
: await _lookupFile(geohash, latitude, longitude, distanceThreshold);
|
2025-02-27 13:43:31 +02:00
|
|
|
}
|
|
|
|
|
|
2025-02-27 15:24:16 +02:00
|
|
|
Future<List<Map<String, dynamic>>> _lookupFile(String geohash, double latitude, double longitude, double distanceThreshold) async {
|
|
|
|
|
final File cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
|
2025-02-27 15:38:39 +02:00
|
|
|
if (!cacheFile.existsSync() || await _isCacheExpired(geohash)) {
|
2025-02-27 13:43:31 +02:00
|
|
|
await _downloadGeoJson(geohash, cacheFile);
|
2025-02-27 15:38:39 +02:00
|
|
|
} else {
|
|
|
|
|
await _updateLastAccessTime(geohash);
|
2025-02-27 13:43:31 +02:00
|
|
|
}
|
|
|
|
|
final String geoJsonStr = utf8.decode(GZipCodec().decode(cacheFile.readAsBytesSync()));
|
2025-02-27 15:38:39 +02:00
|
|
|
return _findMetadata(latitude, longitude, jsonDecode(geoJsonStr), distanceThreshold);
|
2025-02-27 15:24:16 +02:00
|
|
|
}
|
|
|
|
|
|
2025-02-27 15:38:39 +02:00
|
|
|
Future<List<Map<String, dynamic>>> _lookupWeb(String geohash, double latitude, double longitude, double distanceThreshold) async {
|
2025-02-27 15:24:16 +02:00
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
|
final String? cachedGeoJson = prefs.getString('geojson_$geohash');
|
|
|
|
|
if (cachedGeoJson == null || await _isCacheExpired(geohash)) {
|
2025-02-27 15:38:39 +02:00
|
|
|
final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson'));
|
2025-02-27 15:24:16 +02:00
|
|
|
if (response.statusCode == 200) {
|
2025-02-27 15:38:39 +02:00
|
|
|
final geoJsonStr = utf8.decode(response.bodyBytes);
|
|
|
|
|
await prefs.setString('geojson_$geohash', geoJsonStr);
|
2025-02-27 15:24:16 +02:00
|
|
|
await _updateLastAccessTime(geohash);
|
2025-02-27 15:38:39 +02:00
|
|
|
return _findMetadata(latitude, longitude, jsonDecode(geoJsonStr), distanceThreshold);
|
2025-02-27 15:24:16 +02:00
|
|
|
}
|
2025-02-27 15:38:39 +02:00
|
|
|
throw Exception('Failed to download GeoJSON for geohash: $geohash');
|
2025-02-27 15:24:16 +02:00
|
|
|
}
|
2025-02-27 15:38:39 +02:00
|
|
|
await _updateLastAccessTime(geohash);
|
|
|
|
|
return _findMetadata(latitude, longitude, jsonDecode(cachedGeoJson), distanceThreshold);
|
2025-02-27 13:43:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _downloadGeoJson(String geohash, File cacheFile) async {
|
2025-02-27 15:38:39 +02:00
|
|
|
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');
|
|
|
|
|
}
|
2025-02-27 15:24:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 int? lastAccess = prefs.getInt('cache_last_access_$geohash');
|
2025-02-27 15:38:39 +02:00
|
|
|
return lastAccess == null || DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(lastAccess)) > cacheExpirationDuration;
|
2025-02-27 15:24:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 File cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
|
2025-02-27 15:38:39 +02:00
|
|
|
if (cacheFile.existsSync()) cacheFile.deleteSync();
|
2025-02-27 15:24:16 +02:00
|
|
|
await prefs.remove(key);
|
|
|
|
|
}
|
2025-02-27 13:43:31 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-27 15:24:16 +02:00
|
|
|
List<Map<String, dynamic>> _findMetadata(double latitude, double longitude, Map<String, dynamic> geoJson, double distanceThreshold) {
|
2025-02-27 14:38:03 +02:00
|
|
|
final Position point = Position(longitude, latitude);
|
2025-02-27 15:38:39 +02:00
|
|
|
final List<Map<String, dynamic>> results = [];
|
2025-02-27 13:43:31 +02:00
|
|
|
for (var feature in geoJson['features']) {
|
|
|
|
|
final geometry = feature['geometry'];
|
2025-02-27 15:38:39 +02:00
|
|
|
final metadata = Map<String, dynamic>.from(feature['properties']['tags'] ?? {})..removeWhere((_, v) => v == null);
|
|
|
|
|
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)) results.add({"type": "polygon", ...metadata});
|
|
|
|
|
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)) results.add({"type": "line", ...metadata});
|
|
|
|
|
break;
|
2025-02-27 13:43:31 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-27 15:38:39 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2025-02-27 13:43:31 +02:00
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2025-02-27 15:38:39 +02:00
|
|
|
|
|
|
|
|
}
|