diff --git a/lib/core/errors/app_exceptions.dart b/lib/core/errors/app_exceptions.dart deleted file mode 100644 index 0a454f4..0000000 --- a/lib/core/errors/app_exceptions.dart +++ /dev/null @@ -1,142 +0,0 @@ -/// 애플리케이션 전체 예외 클래스들 -/// -/// 각 레이어별로 명확한 예외 계층 구조를 제공합니다. - -/// 앱 예외 기본 클래스 -abstract class AppException implements Exception { - final String message; - final String? code; - final dynamic originalError; - - const AppException({required this.message, this.code, this.originalError}); - - @override - String toString() => - '$runtimeType: $message${code != null ? ' (코드: $code)' : ''}'; -} - -/// 비즈니스 로직 예외 -class BusinessException extends AppException { - const BusinessException({ - required String message, - String? code, - dynamic originalError, - }) : super(message: message, code: code, originalError: originalError); -} - -/// 검증 예외 -class ValidationException extends AppException { - final Map? fieldErrors; - - const ValidationException({ - required String message, - this.fieldErrors, - String? code, - }) : super(message: message, code: code); - - @override - String toString() { - final base = super.toString(); - if (fieldErrors != null && fieldErrors!.isNotEmpty) { - final errors = fieldErrors!.entries - .map((e) => '${e.key}: ${e.value}') - .join(', '); - return '$base [필드 오류: $errors]'; - } - return base; - } -} - -/// 데이터 예외 -class DataException extends AppException { - const DataException({ - required String message, - String? code, - dynamic originalError, - }) : super(message: message, code: code, originalError: originalError); -} - -/// 저장소 예외 -class StorageException extends DataException { - const StorageException({ - required String message, - String? code, - dynamic originalError, - }) : super(message: message, code: code, originalError: originalError); -} - -/// 권한 예외 -class PermissionException extends AppException { - final String permission; - - const PermissionException({ - required String message, - required this.permission, - String? code, - }) : super(message: message, code: code); - - @override - String toString() => '$runtimeType: $message (권한: $permission)'; -} - -/// 위치 서비스 예외 -class LocationException extends AppException { - const LocationException({ - required String message, - String? code, - dynamic originalError, - }) : super(message: message, code: code, originalError: originalError); -} - -/// 설정 예외 -class ConfigurationException extends AppException { - const ConfigurationException({required String message, String? code}) - : super(message: message, code: code); -} - -/// UI 예외 -class UIException extends AppException { - const UIException({ - required String message, - String? code, - dynamic originalError, - }) : super(message: message, code: code, originalError: originalError); -} - -/// 리소스를 찾을 수 없음 예외 -class NotFoundException extends AppException { - final String resourceType; - final dynamic resourceId; - - const NotFoundException({ - required this.resourceType, - required this.resourceId, - String? message, - }) : super( - message: message ?? '$resourceType을(를) 찾을 수 없습니다 (ID: $resourceId)', - code: 'NOT_FOUND', - ); -} - -/// 중복 리소스 예외 -class DuplicateException extends AppException { - final String resourceType; - - const DuplicateException({required this.resourceType, String? message}) - : super(message: message ?? '이미 존재하는 $resourceType입니다', code: 'DUPLICATE'); -} - -/// 추천 엔진 예외 -class RecommendationException extends BusinessException { - const RecommendationException({required String message, String? code}) - : super(message: message, code: code); -} - -/// 알림 예외 -class NotificationException extends AppException { - const NotificationException({ - required String message, - String? code, - dynamic originalError, - }) : super(message: message, code: code, originalError: originalError); -} diff --git a/lib/core/errors/data_exceptions.dart b/lib/core/errors/data_exceptions.dart deleted file mode 100644 index 976f664..0000000 --- a/lib/core/errors/data_exceptions.dart +++ /dev/null @@ -1,142 +0,0 @@ -/// 데이터 레이어 예외 클래스들 -/// -/// API, 데이터베이스, 파싱 관련 예외를 정의합니다. - -import 'app_exceptions.dart'; - -/// API 예외 기본 클래스 -abstract class ApiException extends DataException { - final int? statusCode; - - const ApiException({ - required String message, - this.statusCode, - String? code, - dynamic originalError, - }) : super(message: message, code: code, originalError: originalError); - - @override - String toString() => - '$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}'; -} - -/// 네이버 API 예외 -class NaverApiException extends ApiException { - const NaverApiException({ - required String message, - int? statusCode, - String? code, - dynamic originalError, - }) : super( - message: message, - statusCode: statusCode, - code: code, - originalError: originalError, - ); -} - -/// HTML 파싱 예외 -class HtmlParsingException extends DataException { - final String? url; - - const HtmlParsingException({ - required String message, - this.url, - dynamic originalError, - }) : super( - message: message, - code: 'HTML_PARSE_ERROR', - originalError: originalError, - ); - - @override - String toString() { - final base = super.toString(); - return url != null ? '$base (URL: $url)' : base; - } -} - -/// 데이터 변환 예외 -class DataConversionException extends DataException { - final String fromType; - final String toType; - - const DataConversionException({ - required String message, - required this.fromType, - required this.toType, - dynamic originalError, - }) : super( - message: message, - code: 'DATA_CONVERSION_ERROR', - originalError: originalError, - ); - - @override - String toString() => '$runtimeType: $message ($fromType → $toType)'; -} - -/// 캐시 예외 -class CacheException extends StorageException { - const CacheException({ - required String message, - String? code, - dynamic originalError, - }) : super( - message: message, - code: code ?? 'CACHE_ERROR', - originalError: originalError, - ); -} - -/// Hive 예외 -class HiveException extends StorageException { - const HiveException({ - required String message, - String? code, - dynamic originalError, - }) : super( - message: message, - code: code ?? 'HIVE_ERROR', - originalError: originalError, - ); -} - -/// URL 처리 예외 -class UrlProcessingException extends DataException { - final String url; - - const UrlProcessingException({ - required String message, - required this.url, - String? code, - dynamic originalError, - }) : super( - message: message, - code: code ?? 'URL_PROCESSING_ERROR', - originalError: originalError, - ); - - @override - String toString() => '$runtimeType: $message (URL: $url)'; -} - -/// 잘못된 URL 형식 예외 -class InvalidUrlException extends UrlProcessingException { - const InvalidUrlException({required String url, String? message}) - : super( - message: message ?? '올바르지 않은 URL 형식입니다', - url: url, - code: 'INVALID_URL', - ); -} - -/// 지원하지 않는 URL 예외 -class UnsupportedUrlException extends UrlProcessingException { - const UnsupportedUrlException({required String url, String? message}) - : super( - message: message ?? '지원하지 않는 URL입니다', - url: url, - code: 'UNSUPPORTED_URL', - ); -} diff --git a/lib/core/widgets/empty_state_widget.dart b/lib/core/widgets/empty_state_widget.dart deleted file mode 100644 index 4f381c7..0000000 --- a/lib/core/widgets/empty_state_widget.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:flutter/material.dart'; -import '../constants/app_colors.dart'; -import '../constants/app_typography.dart'; - -/// 빈 상태 위젯 -/// -/// 데이터가 없을 때 표시하는 공통 위젯 -class EmptyStateWidget extends StatelessWidget { - /// 제목 - final String title; - - /// 설명 메시지 (선택사항) - final String? message; - - /// 아이콘 (선택사항) - final IconData? icon; - - /// 아이콘 크기 - final double iconSize; - - /// 액션 버튼 텍스트 (선택사항) - final String? actionText; - - /// 액션 버튼 콜백 (선택사항) - final VoidCallback? onAction; - - /// 커스텀 위젯 (아이콘 대신 사용할 수 있음) - final Widget? customWidget; - - const EmptyStateWidget({ - super.key, - required this.title, - this.message, - this.icon, - this.iconSize = 80.0, - this.actionText, - this.onAction, - this.customWidget, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Center( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - // 아이콘 또는 커스텀 위젯 - if (customWidget != null) - customWidget! - else if (icon != null) - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: - (isDark ? AppColors.darkPrimary : AppColors.lightPrimary) - .withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - icon, - size: iconSize, - color: isDark - ? AppColors.darkTextSecondary - : AppColors.lightTextSecondary, - ), - ), - const SizedBox(height: 24), - - // 제목 - Text( - title, - style: AppTypography.heading2(isDark), - textAlign: TextAlign.center, - ), - - // 설명 메시지 (있을 경우) - if (message != null) ...[ - const SizedBox(height: 12), - Text( - message!, - style: AppTypography.body2(isDark), - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - - // 액션 버튼 (있을 경우) - if (actionText != null && onAction != null) ...[ - const SizedBox(height: 32), - ElevatedButton( - onPressed: onAction, - style: ElevatedButton.styleFrom( - backgroundColor: isDark - ? AppColors.darkPrimary - : AppColors.lightPrimary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: Text( - actionText!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ], - ), - ), - ); - } -} - -/// 리스트 빈 상태 위젯 -/// -/// 리스트나 그리드가 비어있을 때 사용하는 특화된 위젯 -class ListEmptyStateWidget extends StatelessWidget { - /// 아이템 유형 (예: "식당", "기록" 등) - final String itemType; - - /// 추가 액션 콜백 (선택사항) - final VoidCallback? onAdd; - - const ListEmptyStateWidget({super.key, required this.itemType, this.onAdd}); - - @override - Widget build(BuildContext context) { - return EmptyStateWidget( - icon: Icons.inbox_outlined, - title: '$itemType이(가) 없습니다', - message: '새로운 $itemType을(를) 추가해보세요', - actionText: onAdd != null ? '$itemType 추가' : null, - onAction: onAdd, - ); - } -} diff --git a/lib/core/widgets/loading_indicator.dart b/lib/core/widgets/loading_indicator.dart deleted file mode 100644 index f99b27d..0000000 --- a/lib/core/widgets/loading_indicator.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:flutter/material.dart'; -import '../constants/app_colors.dart'; - -/// 로딩 인디케이터 위젯 -/// -/// 앱 전체에서 일관된 로딩 표시를 위한 공통 위젯 -class LoadingIndicator extends StatelessWidget { - /// 로딩 메시지 (선택사항) - final String? message; - - /// 인디케이터 크기 - final double size; - - /// 스트로크 너비 - final double strokeWidth; - - const LoadingIndicator({ - super.key, - this.message, - this.size = 40.0, - this.strokeWidth = 4.0, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: size, - height: size, - child: CircularProgressIndicator( - strokeWidth: strokeWidth, - valueColor: AlwaysStoppedAnimation( - isDark ? AppColors.darkPrimary : AppColors.lightPrimary, - ), - ), - ), - if (message != null) ...[ - const SizedBox(height: 16), - Text( - message!, - style: TextStyle( - fontSize: 14, - color: isDark - ? AppColors.darkTextSecondary - : AppColors.lightTextSecondary, - ), - textAlign: TextAlign.center, - ), - ], - ], - ), - ); - } -} - -/// 전체 화면 로딩 인디케이터 -/// -/// 화면 전체를 덮는 로딩 표시를 위한 위젯 -class FullScreenLoadingIndicator extends StatelessWidget { - /// 로딩 메시지 (선택사항) - final String? message; - - /// 배경 투명도 - final double backgroundOpacity; - - const FullScreenLoadingIndicator({ - super.key, - this.message, - this.backgroundOpacity = 0.5, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Container( - color: (isDark ? Colors.black : Colors.white).withValues( - alpha: backgroundOpacity, - ), - child: LoadingIndicator(message: message), - ); - } -} diff --git a/lib/data/api/naver_api_client.dart.backup b/lib/data/api/naver_api_client.dart.backup deleted file mode 100644 index 21c22e9..0000000 --- a/lib/data/api/naver_api_client.dart.backup +++ /dev/null @@ -1,553 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; - -import '../../core/constants/api_keys.dart'; -import '../../core/network/network_client.dart'; -import '../../core/network/network_config.dart'; -import '../../core/errors/network_exceptions.dart'; -import '../../domain/entities/restaurant.dart'; -import '../datasources/remote/naver_html_extractor.dart'; - -/// 네이버 API 클라이언트 -/// -/// 네이버 오픈 API와 지도 서비스를 위한 통합 클라이언트입니다. -class NaverApiClient { - final NetworkClient _networkClient; - - NaverApiClient({NetworkClient? networkClient}) - : _networkClient = networkClient ?? NetworkClient(); - - /// 네이버 로컬 검색 API 호출 - /// - /// 검색어와 좌표를 기반으로 주변 식당을 검색합니다. - Future> 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>( - 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, - }, - ), - ); - - if (response.statusCode == 200 && response.data != null) { - final items = response.data!['items'] as List?; - if (items == null || items.isEmpty) { - return []; - } - - return items - .map( - (item) => - NaverLocalSearchResult.fromJson(item as Map), - ) - .toList(); - } - - throw ParseException(message: '검색 결과를 파싱할 수 없습니다'); - } on DioException catch (e) { - // 에러는 NetworkClient에서 이미 변환됨 - throw e.error ?? - ServerException(message: '네이버 API 호출 실패', statusCode: 500); - } - } - - /// 네이버 단축 URL 리다이렉션 처리 - /// - /// naver.me 단축 URL을 실제 지도 URL로 변환합니다. - Future resolveShortUrl(String shortUrl) async { - if (!shortUrl.contains('naver.me')) { - debugPrint('NaverApiClient: 단축 URL이 아님, 원본 반환 - $shortUrl'); - return shortUrl; - } - - try { - debugPrint('NaverApiClient: 단축 URL 리디렉션 처리 시작 - $shortUrl'); - - // 웹 환경에서는 CORS 프록시 사용 - if (kIsWeb) { - return await _resolveShortUrlViaProxy(shortUrl); - } - - // 모바일 환경에서는 여러 단계의 리다이렉션 처리 - String currentUrl = shortUrl; - int redirectCount = 0; - const maxRedirects = 10; - - while (redirectCount < maxRedirects) { - debugPrint( - 'NaverApiClient: 리다이렉션 시도 #${redirectCount + 1} - $currentUrl', - ); - - final response = await _networkClient.get( - currentUrl, - options: Options( - followRedirects: false, - validateStatus: (status) => true, // 모든 상태 코드 허용 - headers: {'User-Agent': NetworkConfig.userAgent}, - ), - useCache: false, - ); - - debugPrint('NaverApiClient: 응답 상태 코드 - ${response.statusCode}'); - - // 리다이렉션 체크 (301, 302, 307, 308) - if ([301, 302, 307, 308].contains(response.statusCode)) { - final location = response.headers['location']?.firstOrNull; - if (location != null) { - debugPrint('NaverApiClient: Location 헤더 발견 - $location'); - - // 상대 경로인 경우 절대 경로로 변환 - if (!location.startsWith('http')) { - final Uri baseUri = Uri.parse(currentUrl); - currentUrl = baseUri.resolve(location).toString(); - } else { - currentUrl = location; - } - - // 목표 URL에 도달했는지 확인 - if (currentUrl.contains('pcmap.place.naver.com') || - currentUrl.contains('map.naver.com/p/')) { - debugPrint('NaverApiClient: 최종 URL 도착 - $currentUrl'); - return currentUrl; - } - - redirectCount++; - } else { - debugPrint('NaverApiClient: Location 헤더 없음'); - break; - } - } else if (response.statusCode == 200) { - // 200 OK인 경우 meta refresh 태그 확인 - debugPrint('NaverApiClient: 200 OK - meta refresh 태그 확인'); - - final String? html = response.data as String?; - if (html != null && - html.contains('meta') && - html.contains('refresh')) { - final metaRefreshRegex = RegExp( - ']+http-equiv=["\']refresh["\'][^>]+content=["\']\\d+;\\s*url=([^"\'>]+)', - caseSensitive: false, - ); - - final match = metaRefreshRegex.firstMatch(html); - if (match != null) { - final redirectUrl = match.group(1)!; - debugPrint('NaverApiClient: Meta refresh URL 발견 - $redirectUrl'); - - // 상대 경로 처리 - if (!redirectUrl.startsWith('http')) { - final Uri baseUri = Uri.parse(currentUrl); - currentUrl = baseUri.resolve(redirectUrl).toString(); - } else { - currentUrl = redirectUrl; - } - - redirectCount++; - continue; - } - } - - // meta refresh가 없으면 현재 URL이 최종 URL - debugPrint('NaverApiClient: 200 OK - 최종 URL - $currentUrl'); - return currentUrl; - } else { - debugPrint('NaverApiClient: 리다이렉션 아님 - 상태 코드 ${response.statusCode}'); - break; - } - } - - // 모든 시도 후 현재 URL 반환 - debugPrint('NaverApiClient: 최종 URL - $currentUrl'); - return currentUrl; - } catch (e) { - debugPrint('NaverApiClient: 단축 URL 리다이렉션 실패 - $e'); - return shortUrl; - } - } - - /// 프록시를 통한 단축 URL 리다이렉션 (웹 환경) - Future _resolveShortUrlViaProxy(String shortUrl) async { - try { - final proxyUrl = - '${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(shortUrl)}'; - - final response = await _networkClient.get>( - proxyUrl, - options: Options(headers: {'Accept': 'application/json'}), - useCache: false, - ); - - if (response.statusCode == 200 && response.data != null) { - final data = response.data!; - - // status.url 확인 - if (data['status'] != null && - data['status'] is Map && - data['status']['url'] != null) { - final finalUrl = data['status']['url'] as String; - debugPrint('NaverApiClient: 프록시 최종 URL - $finalUrl'); - return finalUrl; - } - - // contents에서 meta refresh 태그 찾기 - final contents = data['contents'] as String?; - if (contents != null && contents.isNotEmpty) { - final metaRefreshRegex = RegExp( - ' fetchMapPageHtml(String url) async { - try { - if (kIsWeb) { - return await _fetchViaProxy(url); - } - - // 모바일 환경에서는 직접 요청 - final response = await _networkClient.get( - url, - options: Options( - responseType: ResponseType.plain, - headers: { - 'User-Agent': NetworkConfig.userAgent, - 'Referer': 'https://map.naver.com', - }, - ), - useCache: false, // 네이버 지도는 동적 콘텐츠이므로 캐시 사용 안함 - ); - - if (response.statusCode == 200 && response.data != null) { - return response.data!; - } - - throw ServerException( - message: 'HTML을 가져올 수 없습니다', - statusCode: response.statusCode ?? 500, - ); - } on DioException catch (e) { - throw e.error ?? - ServerException(message: 'HTML 가져오기 실패', statusCode: 500); - } - } - - /// 프록시를 통한 HTML 가져오기 (웹 환경) - Future _fetchViaProxy(String url) async { - final proxyUrl = - '${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(url)}'; - - final response = await _networkClient.get>( - proxyUrl, - options: Options(headers: {'Accept': 'application/json'}), - ); - - if (response.statusCode == 200 && response.data != null) { - final data = response.data!; - - // 상태 코드 확인 - if (data['status'] != null && data['status'] is Map) { - final statusMap = data['status'] as Map; - final httpCode = statusMap['http_code']; - if (httpCode != null && httpCode != 200) { - throw ServerException( - message: '네이버 서버 응답 오류', - statusCode: httpCode as int, - ); - } - } - - // contents 반환 - final contents = data['contents']; - if (contents == null || contents.toString().isEmpty) { - throw ParseException(message: '빈 응답을 받았습니다'); - } - - return contents.toString(); - } - - throw ServerException( - message: '프록시 요청 실패', - statusCode: response.statusCode ?? 500, - ); - } - - /// GraphQL 쿼리 실행 - /// - /// 네이버 지도 API의 GraphQL 엔드포인트에 요청을 보냅니다. - Future> fetchGraphQL({ - required String operationName, - required Map variables, - required String query, - }) async { - const String graphqlUrl = 'https://pcmap-api.place.naver.com/graphql'; - - try { - final response = await _networkClient.post>( - graphqlUrl, - data: { - 'operationName': operationName, - 'variables': variables, - 'query': query, - }, - options: Options( - headers: { - 'Content-Type': 'application/json', - 'Referer': 'https://map.naver.com/', - 'User-Agent': NetworkConfig.userAgent, - }, - ), - ); - - if (response.statusCode == 200 && response.data != null) { - return response.data!; - } - - throw ParseException(message: 'GraphQL 응답을 파싱할 수 없습니다'); - } on DioException catch (e) { - throw e.error ?? - ServerException(message: 'GraphQL 요청 실패', statusCode: 500); - } - } - - /// pcmap URL에서 한글 텍스트 리스트 가져오기 - /// - /// restaurant/{ID}/home 형식의 URL에서 모든 한글 텍스트를 추출합니다. - Future> fetchKoreanTextsFromPcmap(String placeId) async { - // restaurant 타입 URL 사용 - final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home'; - - try { - debugPrint('========== 네이버 pcmap 한글 추출 시작 =========='); - debugPrint('요청 URL: $pcmapUrl'); - debugPrint('Place ID: $placeId'); - - String html; - if (kIsWeb) { - // 웹 환경에서는 프록시 사용 - html = await _fetchViaProxy(pcmapUrl); - } else { - // 모바일 환경에서는 직접 요청 - final response = await _networkClient.get( - pcmapUrl, - options: Options( - responseType: ResponseType.plain, - headers: { - 'User-Agent': NetworkConfig.userAgent, - 'Accept': 'text/html,application/xhtml+xml', - 'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8', - 'Referer': 'https://map.naver.com/', - }, - ), - useCache: false, - ); - - if (response.statusCode != 200 || response.data == null) { - debugPrint( - 'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}', - ); - return { - 'success': false, - 'error': 'HTTP ${response.statusCode}', - 'koreanTexts': [], - }; - } - - html = response.data!; - } - - // 모든 한글 텍스트 추출 - final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(html); - - // JSON-LD 데이터 추출 시도 - final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(html); - - // Apollo State 데이터 추출 시도 - final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(html); - - debugPrint('========== 추출 결과 =========='); - debugPrint('총 한글 텍스트 수: ${koreanTexts.length}'); - debugPrint('JSON-LD 상호명: $jsonLdName'); - debugPrint('Apollo State 상호명: $apolloName'); - debugPrint('====================================='); - - return { - 'success': true, - 'placeId': placeId, - 'url': pcmapUrl, - 'koreanTexts': koreanTexts, - 'jsonLdName': jsonLdName, - 'apolloStateName': apolloName, - 'extractedAt': DateTime.now().toIso8601String(), - }; - } catch (e) { - debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e'); - return { - 'success': false, - 'error': e.toString(), - 'koreanTexts': [], - }; - } - } - - - /// 최종 리디렉션 URL 획득 - /// - /// 주어진 URL이 리디렉션되는 최종 URL을 반환합니다. - Future getFinalRedirectUrl(String url) async { - try { - debugPrint('NaverApiClient: 최종 리디렉션 URL 획득 중 - $url'); - - // 429 에러 방지를 위한 지연 - await Future.delayed(const Duration(milliseconds: 500)); - - final response = await _networkClient.get( - url, - options: Options( - followRedirects: true, - maxRedirects: 5, - responseType: ResponseType.plain, - ), - useCache: false, - ); - - final finalUrl = response.realUri.toString(); - debugPrint('NaverApiClient: 최종 리디렉션 URL - $finalUrl'); - - return finalUrl; - } catch (e) { - debugPrint('NaverApiClient: 최종 리디렉션 URL 획득 실패 - $e'); - return url; - } - } - - - - - /// 리소스 정리 - void dispose() { - _networkClient.dispose(); - } -} - -/// 네이버 로컬 검색 결과 -class NaverLocalSearchResult { - final String title; - final String link; - final String category; - final String description; - final String telephone; - final String address; - final String roadAddress; - final int mapx; // 경도 (x좌표) - final int mapy; // 위도 (y좌표) - - NaverLocalSearchResult({ - required this.title, - required this.link, - required this.category, - required this.description, - required this.telephone, - required this.address, - required this.roadAddress, - required this.mapx, - required this.mapy, - }); - - factory NaverLocalSearchResult.fromJson(Map json) { - 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: int.tryParse(json['mapx']?.toString() ?? '0') ?? 0, - mapy: int.tryParse(json['mapy']?.toString() ?? '0') ?? 0, - ); - } - - /// HTML 태그 제거 - static String _removeHtmlTags(String text) { - return text.replaceAll(RegExp(r'<[^>]+>'), ''); - } - - /// 위도 (십진도) - double get latitude => mapy / 10000000.0; - - /// 경도 (십진도) - double get longitude => mapx / 10000000.0; - - /// Restaurant 엔티티로 변환 - Restaurant toRestaurant({required String id}) { - // 카테고리 파싱 - final categories = category.split('>').map((c) => c.trim()).toList(); - final mainCategory = categories.isNotEmpty ? categories.first : '기타'; - final subCategory = categories.length > 1 ? categories.last : mainCategory; - - return Restaurant( - id: id, - name: title, - category: mainCategory, - subCategory: subCategory, - description: description.isNotEmpty ? description : null, - phoneNumber: telephone.isNotEmpty ? telephone : null, - roadAddress: roadAddress.isNotEmpty ? roadAddress : address, - jibunAddress: address, - latitude: latitude, - longitude: longitude, - lastVisitDate: null, - source: DataSource.NAVER, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - naverPlaceId: null, - naverUrl: link.isNotEmpty ? link : null, - businessHours: null, - lastVisited: null, - visitCount: 0, - ); - } -} diff --git a/lib/presentation/pages/calendar/widgets/debug_test_data_banner.dart b/lib/presentation/pages/calendar/widgets/debug_test_data_banner.dart deleted file mode 100644 index 7371201..0000000 --- a/lib/presentation/pages/calendar/widgets/debug_test_data_banner.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lunchpick/core/constants/app_colors.dart'; -import 'package:lunchpick/core/constants/app_typography.dart'; -import 'package:lunchpick/presentation/providers/debug_test_data_provider.dart'; - -class DebugTestDataBanner extends ConsumerWidget { - final EdgeInsetsGeometry? margin; - - const DebugTestDataBanner({super.key, this.margin}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (!kDebugMode) { - return const SizedBox.shrink(); - } - - final isDark = Theme.of(context).brightness == Brightness.dark; - final state = ref.watch(debugTestDataNotifierProvider); - final notifier = ref.read(debugTestDataNotifierProvider.notifier); - - return Card( - margin: margin ?? const EdgeInsets.all(16), - color: isDark ? AppColors.darkSurface : AppColors.lightSurface, - elevation: 1, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.science_outlined, - color: AppColors.lightPrimary, - size: 20, - ), - const SizedBox(width: 8), - Text( - '테스트 데이터 미리보기', - style: AppTypography.body1( - isDark, - ).copyWith(fontWeight: FontWeight.w600), - ), - const Spacer(), - if (state.isProcessing) - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 8), - Switch.adaptive( - value: state.isEnabled, - onChanged: state.isProcessing - ? null - : (value) async { - if (value) { - await notifier.enableTestData(); - } else { - await notifier.disableTestData(); - } - }, - activeColor: AppColors.lightPrimary, - ), - ], - ), - const SizedBox(height: 8), - Text( - state.isEnabled - ? '디버그 빌드에서만 적용됩니다. 기록/통계 UI를 테스트용 데이터로 확인하세요.' - : '디버그 빌드에서만 사용 가능합니다. 스위치를 켜면 추천·방문 기록이 자동으로 채워집니다.', - style: AppTypography.caption(isDark), - ), - if (state.errorMessage != null) ...[ - const SizedBox(height: 6), - Text( - state.errorMessage!, - style: AppTypography.caption(isDark).copyWith( - color: AppColors.lightError, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ), - ), - ); - } -} diff --git a/lib/presentation/pages/restaurant_list/widgets/add_restaurant_dialog.dart.backup b/lib/presentation/pages/restaurant_list/widgets/add_restaurant_dialog.dart.backup deleted file mode 100644 index 0fdbbe2..0000000 --- a/lib/presentation/pages/restaurant_list/widgets/add_restaurant_dialog.dart.backup +++ /dev/null @@ -1,925 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lunchpick/core/constants/app_colors.dart'; -import 'package:lunchpick/core/constants/app_typography.dart'; -import 'package:lunchpick/core/utils/validators.dart'; -import 'package:lunchpick/domain/entities/restaurant.dart'; -import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; - -class AddRestaurantDialog extends ConsumerStatefulWidget { - final int initialTabIndex; - - const AddRestaurantDialog({ - super.key, - this.initialTabIndex = 0, - }); - - @override - ConsumerState createState() => _AddRestaurantDialogState(); -} - -class _AddRestaurantDialogState extends ConsumerState with SingleTickerProviderStateMixin { - final _formKey = GlobalKey(); - final _nameController = TextEditingController(); - final _categoryController = TextEditingController(); - final _subCategoryController = TextEditingController(); - final _descriptionController = TextEditingController(); - final _phoneController = TextEditingController(); - final _roadAddressController = TextEditingController(); - final _jibunAddressController = TextEditingController(); - final _latitudeController = TextEditingController(); - final _longitudeController = TextEditingController(); - final _naverUrlController = TextEditingController(); - - // 기본 좌표 (서울시청) - final double _defaultLatitude = 37.5665; - final double _defaultLongitude = 126.9780; - - // UI 상태 관리 - late TabController _tabController; - bool _isLoading = false; - String? _errorMessage; - Restaurant? _fetchedRestaurantData; - final _linkController = TextEditingController(); - - @override - void initState() { - super.initState(); - _tabController = TabController( - length: 2, - vsync: this, - initialIndex: widget.initialTabIndex, - ); - } - - @override - void dispose() { - _nameController.dispose(); - _categoryController.dispose(); - _subCategoryController.dispose(); - _descriptionController.dispose(); - _phoneController.dispose(); - _roadAddressController.dispose(); - _jibunAddressController.dispose(); - _latitudeController.dispose(); - _longitudeController.dispose(); - _naverUrlController.dispose(); - _linkController.dispose(); - _tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Dialog( - backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Container( - constraints: const BoxConstraints(maxWidth: 400), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 제목과 탭바 - Container( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), - child: Column( - children: [ - Text( - '맛집 추가', - style: AppTypography.heading1(isDark), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - color: isDark ? AppColors.darkBackground : AppColors.lightBackground, - borderRadius: BorderRadius.circular(12), - ), - child: TabBar( - controller: _tabController, - indicator: BoxDecoration( - color: AppColors.lightPrimary, - borderRadius: BorderRadius.circular(12), - ), - indicatorSize: TabBarIndicatorSize.tab, - labelColor: Colors.white, - unselectedLabelColor: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, - labelStyle: AppTypography.body1(false).copyWith(fontWeight: FontWeight.w600), - tabs: const [ - Tab(text: '직접 입력'), - Tab(text: '네이버 지도에서 가져오기'), - ], - ), - ), - ], - ), - ), - // 탭뷰 컨텐츠 - Flexible( - child: TabBarView( - controller: _tabController, - physics: const NeverScrollableScrollPhysics(), - children: [ - // 직접 입력 탭 - SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - - // 가게 이름 - TextFormField( - controller: _nameController, - decoration: InputDecoration( - labelText: '가게 이름 *', - hintText: '예: 서울갈비', - prefixIcon: const Icon(Icons.store), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return '가게 이름을 입력해주세요'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // 카테고리 - Row( - children: [ - Expanded( - child: TextFormField( - controller: _categoryController, - decoration: InputDecoration( - labelText: '카테고리 *', - hintText: '예: 한식', - prefixIcon: const Icon(Icons.category), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return '카테고리를 입력해주세요'; - } - return null; - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextFormField( - controller: _subCategoryController, - decoration: InputDecoration( - labelText: '세부 카테고리', - hintText: '예: 갈비', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // 설명 - TextFormField( - controller: _descriptionController, - maxLines: 2, - decoration: InputDecoration( - labelText: '설명', - hintText: '맛집에 대한 간단한 설명', - prefixIcon: const Icon(Icons.description), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - const SizedBox(height: 16), - - // 전화번호 - TextFormField( - controller: _phoneController, - keyboardType: TextInputType.phone, - decoration: InputDecoration( - labelText: '전화번호', - hintText: '예: 02-1234-5678', - prefixIcon: const Icon(Icons.phone), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - const SizedBox(height: 16), - - // 도로명 주소 - TextFormField( - controller: _roadAddressController, - decoration: InputDecoration( - labelText: '도로명 주소 *', - hintText: '예: 서울시 중구 세종대로 110', - prefixIcon: const Icon(Icons.location_on), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return '도로명 주소를 입력해주세요'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // 지번 주소 - TextFormField( - controller: _jibunAddressController, - decoration: InputDecoration( - labelText: '지번 주소', - hintText: '예: 서울시 중구 태평로1가 31', - prefixIcon: const Icon(Icons.map), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - const SizedBox(height: 16), - - // 위도/경도 입력 - Row( - children: [ - Expanded( - child: TextFormField( - controller: _latitudeController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: InputDecoration( - labelText: '위도', - hintText: '37.5665', - prefixIcon: const Icon(Icons.explore), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: Validators.validateLatitude, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextFormField( - controller: _longitudeController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: InputDecoration( - labelText: '경도', - hintText: '126.9780', - prefixIcon: const Icon(Icons.explore), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: Validators.validateLongitude, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - '* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다', - style: TextStyle( - fontSize: 12, - color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, - ), - ), - const SizedBox(height: 24), - - // 버튼 - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - '취소', - style: TextStyle( - color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, - ), - ), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _saveRestaurant, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.lightPrimary, - foregroundColor: Colors.white, - ), - child: const Text('저장'), - ), - ], - ), - ], - ), - ), - ), - // 네이버 지도 탭 - _buildNaverMapTab(isDark), - ], - ), - ), - ], - ), - ), - ); - } - - // 네이버 지도 탭 빌드 - Widget _buildNaverMapTab(bool isDark) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 안내 메시지 - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.lightPrimary.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.lightPrimary.withOpacity(0.3), - ), - ), - child: Row( - children: [ - Icon( - Icons.info_outline, - color: AppColors.lightPrimary, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - kIsWeb - ? '네이버 지도에서 맛집 페이지 URL을 복사하여\n붙여넣어 주세요.\n\n웹 환경에서는 프록시 서버를 통해 정보를 가져옵니다.\n네트워크 상황에 따라 시간이 걸릴 수 있습니다.' - : '네이버 지도에서 맛집 페이지 URL을 복사하여\n붙여넣어 주세요.', - style: TextStyle( - fontSize: 14, - color: isDark ? AppColors.darkText : AppColors.lightText, - height: 1.5, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // URL 입력 필드 - TextFormField( - controller: _naverUrlController, - decoration: InputDecoration( - labelText: '네이버 지도 URL', - hintText: 'https://map.naver.com/... 또는 https://naver.me/...', - prefixIcon: Icon( - Icons.link, - color: AppColors.lightPrimary, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: isDark ? AppColors.darkDivider : AppColors.lightDivider, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: AppColors.lightPrimary, - width: 2, - ), - ), - errorText: _errorMessage, - errorMaxLines: 2, - ), - enabled: !_isLoading, - ), - const SizedBox(height: 24), - - // 가져온 정보 표시 (JSON 스타일) - if (_fetchedRestaurantData != null) ...[ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: isDark - ? AppColors.darkBackground - : AppColors.lightBackground.withOpacity(0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark - ? AppColors.darkDivider - : AppColors.lightDivider, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 타이틀 - Row( - children: [ - Icon( - Icons.code, - size: 20, - color: isDark - ? AppColors.darkTextSecondary - : AppColors.lightTextSecondary, - ), - const SizedBox(width: 8), - Text( - '가져온 정보', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: isDark - ? AppColors.darkTextSecondary - : AppColors.lightTextSecondary, - ), - ), - ], - ), - const SizedBox(height: 12), - // JSON 스타일 정보 표시 - _buildJsonField( - '이름', - _nameController, - isDark, - icon: Icons.store, - ), - _buildJsonField( - '카테고리', - _categoryController, - isDark, - icon: Icons.category, - ), - _buildJsonField( - '세부 카테고리', - _subCategoryController, - isDark, - icon: Icons.label_outline, - ), - _buildJsonField( - '주소', - _roadAddressController, - isDark, - icon: Icons.location_on, - ), - _buildJsonField( - '전화', - _phoneController, - isDark, - icon: Icons.phone, - ), - _buildJsonField( - '설명', - _descriptionController, - isDark, - icon: Icons.description, - maxLines: 2, - ), - _buildJsonField( - '좌표', - TextEditingController( - text: '${_latitudeController.text}, ${_longitudeController.text}' - ), - isDark, - icon: Icons.my_location, - isCoordinate: true, - ), - if (_linkController.text.isNotEmpty) - _buildJsonField( - '링크', - _linkController, - isDark, - icon: Icons.link, - isLink: true, - ), - ], - ), - ), - const SizedBox(height: 24), - ], - - // 버튼 - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: _isLoading ? null : () => Navigator.pop(context), - child: Text( - '취소', - style: TextStyle( - color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, - ), - ), - ), - const SizedBox(width: 8), - if (_fetchedRestaurantData == null) - ElevatedButton( - onPressed: _isLoading ? null : _fetchFromNaverUrl, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.lightPrimary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.download, size: 18), - SizedBox(width: 8), - Text('가져오기'), - ], - ), - ) - else ...[ - OutlinedButton( - onPressed: () { - setState(() { - _fetchedRestaurantData = null; - _clearControllers(); - }); - }, - style: OutlinedButton.styleFrom( - foregroundColor: isDark - ? AppColors.darkTextSecondary - : AppColors.lightTextSecondary, - side: BorderSide( - color: isDark - ? AppColors.darkDivider - : AppColors.lightDivider, - ), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - child: const Text('초기화'), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _saveRestaurant, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.lightPrimary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.save, size: 18), - SizedBox(width: 8), - Text('저장'), - ], - ), - ), - ], - ], - ), - ], - ), - ); - } - - // JSON 스타일 필드 빌드 - Widget _buildJsonField( - String label, - TextEditingController controller, - bool isDark, { - IconData? icon, - int maxLines = 1, - bool isCoordinate = false, - bool isLink = false, - }) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (icon != null) ...[ - Icon( - icon, - size: 16, - color: isDark - ? AppColors.darkTextSecondary - : AppColors.lightTextSecondary, - ), - const SizedBox(width: 8), - ], - Text( - '$label:', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: isDark - ? AppColors.darkTextSecondary - : AppColors.lightTextSecondary, - ), - ), - ], - ), - const SizedBox(height: 4), - if (isCoordinate) - Row( - children: [ - Expanded( - child: TextFormField( - controller: _latitudeController, - style: TextStyle( - fontSize: 14, - fontFamily: 'monospace', - color: isDark ? AppColors.darkText : AppColors.lightText, - ), - decoration: InputDecoration( - hintText: '위도', - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - filled: true, - fillColor: isDark - ? AppColors.darkSurface - : Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: isDark - ? AppColors.darkDivider - : AppColors.lightDivider, - ), - ), - ), - ), - ), - const SizedBox(width: 8), - Text( - ',', - style: TextStyle( - fontSize: 14, - fontFamily: 'monospace', - color: isDark ? AppColors.darkText : AppColors.lightText, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextFormField( - controller: _longitudeController, - style: TextStyle( - fontSize: 14, - fontFamily: 'monospace', - color: isDark ? AppColors.darkText : AppColors.lightText, - ), - decoration: InputDecoration( - hintText: '경도', - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - filled: true, - fillColor: isDark - ? AppColors.darkSurface - : Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: isDark - ? AppColors.darkDivider - : AppColors.lightDivider, - ), - ), - ), - ), - ), - ], - ) - else - TextFormField( - controller: controller, - maxLines: maxLines, - style: TextStyle( - fontSize: 14, - fontFamily: isLink ? 'monospace' : null, - color: isLink - ? AppColors.lightPrimary - : isDark ? AppColors.darkText : AppColors.lightText, - decoration: isLink ? TextDecoration.underline : null, - ), - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - filled: true, - fillColor: isDark - ? AppColors.darkSurface - : Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: isDark - ? AppColors.darkDivider - : AppColors.lightDivider, - ), - ), - ), - ), - ], - ), - ); - } - - // 컨트롤러 초기화 - void _clearControllers() { - _nameController.clear(); - _categoryController.clear(); - _subCategoryController.clear(); - _descriptionController.clear(); - _phoneController.clear(); - _roadAddressController.clear(); - _jibunAddressController.clear(); - _latitudeController.clear(); - _longitudeController.clear(); - _linkController.clear(); - } - - // 네이버 URL에서 정보 가져오기 - Future _fetchFromNaverUrl() async { - final url = _naverUrlController.text.trim(); - - if (url.isEmpty) { - setState(() { - _errorMessage = 'URL을 입력해주세요.'; - }); - return; - } - - setState(() { - _isLoading = true; - _errorMessage = null; - }); - - try { - final notifier = ref.read(restaurantNotifierProvider.notifier); - final restaurant = await notifier.addRestaurantFromUrl(url); - - // 성공 시 폼에 정보 채우고 _fetchedRestaurantData 설정 - setState(() { - _nameController.text = restaurant.name; - _categoryController.text = restaurant.category; - _subCategoryController.text = restaurant.subCategory; - _descriptionController.text = restaurant.description ?? ''; - _phoneController.text = restaurant.phoneNumber ?? ''; - _roadAddressController.text = restaurant.roadAddress; - _jibunAddressController.text = restaurant.jibunAddress; - _latitudeController.text = restaurant.latitude.toString(); - _longitudeController.text = restaurant.longitude.toString(); - - // 링크 정보가 있다면 설정 - _linkController.text = restaurant.naverUrl ?? ''; - - // Restaurant 객체 저장 - _fetchedRestaurantData = restaurant; - - _isLoading = false; - }); - - // 성공 메시지 표시 - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - Icon(Icons.check_circle, color: Colors.white, size: 20), - const SizedBox(width: 8), - Text('맛집 정보를 가져왔습니다. 확인 후 저장해주세요.'), - ], - ), - backgroundColor: AppColors.lightPrimary, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ); - } - } catch (e) { - setState(() { - _isLoading = false; - _errorMessage = e.toString().replaceFirst('Exception: ', ''); - }); - } - } - - Future _saveRestaurant() async { - if (_formKey.currentState?.validate() != true) { - return; - } - - final notifier = ref.read(restaurantNotifierProvider.notifier); - - try { - // _fetchedRestaurantData가 있으면 해당 데이터 사용 (네이버에서 가져온 경우) - final fetchedData = _fetchedRestaurantData; - if (fetchedData != null) { - // 사용자가 수정한 필드만 업데이트 - final updatedRestaurant = fetchedData.copyWith( - name: _nameController.text.trim(), - category: _categoryController.text.trim(), - subCategory: _subCategoryController.text.trim().isEmpty - ? _categoryController.text.trim() - : _subCategoryController.text.trim(), - description: _descriptionController.text.trim().isEmpty - ? null - : _descriptionController.text.trim(), - phoneNumber: _phoneController.text.trim().isEmpty - ? null - : _phoneController.text.trim(), - roadAddress: _roadAddressController.text.trim(), - jibunAddress: _jibunAddressController.text.trim().isEmpty - ? _roadAddressController.text.trim() - : _jibunAddressController.text.trim(), - latitude: double.tryParse(_latitudeController.text.trim()) ?? fetchedData.latitude, - longitude: double.tryParse(_longitudeController.text.trim()) ?? fetchedData.longitude, - updatedAt: DateTime.now(), - ); - - // 이미 완성된 Restaurant 객체를 직접 추가 - await notifier.addRestaurantDirect(updatedRestaurant); - } else { - // 직접 입력한 경우 (기존 로직) - await notifier.addRestaurant( - name: _nameController.text.trim(), - category: _categoryController.text.trim(), - subCategory: _subCategoryController.text.trim().isEmpty - ? _categoryController.text.trim() - : _subCategoryController.text.trim(), - description: _descriptionController.text.trim().isEmpty - ? null - : _descriptionController.text.trim(), - phoneNumber: _phoneController.text.trim().isEmpty - ? null - : _phoneController.text.trim(), - roadAddress: _roadAddressController.text.trim(), - jibunAddress: _jibunAddressController.text.trim().isEmpty - ? _roadAddressController.text.trim() - : _jibunAddressController.text.trim(), - latitude: _latitudeController.text.trim().isEmpty - ? _defaultLatitude - : double.tryParse(_latitudeController.text.trim()) ?? _defaultLatitude, - longitude: _longitudeController.text.trim().isEmpty - ? _defaultLongitude - : double.tryParse(_longitudeController.text.trim()) ?? _defaultLongitude, - source: DataSource.USER_INPUT, - ); - } - - if (mounted) { - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('맛집이 추가되었습니다'), - backgroundColor: AppColors.lightPrimary, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('오류가 발생했습니다: ${e.toString()}'), - backgroundColor: AppColors.lightError, - ), - ); - } - } - } -} \ No newline at end of file