feat: 초기 프로젝트 설정 및 LunchPick 앱 구현

LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다.

주요 기능:
- 네이버 지도 연동 맛집 추가
- 랜덤 메뉴 추천 시스템
- 날씨 기반 거리 조정
- 방문 기록 관리
- Bluetooth 맛집 공유
- 다크모드 지원

기술 스택:
- Flutter 3.8.1+
- Riverpod 상태 관리
- Hive 로컬 DB
- Clean Architecture

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-30 19:03:28 +09:00
commit 85fde36157
237 changed files with 30953 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
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';
NaverGraphQLApi({NetworkClient? networkClient})
: _networkClient = networkClient ?? NetworkClient();
/// GraphQL 쿼리 실행
Future<Map<String, dynamic>> fetchGraphQL({
required String operationName,
required String query,
Map<String, dynamic>? variables,
}) async {
try {
final response = await _networkClient.post<Map<String, dynamic>>(
_graphqlEndpoint,
data: {
'operationName': operationName,
'query': query,
'variables': variables ?? {},
},
options: Options(
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Referer': 'https://map.naver.com/',
'Origin': 'https://map.naver.com',
},
),
);
if (response.data == null) {
throw ParseException(
message: 'GraphQL 응답이 비어있습니다',
);
}
return response.data!;
} on DioException catch (e) {
debugPrint('fetchGraphQL error: $e');
throw ServerException(
message: 'GraphQL 요청 중 오류가 발생했습니다',
statusCode: e.response?.statusCode ?? 500,
originalError: e,
);
}
}
/// 장소 상세 정보 가져오기 (한국어 텍스트)
Future<Map<String, dynamic>> fetchKoreanTextsFromPcmap(String placeId) async {
const query = '''
query getKoreanTexts(\$id: String!) {
place(input: { id: \$id }) {
id
name
category
businessHours {
description
isDayOff
openTime
closeTime
dayOfWeek
businessHours
}
phone
address {
roadAddress
jibunAddress
}
description
menuInfo {
menus {
name
price
description
images {
url
}
}
}
keywords
priceCategory
imageCount
visitorReviewCount
visitorReviewScore
}
}
''';
try {
final response = await fetchGraphQL(
operationName: 'getKoreanTexts',
query: query,
variables: {'id': placeId},
);
if (response['errors'] != null) {
debugPrint('GraphQL errors: ${response['errors']}');
throw ParseException(
message: 'GraphQL 오류: ${response['errors']}',
);
}
return response['data']?['place'] ?? {};
} catch (e) {
debugPrint('fetchKoreanTextsFromPcmap error: $e');
rethrow;
}
}
/// 장소 기본 정보 가져오기
Future<Map<String, dynamic>> fetchPlaceBasicInfo(String placeId) async {
const query = '''
query getPlaceBasicInfo(\$id: String!) {
place(input: { id: \$id }) {
id
name
category
phone
address {
roadAddress
jibunAddress
}
location {
latitude
longitude
}
homepageUrl
bookingUrl
}
}
''';
try {
final response = await fetchGraphQL(
operationName: 'getPlaceBasicInfo',
query: query,
variables: {'id': placeId},
);
if (response['errors'] != null) {
throw ParseException(
message: 'GraphQL 오류: ${response['errors']}',
);
}
return response['data']?['place'] ?? {};
} catch (e) {
debugPrint('fetchPlaceBasicInfo error: $e');
rethrow;
}
}
void dispose() {
// 필요시 리소스 정리
}
}

View File

@@ -0,0 +1,52 @@
/// \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!) {
places(id: \$id) {
id
name
category
address
roadAddress
phone
virtualPhone
businessHours {
description
}
description
location {
lat
lng
}
}
}
''';
/// \uc7a5\uc18c \uc0c1\uc138 \uc815\ubcf4 \ucffc\ub9ac - nxPlaces \uc0ac\uc6a9 (\ud3f4\ubc31)
static const String nxPlaceDetailQuery = '''
query getPlaceDetail(\$id: String!) {
nxPlaces(id: \$id) {
id
name
category
address
roadAddress
phone
virtualPhone
businessHours {
description
}
description
location {
lat
lng
}
}
}
''';
}

View File

@@ -0,0 +1,197 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../../../core/constants/api_keys.dart';
import '../../../core/network/network_client.dart';
import '../../../core/errors/network_exceptions.dart';
/// 네이버 로컬 검색 API 결과 모델
class NaverLocalSearchResult {
final String title;
final String link;
final String category;
final String description;
final String telephone;
final String address;
final String roadAddress;
final double? mapx;
final double? mapy;
NaverLocalSearchResult({
required this.title,
required this.link,
required this.category,
required this.description,
required this.telephone,
required this.address,
required this.roadAddress,
this.mapx,
this.mapy,
});
factory NaverLocalSearchResult.fromJson(Map<String, dynamic> json) {
// HTML 태그 제거 헬퍼 함수
String removeHtmlTags(String text) {
return text
.replaceAll(RegExp(r'<[^>]*>'), '')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&nbsp;', ' ');
}
return NaverLocalSearchResult(
title: removeHtmlTags(json['title'] ?? ''),
link: json['link'] ?? '',
category: json['category'] ?? '',
description: removeHtmlTags(json['description'] ?? ''),
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,
);
}
/// 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;
NaverLocalSearchApi({NetworkClient? networkClient})
: _networkClient = networkClient ?? NetworkClient();
/// 로컬 검색 API 호출
///
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
Future<List<NaverLocalSearchResult>> searchLocal({
required String query,
double? latitude,
double? longitude,
int display = 20,
int start = 1,
String sort = 'random', // random, comment
}) async {
// API 키 확인
if (!ApiKeys.areKeysConfigured()) {
throw ApiKeyException();
}
try {
final response = await _networkClient.get<Map<String, dynamic>>(
ApiKeys.naverLocalSearchEndpoint,
queryParameters: {
'query': query,
'display': display,
'start': start,
'sort': sort,
if (latitude != null && longitude != null) ...{
'coordinate': '$longitude,$latitude', // 경도,위도 순서
},
},
options: Options(
headers: {
'X-Naver-Client-Id': ApiKeys.naverClientId,
'X-Naver-Client-Secret': ApiKeys.naverClientSecret,
},
),
);
final data = response.data;
if (data == null || data['items'] == null) {
return [];
}
final items = data['items'] as List;
return items
.map((item) => NaverLocalSearchResult.fromJson(item))
.toList();
} on DioException catch (e) {
debugPrint('NaverLocalSearchApi Error: ${e.message}');
debugPrint('Error type: ${e.type}');
debugPrint('Error response: ${e.response?.data}');
if (e.error is NetworkException) {
throw e.error!;
}
throw ServerException(
message: '네이버 검색 중 오류가 발생했습니다',
statusCode: e.response?.statusCode ?? 500,
originalError: e,
);
}
}
/// 특정 식당 상세 정보 검색
Future<NaverLocalSearchResult?> searchRestaurantDetails({
required String name,
required String address,
double? latitude,
double? longitude,
}) async {
try {
// 주소와 이름을 조합한 검색어
final query = '$name $address';
final results = await searchLocal(
query: query,
latitude: latitude,
longitude: longitude,
display: 5,
sort: 'comment', // 정확도순
);
if (results.isEmpty) {
return null;
}
// 가장 정확한 결과 찾기
for (final result in results) {
if (result.title.contains(name) || name.contains(result.title)) {
return result;
}
}
// 정확한 매칭이 없으면 첫 번째 결과 반환
return results.first;
} catch (e) {
debugPrint('searchRestaurantDetails error: $e');
return null;
}
}
void dispose() {
// 필요시 리소스 정리
}
}

View File

@@ -0,0 +1,101 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../../../core/network/network_client.dart';
import '../../../core/network/network_config.dart';
import '../../../core/errors/network_exceptions.dart';
/// 네이버 프록시 클라이언트
///
/// 웹 환경에서 CORS 문제를 해결하기 위한 프록시 클라이언트입니다.
class NaverProxyClient {
final NetworkClient _networkClient;
NaverProxyClient({NetworkClient? networkClient})
: _networkClient = networkClient ?? NetworkClient();
/// 웹 환경에서 프록시를 통해 HTML 가져오기
Future<String> fetchViaProxy(String url) async {
if (!kIsWeb) {
throw UnsupportedError('프록시는 웹 환경에서만 사용 가능합니다');
}
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-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
},
),
);
if (response.data == null || response.data!.isEmpty) {
throw ParseException(
message: '프록시 응답이 비어있습니다',
);
}
return response.data!;
} on DioException catch (e) {
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 프록시 접근이 거부되었습니다. 잠시 후 다시 시도해주세요.',
statusCode: 403,
originalError: e,
);
}
throw ServerException(
message: '프록시를 통한 페이지 로드에 실패했습니다',
statusCode: e.response?.statusCode ?? 500,
originalError: e,
);
}
}
/// 프록시 상태 확인
Future<bool> checkProxyStatus() async {
if (!kIsWeb) {
return true; // 웹이 아니면 프록시 불필요
}
try {
final testUrl = 'https://map.naver.com';
final proxyUrl = NetworkConfig.getCorsProxyUrl(testUrl);
final response = await _networkClient.head(
proxyUrl,
options: Options(
validateStatus: (status) => status! < 500,
),
);
return response.statusCode == 200;
} catch (e) {
debugPrint('Proxy status check failed: $e');
return false;
}
}
/// 프록시 URL 생성
String getProxyUrl(String originalUrl) {
if (!kIsWeb) {
return originalUrl;
}
return NetworkConfig.getCorsProxyUrl(originalUrl);
}
void dispose() {
// 필요시 리소스 정리
}
}

View File

@@ -0,0 +1,151 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../../../core/network/network_client.dart';
import '../../../core/network/network_config.dart';
/// 네이버 URL 리졸버
///
/// 네이버 단축 URL을 실제 URL로 변환하고 최종 리다이렉트 URL을 추적합니다.
class NaverUrlResolver {
final NetworkClient _networkClient;
NaverUrlResolver({NetworkClient? networkClient})
: _networkClient = networkClient ?? NetworkClient();
/// 단축 URL을 실제 URL로 변환
Future<String> resolveShortUrl(String shortUrl) async {
try {
// 웹 환경에서는 CORS 프록시 사용
if (kIsWeb) {
return await _resolveShortUrlViaProxy(shortUrl);
}
// 모바일 환경에서는 직접 HEAD 요청
final response = await _networkClient.head(
shortUrl,
options: Options(
followRedirects: false,
validateStatus: (status) => status! < 400,
),
);
// Location 헤더에서 리다이렉트 URL 추출
final location = response.headers.value('location');
if (location != null) {
return location;
}
// 리다이렉트가 없으면 원본 URL 반환
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');
if (location != null) {
return location;
}
}
// 오류 발생 시 원본 URL 반환
return shortUrl;
}
}
/// 프록시를 통한 단축 URL 해결 (웹 환경)
Future<String> _resolveShortUrlViaProxy(String shortUrl) async {
try {
final proxyUrl = NetworkConfig.getCorsProxyUrl(shortUrl);
final response = await _networkClient.get(
proxyUrl,
options: Options(
followRedirects: false,
validateStatus: (status) => true,
responseType: ResponseType.plain,
),
);
// 응답에서 URL 정보 추출
final responseData = response.data.toString();
// meta refresh 태그에서 URL 추출
final metaRefreshRegex = RegExp(
'<meta[^>]+http-equiv="refresh"[^>]+content="0;url=([^"]+)"[^>]*>',
caseSensitive: false,
);
final metaMatch = metaRefreshRegex.firstMatch(responseData);
if (metaMatch != null) {
return metaMatch.group(1) ?? shortUrl;
}
// og:url 메타 태그에서 URL 추출
final ogUrlRegex = RegExp(
'<meta[^>]+property="og:url"[^>]+content="([^"]+)"[^>]*>',
caseSensitive: false,
);
final ogMatch = ogUrlRegex.firstMatch(responseData);
if (ogMatch != null) {
return ogMatch.group(1) ?? shortUrl;
}
// Location 헤더 확인
final location = response.headers.value('location');
if (location != null) {
return location;
}
return shortUrl;
} catch (e) {
debugPrint('_resolveShortUrlViaProxy error: $e');
return shortUrl;
}
}
/// 최종 리다이렉트 URL 가져오기
///
/// 여러 단계의 리다이렉트를 거쳐 최종 URL을 반환합니다.
Future<String> getFinalRedirectUrl(String url) async {
try {
String currentUrl = url;
int redirectCount = 0;
const maxRedirects = 5;
while (redirectCount < maxRedirects) {
final response = await _networkClient.head(
currentUrl,
options: Options(
followRedirects: false,
validateStatus: (status) => status! < 400,
),
);
final location = response.headers.value('location');
if (location == null) {
break;
}
// 절대 URL로 변환
if (location.startsWith('/')) {
final uri = Uri.parse(currentUrl);
currentUrl = '${uri.scheme}://${uri.host}$location';
} else {
currentUrl = location;
}
redirectCount++;
}
return currentUrl;
} catch (e) {
debugPrint('getFinalRedirectUrl error: $e');
return url;
}
}
void dispose() {
// 필요시 리소스 정리
}
}