feat(app): add vworld geocoding and native ads placeholders
This commit is contained in:
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -15,6 +16,7 @@ class AddRestaurantState {
|
||||
final Restaurant? fetchedRestaurantData;
|
||||
final RestaurantFormData formData;
|
||||
final List<Restaurant> searchResults;
|
||||
final String geocodingStatus;
|
||||
|
||||
const AddRestaurantState({
|
||||
this.isLoading = false,
|
||||
@@ -23,6 +25,7 @@ class AddRestaurantState {
|
||||
this.fetchedRestaurantData,
|
||||
required this.formData,
|
||||
this.searchResults = const [],
|
||||
this.geocodingStatus = '',
|
||||
});
|
||||
|
||||
AddRestaurantState copyWith({
|
||||
@@ -34,6 +37,7 @@ class AddRestaurantState {
|
||||
List<Restaurant>? searchResults,
|
||||
bool clearFetchedRestaurant = false,
|
||||
bool clearError = false,
|
||||
String? geocodingStatus,
|
||||
}) {
|
||||
return AddRestaurantState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
@@ -44,6 +48,7 @@ class AddRestaurantState {
|
||||
: (fetchedRestaurantData ?? this.fetchedRestaurantData),
|
||||
formData: formData ?? this.formData,
|
||||
searchResults: searchResults ?? this.searchResults,
|
||||
geocodingStatus: geocodingStatus ?? this.geocodingStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -179,24 +184,61 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
|
||||
/// 네이버 URL로부터 식당 정보 가져오기
|
||||
Future<void> fetchFromNaverUrl(String url) async {
|
||||
if (url.trim().isEmpty) {
|
||||
final parsed = _parseSharedNaverContent(url);
|
||||
|
||||
if (parsed.url.trim().isEmpty) {
|
||||
state = state.copyWith(errorMessage: 'URL을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 공유 텍스트에 포함된 상호명/도로명주소를 미리 채워 넣는다.
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
final repository = _ref.read(restaurantRepositoryProvider);
|
||||
final restaurant = await repository.previewRestaurantFromUrl(url);
|
||||
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: restaurant,
|
||||
formData: RestaurantFormData.fromRestaurant(restaurant),
|
||||
fetchedRestaurantData: newForm.toRestaurant(),
|
||||
formData: newForm,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, errorMessage: e.toString());
|
||||
final message = e is NaverMapParseException
|
||||
? '네이버 지도 파싱 실패: ${e.message}'
|
||||
: e.toString();
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: message,
|
||||
geocodingStatus: '지오코딩 실패: ${parsed.roadAddress ?? '주소 없음'}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +283,12 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
/// 식당 정보 저장
|
||||
Future<bool> 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);
|
||||
@@ -260,10 +308,8 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
|
||||
restaurantToSave = fetchedData.copyWith(
|
||||
name: state.formData.name,
|
||||
category: state.formData.category,
|
||||
subCategory: state.formData.subCategory.isEmpty
|
||||
? state.formData.category
|
||||
: state.formData.subCategory,
|
||||
category: fallbackCategory,
|
||||
subCategory: fallbackSubCategory,
|
||||
description: state.formData.description.isEmpty
|
||||
? null
|
||||
: state.formData.description,
|
||||
@@ -292,6 +338,8 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
);
|
||||
|
||||
restaurantToSave = state.formData.toRestaurant().copyWith(
|
||||
category: fallbackCategory,
|
||||
subCategory: fallbackSubCategory,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
needsAddressVerification: coords.usedCurrentLocation,
|
||||
@@ -317,6 +365,69 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
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,
|
||||
@@ -329,6 +440,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
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,
|
||||
@@ -339,13 +454,22 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
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, 현재 위치/기본 좌표로 대체',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +477,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
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,
|
||||
@@ -364,6 +492,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
}
|
||||
|
||||
if (fallbackLatitude != null && fallbackLongitude != null) {
|
||||
state = state.copyWith(
|
||||
geocodingStatus:
|
||||
'네이버 데이터 좌표 사용: ${fallbackLatitude.toStringAsFixed(6)}, ${fallbackLongitude.toStringAsFixed(6)}',
|
||||
);
|
||||
return (
|
||||
latitude: fallbackLatitude,
|
||||
longitude: fallbackLongitude,
|
||||
@@ -372,6 +504,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
}
|
||||
|
||||
final defaultCoords = geocodingService.defaultCoordinates();
|
||||
state = state.copyWith(
|
||||
geocodingStatus:
|
||||
'기본 좌표 사용: ${defaultCoords.latitude.toStringAsFixed(6)}, ${defaultCoords.longitude.toStringAsFixed(6)}',
|
||||
);
|
||||
return (
|
||||
latitude: defaultCoords.latitude,
|
||||
longitude: defaultCoords.longitude,
|
||||
@@ -380,6 +516,14 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
}
|
||||
}
|
||||
|
||||
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<
|
||||
|
||||
Reference in New Issue
Block a user