533 lines
18 KiB
Dart
533 lines
18 KiB
Dart
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<Restaurant> 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<Restaurant>? 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<AddRestaurantState> {
|
|
final Ref _ref;
|
|
|
|
AddRestaurantViewModel(this._ref)
|
|
: super(const AddRestaurantState(formData: RestaurantFormData()));
|
|
|
|
/// 상태 초기화
|
|
void reset() {
|
|
state = const AddRestaurantState(formData: RestaurantFormData());
|
|
}
|
|
|
|
/// 네이버 URL로부터 식당 정보 가져오기
|
|
Future<void> 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<void> 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<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);
|
|
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,
|
|
);
|
|
|
|
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,
|
|
}) 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, 현재 위치/기본 좌표로 대체',
|
|
);
|
|
}
|
|
}
|
|
|
|
// 주소로 좌표를 얻지 못하면 현재 위치를 활용한다.
|
|
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));
|