feat(app): add vworld geocoding and native ads placeholders

This commit is contained in:
JiWoong Sul
2025-12-03 14:30:20 +09:00
parent d101f7d0dc
commit 3ff9e5f837
23 changed files with 1108 additions and 540 deletions

View File

@@ -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<