import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:lunchpick/core/constants/api_keys.dart'; import 'package:lunchpick/core/utils/app_logger.dart'; /// 주소를 위도/경도로 변환하는 간단한 지오코딩(Geocoding) 서비스 class GeocodingService { static const _endpoint = 'https://nominatim.openstreetmap.org/search'; static const _fallbackLatitude = 37.5665; // 서울시청 위도 static const _fallbackLongitude = 126.9780; // 서울시청 경도 /// 도로명/지번 주소를 기반으로 위경도를 조회한다. /// /// 무료(Nominatim) 엔드포인트를 사용하며 별도 API 키가 필요 없다. /// 실패 시 null을 반환하고, 호출 측에서 기본 좌표를 사용할 수 있게 둔다. Future<({double latitude, double longitude})?> geocode(String address) async { if (address.trim().isEmpty) return null; // 1차: VWorld 지오코딩 시도 (키가 존재할 때만) final vworldResult = await _geocodeWithVworld(address); if (vworldResult != null) { return vworldResult; } // 2차: Nominatim (fallback) try { final uri = Uri.parse( '$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(address)}', ); // Nominatim은 User-Agent 헤더를 요구한다. final response = await http.get( uri, headers: const {'User-Agent': 'lunchpick-geocoder/1.0'}, ); if (response.statusCode != 200) { AppLogger.debug('[GeocodingService] 실패 status: ${response.statusCode}'); return null; } final List results = jsonDecode(response.body) as List; if (results.isEmpty) return null; final first = results.first as Map; final lat = double.tryParse(first['lat']?.toString() ?? ''); final lon = double.tryParse(first['lon']?.toString() ?? ''); if (lat == null || lon == null) { AppLogger.debug('[GeocodingService] 응답 파싱 실패: ${first.toString()}'); return null; } return (latitude: lat, longitude: lon); } catch (e) { AppLogger.debug('[GeocodingService] 예외 발생: $e'); return null; } } /// 기본 좌표(서울시청)를 반환한다. ({double latitude, double longitude}) defaultCoordinates() { return (latitude: _fallbackLatitude, longitude: _fallbackLongitude); } Future<({double latitude, double longitude})?> _geocodeWithVworld( String address, ) async { final apiKey = ApiKeys.vworldApiKey; if (apiKey.isEmpty) { return null; } try { final uri = Uri.https('api.vworld.kr', '/req/address', { 'service': 'address', 'request': 'getcoord', 'format': 'json', 'type': 'road', // 도로명 주소 기준 'key': apiKey, 'address': address, }); final response = await http.get( uri, headers: const {'User-Agent': 'lunchpick-geocoder/1.0'}, ); if (response.statusCode != 200) { AppLogger.debug( '[GeocodingService] VWorld 실패 status: ${response.statusCode}', ); return null; } final Map json = jsonDecode(response.body); final responseNode = json['response'] as Map?; if (responseNode == null || responseNode['status'] != 'OK') { AppLogger.debug('[GeocodingService] VWorld 응답 오류: ${response.body}'); return null; } // VWorld 포인트는 WGS84 lon/lat 순서(x=lon, y=lat) final result = responseNode['result'] as Map?; final point = result?['point'] as Map?; final x = point?['x']?.toString(); final y = point?['y']?.toString(); final lon = x != null ? double.tryParse(x) : null; final lat = y != null ? double.tryParse(y) : null; if (lat == null || lon == null) { AppLogger.debug( '[GeocodingService] VWorld 좌표 파싱 실패: ${point.toString()}', ); return null; } return (latitude: lat, longitude: lon); } catch (e) { AppLogger.debug('[GeocodingService] VWorld 예외: $e'); return null; } } }