mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-03-31 17:20:22 +02:00
- Add ServicePolicy framework with OSM-specific rate limiting and TTL - Add per-provider disk tile cache (ProviderTileCacheStore) with O(1) lookup, oldest-modified eviction, and ETag/304 revalidation - Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider) and offline-first (disk cache -> local tiles -> network with caching) - Add zoom-aware offline routing so tiles outside offline area zoom ranges use the efficient common path instead of the overhead-heavy offline path - Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map widget recycling; shutdown() handles permanent teardown - Add TileLayerManager with exponential backoff retry (2s->60s cap), provider switch detection, and backoff reset - Guard null provider/tileType in download dialog with localized error - Fix Nominatim cache key to use normalized viewbox values - Comprehensive test coverage (1800+ lines across 6 test files) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
181 lines
5.9 KiB
Dart
181 lines
5.9 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
import '../models/search_result.dart';
|
|
import 'http_client.dart';
|
|
import 'service_policy.dart';
|
|
|
|
/// Cached search result with expiry.
|
|
class _CachedResult {
|
|
final List<SearchResult> results;
|
|
final DateTime cachedAt;
|
|
|
|
_CachedResult(this.results) : cachedAt = DateTime.now();
|
|
|
|
bool get isExpired =>
|
|
DateTime.now().difference(cachedAt) > const Duration(minutes: 5);
|
|
}
|
|
|
|
class SearchService {
|
|
static const String _baseUrl = 'https://nominatim.openstreetmap.org';
|
|
static const int _maxResults = 5;
|
|
static const Duration _timeout = Duration(seconds: 10);
|
|
final _client = UserAgentClient();
|
|
|
|
/// Client-side result cache, keyed by normalized query + viewbox.
|
|
/// Required by Nominatim usage policy. Static so all SearchService
|
|
/// instances share the cache and don't generate redundant requests.
|
|
static final Map<String, _CachedResult> _resultCache = {};
|
|
|
|
|
|
/// Search for places using Nominatim geocoding service
|
|
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
|
|
if (query.trim().isEmpty) {
|
|
return [];
|
|
}
|
|
|
|
// Check if query looks like coordinates first
|
|
final coordResult = _tryParseCoordinates(query.trim());
|
|
if (coordResult != null) {
|
|
return [coordResult];
|
|
}
|
|
|
|
// Otherwise, use Nominatim API
|
|
return await _searchNominatim(query.trim(), viewbox: viewbox);
|
|
}
|
|
|
|
/// Try to parse various coordinate formats
|
|
SearchResult? _tryParseCoordinates(String query) {
|
|
// Remove common separators and normalize
|
|
final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim();
|
|
final parts = normalized.split(RegExp(r'\s+'));
|
|
|
|
if (parts.length != 2) return null;
|
|
|
|
final lat = double.tryParse(parts[0]);
|
|
final lon = double.tryParse(parts[1]);
|
|
|
|
if (lat == null || lon == null) return null;
|
|
|
|
// Basic validation for Earth coordinates
|
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
|
|
|
return SearchResult(
|
|
displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}',
|
|
coordinates: LatLng(lat, lon),
|
|
category: 'coordinates',
|
|
type: 'point',
|
|
);
|
|
}
|
|
|
|
/// Search using Nominatim API with rate limiting and result caching.
|
|
///
|
|
/// Nominatim usage policy requires:
|
|
/// - Max 1 request per second
|
|
/// - Client-side result caching
|
|
/// - No auto-complete / typeahead
|
|
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
|
// Normalize the viewbox first so both the cache key and the request
|
|
// params use the same effective values (rounded + min-span expanded).
|
|
String? viewboxParam;
|
|
if (viewbox != null) {
|
|
double round1(double v) => (v * 10).round() / 10;
|
|
var west = round1(viewbox.west);
|
|
var east = round1(viewbox.east);
|
|
var south = round1(viewbox.south);
|
|
var north = round1(viewbox.north);
|
|
|
|
if (east - west < 0.5) {
|
|
final mid = (east + west) / 2;
|
|
west = mid - 0.25;
|
|
east = mid + 0.25;
|
|
}
|
|
if (north - south < 0.5) {
|
|
final mid = (north + south) / 2;
|
|
south = mid - 0.25;
|
|
north = mid + 0.25;
|
|
}
|
|
|
|
viewboxParam = '$west,$north,$east,$south';
|
|
}
|
|
|
|
final cacheKey = _buildCacheKey(query, viewboxParam);
|
|
|
|
// Check cache first (Nominatim policy requires client-side caching)
|
|
final cached = _resultCache[cacheKey];
|
|
if (cached != null && !cached.isExpired) {
|
|
debugPrint('[SearchService] Cache hit for "$query"');
|
|
return cached.results;
|
|
}
|
|
|
|
final params = {
|
|
'q': query,
|
|
'format': 'json',
|
|
'limit': _maxResults.toString(),
|
|
'addressdetails': '1',
|
|
'extratags': '1',
|
|
};
|
|
|
|
if (viewboxParam != null) {
|
|
params['viewbox'] = viewboxParam;
|
|
}
|
|
|
|
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);
|
|
|
|
debugPrint('[SearchService] Searching Nominatim: $uri');
|
|
|
|
// Rate limit: max 1 request/sec per Nominatim policy
|
|
await ServiceRateLimiter.acquire(ServiceType.nominatim);
|
|
try {
|
|
final response = await _client.get(uri).timeout(_timeout);
|
|
|
|
if (response.statusCode != 200) {
|
|
throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
|
}
|
|
|
|
final List<dynamic> jsonResults = json.decode(response.body);
|
|
final results = jsonResults
|
|
.map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
// Cache the results
|
|
_resultCache[cacheKey] = _CachedResult(results);
|
|
_pruneCache();
|
|
|
|
debugPrint('[SearchService] Found ${results.length} results');
|
|
return results;
|
|
} catch (e, stackTrace) {
|
|
debugPrint('[SearchService] Search failed: $e');
|
|
Error.throwWithStackTrace(e, stackTrace);
|
|
} finally {
|
|
ServiceRateLimiter.release(ServiceType.nominatim);
|
|
}
|
|
}
|
|
|
|
/// Build a cache key from the query and the already-normalized viewbox string.
|
|
///
|
|
/// The viewbox should be the same `west,north,east,south` string sent to
|
|
/// Nominatim (after rounding and min-span expansion) so that requests with
|
|
/// different raw bounds but the same effective viewbox share a cache entry.
|
|
String _buildCacheKey(String query, String? viewboxParam) {
|
|
final normalizedQuery = query.trim().toLowerCase();
|
|
if (viewboxParam == null) return normalizedQuery;
|
|
return '$normalizedQuery|$viewboxParam';
|
|
}
|
|
|
|
/// Remove expired entries and limit cache size.
|
|
void _pruneCache() {
|
|
_resultCache.removeWhere((_, cached) => cached.isExpired);
|
|
// Limit cache to 50 entries to prevent unbounded growth
|
|
if (_resultCache.length > 50) {
|
|
final sortedKeys = _resultCache.keys.toList()
|
|
..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt));
|
|
for (final key in sortedKeys.take(_resultCache.length - 50)) {
|
|
_resultCache.remove(key);
|
|
}
|
|
}
|
|
}
|
|
}
|