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; // 주소 전처리: 상세 주소(층수, 상호명 등) 제거 final cleanedAddress = _cleanAddress(address); // 1차: VWorld 지오코딩 시도 (키가 존재할 때만) final vworldResult = await _geocodeWithVworld(cleanedAddress); if (vworldResult != null) { return vworldResult; } // 2차: Nominatim (fallback) try { final uri = Uri.parse( '$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(cleanedAddress)}', ); // 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; } } /// 주소에서 상세 주소(층수, 상호명 등)를 제거하여 순수 도로명 주소만 추출한다. /// /// 예시: /// - "서울 관악구 관악로14길 6-4 1층 이자카야 혼네" → "서울 관악구 관악로14길 6-4" /// - "서울특별시 강남구 테헤란로 123 B1 스타벅스" → "서울특별시 강남구 테헤란로 123" String _cleanAddress(String address) { final trimmed = address.trim(); // 패턴 1: 건물번호 뒤에 층수 정보가 있는 경우 (1층, B1, 지하1층 등) // 도로명 주소의 건물번호는 숫자 또는 숫자-숫자 형태 final floorPattern = RegExp( r'(\d+(?:-\d+)?)\s+(?:\d+층|[Bb]\d+|지하\d*층?).*$', ); final floorMatch = floorPattern.firstMatch(trimmed); if (floorMatch != null) { final buildingNumber = floorMatch.group(1); final beforeMatch = trimmed.substring(0, floorMatch.start); return '$beforeMatch$buildingNumber'.trim(); } // 패턴 2: 건물번호 뒤에 상호명이 바로 오는 경우 (공백 + 한글/영문) // 단, 구/동/로/길 같은 주소 구성요소는 제외 final namePattern = RegExp( r'(\d+(?:-\d+)?)\s+(?![가-힣]+[구동로길읍면리]\s)([가-힣a-zA-Z&]+.*)$', ); final nameMatch = namePattern.firstMatch(trimmed); if (nameMatch != null) { final buildingNumber = nameMatch.group(1); final beforeMatch = trimmed.substring(0, nameMatch.start); return '$beforeMatch$buildingNumber'.trim(); } return trimmed; } }