import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../../domain/entities/restaurant.dart'; import '../../data/datasources/remote/naver_map_parser.dart'; import '../providers/di_providers.dart'; import '../providers/restaurant_provider.dart'; import '../providers/location_provider.dart'; /// 식당 추가 화면의 상태 모델 class AddRestaurantState { final bool isLoading; final bool isSearching; final String? errorMessage; final Restaurant? fetchedRestaurantData; final RestaurantFormData formData; final List searchResults; final String geocodingStatus; const AddRestaurantState({ this.isLoading = false, this.isSearching = false, this.errorMessage, this.fetchedRestaurantData, required this.formData, this.searchResults = const [], this.geocodingStatus = '', }); AddRestaurantState copyWith({ bool? isLoading, bool? isSearching, String? errorMessage, Restaurant? fetchedRestaurantData, RestaurantFormData? formData, List? searchResults, bool clearFetchedRestaurant = false, bool clearError = false, String? geocodingStatus, }) { return AddRestaurantState( isLoading: isLoading ?? this.isLoading, isSearching: isSearching ?? this.isSearching, errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), fetchedRestaurantData: clearFetchedRestaurant ? null : (fetchedRestaurantData ?? this.fetchedRestaurantData), formData: formData ?? this.formData, searchResults: searchResults ?? this.searchResults, geocodingStatus: geocodingStatus ?? this.geocodingStatus, ); } } /// 식당 폼 데이터 모델 class RestaurantFormData { final String name; final String category; final String subCategory; final String description; final String phoneNumber; final String roadAddress; final String jibunAddress; final String latitude; final String longitude; final String naverUrl; const RestaurantFormData({ this.name = '', this.category = '', this.subCategory = '', this.description = '', this.phoneNumber = '', this.roadAddress = '', this.jibunAddress = '', this.latitude = '', this.longitude = '', this.naverUrl = '', }); RestaurantFormData copyWith({ String? name, String? category, String? subCategory, String? description, String? phoneNumber, String? roadAddress, String? jibunAddress, String? latitude, String? longitude, String? naverUrl, }) { return RestaurantFormData( name: name ?? this.name, category: category ?? this.category, subCategory: subCategory ?? this.subCategory, description: description ?? this.description, phoneNumber: phoneNumber ?? this.phoneNumber, roadAddress: roadAddress ?? this.roadAddress, jibunAddress: jibunAddress ?? this.jibunAddress, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, naverUrl: naverUrl ?? this.naverUrl, ); } /// TextEditingController로부터 폼 데이터 생성 factory RestaurantFormData.fromControllers({ required TextEditingController nameController, required TextEditingController categoryController, required TextEditingController subCategoryController, required TextEditingController descriptionController, required TextEditingController phoneController, required TextEditingController roadAddressController, required TextEditingController jibunAddressController, required TextEditingController latitudeController, required TextEditingController longitudeController, required TextEditingController naverUrlController, }) { return RestaurantFormData( name: nameController.text.trim(), category: categoryController.text.trim(), subCategory: subCategoryController.text.trim(), description: descriptionController.text.trim(), phoneNumber: phoneController.text.trim(), roadAddress: roadAddressController.text.trim(), jibunAddress: jibunAddressController.text.trim(), latitude: latitudeController.text.trim(), longitude: longitudeController.text.trim(), naverUrl: naverUrlController.text.trim(), ); } /// Restaurant 엔티티로부터 폼 데이터 생성 factory RestaurantFormData.fromRestaurant(Restaurant restaurant) { return RestaurantFormData( name: restaurant.name, category: restaurant.category, subCategory: restaurant.subCategory, description: restaurant.description ?? '', phoneNumber: restaurant.phoneNumber ?? '', roadAddress: restaurant.roadAddress, jibunAddress: restaurant.jibunAddress, latitude: restaurant.latitude.toString(), longitude: restaurant.longitude.toString(), naverUrl: restaurant.naverUrl ?? '', ); } /// Restaurant 엔티티로 변환 Restaurant toRestaurant() { final uuid = const Uuid(); return Restaurant( id: uuid.v4(), name: name, category: category, subCategory: subCategory.isEmpty ? category : subCategory, description: description.isEmpty ? null : description, phoneNumber: phoneNumber.isEmpty ? null : phoneNumber, roadAddress: roadAddress, jibunAddress: jibunAddress.isEmpty ? roadAddress : jibunAddress, latitude: double.tryParse(latitude) ?? 37.5665, longitude: double.tryParse(longitude) ?? 126.9780, naverUrl: naverUrl.isEmpty ? null : naverUrl, source: naverUrl.isNotEmpty ? DataSource.NAVER : DataSource.USER_INPUT, createdAt: DateTime.now(), updatedAt: DateTime.now(), ); } } /// 식당 추가 화면의 ViewModel class AddRestaurantViewModel extends StateNotifier { final Ref _ref; AddRestaurantViewModel(this._ref) : super(const AddRestaurantState(formData: RestaurantFormData())); /// 상태 초기화 void reset() { state = const AddRestaurantState(formData: RestaurantFormData()); } /// 네이버 URL로부터 식당 정보 가져오기 Future fetchFromNaverUrl(String url) async { final parsed = _parseSharedNaverContent(url); if (parsed.url.trim().isEmpty) { state = state.copyWith(errorMessage: 'URL을 입력해주세요.'); return; } // 공유 텍스트에 포함된 상호명/도로명주소를 미리 채워 넣는다. state = state.copyWith(isLoading: true, clearError: true); try { final normalizedUrl = _normalizeUrl(parsed.url); state = state.copyWith( geocodingStatus: '지오코딩 시도: ${parsed.roadAddress ?? ''}', ); final coords = await _tryGeocode(parsed.roadAddress ?? ''); if (coords != null) { state = state.copyWith( geocodingStatus: '지오코딩 성공: ${coords.latitude.toStringAsFixed(6)}, ${coords.longitude.toStringAsFixed(6)}', ); } else { state = state.copyWith( geocodingStatus: '지오코딩 실패: 현재 위치/기본 좌표를 사용할 수 있습니다.', ); } final newForm = state.formData.copyWith( name: parsed.name ?? state.formData.name, roadAddress: parsed.roadAddress ?? state.formData.roadAddress, jibunAddress: state.formData.jibunAddress, latitude: coords != null ? coords.latitude.toString() : state.formData.latitude, longitude: coords != null ? coords.longitude.toString() : state.formData.longitude, category: '기타', subCategory: '기타', naverUrl: normalizedUrl, ); state = state.copyWith( isLoading: false, fetchedRestaurantData: newForm.toRestaurant(), formData: newForm, ); } catch (e) { final message = e is NaverMapParseException ? '네이버 지도 파싱 실패: ${e.message}' : e.toString(); state = state.copyWith( isLoading: false, errorMessage: message, geocodingStatus: '지오코딩 실패: ${parsed.roadAddress ?? '주소 없음'}', ); } } /// 네이버 검색으로 식당 목록 검색 Future searchRestaurants( String query, { double? latitude, double? longitude, }) async { if (query.trim().isEmpty) { state = state.copyWith( errorMessage: '검색어를 입력해주세요.', searchResults: const [], ); return; } state = state.copyWith(isSearching: true, clearError: true); try { final repository = _ref.read(restaurantRepositoryProvider); final results = await repository.searchRestaurantsFromNaver( query: query, latitude: latitude, longitude: longitude, ); state = state.copyWith(isSearching: false, searchResults: results); } catch (e) { state = state.copyWith(isSearching: false, errorMessage: e.toString()); } } /// 검색 결과 선택 void selectSearchResult(Restaurant restaurant) { state = state.copyWith( fetchedRestaurantData: restaurant, formData: RestaurantFormData.fromRestaurant(restaurant), clearError: true, ); } /// 식당 정보 저장 Future saveRestaurant() async { final notifier = _ref.read(restaurantNotifierProvider.notifier); final fallbackCategory = state.formData.category.isNotEmpty ? state.formData.category : '기타'; final fallbackSubCategory = state.formData.subCategory.isNotEmpty ? state.formData.subCategory : fallbackCategory; try { state = state.copyWith(isLoading: true, clearError: true); Restaurant restaurantToSave; // 네이버에서 가져온 데이터가 있으면 업데이트 final fetchedData = state.fetchedRestaurantData; if (fetchedData != null) { final coords = await _resolveCoordinates( latitudeText: state.formData.latitude, longitudeText: state.formData.longitude, roadAddress: state.formData.roadAddress, jibunAddress: state.formData.jibunAddress, fallbackLatitude: fetchedData.latitude, fallbackLongitude: fetchedData.longitude, allowFallbackWhenGeocodingFails: false, ); restaurantToSave = fetchedData.copyWith( name: state.formData.name, category: fallbackCategory, subCategory: fallbackSubCategory, description: state.formData.description.isEmpty ? null : state.formData.description, phoneNumber: state.formData.phoneNumber.isEmpty ? null : state.formData.phoneNumber, roadAddress: state.formData.roadAddress, jibunAddress: state.formData.jibunAddress.isEmpty ? state.formData.roadAddress : state.formData.jibunAddress, latitude: coords.latitude, longitude: coords.longitude, naverUrl: state.formData.naverUrl.isEmpty ? null : state.formData.naverUrl, updatedAt: DateTime.now(), needsAddressVerification: coords.usedCurrentLocation, ); } else { // 직접 입력한 경우 final coords = await _resolveCoordinates( latitudeText: state.formData.latitude, longitudeText: state.formData.longitude, roadAddress: state.formData.roadAddress, jibunAddress: state.formData.jibunAddress, ); restaurantToSave = state.formData.toRestaurant().copyWith( category: fallbackCategory, subCategory: fallbackSubCategory, latitude: coords.latitude, longitude: coords.longitude, needsAddressVerification: coords.usedCurrentLocation, ); } await notifier.addRestaurantDirect(restaurantToSave); state = state.copyWith(isLoading: false); return true; } catch (e) { state = state.copyWith(isLoading: false, errorMessage: e.toString()); return false; } } /// 폼 데이터 업데이트 void updateFormData(RestaurantFormData formData) { state = state.copyWith(formData: formData); } /// 에러 메시지 초기화 void clearError() { state = state.copyWith(clearError: true); } /// 네이버 지도 공유 텍스트에서 URL/상호명/도로명주소를 추출한다. _ParsedNaverShare _parseSharedNaverContent(String raw) { final normalized = raw.replaceAll('\r\n', '\n').trim(); // URL 추출 final urlRegex = RegExp( r'(https?://(?:map\.naver\.com|naver\.me)[^\s]+)', caseSensitive: false, ); final urlMatch = urlRegex.firstMatch(normalized); final url = urlMatch?.group(0) ?? normalized; // 패턴: [네이버지도]\n상호명\n도로명주소\nURL final lines = normalized.split('\n').map((e) => e.trim()).toList(); String? name; String? roadAddress; if (lines.length >= 4 && lines.first.contains('네이버지도')) { name = lines[1].isNotEmpty ? lines[1] : null; roadAddress = lines[2].isNotEmpty ? lines[2] : null; } else { // 줄바꿈이 없거나 공백만 있는 경우: URL 앞 부분에서 이름/주소를 분리 final prefix = normalized.substring(0, urlMatch?.start ?? 0).trim(); if (prefix.isNotEmpty) { final cleaned = prefix.replaceFirst('[네이버지도]', '').trim(); // 주소 패턴(시/도/구/로/길 등)을 먼저 찾는다. final addressRegex = RegExp( r'(서울|부산|대구|인천|광주|대전|울산|세종|제주|경기|강원|충북|충남|전북|전남|경북|경남)[^\n]*', ); final addrMatch = addressRegex.firstMatch(cleaned); if (addrMatch != null) { roadAddress = addrMatch.group(0)?.trim(); final extractedName = cleaned.substring(0, addrMatch.start).trim(); name = extractedName.isNotEmpty ? extractedName : null; } else { // 주소 패턴이 없으면 첫 단어가 아닌 전체를 이름으로 유지해 공백이 있어도 깨지지 않게 함 name = cleaned.isNotEmpty ? cleaned : null; } } } return _ParsedNaverShare(url: url, name: name, roadAddress: roadAddress); } Future<({double latitude, double longitude})?> _tryGeocode( String roadAddress, ) async { if (roadAddress.isEmpty) return null; try { final geocodingService = _ref.read(geocodingServiceProvider); final result = await geocodingService.geocode(roadAddress); if (result == null) return null; return (latitude: result.latitude, longitude: result.longitude); } catch (_) { return null; } } String _normalizeUrl(String rawUrl) { final trimmed = rawUrl.trim(); if (trimmed.startsWith('http')) return trimmed; return 'https://$trimmed'; } Future<({double latitude, double longitude, bool usedCurrentLocation})> _resolveCoordinates({ required String latitudeText, required String longitudeText, required String roadAddress, required String jibunAddress, double? fallbackLatitude, double? fallbackLongitude, bool allowFallbackWhenGeocodingFails = true, }) async { final parsedLat = double.tryParse(latitudeText); final parsedLon = double.tryParse(longitudeText); if (parsedLat != null && parsedLon != null) { state = state.copyWith( geocodingStatus: '사용자 입력 좌표 사용: ${parsedLat.toStringAsFixed(6)}, ${parsedLon.toStringAsFixed(6)}', ); return ( latitude: parsedLat, longitude: parsedLon, usedCurrentLocation: false, ); } final geocodingService = _ref.read(geocodingServiceProvider); final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress; if (address.isNotEmpty) { state = state.copyWith(geocodingStatus: '지오코딩 시도: $address'); final result = await geocodingService.geocode(address); if (result != null) { state = state.copyWith( geocodingStatus: '지오코딩 성공: ${result.latitude.toStringAsFixed(6)}, ${result.longitude.toStringAsFixed(6)}', ); return ( latitude: result.latitude, longitude: result.longitude, usedCurrentLocation: false, ); } else { state = state.copyWith( geocodingStatus: '지오코딩 실패: $address, 주소를 다시 확인해 주세요.', ); if (!allowFallbackWhenGeocodingFails) { state = state.copyWith( errorMessage: '주소가 지도에서 인식되지 않습니다. ' '도로명 주소 전체를 정확히 입력했는지 확인해 주세요.', ); throw Exception('지오코딩 실패: $address'); } } } // 주소로 좌표를 얻지 못하면 현재 위치를 활용한다. try { final position = await _ref.read(currentLocationProvider.future); if (position != null) { state = state.copyWith( geocodingStatus: '현재 위치 사용: ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}', ); return ( latitude: position.latitude, longitude: position.longitude, usedCurrentLocation: true, ); } } catch (_) { // 위치 권한 거부/오류 시 fallback 사용 } if (fallbackLatitude != null && fallbackLongitude != null) { state = state.copyWith( geocodingStatus: '네이버 데이터 좌표 사용: ${fallbackLatitude.toStringAsFixed(6)}, ${fallbackLongitude.toStringAsFixed(6)}', ); return ( latitude: fallbackLatitude, longitude: fallbackLongitude, usedCurrentLocation: false, ); } final defaultCoords = geocodingService.defaultCoordinates(); state = state.copyWith( geocodingStatus: '기본 좌표 사용: ${defaultCoords.latitude.toStringAsFixed(6)}, ${defaultCoords.longitude.toStringAsFixed(6)}', ); return ( latitude: defaultCoords.latitude, longitude: defaultCoords.longitude, usedCurrentLocation: true, ); } } class _ParsedNaverShare { final String url; final String? name; final String? roadAddress; _ParsedNaverShare({required this.url, this.name, this.roadAddress}); } /// AddRestaurantViewModel Provider final addRestaurantViewModelProvider = StateNotifierProvider.autoDispose< AddRestaurantViewModel, AddRestaurantState >((ref) => AddRestaurantViewModel(ref));