import 'dart:convert'; import 'package:flutter/services.dart' show rootBundle; import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import '../hoogerheide_streets.dart'; class GeocodingService { static final GeocodingService _instance = GeocodingService._internal(); factory GeocodingService() => _instance; GeocodingService._internal(); static const _nominatimBaseUrl = 'https://nominatim.openstreetmap.org'; static const _userAgent = 'DeliveryApp/1.0 (delivery-app-openclaw@users.noreply.github.com)'; Map? _addressCache; String _normalize(String name) => name.replaceAll(RegExp(r'[–—]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase(); Future> _loadAddressCache() async { if (_addressCache != null) return _addressCache!; try { final jsonStr = await rootBundle.loadString('assets/hoogerheide_addresses.json'); _addressCache = jsonDecode(jsonStr); return _addressCache!; } catch (_) { return {'addresses': []}; } } /// Geocode a street + house number to LatLng. /// Priority: local JSON cache → Nominatim → hoogerheide_streets.dart → default Future geocode(String street, String houseNum) async { final ns = _normalize(street); final hn = houseNum.trim(); // 1. Local address cache (fast, no rate limits) try { final cache = await _loadAddressCache(); final addresses = cache['addresses'] as List? ?? []; for (final addr in addresses) { final addrStreet = (addr['street'] as String).toLowerCase(); final addrNum = addr['housenumber'] as String; if (addrStreet == ns && addrNum == hn) { return LatLng(addr['lat'] as double, addr['lon'] as double); } } } catch (_) {} // 2. Nominatim API try { final query = Uri.encodeComponent('$street $houseNum, Hoogerheide, Netherlands'); final url = '$_nominatimBaseUrl/search?format=jsonv2&q=$query&limit=1'; final resp = await http.get( Uri.parse(url), headers: {'User-Agent': _userAgent}, ).timeout(const Duration(seconds: 5)); if (resp.statusCode == 200) { final data = jsonDecode(resp.body); if (data is List && data.isNotEmpty) { return LatLng( double.parse(data[0]['lat']), double.parse(data[0]['lon']), ); } } } catch (_) {} // 3. hoogerheide_streets.dart lookup (exact then partial match) final streetResult = _lookupInStreets(street); if (streetResult != null) { // Offset by house number to spread markers along the street final hnInt = int.tryParse(houseNum.replaceAll(RegExp(r'[A-Za-z]'), '')) ?? 1; return LatLng( streetResult.latitude + (hnInt - 1) * 0.00015, streetResult.longitude + ((hnInt / 25).floor() % 2 == 0 ? 0 : 0.0002), ); } // 4. Default to Hoogerheide center return const LatLng(51.4243390, 4.3238380); } LatLng? _lookupInStreets(String street) { final ns = _normalize(street); // Exact match for (final entry in HoogerheideStreets.streetCoords.entries) { if (_normalize(entry.key) == ns) { return LatLng(entry.value[0], entry.value[1]); } } // Partial match for (final entry in HoogerheideStreets.streetCoords.entries) { final ek = _normalize(entry.key); if (ns.contains(ek) || ek.contains(ns)) { return LatLng(entry.value[0], entry.value[1]); } } return null; } /// Batch-geocode a list of stops, updating coords only when they changed. Future> geocodeStops(List<(String street, String houseNum)> addresses) async { final results = []; for (final (street, houseNum) in addresses) { try { results.add(await geocode(street, houseNum)); } catch (_) { results.add(null); } } return results; } }