feat(app): add manual entry and sharing flows
This commit is contained in:
@@ -5,7 +5,7 @@ import '../naver/naver_local_search_api.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
|
||||
/// 네이버 데이터 변환기
|
||||
///
|
||||
///
|
||||
/// 네이버 API 응답을 도메인 엔티티로 변환합니다.
|
||||
class NaverDataConverter {
|
||||
static const _uuid = Uuid();
|
||||
@@ -22,13 +22,21 @@ class NaverDataConverter {
|
||||
);
|
||||
|
||||
// 카테고리 파싱 및 정규화
|
||||
final categoryParts = result.category.split('>').map((s) => s.trim()).toList();
|
||||
final categoryParts = result.category
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
.toList();
|
||||
final mainCategory = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : mainCategory;
|
||||
|
||||
final subCategory = categoryParts.length > 1
|
||||
? categoryParts.last
|
||||
: mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(
|
||||
mainCategory,
|
||||
subCategory,
|
||||
);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
name: result.title,
|
||||
@@ -36,8 +44,8 @@ class NaverDataConverter {
|
||||
subCategory: subCategory,
|
||||
description: result.description.isNotEmpty ? result.description : null,
|
||||
phoneNumber: result.telephone.isNotEmpty ? result.telephone : null,
|
||||
roadAddress: result.roadAddress.isNotEmpty
|
||||
? result.roadAddress
|
||||
roadAddress: result.roadAddress.isNotEmpty
|
||||
? result.roadAddress
|
||||
: result.address,
|
||||
jibunAddress: result.address,
|
||||
latitude: convertedCoords['latitude'] ?? 37.5665,
|
||||
@@ -77,10 +85,15 @@ class NaverDataConverter {
|
||||
final rawCategory = placeData['category'] ?? '음식점';
|
||||
final categoryParts = rawCategory.split('>').map((s) => s.trim()).toList();
|
||||
final mainCategory = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : mainCategory;
|
||||
|
||||
final subCategory = categoryParts.length > 1
|
||||
? categoryParts.last
|
||||
: mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(
|
||||
mainCategory,
|
||||
subCategory,
|
||||
);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
@@ -116,11 +129,6 @@ class NaverDataConverter {
|
||||
final longitude = mapx / 10000000.0;
|
||||
final latitude = mapy / 10000000.0;
|
||||
|
||||
return {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
};
|
||||
return {'latitude': latitude, 'longitude': longitude};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import '../../../core/network/network_client.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 GraphQL API 클라이언트
|
||||
///
|
||||
///
|
||||
/// 네이버 지도의 GraphQL API를 호출하여 상세 정보를 가져옵니다.
|
||||
class NaverGraphQLApi {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
static const String _graphqlEndpoint = 'https://pcmap-api.place.naver.com/graphql';
|
||||
|
||||
static const String _graphqlEndpoint =
|
||||
'https://pcmap-api.place.naver.com/graphql';
|
||||
|
||||
NaverGraphQLApi({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
@@ -40,9 +41,7 @@ class NaverGraphQLApi {
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 응답이 비어있습니다',
|
||||
);
|
||||
throw ParseException(message: 'GraphQL 응답이 비어있습니다');
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
@@ -106,9 +105,7 @@ class NaverGraphQLApi {
|
||||
|
||||
if (response['errors'] != null) {
|
||||
debugPrint('GraphQL errors: ${response['errors']}');
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
@@ -149,9 +146,7 @@ class NaverGraphQLApi {
|
||||
);
|
||||
|
||||
if (response['errors'] != null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
@@ -164,4 +159,4 @@ class NaverGraphQLApi {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// \ub124\uc774\ubc84 \uc9c0\ub3c4 GraphQL \ucffc\ub9ac \ubaa8\uc74c
|
||||
///
|
||||
///
|
||||
/// \ub124\uc774\ubc84 \uc9c0\ub3c4 API\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 GraphQL \ucffc\ub9ac\ub4e4\uc744 \uad00\ub9ac\ud569\ub2c8\ub2e4.
|
||||
class NaverGraphQLQueries {
|
||||
NaverGraphQLQueries._();
|
||||
|
||||
|
||||
/// \uc7a5\uc18c \uc0c1\uc138 \uc815\ubcf4 \ucffc\ub9ac - places \uc0ac\uc6a9
|
||||
static const String placeDetailQuery = '''
|
||||
query getPlaceDetail(\$id: String!) {
|
||||
@@ -26,7 +26,7 @@ class NaverGraphQLQueries {
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
|
||||
/// \uc7a5\uc18c \uc0c1\uc138 \uc815\ubcf4 \ucffc\ub9ac - nxPlaces \uc0ac\uc6a9 (\ud3f4\ubc31)
|
||||
static const String nxPlaceDetailQuery = '''
|
||||
query getPlaceDetail(\$id: String!) {
|
||||
@@ -49,4 +49,4 @@ class NaverGraphQLQueries {
|
||||
}
|
||||
}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,42 +50,46 @@ class NaverLocalSearchResult {
|
||||
telephone: json['telephone'] ?? '',
|
||||
address: json['address'] ?? '',
|
||||
roadAddress: json['roadAddress'] ?? '',
|
||||
mapx: json['mapx'] != null ? double.tryParse(json['mapx'].toString()) : null,
|
||||
mapy: json['mapy'] != null ? double.tryParse(json['mapy'].toString()) : null,
|
||||
mapx: json['mapx'] != null
|
||||
? double.tryParse(json['mapx'].toString())
|
||||
: null,
|
||||
mapy: json['mapy'] != null
|
||||
? double.tryParse(json['mapy'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// link 필드에서 Place ID 추출
|
||||
///
|
||||
///
|
||||
/// link가 비어있거나 Place ID가 없으면 null 반환
|
||||
String? extractPlaceId() {
|
||||
if (link.isEmpty) return null;
|
||||
|
||||
|
||||
// 네이버 지도 URL 패턴에서 Place ID 추출
|
||||
// 예: https://map.naver.com/p/entry/place/1638379069
|
||||
final placeIdMatch = RegExp(r'/place/(\d+)').firstMatch(link);
|
||||
if (placeIdMatch != null) {
|
||||
return placeIdMatch.group(1);
|
||||
}
|
||||
|
||||
|
||||
// 다른 패턴 시도: restaurant/1638379069
|
||||
final restaurantIdMatch = RegExp(r'/restaurant/(\d+)').firstMatch(link);
|
||||
if (restaurantIdMatch != null) {
|
||||
return restaurantIdMatch.group(1);
|
||||
}
|
||||
|
||||
|
||||
// ID만 있는 경우 (10자리 숫자)
|
||||
final idOnlyMatch = RegExp(r'(\d{10})').firstMatch(link);
|
||||
if (idOnlyMatch != null) {
|
||||
return idOnlyMatch.group(1);
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 로컬 검색 API 클라이언트
|
||||
///
|
||||
///
|
||||
/// 네이버 검색 API를 통해 장소 정보를 검색합니다.
|
||||
class NaverLocalSearchApi {
|
||||
final NetworkClient _networkClient;
|
||||
@@ -142,7 +146,7 @@ class NaverLocalSearchApi {
|
||||
debugPrint('NaverLocalSearchApi Error: ${e.message}');
|
||||
debugPrint('Error type: ${e.type}');
|
||||
debugPrint('Error response: ${e.response?.data}');
|
||||
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
}
|
||||
@@ -194,4 +198,4 @@ class NaverLocalSearchApi {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import '../../../core/network/network_config.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 프록시 클라이언트
|
||||
///
|
||||
///
|
||||
/// 웹 환경에서 CORS 문제를 해결하기 위한 프록시 클라이언트입니다.
|
||||
class NaverProxyClient {
|
||||
final NetworkClient _networkClient;
|
||||
@@ -23,22 +23,21 @@ class NaverProxyClient {
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(url);
|
||||
debugPrint('Using proxy URL: $proxyUrl');
|
||||
|
||||
|
||||
final response = await _networkClient.get<String>(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept':
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw ParseException(
|
||||
message: '프록시 응답이 비어있습니다',
|
||||
);
|
||||
throw ParseException(message: '프록시 응답이 비어있습니다');
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
@@ -46,7 +45,7 @@ class NaverProxyClient {
|
||||
debugPrint('Proxy fetch error: ${e.message}');
|
||||
debugPrint('Status code: ${e.response?.statusCode}');
|
||||
debugPrint('Response: ${e.response?.data}');
|
||||
|
||||
|
||||
if (e.response?.statusCode == 403) {
|
||||
throw ServerException(
|
||||
message: 'CORS 프록시 접근이 거부되었습니다. 잠시 후 다시 시도해주세요.',
|
||||
@@ -54,7 +53,7 @@ class NaverProxyClient {
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
throw ServerException(
|
||||
message: '프록시를 통한 페이지 로드에 실패했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
@@ -72,12 +71,10 @@ class NaverProxyClient {
|
||||
try {
|
||||
final testUrl = 'https://map.naver.com';
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(testUrl);
|
||||
|
||||
|
||||
final response = await _networkClient.head(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
validateStatus: (status) => status! < 500,
|
||||
),
|
||||
options: Options(validateStatus: (status) => status! < 500),
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
@@ -98,4 +95,4 @@ class NaverProxyClient {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
|
||||
/// 네이버 URL 리졸버
|
||||
///
|
||||
///
|
||||
/// 네이버 단축 URL을 실제 URL로 변환하고 최종 리다이렉트 URL을 추적합니다.
|
||||
class NaverUrlResolver {
|
||||
final NetworkClient _networkClient;
|
||||
@@ -40,7 +40,7 @@ class NaverUrlResolver {
|
||||
return shortUrl;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('resolveShortUrl error: $e');
|
||||
|
||||
|
||||
// 리다이렉트 응답인 경우 Location 헤더 확인
|
||||
if (e.response?.statusCode == 301 || e.response?.statusCode == 302) {
|
||||
final location = e.response?.headers.value('location');
|
||||
@@ -58,7 +58,7 @@ class NaverUrlResolver {
|
||||
Future<String> _resolveShortUrlViaProxy(String shortUrl) async {
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(shortUrl);
|
||||
|
||||
|
||||
final response = await _networkClient.get(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
@@ -70,7 +70,7 @@ class NaverUrlResolver {
|
||||
|
||||
// 응답에서 URL 정보 추출
|
||||
final responseData = response.data.toString();
|
||||
|
||||
|
||||
// meta refresh 태그에서 URL 추출
|
||||
final metaRefreshRegex = RegExp(
|
||||
'<meta[^>]+http-equiv="refresh"[^>]+content="0;url=([^"]+)"[^>]*>',
|
||||
@@ -105,7 +105,7 @@ class NaverUrlResolver {
|
||||
}
|
||||
|
||||
/// 최종 리다이렉트 URL 가져오기
|
||||
///
|
||||
///
|
||||
/// 여러 단계의 리다이렉트를 거쳐 최종 URL을 반환합니다.
|
||||
Future<String> getFinalRedirectUrl(String url) async {
|
||||
try {
|
||||
@@ -148,4 +148,4 @@ class NaverUrlResolver {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import '../datasources/remote/naver_html_extractor.dart';
|
||||
/// 내부적으로 각 기능별로 분리된 API 클라이언트를 사용합니다.
|
||||
class NaverApiClient {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
|
||||
// 분리된 API 클라이언트들
|
||||
late final NaverLocalSearchApi _localSearchApi;
|
||||
late final NaverUrlResolver _urlResolver;
|
||||
@@ -73,27 +73,27 @@ class NaverApiClient {
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
'Accept':
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw ParseException(
|
||||
message: 'HTML 응답이 비어있습니다',
|
||||
);
|
||||
throw ParseException(message: 'HTML 응답이 비어있습니다');
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('fetchMapPageHtml error: $e');
|
||||
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
}
|
||||
|
||||
|
||||
throw ServerException(
|
||||
message: '페이지를 불러올 수 없습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
@@ -138,7 +138,8 @@ class NaverApiClient {
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
'Referer': 'https://map.naver.com/',
|
||||
@@ -162,12 +163,14 @@ class NaverApiClient {
|
||||
|
||||
// 모든 한글 텍스트 추출
|
||||
final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(html);
|
||||
|
||||
|
||||
// JSON-LD 데이터 추출 시도
|
||||
final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(html);
|
||||
|
||||
|
||||
// Apollo State 데이터 추출 시도
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(html);
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(
|
||||
html,
|
||||
);
|
||||
|
||||
debugPrint('========== 추출 결과 ==========');
|
||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
@@ -214,4 +217,4 @@ extension NaverLocalSearchResultExtension on NaverLocalSearchResult {
|
||||
Restaurant toRestaurant({required String id}) {
|
||||
return NaverDataConverter.fromLocalSearchResult(this, id: id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,37 +5,234 @@ import 'package:flutter/foundation.dart';
|
||||
class NaverHtmlExtractor {
|
||||
// 제외할 UI 텍스트 패턴 (확장)
|
||||
static const List<String> _excludePatterns = [
|
||||
'로그인', '메뉴', '검색', '지도', '리뷰', '사진', '네이버', '영업시간',
|
||||
'전화번호', '주소', '찾아오시는길', '예약', '홈', '이용약관', '개인정보',
|
||||
'고객센터', '신고', '공유', '즐겨찾기', '길찾기', '거리뷰', '저장',
|
||||
'더보기', '접기', '펼치기', '닫기', '취소', '확인', '선택', '전체', '삭제',
|
||||
'플레이스', '지도보기', '상세보기', '평점', '별점', '추천', '인기', '최신',
|
||||
'오늘', '내일', '영업중', '영업종료', '휴무', '정기휴무', '임시휴무',
|
||||
'배달', '포장', '매장', '주차', '단체석', '예약가능', '대기', '웨이팅',
|
||||
'영수증', '현금', '카드', '계산서', '할인', '쿠폰', '적립', '포인트',
|
||||
'회원', '비회원', '로그아웃', '마이페이지', '알림', '설정', '도움말',
|
||||
'문의', '제보', '수정', '삭제', '등록', '작성', '댓글', '답글', '좋아요',
|
||||
'싫어요', '스크랩', '북마크', '태그', '해시태그', '팔로우', '팔로잉',
|
||||
'팔로워', '차단', '신고하기', '게시물', '프로필', '활동', '통계', '분석',
|
||||
'다운로드', '업로드', '첨부', '파일', '이미지', '동영상', '음성', '링크',
|
||||
'복사', '붙여넣기', '되돌리기', '다시실행', '새로고침', '뒤로', '앞으로',
|
||||
'시작', '종료', '일시정지', '재생', '정지', '음량', '화면', '전체화면',
|
||||
'최소화', '최대화', '창닫기', '새창', '새탭', '인쇄', '저장하기', '열기',
|
||||
'가져오기', '내보내기', '동기화', '백업', '복원', '초기화', '재설정',
|
||||
'업데이트', '버전', '정보', '소개', '안내', '공지', '이벤트', '혜택',
|
||||
'쿠키', '개인정보처리방침', '서비스이용약관', '위치정보이용약관',
|
||||
'청소년보호정책', '저작권', '라이선스', '제휴', '광고', '비즈니스',
|
||||
'개발자', 'API', '오픈소스', '기여', '후원', '기부', '결제', '환불',
|
||||
'교환', '반품', '배송', '택배', '운송장', '추적', '도착', '출발',
|
||||
'네이버 지도', '카카오맵', '구글맵', 'T맵', '지도 앱', '내비게이션',
|
||||
'경로', '소요시간', '거리', '도보', '자전거', '대중교통', '자동차',
|
||||
'지하철', '버스', '택시', '기차', '비행기', '선박', '도보', '환승',
|
||||
'출구', '입구', '승강장', '매표소', '화장실', '편의시설', '주차장',
|
||||
'엘리베이터', '에스컬레이터', '계단', '경사로', '점자블록', '휠체어',
|
||||
'유모차', '애완동물', '흡연', '금연', '와이파이', '콘센트', '충전',
|
||||
'PC', '프린터', '팩스', '복사기', '회의실', '세미나실', '강당', '공연장',
|
||||
'전시장', '박물관', '미술관', '도서관', '체육관', '수영장', '운동장',
|
||||
'놀이터', '공원', '산책로', '자전거도로', '등산로', '캠핑장', '낚시터'
|
||||
'로그인',
|
||||
'메뉴',
|
||||
'검색',
|
||||
'지도',
|
||||
'리뷰',
|
||||
'사진',
|
||||
'네이버',
|
||||
'영업시간',
|
||||
'전화번호',
|
||||
'주소',
|
||||
'찾아오시는길',
|
||||
'예약',
|
||||
'홈',
|
||||
'이용약관',
|
||||
'개인정보',
|
||||
'고객센터',
|
||||
'신고',
|
||||
'공유',
|
||||
'즐겨찾기',
|
||||
'길찾기',
|
||||
'거리뷰',
|
||||
'저장',
|
||||
'더보기',
|
||||
'접기',
|
||||
'펼치기',
|
||||
'닫기',
|
||||
'취소',
|
||||
'확인',
|
||||
'선택',
|
||||
'전체',
|
||||
'삭제',
|
||||
'플레이스',
|
||||
'지도보기',
|
||||
'상세보기',
|
||||
'평점',
|
||||
'별점',
|
||||
'추천',
|
||||
'인기',
|
||||
'최신',
|
||||
'오늘',
|
||||
'내일',
|
||||
'영업중',
|
||||
'영업종료',
|
||||
'휴무',
|
||||
'정기휴무',
|
||||
'임시휴무',
|
||||
'배달',
|
||||
'포장',
|
||||
'매장',
|
||||
'주차',
|
||||
'단체석',
|
||||
'예약가능',
|
||||
'대기',
|
||||
'웨이팅',
|
||||
'영수증',
|
||||
'현금',
|
||||
'카드',
|
||||
'계산서',
|
||||
'할인',
|
||||
'쿠폰',
|
||||
'적립',
|
||||
'포인트',
|
||||
'회원',
|
||||
'비회원',
|
||||
'로그아웃',
|
||||
'마이페이지',
|
||||
'알림',
|
||||
'설정',
|
||||
'도움말',
|
||||
'문의',
|
||||
'제보',
|
||||
'수정',
|
||||
'삭제',
|
||||
'등록',
|
||||
'작성',
|
||||
'댓글',
|
||||
'답글',
|
||||
'좋아요',
|
||||
'싫어요',
|
||||
'스크랩',
|
||||
'북마크',
|
||||
'태그',
|
||||
'해시태그',
|
||||
'팔로우',
|
||||
'팔로잉',
|
||||
'팔로워',
|
||||
'차단',
|
||||
'신고하기',
|
||||
'게시물',
|
||||
'프로필',
|
||||
'활동',
|
||||
'통계',
|
||||
'분석',
|
||||
'다운로드',
|
||||
'업로드',
|
||||
'첨부',
|
||||
'파일',
|
||||
'이미지',
|
||||
'동영상',
|
||||
'음성',
|
||||
'링크',
|
||||
'복사',
|
||||
'붙여넣기',
|
||||
'되돌리기',
|
||||
'다시실행',
|
||||
'새로고침',
|
||||
'뒤로',
|
||||
'앞으로',
|
||||
'시작',
|
||||
'종료',
|
||||
'일시정지',
|
||||
'재생',
|
||||
'정지',
|
||||
'음량',
|
||||
'화면',
|
||||
'전체화면',
|
||||
'최소화',
|
||||
'최대화',
|
||||
'창닫기',
|
||||
'새창',
|
||||
'새탭',
|
||||
'인쇄',
|
||||
'저장하기',
|
||||
'열기',
|
||||
'가져오기',
|
||||
'내보내기',
|
||||
'동기화',
|
||||
'백업',
|
||||
'복원',
|
||||
'초기화',
|
||||
'재설정',
|
||||
'업데이트',
|
||||
'버전',
|
||||
'정보',
|
||||
'소개',
|
||||
'안내',
|
||||
'공지',
|
||||
'이벤트',
|
||||
'혜택',
|
||||
'쿠키',
|
||||
'개인정보처리방침',
|
||||
'서비스이용약관',
|
||||
'위치정보이용약관',
|
||||
'청소년보호정책',
|
||||
'저작권',
|
||||
'라이선스',
|
||||
'제휴',
|
||||
'광고',
|
||||
'비즈니스',
|
||||
'개발자',
|
||||
'API',
|
||||
'오픈소스',
|
||||
'기여',
|
||||
'후원',
|
||||
'기부',
|
||||
'결제',
|
||||
'환불',
|
||||
'교환',
|
||||
'반품',
|
||||
'배송',
|
||||
'택배',
|
||||
'운송장',
|
||||
'추적',
|
||||
'도착',
|
||||
'출발',
|
||||
'네이버 지도',
|
||||
'카카오맵',
|
||||
'구글맵',
|
||||
'T맵',
|
||||
'지도 앱',
|
||||
'내비게이션',
|
||||
'경로',
|
||||
'소요시간',
|
||||
'거리',
|
||||
'도보',
|
||||
'자전거',
|
||||
'대중교통',
|
||||
'자동차',
|
||||
'지하철',
|
||||
'버스',
|
||||
'택시',
|
||||
'기차',
|
||||
'비행기',
|
||||
'선박',
|
||||
'도보',
|
||||
'환승',
|
||||
'출구',
|
||||
'입구',
|
||||
'승강장',
|
||||
'매표소',
|
||||
'화장실',
|
||||
'편의시설',
|
||||
'주차장',
|
||||
'엘리베이터',
|
||||
'에스컬레이터',
|
||||
'계단',
|
||||
'경사로',
|
||||
'점자블록',
|
||||
'휠체어',
|
||||
'유모차',
|
||||
'애완동물',
|
||||
'흡연',
|
||||
'금연',
|
||||
'와이파이',
|
||||
'콘센트',
|
||||
'충전',
|
||||
'PC',
|
||||
'프린터',
|
||||
'팩스',
|
||||
'복사기',
|
||||
'회의실',
|
||||
'세미나실',
|
||||
'강당',
|
||||
'공연장',
|
||||
'전시장',
|
||||
'박물관',
|
||||
'미술관',
|
||||
'도서관',
|
||||
'체육관',
|
||||
'수영장',
|
||||
'운동장',
|
||||
'놀이터',
|
||||
'공원',
|
||||
'산책로',
|
||||
'자전거도로',
|
||||
'등산로',
|
||||
'캠핑장',
|
||||
'낚시터',
|
||||
];
|
||||
|
||||
/// HTML에서 유효한 한글 텍스트 추출 (UI 텍스트 제외)
|
||||
@@ -52,21 +249,35 @@ class NaverHtmlExtractor {
|
||||
|
||||
// 특정 태그의 내용만 추출 (제목, 본문 등 중요 텍스트가 있을 가능성이 높은 태그)
|
||||
final contentTags = [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'span', 'div', 'li', 'td', 'th',
|
||||
'strong', 'em', 'b', 'i', 'a'
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'p',
|
||||
'span',
|
||||
'div',
|
||||
'li',
|
||||
'td',
|
||||
'th',
|
||||
'strong',
|
||||
'em',
|
||||
'b',
|
||||
'i',
|
||||
'a',
|
||||
];
|
||||
|
||||
final tagPattern = contentTags.map((tag) =>
|
||||
'<$tag[^>]*>([^<]+)</$tag>'
|
||||
).join('|');
|
||||
final tagPattern = contentTags
|
||||
.map((tag) => '<$tag[^>]*>([^<]+)</$tag>')
|
||||
.join('|');
|
||||
|
||||
final tagRegex = RegExp(tagPattern, multiLine: true, caseSensitive: false);
|
||||
final tagMatches = tagRegex.allMatches(cleanHtml);
|
||||
|
||||
// 추출된 텍스트 수집
|
||||
final extractedTexts = <String>[];
|
||||
|
||||
|
||||
for (final match in tagMatches) {
|
||||
final text = match.group(1)?.trim() ?? '';
|
||||
if (text.isNotEmpty && text.contains(RegExp(r'[가-힣]'))) {
|
||||
@@ -77,11 +288,11 @@ class NaverHtmlExtractor {
|
||||
// 모든 태그 제거 후 남은 텍스트도 추가
|
||||
final textOnly = cleanHtml.replaceAll(RegExp(r'<[^>]+>'), ' ');
|
||||
final cleanedText = textOnly.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
|
||||
// 한글 텍스트 추출
|
||||
final koreanPattern = RegExp(r'[가-힣]+(?:\s[가-힣]+)*');
|
||||
final koreanMatches = koreanPattern.allMatches(cleanedText);
|
||||
|
||||
|
||||
for (final match in koreanMatches) {
|
||||
final text = match.group(0)?.trim() ?? '';
|
||||
if (text.length >= 2) {
|
||||
@@ -91,17 +302,19 @@ class NaverHtmlExtractor {
|
||||
|
||||
// 중복 제거 및 필터링
|
||||
final uniqueTexts = <String>{};
|
||||
|
||||
|
||||
for (final text in extractedTexts) {
|
||||
// UI 패턴 제외
|
||||
bool isExcluded = false;
|
||||
for (final pattern in _excludePatterns) {
|
||||
if (text == pattern || text.startsWith(pattern) || text.endsWith(pattern)) {
|
||||
if (text == pattern ||
|
||||
text.startsWith(pattern) ||
|
||||
text.endsWith(pattern)) {
|
||||
isExcluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!isExcluded && text.length >= 2 && text.length <= 50) {
|
||||
uniqueTexts.add(text);
|
||||
}
|
||||
@@ -109,13 +322,13 @@ class NaverHtmlExtractor {
|
||||
|
||||
// 리스트로 변환하여 반환
|
||||
final resultList = uniqueTexts.toList();
|
||||
|
||||
|
||||
debugPrint('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||
for (int i = 0; i < resultList.length; i++) {
|
||||
debugPrint('[$i] ${resultList[i]}');
|
||||
}
|
||||
debugPrint('========== 총 ${resultList.length}개 추출됨 ==========');
|
||||
|
||||
|
||||
return resultList;
|
||||
}
|
||||
|
||||
@@ -217,7 +430,7 @@ class NaverHtmlExtractor {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/// HTML에서 Place URL 추출 (og:url 메타 태그)
|
||||
static String? extractPlaceLink(String html) {
|
||||
try {
|
||||
@@ -232,7 +445,7 @@ class NaverHtmlExtractor {
|
||||
debugPrint('NaverHtmlExtractor: og:url 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
// canonical 링크 태그에서 추출
|
||||
final canonicalRegex = RegExp(
|
||||
r'<link[^>]+rel="canonical"[^>]+href="([^"]+)"',
|
||||
@@ -247,7 +460,7 @@ class NaverHtmlExtractor {
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:html/dom.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 네이버 지도 HTML 파서
|
||||
///
|
||||
///
|
||||
/// 네이버 지도 페이지의 HTML에서 식당 정보를 추출합니다.
|
||||
class NaverHtmlParser {
|
||||
// CSS 셀렉터 상수
|
||||
@@ -13,38 +13,38 @@ class NaverHtmlParser {
|
||||
'[class*="place_name"]',
|
||||
'meta[property="og:title"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _categorySelectors = [
|
||||
'span.DJJvD',
|
||||
'span.lnJFt',
|
||||
'[class*="category"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _descriptionSelectors = [
|
||||
'span.IH7VW',
|
||||
'div.vV_z_',
|
||||
'meta[name="description"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _phoneSelectors = [
|
||||
'span.xlx7Q',
|
||||
'a[href^="tel:"]',
|
||||
'[class*="phone"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _addressSelectors = [
|
||||
'span.IH7VW',
|
||||
'span.jWDO_',
|
||||
'[class*="address"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _businessHoursSelectors = [
|
||||
'time.aT6WB',
|
||||
'div.O8qbU',
|
||||
'[class*="business"]',
|
||||
'[class*="hours"]',
|
||||
];
|
||||
|
||||
|
||||
/// HTML 문서에서 식당 정보 추출
|
||||
Map<String, dynamic> parseRestaurantInfo(Document document) {
|
||||
return {
|
||||
@@ -60,7 +60,7 @@ class NaverHtmlParser {
|
||||
'businessHours': extractBusinessHours(document),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// 식당 이름 추출
|
||||
String? extractName(Document document) {
|
||||
try {
|
||||
@@ -82,7 +82,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 카테고리 추출
|
||||
String? extractCategory(Document document) {
|
||||
try {
|
||||
@@ -102,7 +102,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 서브 카테고리 추출
|
||||
String? extractSubCategory(Document document) {
|
||||
try {
|
||||
@@ -120,7 +120,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 설명 추출
|
||||
String? extractDescription(Document document) {
|
||||
try {
|
||||
@@ -142,7 +142,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 전화번호 추출
|
||||
String? extractPhoneNumber(Document document) {
|
||||
try {
|
||||
@@ -164,7 +164,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 도로명 주소 추출
|
||||
String? extractRoadAddress(Document document) {
|
||||
try {
|
||||
@@ -184,7 +184,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 지번 주소 추출
|
||||
String? extractJibunAddress(Document document) {
|
||||
try {
|
||||
@@ -193,8 +193,8 @@ class NaverHtmlParser {
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
// 지번 주소 패턴 확인 (숫자-숫자 형식 포함)
|
||||
if (RegExp(r'\d+\-\d+').hasMatch(text) &&
|
||||
!text.contains('로') &&
|
||||
if (RegExp(r'\d+\-\d+').hasMatch(text) &&
|
||||
!text.contains('로') &&
|
||||
!text.contains('길')) {
|
||||
return text;
|
||||
}
|
||||
@@ -206,7 +206,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 위도 추출
|
||||
double? extractLatitude(Document document) {
|
||||
try {
|
||||
@@ -223,7 +223,7 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
@@ -236,14 +236,14 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 경도 추출
|
||||
double? extractLongitude(Document document) {
|
||||
try {
|
||||
@@ -260,7 +260,7 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
@@ -273,14 +273,14 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 영업시간 추출
|
||||
String? extractBusinessHours(Document document) {
|
||||
try {
|
||||
@@ -288,10 +288,10 @@ class NaverHtmlParser {
|
||||
final elements = document.querySelectorAll(selector);
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty &&
|
||||
(text.contains('시') ||
|
||||
text.contains(':') ||
|
||||
text.contains('영업'))) {
|
||||
if (text.isNotEmpty &&
|
||||
(text.contains('시') ||
|
||||
text.contains(':') ||
|
||||
text.contains('영업'))) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -302,4 +302,4 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,34 +18,37 @@ import '../../../core/utils/category_mapper.dart';
|
||||
class NaverMapParser {
|
||||
// URL 관련 상수
|
||||
static const String _naverMapBaseUrl = 'https://map.naver.com';
|
||||
|
||||
|
||||
// 정규식 패턴
|
||||
static final RegExp _placeIdRegex = RegExp(r'/p/(?:restaurant|entry/place)/(\d+)');
|
||||
static final RegExp _placeIdRegex = RegExp(
|
||||
r'/p/(?:restaurant|entry/place)/(\d+)',
|
||||
);
|
||||
static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$');
|
||||
|
||||
|
||||
// 기본 좌표 (서울 시청)
|
||||
static const double _defaultLatitude = 37.5666805;
|
||||
static const double _defaultLongitude = 126.9784147;
|
||||
|
||||
|
||||
// API 요청 관련 상수
|
||||
static const int _shortDelayMillis = 500;
|
||||
static const int _longDelayMillis = 1000;
|
||||
static const int _searchDisplayCount = 10;
|
||||
static const double _coordinateConversionFactor = 10000000.0;
|
||||
|
||||
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverHtmlParser _htmlParser = NaverHtmlParser();
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
NaverMapParser({NaverApiClient? apiClient})
|
||||
bool _isDisposed = false;
|
||||
|
||||
NaverMapParser({NaverApiClient? apiClient})
|
||||
: _apiClient = apiClient ?? NaverApiClient();
|
||||
|
||||
|
||||
/// 네이버 지도 URL에서 식당 정보를 파싱합니다.
|
||||
///
|
||||
///
|
||||
/// 지원하는 URL 형식:
|
||||
/// - https://map.naver.com/p/restaurant/1234567890
|
||||
/// - https://naver.me/abcdefgh
|
||||
///
|
||||
///
|
||||
/// [userLatitude]와 [userLongitude]를 제공하면 중복 상호명이 있을 때
|
||||
/// 가장 가까운 위치의 식당을 선택합니다.
|
||||
Future<Restaurant> parseRestaurantFromUrl(
|
||||
@@ -53,23 +56,26 @@ class NaverMapParser {
|
||||
double? userLatitude,
|
||||
double? userLongitude,
|
||||
}) async {
|
||||
if (_isDisposed) {
|
||||
throw NaverMapParseException('이미 dispose된 파서입니다');
|
||||
}
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Starting to parse URL: $url');
|
||||
}
|
||||
|
||||
|
||||
// URL 유효성 검증
|
||||
if (!_isValidNaverUrl(url)) {
|
||||
throw NaverMapParseException('유효하지 않은 네이버 지도 URL입니다: $url');
|
||||
}
|
||||
|
||||
|
||||
// 짧은 URL인 경우 리다이렉트 처리
|
||||
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
||||
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Final URL after redirect: $finalUrl');
|
||||
}
|
||||
|
||||
|
||||
// Place ID 추출 (10자리 숫자)
|
||||
final String? placeId = _extractPlaceId(finalUrl);
|
||||
if (placeId == null) {
|
||||
@@ -77,24 +83,31 @@ class NaverMapParser {
|
||||
final shortUrlId = _extractShortUrlId(url);
|
||||
if (shortUrlId != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Using short URL ID as place ID: $shortUrlId');
|
||||
debugPrint(
|
||||
'NaverMapParser: Using short URL ID as place ID: $shortUrlId',
|
||||
);
|
||||
}
|
||||
return _createFallbackRestaurant(shortUrlId, url);
|
||||
}
|
||||
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
||||
}
|
||||
|
||||
|
||||
// 단축 URL인 경우 특별 처리
|
||||
final isShortUrl = url.contains('naver.me');
|
||||
|
||||
|
||||
if (isShortUrl) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득
|
||||
final restaurant = await _parseWithLocalSearch(placeId, finalUrl, userLatitude, userLongitude);
|
||||
final restaurant = await _parseWithLocalSearch(
|
||||
placeId,
|
||||
finalUrl,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
);
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
|
||||
}
|
||||
@@ -106,7 +119,7 @@ class NaverMapParser {
|
||||
// 실패 시 기본 파싱으로 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GraphQL API로 식당 정보 가져오기 (기본 플로우)
|
||||
final restaurantData = await _fetchRestaurantFromGraphQL(
|
||||
placeId,
|
||||
@@ -114,7 +127,6 @@ class NaverMapParser {
|
||||
userLongitude: userLongitude,
|
||||
);
|
||||
return _createRestaurant(restaurantData, placeId, finalUrl);
|
||||
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
rethrow;
|
||||
@@ -128,7 +140,7 @@ class NaverMapParser {
|
||||
throw NaverMapParseException('네이버 지도 파싱 중 오류가 발생했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// URL이 유효한 네이버 지도 URL인지 확인
|
||||
bool _isValidNaverUrl(String url) {
|
||||
try {
|
||||
@@ -138,15 +150,15 @@ class NaverMapParser {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// _resolveFinalUrl 메서드는 이제 NaverApiClient.resolveShortUrl로 대체됨
|
||||
|
||||
|
||||
/// URL에서 Place ID 추출
|
||||
String? _extractPlaceId(String url) {
|
||||
final match = _placeIdRegex.firstMatch(url);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
|
||||
/// 짧은 URL에서 ID 추출
|
||||
String? _extractShortUrlId(String url) {
|
||||
try {
|
||||
@@ -156,7 +168,7 @@ class NaverMapParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// GraphQL API로 식당 정보 가져오기
|
||||
Future<Map<String, dynamic>> _fetchRestaurantFromGraphQL(
|
||||
String placeId, {
|
||||
@@ -168,35 +180,41 @@ class NaverMapParser {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 시작');
|
||||
}
|
||||
|
||||
|
||||
// 네이버 지도 URL 구성
|
||||
final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
||||
|
||||
|
||||
// Step 1: URL 자체로 검색 (가장 신뢰할 수 있는 방법)
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 방지
|
||||
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: _shortDelayMillis),
|
||||
); // 429 방지
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: placeUrl,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: _searchDisplayCount,
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isNotEmpty) {
|
||||
// place ID가 포함된 결과 찾기
|
||||
for (final result in searchResults) {
|
||||
if (result.link.contains(placeId)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}');
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
||||
);
|
||||
}
|
||||
return _convertSearchResultToData(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 사용
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}');
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
@@ -205,21 +223,25 @@ class NaverMapParser {
|
||||
debugPrint('NaverMapParser: URL 검색 실패 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Step 2: Place ID로 검색
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: _longDelayMillis)); // 더 긴 지연
|
||||
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: _longDelayMillis),
|
||||
); // 더 긴 지연
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: placeId,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: _searchDisplayCount,
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isNotEmpty) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}');
|
||||
debugPrint(
|
||||
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
@@ -227,7 +249,7 @@ class NaverMapParser {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Place ID 검색 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
throw RateLimitException(
|
||||
@@ -236,12 +258,11 @@ class NaverMapParser {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
throw RateLimitException(
|
||||
@@ -250,7 +271,7 @@ class NaverMapParser {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
|
||||
// 첫 번째 시도: places 쿼리
|
||||
try {
|
||||
@@ -262,7 +283,7 @@ class NaverMapParser {
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.placeDetailQuery,
|
||||
);
|
||||
|
||||
|
||||
// places 응답 처리 (배열일 수도 있음)
|
||||
final placesData = response['data']?['places'];
|
||||
if (placesData != null) {
|
||||
@@ -277,7 +298,7 @@ class NaverMapParser {
|
||||
debugPrint('NaverMapParser: places query failed - $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 두 번째 시도: nxPlaces 쿼리
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
@@ -288,7 +309,7 @@ class NaverMapParser {
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
||||
);
|
||||
|
||||
|
||||
// nxPlaces 응답 처리 (배열일 수도 있음)
|
||||
final nxPlacesData = response['data']?['nxPlaces'];
|
||||
if (nxPlacesData != null) {
|
||||
@@ -303,21 +324,28 @@ class NaverMapParser {
|
||||
debugPrint('NaverMapParser: nxPlaces query failed - $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: All GraphQL queries failed, falling back to HTML parsing');
|
||||
debugPrint(
|
||||
'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing',
|
||||
);
|
||||
}
|
||||
return await _fallbackToHtmlParsing(placeId);
|
||||
}
|
||||
|
||||
|
||||
/// 검색 결과를 데이터 맵으로 변환
|
||||
Map<String, dynamic> _convertSearchResultToData(NaverLocalSearchResult item) {
|
||||
// 카테고리 파싱
|
||||
final categoryParts = item.category.split('>').map((s) => s.trim()).toList();
|
||||
final categoryParts = item.category
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
.toList();
|
||||
final category = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : category;
|
||||
|
||||
final subCategory = categoryParts.length > 1
|
||||
? categoryParts.last
|
||||
: category;
|
||||
|
||||
return {
|
||||
'name': item.title,
|
||||
'category': category,
|
||||
@@ -326,25 +354,32 @@ class NaverMapParser {
|
||||
'roadAddress': item.roadAddress,
|
||||
'phone': item.telephone,
|
||||
'description': item.description.isNotEmpty ? item.description : null,
|
||||
'latitude': item.mapy != null ? item.mapy! / _coordinateConversionFactor : _defaultLatitude,
|
||||
'longitude': item.mapx != null ? item.mapx! / _coordinateConversionFactor : _defaultLongitude,
|
||||
'businessHours': null, // Search API에서는 영업시간 정보 제공 안 함
|
||||
'latitude': item.mapy != null
|
||||
? item.mapy! / _coordinateConversionFactor
|
||||
: _defaultLatitude,
|
||||
'longitude': item.mapx != null
|
||||
? item.mapx! / _coordinateConversionFactor
|
||||
: _defaultLongitude,
|
||||
'businessHours': null, // Search API에서는 영업시간 정보 제공 안 함
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// GraphQL 응답에서 데이터 추출
|
||||
Map<String, dynamic> _extractPlaceData(Map<String, dynamic> placeData) {
|
||||
// 카테고리 파싱
|
||||
final String? fullCategory = placeData['category'];
|
||||
String? category;
|
||||
String? subCategory;
|
||||
|
||||
|
||||
if (fullCategory != null) {
|
||||
final categoryParts = fullCategory.split('>').map((s) => s.trim()).toList();
|
||||
final categoryParts = fullCategory
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
.toList();
|
||||
category = categoryParts.isNotEmpty ? categoryParts.first : null;
|
||||
subCategory = categoryParts.length > 1 ? categoryParts.last : null;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
'name': placeData['name'],
|
||||
'category': category,
|
||||
@@ -360,26 +395,24 @@ class NaverMapParser {
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// HTML 파싱으로 fallback
|
||||
Future<Map<String, dynamic>> _fallbackToHtmlParsing(String placeId) async {
|
||||
try {
|
||||
final finalUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
||||
final String html = await _apiClient.fetchMapPageHtml(finalUrl);
|
||||
final document = html_parser.parse(html);
|
||||
|
||||
|
||||
return _htmlParser.parseRestaurantInfo(document);
|
||||
} catch (e) {
|
||||
// 429 에러인 경우 RateLimitException으로 변환
|
||||
if (e.toString().contains('429')) {
|
||||
throw RateLimitException(
|
||||
originalError: e,
|
||||
);
|
||||
throw RateLimitException(originalError: e);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Restaurant 객체 생성
|
||||
Restaurant _createRestaurant(
|
||||
Map<String, dynamic> data,
|
||||
@@ -397,25 +430,33 @@ class NaverMapParser {
|
||||
final double? latitude = data['latitude'];
|
||||
final double? longitude = data['longitude'];
|
||||
final String? businessHours = data['businessHours'];
|
||||
|
||||
|
||||
// 카테고리 정규화
|
||||
final String normalizedCategory = CategoryMapper.normalizeNaverCategory(rawCategory, rawSubCategory);
|
||||
final String normalizedCategory = CategoryMapper.normalizeNaverCategory(
|
||||
rawCategory,
|
||||
rawSubCategory,
|
||||
);
|
||||
final String finalSubCategory = rawSubCategory ?? rawCategory;
|
||||
|
||||
|
||||
// 좌표가 없는 경우 기본값 설정
|
||||
final double finalLatitude = latitude ?? _defaultLatitude;
|
||||
final double finalLongitude = longitude ?? _defaultLongitude;
|
||||
|
||||
|
||||
// 주소가 비어있는 경우 처리
|
||||
final String finalRoadAddress = roadAddress.isNotEmpty ? roadAddress : '주소 정보를 가져올 수 없습니다';
|
||||
final String finalJibunAddress = jibunAddress.isNotEmpty ? jibunAddress : '주소 정보를 가져올 수 없습니다';
|
||||
|
||||
final String finalRoadAddress = roadAddress.isNotEmpty
|
||||
? roadAddress
|
||||
: '주소 정보를 가져올 수 없습니다';
|
||||
final String finalJibunAddress = jibunAddress.isNotEmpty
|
||||
? jibunAddress
|
||||
: '주소 정보를 가져올 수 없습니다';
|
||||
|
||||
return Restaurant(
|
||||
id: _uuid.v4(),
|
||||
name: name,
|
||||
category: normalizedCategory,
|
||||
subCategory: finalSubCategory,
|
||||
description: description ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
|
||||
description:
|
||||
description ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
|
||||
phoneNumber: phoneNumber,
|
||||
roadAddress: finalRoadAddress,
|
||||
jibunAddress: finalJibunAddress,
|
||||
@@ -432,7 +473,7 @@ class NaverMapParser {
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 기본 정보로 Restaurant 생성 (Fallback)
|
||||
Restaurant _createFallbackRestaurant(String placeId, String url) {
|
||||
return Restaurant(
|
||||
@@ -457,7 +498,7 @@ class NaverMapParser {
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 단축 URL을 위한 향상된 파싱 메서드
|
||||
/// 한글 텍스트를 추출하고 로컬 검색 API를 통해 정확한 정보를 획득
|
||||
Future<Restaurant> _parseWithLocalSearch(
|
||||
@@ -469,16 +510,16 @@ class NaverMapParser {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 향상된 파싱 시작');
|
||||
}
|
||||
|
||||
|
||||
// 1. 한글 텍스트 추출
|
||||
final koreanData = await _apiClient.fetchKoreanTextsFromPcmap(placeId);
|
||||
|
||||
|
||||
if (koreanData['success'] != true || koreanData['koreanTexts'] == null) {
|
||||
throw NaverMapParseException('한글 텍스트 추출 실패');
|
||||
}
|
||||
|
||||
|
||||
final koreanTexts = koreanData['koreanTexts'] as List<dynamic>;
|
||||
|
||||
|
||||
// 상호명 우선순위 결정
|
||||
String searchQuery = '';
|
||||
if (koreanData['jsonLdName'] != null) {
|
||||
@@ -499,25 +540,27 @@ class NaverMapParser {
|
||||
} else {
|
||||
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
|
||||
// 2. 로컬 검색 API 호출
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 에러 방지
|
||||
|
||||
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: _shortDelayMillis),
|
||||
); // 429 에러 방지
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: searchQuery,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: 20, // 더 많은 결과 검색
|
||||
display: 20, // 더 많은 결과 검색
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isEmpty) {
|
||||
throw NaverMapParseException('검색 결과가 없습니다: $searchQuery');
|
||||
}
|
||||
|
||||
|
||||
// 디버깅: 검색 결과 Place ID 분석
|
||||
if (kDebugMode) {
|
||||
debugPrint('=== 로컬 검색 결과 Place ID 분석 ===');
|
||||
@@ -530,10 +573,10 @@ class NaverMapParser {
|
||||
}
|
||||
debugPrint('=====================================');
|
||||
}
|
||||
|
||||
|
||||
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
|
||||
NaverLocalSearchResult? bestMatch;
|
||||
|
||||
|
||||
// 1차: Place ID가 정확히 일치하는 결과 찾기
|
||||
for (final result in searchResults) {
|
||||
final extractedId = result.extractPlaceId();
|
||||
@@ -545,18 +588,19 @@ class NaverMapParser {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 2차: 상호명이 유사한 결과 찾기
|
||||
if (bestMatch == null) {
|
||||
// JSON-LD나 Apollo State에서 추출한 정확한 상호명이 있으면 사용
|
||||
String? exactName = koreanData['jsonLdName'] as String? ??
|
||||
koreanData['apolloStateName'] as String?;
|
||||
|
||||
String? exactName =
|
||||
koreanData['jsonLdName'] as String? ??
|
||||
koreanData['apolloStateName'] as String?;
|
||||
|
||||
if (exactName != null) {
|
||||
for (final result in searchResults) {
|
||||
// 상호명 완전 일치 또는 포함 관계 확인
|
||||
if (result.title == exactName ||
|
||||
result.title.contains(exactName) ||
|
||||
if (result.title == exactName ||
|
||||
result.title.contains(exactName) ||
|
||||
exactName.contains(result.title)) {
|
||||
bestMatch = result;
|
||||
if (kDebugMode) {
|
||||
@@ -567,15 +611,19 @@ class NaverMapParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 3차: 거리 기반 선택 (사용자 위치가 있는 경우)
|
||||
if (bestMatch == null && userLatitude != null && userLongitude != null) {
|
||||
bestMatch = _findNearestResult(searchResults, userLatitude, userLongitude);
|
||||
bestMatch = _findNearestResult(
|
||||
searchResults,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
);
|
||||
if (bestMatch != null && kDebugMode) {
|
||||
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 최종: 첫 번째 결과 사용
|
||||
if (bestMatch == null) {
|
||||
bestMatch = searchResults.first;
|
||||
@@ -583,10 +631,10 @@ class NaverMapParser {
|
||||
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4. Restaurant 객체 생성
|
||||
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
|
||||
|
||||
|
||||
// 추가 정보 보완
|
||||
return restaurant.copyWith(
|
||||
naverPlaceId: placeId,
|
||||
@@ -595,7 +643,7 @@ class NaverMapParser {
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 가장 가까운 결과 찾기 (거리 기반)
|
||||
NaverLocalSearchResult? _findNearestResult(
|
||||
List<NaverLocalSearchResult> results,
|
||||
@@ -604,56 +652,66 @@ class NaverMapParser {
|
||||
) {
|
||||
NaverLocalSearchResult? nearest;
|
||||
double minDistance = double.infinity;
|
||||
|
||||
|
||||
for (final result in results) {
|
||||
if (result.mapy != null && result.mapx != null) {
|
||||
// 네이버 좌표를 일반 좌표로 변환
|
||||
final lat = result.mapy! / _coordinateConversionFactor;
|
||||
final lng = result.mapx! / _coordinateConversionFactor;
|
||||
|
||||
|
||||
// 거리 계산
|
||||
final distance = _calculateDistance(userLat, userLng, lat, lng);
|
||||
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearest = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (kDebugMode && nearest != null) {
|
||||
debugPrint('가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)');
|
||||
debugPrint(
|
||||
'가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
|
||||
/// 두 지점 간의 거리 계산 (Haversine 공식 사용)
|
||||
///
|
||||
///
|
||||
/// 반환값: 킬로미터 단위의 거리
|
||||
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
double _calculateDistance(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
) {
|
||||
const double earthRadius = 6371.0; // 지구 반지름 (km)
|
||||
|
||||
|
||||
// 라디안으로 변환
|
||||
final double lat1Rad = lat1 * (3.141592653589793 / 180.0);
|
||||
final double lon1Rad = lon1 * (3.141592653589793 / 180.0);
|
||||
final double lat2Rad = lat2 * (3.141592653589793 / 180.0);
|
||||
final double lon2Rad = lon2 * (3.141592653589793 / 180.0);
|
||||
|
||||
|
||||
// 위도와 경도의 차이
|
||||
final double dLat = lat2Rad - lat1Rad;
|
||||
final double dLon = lon2Rad - lon1Rad;
|
||||
|
||||
|
||||
// Haversine 공식
|
||||
final double a = (sin(dLat / 2) * sin(dLat / 2)) +
|
||||
final double a =
|
||||
(sin(dLat / 2) * sin(dLat / 2)) +
|
||||
(cos(lat1Rad) * cos(lat2Rad) * sin(dLon / 2) * sin(dLon / 2));
|
||||
final double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
|
||||
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
_apiClient.dispose();
|
||||
}
|
||||
}
|
||||
@@ -661,9 +719,9 @@ class NaverMapParser {
|
||||
/// 네이버 지도 파싱 예외
|
||||
class NaverMapParseException implements Exception {
|
||||
final String message;
|
||||
|
||||
|
||||
NaverMapParseException(this.message);
|
||||
|
||||
|
||||
@override
|
||||
String toString() => 'NaverMapParseException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,26 @@ import '../../../core/errors/network_exceptions.dart';
|
||||
import 'naver_map_parser.dart';
|
||||
|
||||
/// 네이버 검색 서비스
|
||||
///
|
||||
///
|
||||
/// 네이버 지도 URL 파싱과 로컬 검색 API를 통합한 서비스입니다.
|
||||
class NaverSearchService {
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverMapParser _mapParser;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
|
||||
// 성능 최적화를 위한 정규식 캐싱
|
||||
static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]');
|
||||
|
||||
NaverSearchService({
|
||||
NaverApiClient? apiClient,
|
||||
NaverMapParser? mapParser,
|
||||
}) : _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||
|
||||
|
||||
NaverSearchService({NaverApiClient? apiClient, NaverMapParser? mapParser})
|
||||
: _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||
|
||||
/// URL에서 식당 정보 가져오기
|
||||
///
|
||||
///
|
||||
/// 네이버 지도 URL(단축 URL 포함)에서 식당 정보를 추출합니다.
|
||||
///
|
||||
///
|
||||
/// [url] 네이버 지도 URL 또는 단축 URL
|
||||
///
|
||||
///
|
||||
/// Throws:
|
||||
/// - [NaverMapParseException] URL 파싱 실패 시
|
||||
/// - [NetworkException] 네트워크 오류 발생 시
|
||||
@@ -39,15 +37,12 @@ class NaverSearchService {
|
||||
if (e is NaverMapParseException || e is NetworkException) {
|
||||
rethrow;
|
||||
}
|
||||
throw ParseException(
|
||||
message: '식당 정보를 가져올 수 없습니다: $e',
|
||||
originalError: e,
|
||||
);
|
||||
throw ParseException(message: '식당 정보를 가져올 수 없습니다: $e', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 키워드로 주변 식당 검색
|
||||
///
|
||||
///
|
||||
/// 검색어와 현재 위치를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<Restaurant>> searchNearbyRestaurants({
|
||||
required String query,
|
||||
@@ -64,7 +59,7 @@ class NaverSearchService {
|
||||
display: maxResults,
|
||||
sort: sort,
|
||||
);
|
||||
|
||||
|
||||
return searchResults
|
||||
.map((result) => result.toRestaurant(id: _uuid.v4()))
|
||||
.toList();
|
||||
@@ -72,15 +67,12 @@ class NaverSearchService {
|
||||
if (e is NetworkException) {
|
||||
rethrow;
|
||||
}
|
||||
throw ParseException(
|
||||
message: '식당 검색에 실패했습니다: $e',
|
||||
originalError: e,
|
||||
);
|
||||
throw ParseException(message: '식당 검색에 실패했습니다: $e', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 식당 이름으로 상세 정보 검색
|
||||
///
|
||||
///
|
||||
/// 식당 이름과 위치를 기반으로 더 자세한 정보를 검색합니다.
|
||||
Future<Restaurant?> searchRestaurantDetails({
|
||||
required String name,
|
||||
@@ -98,7 +90,7 @@ class NaverSearchService {
|
||||
query = '${addressParts[0]} ${addressParts[1]} $name';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
@@ -106,37 +98,38 @@ class NaverSearchService {
|
||||
display: 5,
|
||||
sort: 'comment', // 상세 검색 시 리뷰가 많은 곳 우선
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 가장 유사한 결과 찾기 (주소가 없으면 거리 기반 선택 포함)
|
||||
final bestMatch = _findBestMatch(
|
||||
name,
|
||||
name,
|
||||
searchResults,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
);
|
||||
|
||||
|
||||
if (bestMatch != null) {
|
||||
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
|
||||
|
||||
|
||||
// 네이버 지도 URL이 있으면 상세 정보 파싱 시도
|
||||
if (restaurant.naverUrl != null) {
|
||||
try {
|
||||
final detailedRestaurant = await _mapParser.parseRestaurantFromUrl(
|
||||
restaurant.naverUrl!,
|
||||
);
|
||||
|
||||
|
||||
// 기존 정보와 병합
|
||||
return Restaurant(
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
category: restaurant.category,
|
||||
subCategory: restaurant.subCategory,
|
||||
description: detailedRestaurant.description ?? restaurant.description,
|
||||
description:
|
||||
detailedRestaurant.description ?? restaurant.description,
|
||||
phoneNumber: restaurant.phoneNumber,
|
||||
roadAddress: restaurant.roadAddress,
|
||||
jibunAddress: restaurant.jibunAddress,
|
||||
@@ -146,9 +139,11 @@ class NaverSearchService {
|
||||
source: restaurant.source,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId,
|
||||
naverPlaceId:
|
||||
detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
businessHours:
|
||||
detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
lastVisited: restaurant.lastVisited,
|
||||
visitCount: restaurant.visitCount,
|
||||
);
|
||||
@@ -159,10 +154,10 @@ class NaverSearchService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e is NetworkException) {
|
||||
@@ -174,7 +169,7 @@ class NaverSearchService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 가장 유사한 검색 결과 찾기
|
||||
NaverLocalSearchResult? _findBestMatch(
|
||||
String targetName,
|
||||
@@ -184,30 +179,32 @@ class NaverSearchService {
|
||||
String? address,
|
||||
}) {
|
||||
if (results.isEmpty) return null;
|
||||
|
||||
|
||||
// 정확히 일치하는 결과 우선
|
||||
final exactMatch = results.firstWhere(
|
||||
(result) => result.title.toLowerCase() == targetName.toLowerCase(),
|
||||
orElse: () => results.first,
|
||||
);
|
||||
|
||||
|
||||
if (exactMatch.title.toLowerCase() == targetName.toLowerCase()) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
|
||||
// 주소가 없고 위치 정보가 있는 경우 - 가장 가까운 업체 선택
|
||||
// TODO: 네이버 좌표계(mapx, mapy)를 WGS84 좌표계로 변환하는 로직 필요
|
||||
// 현재는 네이버 API가 좌표 기반 정렬을 지원하므로 첫 번째 결과 사용
|
||||
if ((address == null || address.isEmpty) && latitude != null && longitude != null) {
|
||||
if ((address == null || address.isEmpty) &&
|
||||
latitude != null &&
|
||||
longitude != null) {
|
||||
// 네이버 API는 coordinate 파라미터로 좌표 기반 정렬을 지원
|
||||
// searchRestaurants에서 이미 가까운 순으로 정렬되어 반환됨
|
||||
return results.first;
|
||||
}
|
||||
|
||||
|
||||
// 유사도 계산 (간단한 버전)
|
||||
NaverLocalSearchResult? bestMatch;
|
||||
double bestScore = 0.0;
|
||||
|
||||
|
||||
for (final result in results) {
|
||||
final score = _calculateSimilarity(targetName, result.title);
|
||||
if (score > bestScore) {
|
||||
@@ -215,44 +212,44 @@ class NaverSearchService {
|
||||
bestMatch = result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 유사도가 너무 낮으면 null 반환
|
||||
if (bestScore < 0.5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return bestMatch ?? results.first;
|
||||
}
|
||||
|
||||
|
||||
/// 문자열 유사도 계산 (Jaccard 유사도)
|
||||
double _calculateSimilarity(String str1, String str2) {
|
||||
final s1 = str1.toLowerCase().replaceAll(_nonAlphanumericRegex, '');
|
||||
final s2 = str2.toLowerCase().replaceAll(_nonAlphanumericRegex, '');
|
||||
|
||||
|
||||
if (s1.isEmpty || s2.isEmpty) return 0.0;
|
||||
|
||||
|
||||
// 포함 관계 확인
|
||||
if (s1.contains(s2) || s2.contains(s1)) {
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
|
||||
// 문자 집합으로 변환
|
||||
final set1 = s1.split('').toSet();
|
||||
final set2 = s2.split('').toSet();
|
||||
|
||||
|
||||
// Jaccard 유사도 계산
|
||||
final intersection = set1.intersection(set2).length;
|
||||
final union = set1.union(set2).length;
|
||||
|
||||
|
||||
return union > 0 ? intersection / union : 0.0;
|
||||
}
|
||||
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_apiClient.dispose();
|
||||
_mapParser.dispose();
|
||||
}
|
||||
|
||||
|
||||
// 테스트를 위한 내부 메서드 접근
|
||||
@visibleForTesting
|
||||
NaverLocalSearchResult? findBestMatchForTesting(
|
||||
@@ -263,16 +260,16 @@ class NaverSearchService {
|
||||
String? address,
|
||||
}) {
|
||||
return _findBestMatch(
|
||||
targetName,
|
||||
targetName,
|
||||
results,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@visibleForTesting
|
||||
double calculateSimilarityForTesting(String str1, String str2) {
|
||||
return _calculateSimilarity(str1, str2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,32 @@ import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
|
||||
|
||||
class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
static const String _boxName = 'recommendations';
|
||||
|
||||
Future<Box<RecommendationRecord>> get _box async =>
|
||||
|
||||
Future<Box<RecommendationRecord>> get _box async =>
|
||||
await Hive.openBox<RecommendationRecord>(_boxName);
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getAllRecommendationRecords() async {
|
||||
final box = await _box;
|
||||
final records = box.values.toList();
|
||||
records.sort((a, b) => b.recommendationDate.compareTo(a.recommendationDate));
|
||||
records.sort(
|
||||
(a, b) => b.recommendationDate.compareTo(a.recommendationDate),
|
||||
);
|
||||
return records;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getRecommendationsByRestaurantId(String restaurantId) async {
|
||||
Future<List<RecommendationRecord>> getRecommendationsByRestaurantId(
|
||||
String restaurantId,
|
||||
) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((r) => r.restaurantId == restaurantId).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDate(DateTime date) async {
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDate(
|
||||
DateTime date,
|
||||
) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((record) {
|
||||
return record.recommendationDate.year == date.year &&
|
||||
@@ -39,8 +45,12 @@ class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
}) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((record) {
|
||||
return record.recommendationDate.isAfter(startDate.subtract(const Duration(days: 1))) &&
|
||||
record.recommendationDate.isBefore(endDate.add(const Duration(days: 1)));
|
||||
return record.recommendationDate.isAfter(
|
||||
startDate.subtract(const Duration(days: 1)),
|
||||
) &&
|
||||
record.recommendationDate.isBefore(
|
||||
endDate.add(const Duration(days: 1)),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -93,14 +103,19 @@ class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
} catch (_) {
|
||||
yield <RecommendationRecord>[];
|
||||
}
|
||||
yield* box.watch().asyncMap((_) async => await getAllRecommendationRecords());
|
||||
yield* box.watch().asyncMap(
|
||||
(_) async => await getAllRecommendationRecords(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> getMonthlyRecommendationStats(int year, int month) async {
|
||||
Future<Map<String, int>> getMonthlyRecommendationStats(
|
||||
int year,
|
||||
int month,
|
||||
) async {
|
||||
final startDate = DateTime(year, month, 1);
|
||||
final endDate = DateTime(year, month + 1, 0); // 해당 월의 마지막 날
|
||||
|
||||
|
||||
final records = await getRecommendationsByDateRange(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
@@ -111,7 +126,7 @@ class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
final dayKey = record.recommendationDate.day.toString();
|
||||
stats[dayKey] = (stats[dayKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ import 'package:lunchpick/core/constants/api_keys.dart';
|
||||
class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
static const String _boxName = 'restaurants';
|
||||
final NaverSearchService _naverSearchService;
|
||||
|
||||
RestaurantRepositoryImpl({
|
||||
NaverSearchService? naverSearchService,
|
||||
}) : _naverSearchService = naverSearchService ?? NaverSearchService();
|
||||
|
||||
Future<Box<Restaurant>> get _box async =>
|
||||
|
||||
RestaurantRepositoryImpl({NaverSearchService? naverSearchService})
|
||||
: _naverSearchService = naverSearchService ?? NaverSearchService();
|
||||
|
||||
Future<Box<Restaurant>> get _box async =>
|
||||
await Hive.openBox<Restaurant>(_boxName);
|
||||
|
||||
@override
|
||||
@@ -69,7 +68,10 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateLastVisitDate(String restaurantId, DateTime visitDate) async {
|
||||
Future<void> updateLastVisitDate(
|
||||
String restaurantId,
|
||||
DateTime visitDate,
|
||||
) async {
|
||||
final restaurant = await getRestaurantById(restaurantId);
|
||||
if (restaurant != null) {
|
||||
final updatedRestaurant = Restaurant(
|
||||
@@ -120,7 +122,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
Future<List<Restaurant>> getRestaurantsNotVisitedInDays(int days) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
final cutoffDate = DateTime.now().subtract(Duration(days: days));
|
||||
|
||||
|
||||
return restaurants.where((restaurant) {
|
||||
if (restaurant.lastVisitDate == null) return true;
|
||||
return restaurant.lastVisitDate!.isBefore(cutoffDate);
|
||||
@@ -132,39 +134,68 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
if (query.isEmpty) {
|
||||
return await getAllRestaurants();
|
||||
}
|
||||
|
||||
|
||||
final restaurants = await getAllRestaurants();
|
||||
final lowercaseQuery = query.toLowerCase();
|
||||
|
||||
|
||||
return restaurants.where((restaurant) {
|
||||
return restaurant.name.toLowerCase().contains(lowercaseQuery) ||
|
||||
(restaurant.description?.toLowerCase().contains(lowercaseQuery) ?? false) ||
|
||||
(restaurant.description?.toLowerCase().contains(lowercaseQuery) ??
|
||||
false) ||
|
||||
restaurant.category.toLowerCase().contains(lowercaseQuery) ||
|
||||
restaurant.roadAddress.toLowerCase().contains(lowercaseQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> searchRestaurantsFromNaver({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
return _naverSearchService.searchNearbyRestaurants(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant> addRestaurantFromUrl(String url) async {
|
||||
return _processRestaurantFromUrl(url, persist: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant> previewRestaurantFromUrl(String url) async {
|
||||
return _processRestaurantFromUrl(url, persist: false);
|
||||
}
|
||||
|
||||
Future<Restaurant> _processRestaurantFromUrl(
|
||||
String url, {
|
||||
required bool persist,
|
||||
}) async {
|
||||
try {
|
||||
// URL 유효성 검증
|
||||
if (!url.contains('naver.com') && !url.contains('naver.me')) {
|
||||
throw Exception('유효하지 않은 네이버 지도 URL입니다.');
|
||||
}
|
||||
|
||||
|
||||
// NaverSearchService로 식당 정보 추출
|
||||
Restaurant restaurant = await _naverSearchService.getRestaurantFromUrl(url);
|
||||
|
||||
Restaurant restaurant = await _naverSearchService.getRestaurantFromUrl(
|
||||
url,
|
||||
);
|
||||
|
||||
// API 키가 설정되어 있으면 추가 정보 검색
|
||||
if (ApiKeys.areKeysConfigured() && restaurant.name != '네이버 지도 장소') {
|
||||
try {
|
||||
final detailedRestaurant = await _naverSearchService.searchRestaurantDetails(
|
||||
name: restaurant.name,
|
||||
address: restaurant.roadAddress,
|
||||
latitude: restaurant.latitude,
|
||||
longitude: restaurant.longitude,
|
||||
);
|
||||
|
||||
final detailedRestaurant = await _naverSearchService
|
||||
.searchRestaurantDetails(
|
||||
name: restaurant.name,
|
||||
address: restaurant.roadAddress,
|
||||
latitude: restaurant.latitude,
|
||||
longitude: restaurant.longitude,
|
||||
);
|
||||
|
||||
if (detailedRestaurant != null) {
|
||||
// 기존 정보와 API 검색 결과 병합
|
||||
restaurant = Restaurant(
|
||||
@@ -172,8 +203,10 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
name: restaurant.name,
|
||||
category: detailedRestaurant.category,
|
||||
subCategory: detailedRestaurant.subCategory,
|
||||
description: detailedRestaurant.description ?? restaurant.description,
|
||||
phoneNumber: detailedRestaurant.phoneNumber ?? restaurant.phoneNumber,
|
||||
description:
|
||||
detailedRestaurant.description ?? restaurant.description,
|
||||
phoneNumber:
|
||||
detailedRestaurant.phoneNumber ?? restaurant.phoneNumber,
|
||||
roadAddress: detailedRestaurant.roadAddress,
|
||||
jibunAddress: detailedRestaurant.jibunAddress,
|
||||
latitude: detailedRestaurant.latitude,
|
||||
@@ -184,7 +217,8 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
businessHours:
|
||||
detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
lastVisited: restaurant.lastVisited,
|
||||
visitCount: restaurant.visitCount,
|
||||
);
|
||||
@@ -193,50 +227,31 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
print('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 체크 개선
|
||||
final restaurants = await getAllRestaurants();
|
||||
|
||||
// 1. 주소 기반 중복 체크
|
||||
if (restaurant.roadAddress.isNotEmpty || restaurant.jibunAddress.isNotEmpty) {
|
||||
final addressDuplicate = restaurants.firstWhere(
|
||||
(r) => r.name == restaurant.name &&
|
||||
(r.roadAddress == restaurant.roadAddress ||
|
||||
r.jibunAddress == restaurant.jibunAddress),
|
||||
orElse: () => Restaurant(
|
||||
id: '',
|
||||
name: '',
|
||||
category: '',
|
||||
subCategory: '',
|
||||
roadAddress: '',
|
||||
jibunAddress: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
source: DataSource.USER_INPUT,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (addressDuplicate.id.isNotEmpty) {
|
||||
throw Exception('동일한 이름과 주소의 맛집이 이미 존재합니다: ${addressDuplicate.name}');
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
await _ensureRestaurantIsUnique(restaurant);
|
||||
await addRestaurant(restaurant);
|
||||
}
|
||||
|
||||
// 2. 위치 기반 중복 체크 (50m 이내 같은 이름)
|
||||
final locationDuplicate = restaurants.firstWhere(
|
||||
(r) {
|
||||
if (r.name != restaurant.name) return false;
|
||||
|
||||
final distanceInKm = DistanceCalculator.calculateDistance(
|
||||
lat1: r.latitude,
|
||||
lon1: r.longitude,
|
||||
lat2: restaurant.latitude,
|
||||
lon2: restaurant.longitude,
|
||||
);
|
||||
final distanceInMeters = distanceInKm * 1000;
|
||||
return distanceInMeters < 50; // 50m 이내
|
||||
},
|
||||
|
||||
return restaurant;
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
throw Exception('네이버 지도 파싱 실패: ${e.message}');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureRestaurantIsUnique(Restaurant restaurant) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
|
||||
if (restaurant.roadAddress.isNotEmpty ||
|
||||
restaurant.jibunAddress.isNotEmpty) {
|
||||
final addressDuplicate = restaurants.firstWhere(
|
||||
(r) =>
|
||||
r.name == restaurant.name &&
|
||||
(r.roadAddress == restaurant.roadAddress ||
|
||||
r.jibunAddress == restaurant.jibunAddress),
|
||||
orElse: () => Restaurant(
|
||||
id: '',
|
||||
name: '',
|
||||
@@ -251,20 +266,44 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (locationDuplicate.id.isNotEmpty) {
|
||||
throw Exception('50m 이내에 동일한 이름의 맛집이 이미 존재합니다: ${locationDuplicate.name}');
|
||||
|
||||
if (addressDuplicate.id.isNotEmpty) {
|
||||
throw Exception('동일한 이름과 주소의 맛집이 이미 존재합니다: ${addressDuplicate.name}');
|
||||
}
|
||||
|
||||
// 새 맛집 추가
|
||||
await addRestaurant(restaurant);
|
||||
|
||||
return restaurant;
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
throw Exception('네이버 지도 파싱 실패: ${e.message}');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final locationDuplicate = restaurants.firstWhere(
|
||||
(r) {
|
||||
if (r.name != restaurant.name) return false;
|
||||
|
||||
final distanceInKm = DistanceCalculator.calculateDistance(
|
||||
lat1: r.latitude,
|
||||
lon1: r.longitude,
|
||||
lat2: restaurant.latitude,
|
||||
lon2: restaurant.longitude,
|
||||
);
|
||||
final distanceInMeters = distanceInKm * 1000;
|
||||
return distanceInMeters < 50;
|
||||
},
|
||||
orElse: () => Restaurant(
|
||||
id: '',
|
||||
name: '',
|
||||
category: '',
|
||||
subCategory: '',
|
||||
roadAddress: '',
|
||||
jibunAddress: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
source: DataSource.USER_INPUT,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (locationDuplicate.id.isNotEmpty) {
|
||||
throw Exception(
|
||||
'50m 이내에 동일한 이름의 맛집이 이미 존재합니다: ${locationDuplicate.name}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,12 +311,9 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
Future<Restaurant?> getRestaurantByNaverPlaceId(String naverPlaceId) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
try {
|
||||
return restaurants.firstWhere(
|
||||
(r) => r.naverPlaceId == naverPlaceId,
|
||||
);
|
||||
return restaurants.firstWhere((r) => r.naverPlaceId == naverPlaceId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,18 @@ import 'package:lunchpick/domain/entities/user_settings.dart';
|
||||
|
||||
class SettingsRepositoryImpl implements SettingsRepository {
|
||||
static const String _boxName = 'settings';
|
||||
|
||||
|
||||
// Setting keys
|
||||
static const String _keyDaysToExclude = 'days_to_exclude';
|
||||
static const String _keyMaxDistanceRainy = 'max_distance_rainy';
|
||||
static const String _keyMaxDistanceNormal = 'max_distance_normal';
|
||||
static const String _keyNotificationDelayMinutes = 'notification_delay_minutes';
|
||||
static const String _keyNotificationDelayMinutes =
|
||||
'notification_delay_minutes';
|
||||
static const String _keyNotificationEnabled = 'notification_enabled';
|
||||
static const String _keyDarkModeEnabled = 'dark_mode_enabled';
|
||||
static const String _keyFirstRun = 'first_run';
|
||||
static const String _keyCategoryWeights = 'category_weights';
|
||||
|
||||
|
||||
// Default values
|
||||
static const int _defaultDaysToExclude = 7;
|
||||
static const int _defaultMaxDistanceRainy = 500;
|
||||
@@ -29,24 +30,34 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<UserSettings> getUserSettings() async {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// 저장된 설정값들을 읽어옴
|
||||
final revisitPreventionDays = box.get(_keyDaysToExclude, defaultValue: _defaultDaysToExclude);
|
||||
final notificationEnabled = box.get(_keyNotificationEnabled, defaultValue: _defaultNotificationEnabled);
|
||||
final notificationDelayMinutes = box.get(_keyNotificationDelayMinutes, defaultValue: _defaultNotificationDelayMinutes);
|
||||
|
||||
final revisitPreventionDays = box.get(
|
||||
_keyDaysToExclude,
|
||||
defaultValue: _defaultDaysToExclude,
|
||||
);
|
||||
final notificationEnabled = box.get(
|
||||
_keyNotificationEnabled,
|
||||
defaultValue: _defaultNotificationEnabled,
|
||||
);
|
||||
final notificationDelayMinutes = box.get(
|
||||
_keyNotificationDelayMinutes,
|
||||
defaultValue: _defaultNotificationDelayMinutes,
|
||||
);
|
||||
|
||||
// 카테고리 가중치 읽기 (Map<String, double>으로 저장됨)
|
||||
final categoryWeightsData = box.get(_keyCategoryWeights);
|
||||
Map<String, double> categoryWeights = {};
|
||||
if (categoryWeightsData != null) {
|
||||
categoryWeights = Map<String, double>.from(categoryWeightsData);
|
||||
}
|
||||
|
||||
|
||||
// 알림 시간은 분을 시간:분 형식으로 변환
|
||||
final hours = notificationDelayMinutes ~/ 60;
|
||||
final minutes = notificationDelayMinutes % 60;
|
||||
final notificationTime = '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}';
|
||||
|
||||
final notificationTime =
|
||||
'${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}';
|
||||
|
||||
return UserSettings(
|
||||
revisitPreventionDays: revisitPreventionDays,
|
||||
notificationEnabled: notificationEnabled,
|
||||
@@ -59,12 +70,15 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<void> updateUserSettings(UserSettings settings) async {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// 각 설정값 저장
|
||||
await box.put(_keyDaysToExclude, settings.revisitPreventionDays);
|
||||
await box.put(_keyNotificationEnabled, settings.notificationEnabled);
|
||||
await box.put(_keyNotificationDelayMinutes, settings.notificationDelayMinutes);
|
||||
|
||||
await box.put(
|
||||
_keyNotificationDelayMinutes,
|
||||
settings.notificationDelayMinutes,
|
||||
);
|
||||
|
||||
// 카테고리 가중치 저장
|
||||
await box.put(_keyCategoryWeights, settings.categoryWeights);
|
||||
}
|
||||
@@ -84,7 +98,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<int> getMaxDistanceRainy() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyMaxDistanceRainy, defaultValue: _defaultMaxDistanceRainy);
|
||||
return box.get(
|
||||
_keyMaxDistanceRainy,
|
||||
defaultValue: _defaultMaxDistanceRainy,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -96,7 +113,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<int> getMaxDistanceNormal() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyMaxDistanceNormal, defaultValue: _defaultMaxDistanceNormal);
|
||||
return box.get(
|
||||
_keyMaxDistanceNormal,
|
||||
defaultValue: _defaultMaxDistanceNormal,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -108,7 +128,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<int> getNotificationDelayMinutes() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyNotificationDelayMinutes, defaultValue: _defaultNotificationDelayMinutes);
|
||||
return box.get(
|
||||
_keyNotificationDelayMinutes,
|
||||
defaultValue: _defaultNotificationDelayMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -120,7 +143,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<bool> isNotificationEnabled() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyNotificationEnabled, defaultValue: _defaultNotificationEnabled);
|
||||
return box.get(
|
||||
_keyNotificationEnabled,
|
||||
defaultValue: _defaultNotificationEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -157,12 +183,15 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
Future<void> resetSettings() async {
|
||||
final box = await _box;
|
||||
await box.clear();
|
||||
|
||||
|
||||
// 기본값으로 재설정
|
||||
await box.put(_keyDaysToExclude, _defaultDaysToExclude);
|
||||
await box.put(_keyMaxDistanceRainy, _defaultMaxDistanceRainy);
|
||||
await box.put(_keyMaxDistanceNormal, _defaultMaxDistanceNormal);
|
||||
await box.put(_keyNotificationDelayMinutes, _defaultNotificationDelayMinutes);
|
||||
await box.put(
|
||||
_keyNotificationDelayMinutes,
|
||||
_defaultNotificationDelayMinutes,
|
||||
);
|
||||
await box.put(_keyNotificationEnabled, _defaultNotificationEnabled);
|
||||
await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled);
|
||||
await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님
|
||||
@@ -171,10 +200,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Stream<Map<String, dynamic>> watchSettings() async* {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// 초기 값 전송
|
||||
yield await _getCurrentSettings();
|
||||
|
||||
|
||||
// 변경사항 감시
|
||||
yield* box.watch().asyncMap((_) async => await _getCurrentSettings());
|
||||
}
|
||||
@@ -194,11 +223,11 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Stream<UserSettings> watchUserSettings() async* {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// 초기 값 전송
|
||||
yield await getUserSettings();
|
||||
|
||||
|
||||
// 변경사항 감시
|
||||
yield* box.watch().asyncMap((_) async => await getUserSettings());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
|
||||
class VisitRepositoryImpl implements VisitRepository {
|
||||
static const String _boxName = 'visit_records';
|
||||
|
||||
Future<Box<VisitRecord>> get _box async =>
|
||||
|
||||
Future<Box<VisitRecord>> get _box async =>
|
||||
await Hive.openBox<VisitRecord>(_boxName);
|
||||
|
||||
@override
|
||||
@@ -17,7 +17,9 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getVisitRecordsByRestaurantId(String restaurantId) async {
|
||||
Future<List<VisitRecord>> getVisitRecordsByRestaurantId(
|
||||
String restaurantId,
|
||||
) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((r) => r.restaurantId == restaurantId).toList();
|
||||
}
|
||||
@@ -39,7 +41,9 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
}) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((record) {
|
||||
return record.visitDate.isAfter(startDate.subtract(const Duration(days: 1))) &&
|
||||
return record.visitDate.isAfter(
|
||||
startDate.subtract(const Duration(days: 1)),
|
||||
) &&
|
||||
record.visitDate.isBefore(endDate.add(const Duration(days: 1)));
|
||||
}).toList();
|
||||
}
|
||||
@@ -93,7 +97,7 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
Future<DateTime?> getLastVisitDate(String restaurantId) async {
|
||||
final records = await getVisitRecordsByRestaurantId(restaurantId);
|
||||
if (records.isEmpty) return null;
|
||||
|
||||
|
||||
// 이미 visitDate 기준으로 정렬되어 있으므로 첫 번째가 가장 최근
|
||||
return records.first.visitDate;
|
||||
}
|
||||
@@ -102,7 +106,7 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
Future<Map<String, int>> getMonthlyVisitStats(int year, int month) async {
|
||||
final startDate = DateTime(year, month, 1);
|
||||
final endDate = DateTime(year, month + 1, 0); // 해당 월의 마지막 날
|
||||
|
||||
|
||||
final records = await getVisitRecordsByDateRange(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
@@ -113,7 +117,7 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
final dayKey = record.visitDate.day.toString();
|
||||
stats[dayKey] = (stats[dayKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@@ -124,4 +128,4 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
// 여기서는 빈 Map 반환
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,30 +17,22 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
}) async {
|
||||
// TODO: 실제 날씨 API 호출 구현
|
||||
// 여기서는 임시로 더미 데이터 반환
|
||||
|
||||
|
||||
final dummyWeather = WeatherInfo(
|
||||
current: WeatherData(
|
||||
temperature: 20,
|
||||
isRainy: false,
|
||||
description: '맑음',
|
||||
),
|
||||
nextHour: WeatherData(
|
||||
temperature: 22,
|
||||
isRainy: false,
|
||||
description: '맑음',
|
||||
),
|
||||
current: WeatherData(temperature: 20, isRainy: false, description: '맑음'),
|
||||
nextHour: WeatherData(temperature: 22, isRainy: false, description: '맑음'),
|
||||
);
|
||||
|
||||
// 캐시에 저장
|
||||
await cacheWeatherInfo(dummyWeather);
|
||||
|
||||
|
||||
return dummyWeather;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WeatherInfo?> getCachedWeather() async {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// 캐시가 유효한지 확인
|
||||
final isValid = await _isCacheValid();
|
||||
if (!isValid) {
|
||||
@@ -56,20 +48,25 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
try {
|
||||
// 안전한 타입 변환
|
||||
if (cachedData is! Map) {
|
||||
print('WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}');
|
||||
print(
|
||||
'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}',
|
||||
);
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> weatherMap = Map<String, dynamic>.from(cachedData);
|
||||
|
||||
|
||||
final Map<String, dynamic> weatherMap = Map<String, dynamic>.from(
|
||||
cachedData,
|
||||
);
|
||||
|
||||
// Map 구조 검증
|
||||
if (!weatherMap.containsKey('current') || !weatherMap.containsKey('nextHour')) {
|
||||
if (!weatherMap.containsKey('current') ||
|
||||
!weatherMap.containsKey('nextHour')) {
|
||||
print('WeatherCache: Missing required fields in weather data');
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return _weatherInfoFromMap(weatherMap);
|
||||
} catch (e) {
|
||||
// 캐시 데이터가 손상된 경우
|
||||
@@ -82,7 +79,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
@override
|
||||
Future<void> cacheWeatherInfo(WeatherInfo weatherInfo) async {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// WeatherInfo를 Map으로 변환하여 저장
|
||||
final weatherMap = _weatherInfoToMap(weatherInfo);
|
||||
await box.put(_keyCachedWeather, weatherMap);
|
||||
@@ -99,7 +96,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
@override
|
||||
Future<bool> isWeatherUpdateNeeded() async {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// 캐시된 날씨 정보가 없으면 업데이트 필요
|
||||
if (!box.containsKey(_keyCachedWeather)) {
|
||||
return true;
|
||||
@@ -111,7 +108,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
|
||||
Future<bool> _isCacheValid() async {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
final lastUpdateTimeStr = box.get(_keyLastUpdateTime);
|
||||
if (lastUpdateTimeStr == null) {
|
||||
return false;
|
||||
@@ -124,10 +121,10 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
print('WeatherCache: Invalid date format in cache: $lastUpdateTimeStr');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(lastUpdateTime);
|
||||
|
||||
|
||||
return difference < _cacheValidDuration;
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error checking cache validity: $e');
|
||||
@@ -157,22 +154,22 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
if (currentMap == null) {
|
||||
throw FormatException('Missing current weather data');
|
||||
}
|
||||
|
||||
|
||||
// nextHour 필드 검증
|
||||
final nextHourMap = map['nextHour'] as Map<String, dynamic>?;
|
||||
if (nextHourMap == null) {
|
||||
throw FormatException('Missing nextHour weather data');
|
||||
}
|
||||
|
||||
|
||||
// 필수 필드 검증 및 기본값 제공
|
||||
final currentTemp = currentMap['temperature'] as num? ?? 20;
|
||||
final currentRainy = currentMap['isRainy'] as bool? ?? false;
|
||||
final currentDesc = currentMap['description'] as String? ?? '알 수 없음';
|
||||
|
||||
|
||||
final nextTemp = nextHourMap['temperature'] as num? ?? 20;
|
||||
final nextRainy = nextHourMap['isRainy'] as bool? ?? false;
|
||||
final nextDesc = nextHourMap['description'] as String? ?? '알 수 없음';
|
||||
|
||||
|
||||
return WeatherInfo(
|
||||
current: WeatherData(
|
||||
temperature: currentTemp.round(),
|
||||
@@ -191,4 +188,4 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
208
lib/data/sample/manual_restaurant_samples.dart
Normal file
208
lib/data/sample/manual_restaurant_samples.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
|
||||
/// 샘플 맛집과 방문 이력을 함께 제공하는 데이터 모델
|
||||
class ManualSampleData {
|
||||
final Restaurant restaurant;
|
||||
final List<VisitRecord> visits;
|
||||
|
||||
const ManualSampleData({required this.restaurant, required this.visits});
|
||||
}
|
||||
|
||||
/// 수동 입력을 위한 기본 맛집 샘플 세트
|
||||
class ManualRestaurantSamples {
|
||||
static List<ManualSampleData> build() {
|
||||
return [
|
||||
_buildSample(
|
||||
id: 'sample-euljiro-jinmi',
|
||||
name: '을지로 진미식당',
|
||||
category: '한식',
|
||||
subCategory: '백반/한정식',
|
||||
description:
|
||||
'50년 전통의 정갈한 백반집으로 제철 반찬과 명란구이가 유명합니다. 점심 회전율이 빨라 예약 없이 방문 가능.',
|
||||
phoneNumber: '02-777-1234',
|
||||
roadAddress: '서울 중구 을지로12길 34',
|
||||
jibunAddress: '서울 중구 수표동 67-1',
|
||||
latitude: 37.56698,
|
||||
longitude: 127.00531,
|
||||
visitDaysAgo: [3, 14, 27],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-seongsu-butter',
|
||||
name: '성수연방 버터',
|
||||
category: '카페',
|
||||
subCategory: '디저트 카페',
|
||||
description: '버터 향이 진한 크루아상과 라즈베리 타르트를 파는 성수연방 내 디저트 카페. 아침 9시에 오픈.',
|
||||
phoneNumber: '02-6204-1231',
|
||||
roadAddress: '서울 성동구 성수이로14길 14',
|
||||
jibunAddress: '서울 성동구 성수동2가 320-10',
|
||||
latitude: 37.54465,
|
||||
longitude: 127.05692,
|
||||
visitDaysAgo: [1, 2, 5, 12],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-mangwon-ramen',
|
||||
name: '망원 라라멘',
|
||||
category: '일식',
|
||||
subCategory: '라멘',
|
||||
description: '돼지뼈 육수에 유자 오일을 더한 하카타 스타일 라멘. 저녁에는 한정 교자도 제공.',
|
||||
phoneNumber: '02-333-9086',
|
||||
roadAddress: '서울 마포구 포은로 78-1',
|
||||
jibunAddress: '서울 마포구 망원동 389-50',
|
||||
latitude: 37.55721,
|
||||
longitude: 126.90763,
|
||||
visitDaysAgo: [9],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-haebangchon-salsa',
|
||||
name: '해방촌 살사포차',
|
||||
category: '세계요리',
|
||||
subCategory: '멕시칸/타코',
|
||||
description: '직접 구운 토르티야 위에 매콤한 살사를 얹어주는 캐주얼 타코펍. 주말에는 살사 댄스 클래스 운영.',
|
||||
phoneNumber: '02-792-7764',
|
||||
roadAddress: '서울 용산구 신흥로 68',
|
||||
jibunAddress: '서울 용산구 용산동2가 22-16',
|
||||
latitude: 37.54241,
|
||||
longitude: 126.9862,
|
||||
visitDaysAgo: [30, 45],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-yeonnam-poke',
|
||||
name: '연남 그로서리 포케',
|
||||
category: '세계요리',
|
||||
subCategory: '포케/샐러드',
|
||||
description: '직접 고른 토핑으로 만드는 하와이안 포케 볼 전문점. 비건 토핑과 현미밥 선택 가능.',
|
||||
phoneNumber: '02-336-0214',
|
||||
roadAddress: '서울 마포구 동교로38길 33',
|
||||
jibunAddress: '서울 마포구 연남동 229-54',
|
||||
latitude: 37.55955,
|
||||
longitude: 126.92579,
|
||||
visitDaysAgo: [6, 21],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-jeongdong-brewery',
|
||||
name: '정동 브루어리',
|
||||
category: '주점',
|
||||
subCategory: '수제맥주펍',
|
||||
description: '소규모 양조 탱크를 갖춘 다운타운 브루펍. 시즈널 IPA와 훈제 플래터를 함께 즐길 수 있습니다.',
|
||||
phoneNumber: '02-720-8183',
|
||||
roadAddress: '서울 중구 정동길 21-15',
|
||||
jibunAddress: '서울 중구 정동 1-18',
|
||||
latitude: 37.56605,
|
||||
longitude: 126.97013,
|
||||
visitDaysAgo: [10, 60, 120],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-mokdong-lamb',
|
||||
name: '목동 참숯 양꼬치',
|
||||
category: '중식',
|
||||
subCategory: '양꼬치/바비큐',
|
||||
description: '매장에서 직접 손질한 어린양 꼬치를 참숯에 구워내는 곳. 마라볶음과 칭다오 생맥 조합 추천.',
|
||||
phoneNumber: '02-2653-4411',
|
||||
roadAddress: '서울 양천구 목동동로 377',
|
||||
jibunAddress: '서울 양천구 목동 907-2',
|
||||
latitude: 37.52974,
|
||||
longitude: 126.86455,
|
||||
visitDaysAgo: [2],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-busan-minrak-burger',
|
||||
name: '부산 민락 수제버거',
|
||||
category: '패스트푸드',
|
||||
subCategory: '수제버거',
|
||||
description:
|
||||
'광안리 바다가 내려다보이는 루프탑 버거 전문점. 패티를 미디엄으로 구워 치즈와 구운 파인애플을 올립니다.',
|
||||
phoneNumber: '051-754-2278',
|
||||
roadAddress: '부산 수영구 광안해변로 141',
|
||||
jibunAddress: '부산 수영구 민락동 181-5',
|
||||
latitude: 35.15302,
|
||||
longitude: 129.1183,
|
||||
visitDaysAgo: [15, 32],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-jeju-dongmun-pasta',
|
||||
name: '제주 동문 파스타바',
|
||||
category: '양식',
|
||||
subCategory: '파스타/와인바',
|
||||
description: '동문시장 골목의 오픈키친 파스타바. 한치 크림 파스타와 제주산 와인을 코스로 제공.',
|
||||
phoneNumber: '064-723-9012',
|
||||
roadAddress: '제주 제주시 관덕로14길 18',
|
||||
jibunAddress: '제주 제주시 일도일동 1113-4',
|
||||
latitude: 33.51227,
|
||||
longitude: 126.52686,
|
||||
visitDaysAgo: [4, 11, 19],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-daegu-market-sand',
|
||||
name: '대구 중앙시장 샌드',
|
||||
category: '카페',
|
||||
subCategory: '샌드위치/브런치',
|
||||
description:
|
||||
'직접 구운 식빵과 사과 절임으로 만드는 시그니처 에그샐러드 샌드. 평일 오전 8시부터 테이크아웃 가능.',
|
||||
phoneNumber: '053-256-8874',
|
||||
roadAddress: '대구 중구 중앙대로 363-1',
|
||||
jibunAddress: '대구 중구 남일동 135-1',
|
||||
latitude: 35.87053,
|
||||
longitude: 128.59404,
|
||||
visitDaysAgo: [7, 44, 90],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static ManualSampleData _buildSample({
|
||||
required String id,
|
||||
required String name,
|
||||
required String category,
|
||||
required String subCategory,
|
||||
required String description,
|
||||
required String phoneNumber,
|
||||
required String roadAddress,
|
||||
required String jibunAddress,
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required List<int> visitDaysAgo,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final visitDates =
|
||||
visitDaysAgo.map((days) => now.subtract(Duration(days: days))).toList()
|
||||
..sort((a, b) => b.compareTo(a)); // 최신순
|
||||
|
||||
final restaurant = Restaurant(
|
||||
id: id,
|
||||
name: name,
|
||||
category: category,
|
||||
subCategory: subCategory,
|
||||
description: description,
|
||||
phoneNumber: phoneNumber,
|
||||
roadAddress: roadAddress,
|
||||
jibunAddress: jibunAddress,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
lastVisitDate: visitDates.isNotEmpty ? visitDates.first : null,
|
||||
source: DataSource.USER_INPUT,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: null,
|
||||
lastVisited: visitDates.isNotEmpty ? visitDates.first : null,
|
||||
visitCount: visitDates.length,
|
||||
);
|
||||
|
||||
final visits = <VisitRecord>[];
|
||||
for (var i = 0; i < visitDates.length; i++) {
|
||||
final visitDate = visitDates[i];
|
||||
visits.add(
|
||||
VisitRecord(
|
||||
id: '${id}_visit_$i',
|
||||
restaurantId: id,
|
||||
visitDate: visitDate,
|
||||
isConfirmed: true,
|
||||
createdAt: visitDate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ManualSampleData(restaurant: restaurant, visits: visits);
|
||||
}
|
||||
}
|
||||
27
lib/data/sample/sample_data_initializer.dart
Normal file
27
lib/data/sample/sample_data_initializer.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/core/constants/app_constants.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
|
||||
import 'manual_restaurant_samples.dart';
|
||||
|
||||
/// 초기 구동 시 샘플 데이터를 채워 넣는 도우미
|
||||
class SampleDataInitializer {
|
||||
static Future<void> seedManualRestaurantsIfNeeded() async {
|
||||
final restaurantBox = Hive.box<Restaurant>(AppConstants.restaurantBox);
|
||||
final visitBox = Hive.box<VisitRecord>(AppConstants.visitRecordBox);
|
||||
|
||||
// 이미 사용자 데이터가 있으면 샘플을 추가하지 않음
|
||||
if (restaurantBox.isNotEmpty || visitBox.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final samples = ManualRestaurantSamples.build();
|
||||
for (final sample in samples) {
|
||||
await restaurantBox.put(sample.restaurant.id, sample.restaurant);
|
||||
for (final visit in sample.visits) {
|
||||
await visitBox.put(visit.id, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user