Files
deflock-app/lib/services/search_service.dart
Doug Borg 2d92214bed Add offline-first tile system with per-provider caching and error retry
- 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>
2026-03-07 12:34:01 -07:00

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);
}
}
}
}