diff --git a/AGENTS.md b/AGENTS.md index 3d3b81d..8472c47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Never commit API secrets. Instead, create `lib/core/constants/api_keys.dart` loc - Keep changes scoped to this workspace and prefer the smallest safe diffs. Avoid destructive rewrites, config changes, or dependency updates unless someone explicitly asks for them. - When a task requires multiple steps, maintain an `update_plan` with exactly one step marked `in_progress`. - Responses stay concise and list code/logs before rationale. If unsure, prefix with `Uncertain:` and surface at most the top two viable options. +- Workspace 한정으로 작업하고, 의존성 추가나 새 네트워크 호출이 필요하면 먼저 확인을 받습니다. 명시 없는 파일 삭제·재작성·설정 변경은 피합니다. ## Collaboration & Language - 기본 응답은 한국어로 작성하고, 코드/로그/명령어는 원문을 유지합니다. @@ -45,6 +46,11 @@ Never commit API secrets. Instead, create `lib/core/constants/api_keys.dart` loc - Create task branches as `codex/-` (e.g., `codex/fix-search-null`). - Continue using `type(scope): summary` commit messages, but keep explanations short and focused on observable behavior changes. - When presenting alternatives, only show the top two concise options to speed up decision-making. +- When ending a work report, always propose concrete next tasks; if there are no follow-up items, explicitly state that all work is complete. + +## Notification & Wrap-up +- 최종 보고나 대화 종료 직전에 `/Users/maximilian.j.sul/.codex/notify.py`를 실행해 완료 알림을 보냅니다. +- 필요한 진행 문서를 업데이트한 뒤 결과를 보고합니다. ## SRP & Layering Checklist - Every file/class should have a single reason to change; split widgets over ~400 lines and methods over ~60 lines into helpers. diff --git a/doc/08_pending_tasks.md b/doc/08_pending_tasks.md index bec1ceb..a2072d3 100644 --- a/doc/08_pending_tasks.md +++ b/doc/08_pending_tasks.md @@ -2,10 +2,10 @@ - [x] API 키를 환경 변수/난독화 전략으로 분리하고 Git에서 추적되지 않게 재구성하기 (doc/03_architecture/tech_stack_decision.md:247-256, lib/core/constants/api_keys.dart:1-20). `ApiKeys`가 `--dart-define`으로 주입된(base64 인코딩) 값을 복호화하도록 수정하고 관련 문서를 업데이트했습니다. - [x] AddRestaurantDialog(JSON 뷰)와 네이버 URL/검색/직접 입력 흐름 구현 및 자동 저장 제거 (doc/02_design/add_restaurant_dialog_design.md:1-137, doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1491-1605, lib/presentation/pages/restaurant_list/widgets/add_restaurant_dialog.dart:108-177, lib/presentation/view_models/add_restaurant_view_model.dart:171-224). FAB → 바텀시트(링크/검색/직접 입력) 흐름을 추가하고, 링크·검색 다이얼로그에는 JSON 필드 에디터를 구성하여 데이터 확인 후 저장하도록 변경했으며 `ManualRestaurantInputScreen`에서 직접 입력을 처리합니다. -- [ ] 광고보고 추천받기 플로우에서 광고 게이팅, 조건 필터링, 추천 팝업, 방문 처리, 재추천, 알림 연동까지 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:820-920, lib/presentation/pages/random_selection/random_selection_screen.dart:1-400, lib/presentation/providers/recommendation_provider.dart:1-220, lib/core/services/notification_service.dart:1-260). 현재 광고 버튼을 눌러도 조건에 맞는 식당이 제시되지 않으며, 광고·추천 다이얼로그·재추천 버튼·알림 예약 로직이 모두 누락되어 있다. 임시 광고 화면(닫기 이벤트 포함)을 띄운 뒤 n일/거리 조건을 만족하는 식당을 찾으면 팝업으로 노출하고, 다시 추천받기/닫기 버튼을 제공하며, 닫거나 추가 행동 없이 유지되면 방문 처리 및 옵션으로 지정한 시간 이후 푸시 알림을 예약해야 한다. 조건에 맞는 식당이 없으면 광고 없이 “조건에 맞는 식당이 존재하지 않습니다” 토스트만 출력한다. -- [ ] P2P 리스트 공유 기능(광고 시청(임시화면만 제공) → 코드 생성 → Bluetooth 스캔/수신 → JSON 병합)을 서비스 계층(bluetoothServiceProvider, adServiceProvider, PermissionService 등)과 함께 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1874-1978, lib/presentation/pages/share/share_screen.dart:13-218). 현재 화면은 단순 토글과 더미 코드만 제공하며 요구된 광고 게이팅·기기 리스트·데이터 병합 로직이 없습니다. -- [ ] 실시간 날씨 API(기상청) 연동 및 캐시 무효화 로직 구현하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:306-329, lib/data/repositories/weather_repository_impl.dart:16-92). 지금은 더미 WeatherInfo를 반환하고 있어 추천 화면의 날씨 카드가 실제 데이터를 사용하지 못합니다. -- [ ] 방문 캘린더에서 추천 이력(`recommendationHistoryProvider`)과 방문 기록을 함께 로딩하고 카드 액션(방문 확인)까지 연결하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:2055-2127, lib/presentation/pages/calendar/calendar_screen.dart:18-210). 구현본은 `visitRecordsProvider`만 사용하며 추천 기록, 방문 확인 버튼, 추천 이벤트 마커가 모두 빠져 있습니다. -- [ ] 문서에서 요구한 NaverUrlProcessor/NaverLocalApiClient 파이프라인을 별도 데이터 소스로 구축하고 캐싱·병렬 처리·에러 복구를 담당하도록 리팩터링하기 (doc/03_architecture/naver_url_processing_architecture.md:29-90,392-400, lib/data/datasources/remote/naver_map_parser.dart:32-640, lib/data/datasources/remote/naver_search_service.dart:19-210). 현재 구조에는 `naver_url_processor.dart`가 없고, 파싱·API 호출이 Parser와 SearchService에 산재해 있어 요구된 책임 분리가 이뤄지지 않습니다. -- [ ] `print` 기반 디버그 출력을 공통 Logger로 치환하고 lints가 지적한 100+개 로그를 정리하기 (doc/06_testing/2025-07-30_update_summary.md:42-45, lib/core/services/notification_service.dart:209-214, lib/data/repositories/weather_repository_impl.dart:59-133, lib/presentation/providers/notification_handler_provider.dart:55-154 등). 현재 analyze 단계에서 warning을 유발하고 프로덕션 빌드에 불필요한 로그가 남습니다. -- [ ] RestaurantRepositoryImpl 단위 테스트를 복구하고 `path_provider` 초기화 문제를 해결하기 (doc/07_test_report_lunchpick.md:52-57, test/unit/data/repositories/restaurant_repository_impl_test.dart). 관련 테스트 파일이 삭제된 상태라 7건 실패를 수정하지 못했고, Hive 경로 세팅도 검증되지 않습니다. +- [x] 광고보고 추천받기 플로우에서 광고 게이팅, 조건 필터링, 추천 팝업, 방문 처리, 재추천, 알림 연동까지 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:820-920, lib/presentation/pages/random_selection/random_selection_screen.dart:1-400, lib/presentation/providers/recommendation_provider.dart:1-220, lib/core/services/notification_service.dart:1-260). 임시 광고(닫기 포함) 재생 후 조건 충족 시 추천 팝업을 띄우고, 닫기/자동확정 시 방문 기록·알림 예약을 처리하며 다시 추천은 광고 없이 제외 목록을 적용한다. 조건 불충족 시 광고 없이 토스트를 노출한다. +- [x] P2P 리스트 공유 기능(광고 시청(임시화면만 제공) → 코드 생성 → Bluetooth 스캔/수신 → JSON 병합)을 서비스 계층(bluetoothServiceProvider, adServiceProvider, PermissionService 등)과 함께 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1874-1978, lib/presentation/pages/share/share_screen.dart:13-218). 공유 코드 생성 시 블루투스 권한 확인과 광고 게이팅을 적용하고, 수신 데이터는 광고 시청 후 중복 제거 병합하며, 스캔/전송/취소 흐름을 UI에 연결했습니다. +- [x] 실시간 날씨 API(기상청) 연동 및 캐시 무효화 로직 구현하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:306-329, lib/data/repositories/weather_repository_impl.dart:16-92). 위경도→기상청 좌표 변환 후 초단기 실황/예보 API를 호출해 현재·1시간 후 데이터를 구성하고, 실패 시 캐시/기본값으로 폴백합니다. 캐시는 1시간 유효하며 `KMA_SERVICE_KEY`(base64 인코딩)를 `--dart-define`으로 주입해야 동작합니다. +- [x] 방문 캘린더에서 추천 이력(`recommendationHistoryProvider`)과 방문 기록을 함께 로딩하고 카드 액션(방문 확인)까지 연결하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:2055-2127, lib/presentation/pages/calendar/calendar_screen.dart:18-210). 추천 기록을 함께 로딩해 마커/목록에 표시하고, 추천 카드에서 방문 확인 시 `RecommendationNotifier.confirmVisit`를 호출하도록 연계했습니다. +- [x] 문서에서 요구한 NaverUrlProcessor/NaverLocalApiClient 파이프라인을 별도 데이터 소스로 구축하고 캐싱·병렬 처리·에러 복구를 담당하도록 리팩터링하기 (doc/03_architecture/naver_url_processing_architecture.md:29-90,392-400, lib/data/datasources/remote/naver_map_parser.dart:32-640, lib/data/datasources/remote/naver_search_service.dart:19-210). URL 처리 전용 `NaverUrlProcessor`를 추가하고 DI에 등록해 단축 URL 해석→지도 파싱→캐싱 흐름을 분리했습니다. `NaverSearchService`는 프로세서를 통해 URL을 처리하여 중복 호출을 줄입니다. +- [x] `print` 기반 디버그 출력을 공통 Logger로 치환하고 lints가 지적한 100+개 로그를 정리하기 (doc/06_testing/2025-07-30_update_summary.md:42-45, lib/core/services/notification_service.dart:209-214, lib/data/repositories/weather_repository_impl.dart:59-133, lib/presentation/providers/notification_handler_provider.dart:55-154 등). 현재 analyze 단계에서 warning을 유발하고 프로덕션 빌드에 불필요한 로그가 남습니다. +- [x] RestaurantRepositoryImpl 단위 테스트를 복구하고 `path_provider` 초기화 문제를 해결하기 (doc/07_test_report_lunchpick.md:52-57, test/unit/data/repositories/restaurant_repository_impl_test.dart). Hive 임시 디렉터리 초기화와 어댑터 등록 후 CRUD/URL 추가/미리보기 흐름을 검증하는 단위 테스트를 복구했습니다. diff --git a/doc/public_data_api/기상청41_단기예보 조회서비스_오픈API활용가이드_241128.docx b/doc/public_data_api/기상청41_단기예보 조회서비스_오픈API활용가이드_241128.docx new file mode 100644 index 0000000..09154ab Binary files /dev/null and b/doc/public_data_api/기상청41_단기예보 조회서비스_오픈API활용가이드_241128.docx differ diff --git a/doc/public_data_api/기상청41_단기예보 조회서비스_오픈API활용가이드_격자_위경도(2510).xlsx b/doc/public_data_api/기상청41_단기예보 조회서비스_오픈API활용가이드_격자_위경도(2510).xlsx new file mode 100644 index 0000000..8451010 Binary files /dev/null and b/doc/public_data_api/기상청41_단기예보 조회서비스_오픈API활용가이드_격자_위경도(2510).xlsx differ diff --git a/lib/core/constants/api_keys.dart b/lib/core/constants/api_keys.dart index 9bad70d..2862e94 100644 --- a/lib/core/constants/api_keys.dart +++ b/lib/core/constants/api_keys.dart @@ -18,6 +18,14 @@ class ApiKeys { static String get naverClientId => _decodeIfNeeded(_encodedClientId); static String get naverClientSecret => _decodeIfNeeded(_encodedClientSecret); + static const String _encodedWeatherServiceKey = String.fromEnvironment( + 'KMA_SERVICE_KEY', + defaultValue: '', + ); + + static String get weatherServiceKey => + _decodeIfNeeded(_encodedWeatherServiceKey); + static const String naverLocalSearchEndpoint = 'https://openapi.naver.com/v1/search/local.json'; diff --git a/lib/core/network/interceptors/logging_interceptor.dart b/lib/core/network/interceptors/logging_interceptor.dart index 76f0b9a..1c0f306 100644 --- a/lib/core/network/interceptors/logging_interceptor.dart +++ b/lib/core/network/interceptors/logging_interceptor.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import '../../utils/app_logger.dart'; /// 로깅 인터셉터 /// @@ -13,16 +14,18 @@ class LoggingInterceptor extends Interceptor { final method = options.method; final headers = options.headers; - print('═══════════════════════════════════════════════════════════════'); - print('>>> REQUEST [$method] $uri'); - print('>>> Headers: $headers'); + AppLogger.debug( + '═══════════════════════════════════════════════════════════════', + ); + AppLogger.debug('>>> REQUEST [$method] $uri'); + AppLogger.debug('>>> Headers: $headers'); if (options.data != null) { - print('>>> Body: ${options.data}'); + AppLogger.debug('>>> Body: ${options.data}'); } if (options.queryParameters.isNotEmpty) { - print('>>> Query Parameters: ${options.queryParameters}'); + AppLogger.debug('>>> Query Parameters: ${options.queryParameters}'); } } @@ -35,21 +38,25 @@ class LoggingInterceptor extends Interceptor { final statusCode = response.statusCode; final uri = response.requestOptions.uri; - print('<<< RESPONSE [$statusCode] $uri'); + AppLogger.debug('<<< RESPONSE [$statusCode] $uri'); if (response.headers.map.isNotEmpty) { - print('<<< Headers: ${response.headers.map}'); + AppLogger.debug('<<< Headers: ${response.headers.map}'); } // 응답 본문은 너무 길 수 있으므로 처음 500자만 출력 final responseData = response.data.toString(); if (responseData.length > 500) { - print('<<< Body: ${responseData.substring(0, 500)}...(truncated)'); + AppLogger.debug( + '<<< Body: ${responseData.substring(0, 500)}...(truncated)', + ); } else { - print('<<< Body: $responseData'); + AppLogger.debug('<<< Body: $responseData'); } - print('═══════════════════════════════════════════════════════════════'); + AppLogger.debug( + '═══════════════════════════════════════════════════════════════', + ); } return handler.next(response); @@ -61,17 +68,21 @@ class LoggingInterceptor extends Interceptor { final uri = err.requestOptions.uri; final message = err.message; - print('═══════════════════════════════════════════════════════════════'); - print('!!! ERROR $uri'); - print('!!! Message: $message'); + AppLogger.debug( + '═══════════════════════════════════════════════════════════════', + ); + AppLogger.debug('!!! ERROR $uri'); + AppLogger.debug('!!! Message: $message'); if (err.response != null) { - print('!!! Status Code: ${err.response!.statusCode}'); - print('!!! Response: ${err.response!.data}'); + AppLogger.debug('!!! Status Code: ${err.response!.statusCode}'); + AppLogger.debug('!!! Response: ${err.response!.data}'); } - print('!!! Error Type: ${err.type}'); - print('═══════════════════════════════════════════════════════════════'); + AppLogger.debug('!!! Error Type: ${err.type}'); + AppLogger.debug( + '═══════════════════════════════════════════════════════════════', + ); } return handler.next(err); diff --git a/lib/core/network/interceptors/retry_interceptor.dart b/lib/core/network/interceptors/retry_interceptor.dart index eeb4a4f..fd5e35d 100644 --- a/lib/core/network/interceptors/retry_interceptor.dart +++ b/lib/core/network/interceptors/retry_interceptor.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:dio/dio.dart'; import '../network_config.dart'; +import '../../utils/app_logger.dart'; import '../../errors/network_exceptions.dart'; /// 재시도 인터셉터 @@ -24,7 +25,7 @@ class RetryInterceptor extends Interceptor { // 지수 백오프 계산 final delay = _calculateBackoffDelay(retryCount); - print( + AppLogger.debug( 'RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기', ); @@ -59,7 +60,7 @@ class RetryInterceptor extends Interceptor { // 네이버 관련 요청은 재시도하지 않음 final url = err.requestOptions.uri.toString(); if (url.contains('naver.com') || url.contains('naver.me')) { - print('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url'); + AppLogger.debug('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url'); return false; } diff --git a/lib/core/network/network_client.dart b/lib/core/network/network_client.dart index c729b35..ae5ffc8 100644 --- a/lib/core/network/network_client.dart +++ b/lib/core/network/network_client.dart @@ -1,9 +1,11 @@ +import 'dart:io'; + import 'package:dio/dio.dart'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import 'package:flutter/foundation.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:path_provider/path_provider.dart'; -import 'dart:io'; import 'network_config.dart'; import '../errors/network_exceptions.dart'; @@ -88,8 +90,12 @@ class NetworkClient { ); _dio.interceptors.add(DioCacheInterceptor(options: cacheOptions)); - } catch (e) { - debugPrint('NetworkClient: 캐시 설정 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NetworkClient: 캐시 설정 실패 - $e', + error: e, + stackTrace: stackTrace, + ); // 캐시 실패해도 계속 진행 } } diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index 08282df..8b411da 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz; +import '../utils/app_logger.dart'; /// 알림 서비스 싱글톤 클래스 class NotificationService { @@ -14,6 +15,7 @@ class NotificationService { // Flutter Local Notifications 플러그인 final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); + bool _initialized = false; // 알림 채널 정보 static const String _channelId = 'lunchpick_visit_reminder'; @@ -22,11 +24,36 @@ class NotificationService { // 알림 ID (방문 확인용) static const int _visitReminderNotificationId = 1; + bool _timezoneReady = false; + tz.Location? _cachedLocation; + + /// 초기화 여부 + bool get isInitialized => _initialized; + + /// 초기화 및 권한 요청 보장 + Future ensureInitialized({bool requestPermission = false}) async { + if (!_initialized) { + _initialized = await initialize(); + } + + if (!_initialized) return false; + + if (requestPermission) { + final alreadyGranted = await checkPermission(); + if (alreadyGranted) return true; + return await this.requestPermission(); + } + + return true; + } /// 알림 서비스 초기화 Future initialize() async { + if (_initialized) return true; + if (kIsWeb) return false; + // 시간대 초기화 - tz.initializeTimeZones(); + await _ensureLocalTimezone(); // Android 초기화 설정 const androidInitSettings = AndroidInitializationSettings( @@ -58,18 +85,25 @@ class NotificationService { ); // 알림 플러그인 초기화 - final initialized = await _notifications.initialize( - initSettings, - onDidReceiveNotificationResponse: _onNotificationTap, - onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap, - ); + try { + final initialized = await _notifications.initialize( + initSettings, + onDidReceiveNotificationResponse: _onNotificationTap, + onDidReceiveBackgroundNotificationResponse: + _onBackgroundNotificationTap, + ); + _initialized = initialized ?? false; + } catch (e) { + _initialized = false; + AppLogger.debug('알림 초기화 실패: $e'); + } // Android 알림 채널 생성 (웹이 아닌 경우에만) - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + if (_initialized && defaultTargetPlatform == TargetPlatform.android) { await _createNotificationChannel(); } - return initialized ?? false; + return _initialized; } /// Android 알림 채널 생성 @@ -92,25 +126,21 @@ class NotificationService { /// 알림 권한 요청 Future requestPermission() async { - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + if (kIsWeb) return false; + + if (defaultTargetPlatform == TargetPlatform.android) { final androidImplementation = _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); if (androidImplementation != null) { - // Android 13 (API 33) 이상에서는 권한 요청이 필요 - if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) { - final granted = await androidImplementation - .requestNotificationsPermission(); - return granted ?? false; - } - // Android 12 이하는 자동 허용 - return true; + final granted = await androidImplementation + .requestNotificationsPermission(); + return granted ?? false; } - } else if (!kIsWeb && - (defaultTargetPlatform == TargetPlatform.iOS || - defaultTargetPlatform == TargetPlatform.macOS)) { + } else if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { final iosImplementation = _notifications .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin @@ -144,24 +174,43 @@ class NotificationService { /// 권한 상태 확인 Future checkPermission() async { - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + if (kIsWeb) return false; + + if (defaultTargetPlatform == TargetPlatform.android) { final androidImplementation = _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); if (androidImplementation != null) { - // Android 13 이상에서만 권한 확인 - if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) { - final granted = await androidImplementation.areNotificationsEnabled(); - return granted ?? false; - } - // Android 12 이하는 자동 허용 - return true; + final granted = await androidImplementation.areNotificationsEnabled(); + return granted ?? false; } } - // iOS/macOS는 설정에서 확인 + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { + final iosImplementation = _notifications + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(); + final macosImplementation = _notifications + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >(); + + if (iosImplementation != null) { + final settings = await iosImplementation.checkPermissions(); + return settings?.isEnabled ?? false; + } + + if (macosImplementation != null) { + final settings = await macosImplementation.checkPermissions(); + return settings?.isEnabled ?? false; + } + } + + // iOS/macOS 외 플랫폼은 기본적으로 허용으로 간주 return true; } @@ -176,9 +225,22 @@ class NotificationService { int? delayMinutes, }) async { try { + final ready = await ensureInitialized(); + if (!ready) { + AppLogger.debug('알림 서비스가 초기화되지 않아 예약을 건너뜁니다.'); + return; + } + + final permissionGranted = await checkPermission(); + if (!permissionGranted) { + AppLogger.debug('알림 권한이 없어 예약을 건너뜁니다.'); + return; + } + + final location = await _ensureLocalTimezone(); final minutesToWait = delayMinutes ?? 90 + Random().nextInt(31); final scheduledTime = tz.TZDateTime.now( - tz.local, + location, ).add(Duration(minutes: minutesToWait)); // 알림 상세 설정 @@ -215,45 +277,95 @@ class NotificationService { '$restaurantName 어땠어요? 방문 기록을 남겨주세요!', scheduledTime, notificationDetails, - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + androidScheduleMode: await _resolveAndroidScheduleMode(), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, payload: 'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}', ); - if (kDebugMode) { - print('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)'); - } + AppLogger.debug('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)'); } catch (e) { - if (kDebugMode) { - print('알림 예약 실패: $e'); - } + AppLogger.debug('알림 예약 실패: $e'); } } /// 예약된 방문 확인 알림 취소 Future cancelVisitReminder() async { + if (!await ensureInitialized()) return; await _notifications.cancel(_visitReminderNotificationId); } /// 모든 알림 취소 Future cancelAllNotifications() async { + if (!await ensureInitialized()) return; await _notifications.cancelAll(); } /// 예약된 알림 목록 조회 Future> getPendingNotifications() async { + if (!await ensureInitialized()) return []; return await _notifications.pendingNotificationRequests(); } + /// 방문 확인 알림이 예약되어 있는지 확인 + Future hasVisitReminderScheduled() async { + if (!await ensureInitialized()) return false; + final pending = await getPendingNotifications(); + return pending.any((item) => item.id == _visitReminderNotificationId); + } + + /// 타임존을 안전하게 초기화하고 tz.local을 반환 + Future _ensureLocalTimezone() async { + if (_cachedLocation != null) return _cachedLocation!; + if (!_timezoneReady) { + try { + tz.initializeTimeZones(); + } catch (_) { + // 초기화 실패 시에도 계속 진행 + } + _timezoneReady = true; + } + + try { + tz.setLocalLocation(tz.getLocation('Asia/Seoul')); + _cachedLocation = tz.local; + } catch (_) { + // 로컬 타임존을 가져오지 못하면 UTC로 강제 설정 + tz.setLocalLocation(tz.UTC); + _cachedLocation = tz.UTC; + } + return _cachedLocation!; + } + + /// 정확 알람 권한 여부에 따라 스케줄 모드 결정 + Future _resolveAndroidScheduleMode() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + return AndroidScheduleMode.exactAllowWhileIdle; + } + + try { + final androidImplementation = _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + final canExact = await androidImplementation + ?.canScheduleExactNotifications(); + if (canExact == false) { + return AndroidScheduleMode.inexactAllowWhileIdle; + } + } catch (_) { + return AndroidScheduleMode.inexactAllowWhileIdle; + } + + return AndroidScheduleMode.exactAllowWhileIdle; + } + /// 알림 탭 이벤트 처리 void _onNotificationTap(NotificationResponse response) { if (onNotificationTap != null) { onNotificationTap!(response); } else if (response.payload != null) { - if (kDebugMode) { - print('알림 탭: ${response.payload}'); - } + AppLogger.debug('알림 탭: ${response.payload}'); } } @@ -263,9 +375,7 @@ class NotificationService { if (onNotificationTap != null) { onNotificationTap!(response); } else if (response.payload != null) { - if (kDebugMode) { - print('백그라운드 알림 탭: ${response.payload}'); - } + AppLogger.debug('백그라운드 알림 탭: ${response.payload}'); } } diff --git a/lib/core/utils/app_logger.dart b/lib/core/utils/app_logger.dart new file mode 100644 index 0000000..ce929e9 --- /dev/null +++ b/lib/core/utils/app_logger.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; + +/// 앱 전역에서 사용하는 로거. +/// debugPrint를 감싸 경고 없이 로그를 남기며, debug 레벨은 디버그 모드에서만 출력합니다. +class AppLogger { + AppLogger._(); + + static void debug(String message) { + if (kDebugMode) { + debugPrint(message); + } + } + + static void info(String message) { + debugPrint(message); + } + + static void error(String message, {Object? error, StackTrace? stackTrace}) { + final buffer = StringBuffer(message); + if (error != null) { + buffer.write(' | error: $error'); + } + if (stackTrace != null) { + buffer.write('\n$stackTrace'); + } + debugPrint(buffer.toString()); + } +} diff --git a/lib/data/api/naver/naver_graphql_api.dart b/lib/data/api/naver/naver_graphql_api.dart index 81e89c8..37a953b 100644 --- a/lib/data/api/naver/naver_graphql_api.dart +++ b/lib/data/api/naver/naver_graphql_api.dart @@ -1,5 +1,5 @@ import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import '../../../core/network/network_client.dart'; import '../../../core/errors/network_exceptions.dart'; @@ -46,7 +46,11 @@ class NaverGraphQLApi { return response.data!; } on DioException catch (e) { - debugPrint('fetchGraphQL error: $e'); + AppLogger.error( + 'fetchGraphQL error: $e', + error: e, + stackTrace: e.stackTrace, + ); throw ServerException( message: 'GraphQL 요청 중 오류가 발생했습니다', statusCode: e.response?.statusCode ?? 500, @@ -104,13 +108,17 @@ class NaverGraphQLApi { ); if (response['errors'] != null) { - debugPrint('GraphQL errors: ${response['errors']}'); + AppLogger.error('GraphQL errors: ${response['errors']}'); throw ParseException(message: 'GraphQL 오류: ${response['errors']}'); } return response['data']?['place'] ?? {}; - } catch (e) { - debugPrint('fetchKoreanTextsFromPcmap error: $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'fetchKoreanTextsFromPcmap error: $e', + error: e, + stackTrace: stackTrace, + ); rethrow; } } @@ -150,8 +158,12 @@ class NaverGraphQLApi { } return response['data']?['place'] ?? {}; - } catch (e) { - debugPrint('fetchPlaceBasicInfo error: $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'fetchPlaceBasicInfo error: $e', + error: e, + stackTrace: stackTrace, + ); rethrow; } } diff --git a/lib/data/api/naver/naver_local_search_api.dart b/lib/data/api/naver/naver_local_search_api.dart index 3ec4da4..f0a73b6 100644 --- a/lib/data/api/naver/naver_local_search_api.dart +++ b/lib/data/api/naver/naver_local_search_api.dart @@ -1,5 +1,5 @@ import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import '../../../core/constants/api_keys.dart'; import '../../../core/network/network_client.dart'; @@ -143,9 +143,13 @@ class NaverLocalSearchApi { .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}'); + AppLogger.error( + 'NaverLocalSearchApi error: ${e.message}', + error: e, + stackTrace: e.stackTrace, + ); + AppLogger.debug('Error type: ${e.type}'); + AppLogger.debug('Error response: ${e.response?.data}'); if (e.error is NetworkException) { throw e.error!; @@ -189,8 +193,12 @@ class NaverLocalSearchApi { // 정확한 매칭이 없으면 첫 번째 결과 반환 return results.first; - } catch (e) { - debugPrint('searchRestaurantDetails error: $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'searchRestaurantDetails error: $e', + error: e, + stackTrace: stackTrace, + ); return null; } } diff --git a/lib/data/api/naver/naver_proxy_client.dart b/lib/data/api/naver/naver_proxy_client.dart index 9bffedd..839194f 100644 --- a/lib/data/api/naver/naver_proxy_client.dart +++ b/lib/data/api/naver/naver_proxy_client.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import '../../../core/network/network_client.dart'; import '../../../core/network/network_config.dart'; @@ -22,7 +23,7 @@ class NaverProxyClient { try { final proxyUrl = NetworkConfig.getCorsProxyUrl(url); - debugPrint('Using proxy URL: $proxyUrl'); + AppLogger.debug('Using proxy URL: $proxyUrl'); final response = await _networkClient.get( proxyUrl, @@ -42,9 +43,13 @@ class NaverProxyClient { return response.data!; } on DioException catch (e) { - debugPrint('Proxy fetch error: ${e.message}'); - debugPrint('Status code: ${e.response?.statusCode}'); - debugPrint('Response: ${e.response?.data}'); + AppLogger.error( + 'Proxy fetch error: ${e.message}', + error: e, + stackTrace: e.stackTrace, + ); + AppLogger.debug('Status code: ${e.response?.statusCode}'); + AppLogger.debug('Response: ${e.response?.data}'); if (e.response?.statusCode == 403) { throw ServerException( @@ -78,8 +83,12 @@ class NaverProxyClient { ); return response.statusCode == 200; - } catch (e) { - debugPrint('Proxy status check failed: $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'Proxy status check failed: $e', + error: e, + stackTrace: stackTrace, + ); return false; } } diff --git a/lib/data/api/naver/naver_url_resolver.dart b/lib/data/api/naver/naver_url_resolver.dart index cfb962e..6525843 100644 --- a/lib/data/api/naver/naver_url_resolver.dart +++ b/lib/data/api/naver/naver_url_resolver.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import '../../../core/network/network_client.dart'; import '../../../core/network/network_config.dart'; @@ -39,7 +40,11 @@ class NaverUrlResolver { // 리다이렉트가 없으면 원본 URL 반환 return shortUrl; } on DioException catch (e) { - debugPrint('resolveShortUrl error: $e'); + AppLogger.error( + 'resolveShortUrl error: $e', + error: e, + stackTrace: e.stackTrace, + ); // 리다이렉트 응답인 경우 Location 헤더 확인 if (e.response?.statusCode == 301 || e.response?.statusCode == 302) { @@ -98,8 +103,12 @@ class NaverUrlResolver { } return shortUrl; - } catch (e) { - debugPrint('_resolveShortUrlViaProxy error: $e'); + } catch (e, stackTrace) { + AppLogger.error( + '_resolveShortUrlViaProxy error: $e', + error: e, + stackTrace: stackTrace, + ); return shortUrl; } } @@ -139,8 +148,12 @@ class NaverUrlResolver { } return currentUrl; - } catch (e) { - debugPrint('getFinalRedirectUrl error: $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'getFinalRedirectUrl error: $e', + error: e, + stackTrace: stackTrace, + ); return url; } } diff --git a/lib/data/api/naver_api_client.dart b/lib/data/api/naver_api_client.dart index a1c966a..1ee5b95 100644 --- a/lib/data/api/naver_api_client.dart +++ b/lib/data/api/naver_api_client.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import '../../core/network/network_client.dart'; import '../../core/errors/network_exceptions.dart'; @@ -88,7 +89,11 @@ class NaverApiClient { return response.data!; } on DioException catch (e) { - debugPrint('fetchMapPageHtml error: $e'); + AppLogger.error( + 'fetchMapPageHtml error: $e', + error: e, + stackTrace: e.stackTrace, + ); if (e.error is NetworkException) { throw e.error!; @@ -123,9 +128,9 @@ class NaverApiClient { final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home'; try { - debugPrint('========== 네이버 pcmap 한글 추출 시작 =========='); - debugPrint('요청 URL: $pcmapUrl'); - debugPrint('Place ID: $placeId'); + AppLogger.debug('========== 네이버 pcmap 한글 추출 시작 =========='); + AppLogger.debug('요청 URL: $pcmapUrl'); + AppLogger.debug('Place ID: $placeId'); String html; if (kIsWeb) { @@ -148,7 +153,7 @@ class NaverApiClient { ); if (response.statusCode != 200 || response.data == null) { - debugPrint( + AppLogger.error( 'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}', ); return { @@ -172,11 +177,11 @@ class NaverApiClient { html, ); - debugPrint('========== 추출 결과 =========='); - debugPrint('총 한글 텍스트 수: ${koreanTexts.length}'); - debugPrint('JSON-LD 상호명: $jsonLdName'); - debugPrint('Apollo State 상호명: $apolloName'); - debugPrint('====================================='); + AppLogger.debug('========== 추출 결과 =========='); + AppLogger.debug('총 한글 텍스트 수: ${koreanTexts.length}'); + AppLogger.debug('JSON-LD 상호명: $jsonLdName'); + AppLogger.debug('Apollo State 상호명: $apolloName'); + AppLogger.debug('====================================='); return { 'success': true, @@ -187,8 +192,12 @@ class NaverApiClient { 'apolloStateName': apolloName, 'extractedAt': DateTime.now().toIso8601String(), }; - } catch (e) { - debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverApiClient: pcmap 페이지 파싱 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return { 'success': false, 'error': e.toString(), diff --git a/lib/data/datasources/remote/naver_html_extractor.dart b/lib/data/datasources/remote/naver_html_extractor.dart index 6a04121..438cb16 100644 --- a/lib/data/datasources/remote/naver_html_extractor.dart +++ b/lib/data/datasources/remote/naver_html_extractor.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'package:flutter/foundation.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; /// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스 class NaverHtmlExtractor { @@ -323,11 +323,11 @@ class NaverHtmlExtractor { // 리스트로 변환하여 반환 final resultList = uniqueTexts.toList(); - debugPrint('========== 유효한 한글 텍스트 추출 결과 =========='); + AppLogger.debug('========== 유효한 한글 텍스트 추출 결과 =========='); for (int i = 0; i < resultList.length; i++) { - debugPrint('[$i] ${resultList[i]}'); + AppLogger.debug('[$i] ${resultList[i]}'); } - debugPrint('========== 총 ${resultList.length}개 추출됨 =========='); + AppLogger.debug('========== 총 ${resultList.length}개 추출됨 =========='); return resultList; } @@ -377,8 +377,12 @@ class NaverHtmlExtractor { continue; } } - } catch (e) { - debugPrint('NaverHtmlExtractor: JSON-LD 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlExtractor: JSON-LD 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); } return null; @@ -418,14 +422,21 @@ class NaverHtmlExtractor { } } } - } catch (e) { - // JSON 파싱 실패 - debugPrint('NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e', + error: e, + stackTrace: stackTrace, + ); } } } - } catch (e) { - debugPrint('NaverHtmlExtractor: Apollo State 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlExtractor: Apollo State 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); } return null; @@ -442,7 +453,7 @@ class NaverHtmlExtractor { final match = ogUrlRegex.firstMatch(html); if (match != null) { final url = match.group(1); - debugPrint('NaverHtmlExtractor: og:url 추출 - $url'); + AppLogger.debug('NaverHtmlExtractor: og:url 추출 - $url'); return url; } @@ -454,11 +465,15 @@ class NaverHtmlExtractor { final canonicalMatch = canonicalRegex.firstMatch(html); if (canonicalMatch != null) { final url = canonicalMatch.group(1); - debugPrint('NaverHtmlExtractor: canonical URL 추출 - $url'); + AppLogger.debug('NaverHtmlExtractor: canonical URL 추출 - $url'); return url; } - } catch (e) { - debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlExtractor: Place Link 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); } return null; diff --git a/lib/data/datasources/remote/naver_html_parser.dart b/lib/data/datasources/remote/naver_html_parser.dart index 87c2f0d..2859f58 100644 --- a/lib/data/datasources/remote/naver_html_parser.dart +++ b/lib/data/datasources/remote/naver_html_parser.dart @@ -1,5 +1,5 @@ import 'package:html/dom.dart'; -import 'package:flutter/foundation.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; /// 네이버 지도 HTML 파서 /// @@ -77,8 +77,12 @@ class NaverHtmlParser { } } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 이름 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 이름 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } @@ -97,8 +101,12 @@ class NaverHtmlParser { } } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 카테고리 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 카테고리 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } @@ -115,8 +123,12 @@ class NaverHtmlParser { } } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 서브 카테고리 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 서브 카테고리 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } @@ -137,8 +149,12 @@ class NaverHtmlParser { } } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 설명 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 설명 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } @@ -159,8 +175,12 @@ class NaverHtmlParser { } } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 전화번호 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 전화번호 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } @@ -179,8 +199,12 @@ class NaverHtmlParser { } } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 도로명 주소 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 도로명 주소 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } @@ -201,8 +225,12 @@ class NaverHtmlParser { } } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 지번 주소 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 지번 주소 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } @@ -238,8 +266,12 @@ class NaverHtmlParser { } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 위도 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 위도 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } @@ -275,8 +307,12 @@ class NaverHtmlParser { } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 경도 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 경도 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } @@ -297,8 +333,12 @@ class NaverHtmlParser { } } return null; - } catch (e) { - debugPrint('NaverHtmlParser: 영업시간 추출 실패 - $e'); + } catch (e, stackTrace) { + AppLogger.error( + 'NaverHtmlParser: 영업시간 추출 실패 - $e', + error: e, + stackTrace: stackTrace, + ); return null; } } diff --git a/lib/data/datasources/remote/naver_map_parser.dart b/lib/data/datasources/remote/naver_map_parser.dart index 906a5b2..7b16402 100644 --- a/lib/data/datasources/remote/naver_map_parser.dart +++ b/lib/data/datasources/remote/naver_map_parser.dart @@ -1,16 +1,18 @@ import 'dart:math'; import 'package:dio/dio.dart'; -import 'package:html/parser.dart' as html_parser; -import 'package:uuid/uuid.dart'; -import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:flutter/foundation.dart'; -import '../../api/naver_api_client.dart'; -import '../../api/naver/naver_local_search_api.dart'; -import '../../../core/errors/network_exceptions.dart'; -import 'naver_html_parser.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:lunchpick/core/utils/app_logger.dart'; +import 'package:lunchpick/domain/entities/restaurant.dart'; +import 'package:uuid/uuid.dart'; + import '../../api/naver/naver_graphql_queries.dart'; +import '../../api/naver/naver_local_search_api.dart'; +import '../../api/naver_api_client.dart'; +import '../../../core/errors/network_exceptions.dart'; import '../../../core/utils/category_mapper.dart'; +import 'naver_html_parser.dart'; /// 네이버 지도 URL 파서 /// 네이버 지도 URL에서 식당 정보를 추출합니다. @@ -60,9 +62,7 @@ class NaverMapParser { throw NaverMapParseException('이미 dispose된 파서입니다'); } try { - if (kDebugMode) { - debugPrint('NaverMapParser: Starting to parse URL: $url'); - } + AppLogger.debug('NaverMapParser: Starting to parse URL: $url'); // URL 유효성 검증 if (!_isValidNaverUrl(url)) { @@ -72,9 +72,7 @@ class NaverMapParser { // 짧은 URL인 경우 리다이렉트 처리 final String finalUrl = await _apiClient.resolveShortUrl(url); - if (kDebugMode) { - debugPrint('NaverMapParser: Final URL after redirect: $finalUrl'); - } + AppLogger.debug('NaverMapParser: Final URL after redirect: $finalUrl'); // Place ID 추출 (10자리 숫자) final String? placeId = _extractPlaceId(finalUrl); @@ -82,11 +80,9 @@ class NaverMapParser { // 짧은 URL에서 직접 ID 추출 시도 final shortUrlId = _extractShortUrlId(url); if (shortUrlId != null) { - if (kDebugMode) { - debugPrint( - 'NaverMapParser: Using short URL ID as place ID: $shortUrlId', - ); - } + AppLogger.debug( + 'NaverMapParser: Using short URL ID as place ID: $shortUrlId', + ); return _createFallbackRestaurant(shortUrlId, url); } throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url'); @@ -96,9 +92,7 @@ class NaverMapParser { final isShortUrl = url.contains('naver.me'); if (isShortUrl) { - if (kDebugMode) { - debugPrint('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작'); - } + AppLogger.debug('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작'); try { // 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득 @@ -108,14 +102,14 @@ class NaverMapParser { userLatitude, userLongitude, ); - if (kDebugMode) { - debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}'); - } + AppLogger.debug('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}'); return restaurant; - } catch (e) { - if (kDebugMode) { - debugPrint('NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e'); - } + } catch (e, stackTrace) { + AppLogger.error( + 'NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e', + error: e, + stackTrace: stackTrace, + ); // 실패 시 기본 파싱으로 계속 진행 } } @@ -177,9 +171,7 @@ class NaverMapParser { }) async { // 심플한 접근: URL로 직접 검색 try { - if (kDebugMode) { - debugPrint('NaverMapParser: URL 기반 검색 시작'); - } + AppLogger.debug('NaverMapParser: URL 기반 검색 시작'); // 네이버 지도 URL 구성 final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId'; @@ -201,27 +193,25 @@ class NaverMapParser { // place ID가 포함된 결과 찾기 for (final result in searchResults) { if (result.link.contains(placeId)) { - if (kDebugMode) { - debugPrint( - 'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}', - ); - } + AppLogger.debug( + 'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}', + ); return _convertSearchResultToData(result); } } // 정확한 매칭이 없으면 첫 번째 결과 사용 - if (kDebugMode) { - debugPrint( - 'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}', - ); - } + AppLogger.debug( + 'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}', + ); return _convertSearchResultToData(searchResults.first); } - } catch (e) { - if (kDebugMode) { - debugPrint('NaverMapParser: URL 검색 실패 - $e'); - } + } catch (e, stackTrace) { + AppLogger.error( + 'NaverMapParser: URL 검색 실패 - $e', + error: e, + stackTrace: stackTrace, + ); } // Step 2: Place ID로 검색 @@ -238,17 +228,17 @@ class NaverMapParser { ); if (searchResults.isNotEmpty) { - if (kDebugMode) { - debugPrint( - 'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}', - ); - } + AppLogger.debug( + 'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}', + ); return _convertSearchResultToData(searchResults.first); } - } catch (e) { - if (kDebugMode) { - debugPrint('NaverMapParser: Place ID 검색 실패 - $e'); - } + } catch (e, stackTrace) { + AppLogger.error( + 'NaverMapParser: Place ID 검색 실패 - $e', + error: e, + stackTrace: stackTrace, + ); // 429 에러인 경우 즉시 예외 발생 if (e is DioException && e.response?.statusCode == 429) { @@ -258,10 +248,12 @@ class NaverMapParser { ); } } - } catch (e) { - if (kDebugMode) { - debugPrint('NaverMapParser: URL 기반 검색 실패 - $e'); - } + } catch (e, stackTrace) { + AppLogger.error( + 'NaverMapParser: URL 기반 검색 실패 - $e', + error: e, + stackTrace: stackTrace, + ); // 429 에러인 경우 즉시 예외 발생 if (e is DioException && e.response?.statusCode == 429) { @@ -275,9 +267,7 @@ class NaverMapParser { // 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도) // 첫 번째 시도: places 쿼리 try { - if (kDebugMode) { - debugPrint('NaverMapParser: Trying places query...'); - } + AppLogger.debug('NaverMapParser: Trying places query...'); final response = await _apiClient.fetchGraphQL( operationName: 'getPlaceDetail', variables: {'id': placeId}, @@ -293,17 +283,17 @@ class NaverMapParser { return _extractPlaceData(placesData as Map); } } - } catch (e) { - if (kDebugMode) { - debugPrint('NaverMapParser: places query failed - $e'); - } + } catch (e, stackTrace) { + AppLogger.error( + 'NaverMapParser: places query failed - $e', + error: e, + stackTrace: stackTrace, + ); } // 두 번째 시도: nxPlaces 쿼리 try { - if (kDebugMode) { - debugPrint('NaverMapParser: Trying nxPlaces query...'); - } + AppLogger.debug('NaverMapParser: Trying nxPlaces query...'); final response = await _apiClient.fetchGraphQL( operationName: 'getPlaceDetail', variables: {'id': placeId}, @@ -319,18 +309,18 @@ class NaverMapParser { return _extractPlaceData(nxPlacesData as Map); } } - } catch (e) { - if (kDebugMode) { - debugPrint('NaverMapParser: nxPlaces query failed - $e'); - } + } catch (e, stackTrace) { + AppLogger.error( + 'NaverMapParser: nxPlaces query failed - $e', + error: e, + stackTrace: stackTrace, + ); } // 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback - if (kDebugMode) { - debugPrint( - 'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing', - ); - } + AppLogger.debug( + 'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing', + ); return await _fallbackToHtmlParsing(placeId); } @@ -508,7 +498,7 @@ class NaverMapParser { double? userLongitude, ) async { if (kDebugMode) { - debugPrint('NaverMapParser: 단축 URL 향상된 파싱 시작'); + AppLogger.debug('NaverMapParser: 단축 URL 향상된 파싱 시작'); } // 1. 한글 텍스트 추출 @@ -525,17 +515,17 @@ class NaverMapParser { if (koreanData['jsonLdName'] != null) { searchQuery = koreanData['jsonLdName'] as String; if (kDebugMode) { - debugPrint('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery'); + AppLogger.debug('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery'); } } else if (koreanData['apolloStateName'] != null) { searchQuery = koreanData['apolloStateName'] as String; if (kDebugMode) { - debugPrint('NaverMapParser: Apollo State 상호명 사용 - $searchQuery'); + AppLogger.debug('NaverMapParser: Apollo State 상호명 사용 - $searchQuery'); } } else if (koreanTexts.isNotEmpty) { searchQuery = koreanTexts.first as String; if (kDebugMode) { - debugPrint('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery'); + AppLogger.debug('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery'); } } else { throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다'); @@ -543,7 +533,7 @@ class NaverMapParser { // 2. 로컬 검색 API 호출 if (kDebugMode) { - debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"'); + AppLogger.debug('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"'); } await Future.delayed( @@ -563,15 +553,15 @@ class NaverMapParser { // 디버깅: 검색 결과 Place ID 분석 if (kDebugMode) { - debugPrint('=== 로컬 검색 결과 Place ID 분석 ==='); + AppLogger.debug('=== 로컬 검색 결과 Place ID 분석 ==='); for (int i = 0; i < searchResults.length; i++) { final result = searchResults[i]; final extractedId = result.extractPlaceId(); - debugPrint('[$i] ${result.title}'); - debugPrint(' 링크: ${result.link}'); - debugPrint(' 추출된 Place ID: $extractedId (타겟: $placeId)'); + AppLogger.debug('[$i] ${result.title}'); + AppLogger.debug(' 링크: ${result.link}'); + AppLogger.debug(' 추출된 Place ID: $extractedId (타겟: $placeId)'); } - debugPrint('====================================='); + AppLogger.debug('====================================='); } // 3. 최적의 결과 선택 - 3단계 매칭 알고리즘 @@ -583,7 +573,7 @@ class NaverMapParser { if (extractedId == placeId) { bestMatch = result; if (kDebugMode) { - debugPrint('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}'); + AppLogger.debug('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}'); } break; } @@ -604,7 +594,7 @@ class NaverMapParser { exactName.contains(result.title)) { bestMatch = result; if (kDebugMode) { - debugPrint('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}'); + AppLogger.debug('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}'); } break; } @@ -620,7 +610,7 @@ class NaverMapParser { userLongitude, ); if (bestMatch != null && kDebugMode) { - debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}'); + AppLogger.debug('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}'); } } @@ -628,7 +618,7 @@ class NaverMapParser { if (bestMatch == null) { bestMatch = searchResults.first; if (kDebugMode) { - debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}'); + AppLogger.debug('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}'); } } @@ -670,7 +660,7 @@ class NaverMapParser { } if (kDebugMode && nearest != null) { - debugPrint( + AppLogger.debug( '가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)', ); } diff --git a/lib/data/datasources/remote/naver_search_service.dart b/lib/data/datasources/remote/naver_search_service.dart index 10be015..e4e0cee 100644 --- a/lib/data/datasources/remote/naver_search_service.dart +++ b/lib/data/datasources/remote/naver_search_service.dart @@ -1,10 +1,13 @@ import 'package:flutter/foundation.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:uuid/uuid.dart'; + import '../../api/naver_api_client.dart'; import '../../api/naver/naver_local_search_api.dart'; -import '../../../domain/entities/restaurant.dart'; import '../../../core/errors/network_exceptions.dart'; +import '../../../domain/entities/restaurant.dart'; import 'naver_map_parser.dart'; +import 'naver_url_processor.dart'; /// 네이버 검색 서비스 /// @@ -12,14 +15,21 @@ import 'naver_map_parser.dart'; class NaverSearchService { final NaverApiClient _apiClient; final NaverMapParser _mapParser; + final NaverUrlProcessor _urlProcessor; 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, + NaverUrlProcessor? urlProcessor, + }) : _apiClient = apiClient ?? NaverApiClient(), + _mapParser = mapParser ?? NaverMapParser(apiClient: apiClient), + _urlProcessor = + urlProcessor ?? + NaverUrlProcessor(apiClient: apiClient, mapParser: mapParser); /// URL에서 식당 정보 가져오기 /// @@ -32,7 +42,7 @@ class NaverSearchService { /// - [NetworkException] 네트워크 오류 발생 시 Future getRestaurantFromUrl(String url) async { try { - return await _mapParser.parseRestaurantFromUrl(url); + return await _urlProcessor.processUrl(url); } catch (e) { if (e is NaverMapParseException || e is NetworkException) { rethrow; @@ -149,9 +159,9 @@ class NaverSearchService { ); } catch (e) { // 상세 파싱 실패해도 기본 정보 반환 - if (kDebugMode) { - debugPrint('[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}'); - } + AppLogger.debug( + '[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}', + ); } } diff --git a/lib/data/datasources/remote/naver_url_processor.dart b/lib/data/datasources/remote/naver_url_processor.dart new file mode 100644 index 0000000..476dec0 --- /dev/null +++ b/lib/data/datasources/remote/naver_url_processor.dart @@ -0,0 +1,42 @@ +import 'dart:collection'; + +import 'package:lunchpick/domain/entities/restaurant.dart'; + +import '../../api/naver_api_client.dart'; +import 'naver_map_parser.dart'; + +/// 네이버 지도 URL을 처리하고 결과를 캐시하는 경량 프로세서. +/// - 단축 URL 해석 → 지도 파서 실행 +/// - 동일 URL 재요청 시 메모리 캐시 반환 +class NaverUrlProcessor { + final NaverApiClient _apiClient; + final NaverMapParser _mapParser; + final _cache = HashMap(); + + NaverUrlProcessor({NaverApiClient? apiClient, NaverMapParser? mapParser}) + : _apiClient = apiClient ?? NaverApiClient(), + _mapParser = mapParser ?? NaverMapParser(apiClient: apiClient); + + Future processUrl( + String url, { + double? userLatitude, + double? userLongitude, + }) async { + final normalizedUrl = url.trim(); + if (_cache.containsKey(normalizedUrl)) { + return _cache[normalizedUrl]!; + } + + final resolved = await _apiClient.resolveShortUrl(normalizedUrl); + final restaurant = await _mapParser.parseRestaurantFromUrl( + resolved, + userLatitude: userLatitude, + userLongitude: userLongitude, + ); + _cache[normalizedUrl] = restaurant; + _cache[resolved] = restaurant; + return restaurant; + } + + void clearCache() => _cache.clear(); +} diff --git a/lib/data/repositories/restaurant_repository_impl.dart b/lib/data/repositories/restaurant_repository_impl.dart index 17274dd..46a65aa 100644 --- a/lib/data/repositories/restaurant_repository_impl.dart +++ b/lib/data/repositories/restaurant_repository_impl.dart @@ -5,6 +5,7 @@ import 'package:lunchpick/core/utils/distance_calculator.dart'; import 'package:lunchpick/data/datasources/remote/naver_search_service.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/core/constants/api_keys.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; class RestaurantRepositoryImpl implements RestaurantRepository { static const String _boxName = 'restaurants'; @@ -224,7 +225,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository { ); } } catch (e) { - print('API 검색 실패, 스크래핑된 정보만 사용: $e'); + AppLogger.debug('API 검색 실패, 스크래핑된 정보만 사용: $e'); } } diff --git a/lib/data/repositories/weather_repository_impl.dart b/lib/data/repositories/weather_repository_impl.dart index 3716996..1d60410 100644 --- a/lib/data/repositories/weather_repository_impl.dart +++ b/lib/data/repositories/weather_repository_impl.dart @@ -1,4 +1,10 @@ +import 'dart:convert'; +import 'dart:math'; + import 'package:hive_flutter/hive_flutter.dart'; +import 'package:http/http.dart' as http; +import 'package:lunchpick/core/constants/api_keys.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:lunchpick/domain/entities/weather_info.dart'; import 'package:lunchpick/domain/repositories/weather_repository.dart'; @@ -15,18 +21,32 @@ class WeatherRepositoryImpl implements WeatherRepository { required double latitude, required double longitude, }) async { - // TODO: 실제 날씨 API 호출 구현 - // 여기서는 임시로 더미 데이터 반환 + final cached = await getCachedWeather(); - final dummyWeather = WeatherInfo( - current: WeatherData(temperature: 20, isRainy: false, description: '맑음'), - nextHour: WeatherData(temperature: 22, isRainy: false, description: '맑음'), - ); - - // 캐시에 저장 - await cacheWeatherInfo(dummyWeather); - - return dummyWeather; + try { + final weather = await _fetchWeatherFromKma( + latitude: latitude, + longitude: longitude, + ); + await cacheWeatherInfo(weather); + return weather; + } catch (_) { + if (cached != null) { + return cached; + } + return WeatherInfo( + current: WeatherData( + temperature: 20, + isRainy: false, + description: '날씨 정보를 불러오지 못했어요', + ), + nextHour: WeatherData( + temperature: 20, + isRainy: false, + description: '날씨 정보를 불러오지 못했어요', + ), + ); + } } @override @@ -48,7 +68,7 @@ class WeatherRepositoryImpl implements WeatherRepository { try { // 안전한 타입 변환 if (cachedData is! Map) { - print( + AppLogger.debug( 'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}', ); await clearWeatherCache(); @@ -62,7 +82,9 @@ class WeatherRepositoryImpl implements WeatherRepository { // Map 구조 검증 if (!weatherMap.containsKey('current') || !weatherMap.containsKey('nextHour')) { - print('WeatherCache: Missing required fields in weather data'); + AppLogger.debug( + 'WeatherCache: Missing required fields in weather data', + ); await clearWeatherCache(); return null; } @@ -70,7 +92,10 @@ class WeatherRepositoryImpl implements WeatherRepository { return _weatherInfoFromMap(weatherMap); } catch (e) { // 캐시 데이터가 손상된 경우 - print('WeatherCache: Error parsing cached weather data: $e'); + AppLogger.error( + 'WeatherCache: Error parsing cached weather data', + error: e, + ); await clearWeatherCache(); return null; } @@ -118,7 +143,9 @@ class WeatherRepositoryImpl implements WeatherRepository { // 날짜 파싱 시도 final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr); if (lastUpdateTime == null) { - print('WeatherCache: Invalid date format in cache: $lastUpdateTimeStr'); + AppLogger.debug( + 'WeatherCache: Invalid date format in cache: $lastUpdateTimeStr', + ); return false; } @@ -127,7 +154,7 @@ class WeatherRepositoryImpl implements WeatherRepository { return difference < _cacheValidDuration; } catch (e) { - print('WeatherCache: Error checking cache validity: $e'); + AppLogger.error('WeatherCache: Error checking cache validity', error: e); return false; } } @@ -183,9 +210,284 @@ class WeatherRepositoryImpl implements WeatherRepository { ), ); } catch (e) { - print('WeatherCache: Error converting map to WeatherInfo: $e'); - print('WeatherCache: Map data: $map'); + AppLogger.error( + 'WeatherCache: Error converting map to WeatherInfo', + error: e, + stackTrace: StackTrace.current, + ); + AppLogger.debug('WeatherCache: Map data: $map'); rethrow; } } + + Future _fetchWeatherFromKma({ + required double latitude, + required double longitude, + }) async { + final serviceKey = _encodeServiceKey(ApiKeys.weatherServiceKey); + if (serviceKey.isEmpty) { + throw Exception('기상청 서비스 키가 설정되지 않았습니다.'); + } + + final gridPoint = _latLonToGrid(latitude, longitude); + final baseDateTime = _resolveBaseDateTime(); + + final ncstUri = Uri.https( + 'apis.data.go.kr', + '/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst', + { + 'serviceKey': serviceKey, + 'numOfRows': '100', + 'pageNo': '1', + 'dataType': 'JSON', + 'base_date': baseDateTime.date, + 'base_time': baseDateTime.ncstTime, + 'nx': gridPoint.x.toString(), + 'ny': gridPoint.y.toString(), + }, + ); + + final fcstUri = Uri.https( + 'apis.data.go.kr', + '/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst', + { + 'serviceKey': serviceKey, + 'numOfRows': '200', + 'pageNo': '1', + 'dataType': 'JSON', + 'base_date': baseDateTime.date, + 'base_time': baseDateTime.fcstTime, + 'nx': gridPoint.x.toString(), + 'ny': gridPoint.y.toString(), + }, + ); + + final ncstItems = await _requestKmaItems(ncstUri); + final fcstItems = await _requestKmaItems(fcstUri); + + final currentTemp = _extractLatestValue(ncstItems, 'T1H')?.round(); + final currentPty = _extractLatestValue(ncstItems, 'PTY')?.round() ?? 0; + final currentSky = _extractLatestValue(ncstItems, 'SKY')?.round() ?? 1; + + final now = DateTime.now(); + final nextHourData = _extractForecast(fcstItems, after: now); + final nextTemp = nextHourData.temperature?.round(); + final nextPty = nextHourData.pty ?? 0; + final nextSky = nextHourData.sky ?? 1; + + final currentWeather = WeatherData( + temperature: currentTemp ?? 20, + isRainy: _isRainy(currentPty), + description: _describeWeather(currentSky, currentPty), + ); + + final nextWeather = WeatherData( + temperature: nextTemp ?? currentTemp ?? 20, + isRainy: _isRainy(nextPty), + description: _describeWeather(nextSky, nextPty), + ); + + return WeatherInfo(current: currentWeather, nextHour: nextWeather); + } + + Future> _requestKmaItems(Uri uri) async { + final response = await http.get(uri); + if (response.statusCode != 200) { + throw Exception('Weather API 호출 실패: ${response.statusCode}'); + } + + final body = jsonDecode(response.body) as Map; + final items = body['response']?['body']?['items']?['item']; + if (items is List) { + return items; + } + throw Exception('Weather API 응답 파싱 실패'); + } + + double? _extractLatestValue(List items, String category) { + final filtered = items.where((item) => item['category'] == category); + if (filtered.isEmpty) return null; + final sorted = filtered.toList() + ..sort((a, b) { + final dateA = a['baseDate'] as String? ?? ''; + final timeA = a['baseTime'] as String? ?? ''; + final dateB = b['baseDate'] as String? ?? ''; + final timeB = b['baseTime'] as String? ?? ''; + final dtA = _parseKmaDateTime(dateA, timeA); + final dtB = _parseKmaDateTime(dateB, timeB); + return dtB.compareTo(dtA); + }); + final value = sorted.first['obsrValue'] ?? sorted.first['fcstValue']; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + ({double? temperature, int? pty, int? sky}) _extractForecast( + List items, { + required DateTime after, + }) { + DateTime? targetTime; + double? temperature; + int? pty; + int? sky; + + DateTime fcstDateTime(Map item) { + final date = item['fcstDate'] as String? ?? ''; + final time = item['fcstTime'] as String? ?? ''; + return _parseKmaDateTime(date, time); + } + + for (final item in items) { + final dt = fcstDateTime(item as Map); + if (!dt.isAfter(after)) continue; + if (targetTime == null || dt.isBefore(targetTime)) { + targetTime = dt; + } + } + + if (targetTime == null) { + return (temperature: null, pty: null, sky: null); + } + + for (final item in items) { + final map = item as Map; + final dt = fcstDateTime(map); + if (dt != targetTime) continue; + + final category = map['category']; + final value = map['fcstValue']; + if (value == null) continue; + + if (category == 'T1H' && temperature == null) { + temperature = value is num + ? value.toDouble() + : double.tryParse('$value'); + } else if (category == 'PTY' && pty == null) { + pty = value is num ? value.toInt() : int.tryParse('$value'); + } else if (category == 'SKY' && sky == null) { + sky = value is num ? value.toInt() : int.tryParse('$value'); + } + } + + return (temperature: temperature, pty: pty, sky: sky); + } + + _GridPoint _latLonToGrid(double lat, double lon) { + const re = 6371.00877; + const grid = 5.0; + const slat1 = 30.0 * pi / 180.0; + const slat2 = 60.0 * pi / 180.0; + const olon = 126.0 * pi / 180.0; + const olat = 38.0 * pi / 180.0; + const xo = 43.0; + const yo = 136.0; + + final sn = + log(cos(slat1) / cos(slat2)) / + log(tan(pi * 0.25 + slat2 * 0.5) / tan(pi * 0.25 + slat1 * 0.5)); + final sf = pow(tan(pi * 0.25 + slat1 * 0.5), sn) * cos(slat1) / sn; + final ro = re / grid * sf / pow(tan(pi * 0.25 + olat * 0.5), sn); + final ra = + re / grid * sf / pow(tan(pi * 0.25 + (lat * pi / 180.0) * 0.5), sn); + var theta = lon * pi / 180.0 - olon; + if (theta > pi) theta -= 2.0 * pi; + if (theta < -pi) theta += 2.0 * pi; + theta *= sn; + + final x = (ra * sin(theta) + xo + 0.5).floor(); + final y = (ro - ra * cos(theta) + yo + 0.5).floor(); + return _GridPoint(x: x, y: y); + } + + bool _isRainy(int pty) => pty > 0; + + String _describeWeather(int sky, int pty) { + if (pty == 1) return '비'; + if (pty == 2) return '비/눈'; + if (pty == 3) return '눈'; + if (pty == 4) return '소나기'; + if (pty == 5) return '빗방울'; + if (pty == 6) return '빗방울/눈날림'; + if (pty == 7) return '눈날림'; + + switch (sky) { + case 1: + return '맑음'; + case 3: + return '구름 많음'; + case 4: + return '흐림'; + default: + return '맑음'; + } + } + + /// 서비스 키를 안전하게 URL 인코딩한다. + /// 이미 인코딩된 값(%)이 포함되어 있으면 그대로 사용한다. + String _encodeServiceKey(String key) { + if (key.isEmpty) return ''; + if (key.contains('%')) return key; + return Uri.encodeComponent(key); + } + + _BaseDateTime _resolveBaseDateTime() { + final now = DateTime.now(); + + // 초단기실황은 매시 정시 발표(정시+10분 이후 호출 권장) + // 초단기예보는 매시 30분 발표(30분+10분 이후 호출 권장) + final ncstAnchor = now.minute >= 10 + ? DateTime(now.year, now.month, now.day, now.hour, 0) + : DateTime(now.year, now.month, now.day, now.hour - 1, 0); + final fcstAnchor = now.minute >= 40 + ? DateTime(now.year, now.month, now.day, now.hour, 30) + : DateTime(now.year, now.month, now.day, now.hour - 1, 30); + + final date = _formatDate(fcstAnchor); // 둘 다 같은 날짜/시점 기준 + final ncstTime = _formatTime(ncstAnchor); + final fcstTime = _formatTime(fcstAnchor); + + return _BaseDateTime(date: date, ncstTime: ncstTime, fcstTime: fcstTime); + } + + String _formatDate(DateTime dt) { + final y = dt.year.toString().padLeft(4, '0'); + final m = dt.month.toString().padLeft(2, '0'); + final d = dt.day.toString().padLeft(2, '0'); + return '$y$m$d'; + } + + String _formatTime(DateTime dt) { + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + return '$h$m'; + } + + DateTime _parseKmaDateTime(String date, String time) { + final year = int.parse(date.substring(0, 4)); + final month = int.parse(date.substring(4, 6)); + final day = int.parse(date.substring(6, 8)); + final hour = int.parse(time.substring(0, 2)); + final minute = int.parse(time.substring(2, 4)); + return DateTime(year, month, day, hour, minute); + } +} + +class _GridPoint { + final int x; + final int y; + + _GridPoint({required this.x, required this.y}); +} + +class _BaseDateTime { + final String date; + final String ncstTime; + final String fcstTime; + + _BaseDateTime({ + required this.date, + required this.ncstTime, + required this.fcstTime, + }); } diff --git a/lib/main.dart b/lib/main.dart index f3d749b..f671e13 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -44,8 +44,7 @@ void main() async { // Initialize Notification Service (only for non-web platforms) if (!kIsWeb) { final notificationService = NotificationService(); - await notificationService.initialize(); - await notificationService.requestPermission(); + await notificationService.ensureInitialized(requestPermission: true); } // Get saved theme mode diff --git a/lib/presentation/pages/calendar/calendar_screen.dart b/lib/presentation/pages/calendar/calendar_screen.dart index fec4089..e251a2f 100644 --- a/lib/presentation/pages/calendar/calendar_screen.dart +++ b/lib/presentation/pages/calendar/calendar_screen.dart @@ -3,9 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:table_calendar/table_calendar.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_typography.dart'; +import '../../../domain/entities/recommendation_record.dart'; import '../../../domain/entities/visit_record.dart'; +import '../../providers/recommendation_provider.dart'; import '../../providers/visit_provider.dart'; import 'widgets/visit_record_card.dart'; +import 'widgets/recommendation_record_card.dart'; import 'widgets/visit_statistics.dart'; class CalendarScreen extends ConsumerStatefulWidget { @@ -21,7 +24,7 @@ class _CalendarScreenState extends ConsumerState late DateTime _focusedDay; CalendarFormat _calendarFormat = CalendarFormat.month; late TabController _tabController; - Map> _visitRecordEvents = {}; + Map> _events = {}; @override void initState() { @@ -37,9 +40,9 @@ class _CalendarScreenState extends ConsumerState super.dispose(); } - List _getEventsForDay(DateTime day) { + List<_CalendarEvent> _getEventsForDay(DateTime day) { final normalizedDay = DateTime(day.year, day.month, day.day); - return _visitRecordEvents[normalizedDay] ?? []; + return _events[normalizedDay] ?? []; } @override @@ -83,22 +86,17 @@ class _CalendarScreenState extends ConsumerState return Consumer( builder: (context, ref, child) { final visitRecordsAsync = ref.watch(visitRecordsProvider); + final recommendationRecordsAsync = ref.watch( + recommendationRecordsProvider, + ); - // 방문 기록을 날짜별로 그룹화 - visitRecordsAsync.whenData((records) { - _visitRecordEvents = {}; - for (final record in records) { - final normalizedDate = DateTime( - record.visitDate.year, - record.visitDate.month, - record.visitDate.day, - ); - _visitRecordEvents[normalizedDate] = [ - ...(_visitRecordEvents[normalizedDate] ?? []), - record, - ]; - } - }); + if (visitRecordsAsync.hasValue && recommendationRecordsAsync.hasValue) { + final visits = visitRecordsAsync.value ?? []; + final recommendations = + recommendationRecordsAsync.valueOrNull ?? + []; + _events = _buildEvents(visits, recommendations); + } return Column( children: [ @@ -132,17 +130,18 @@ class _CalendarScreenState extends ConsumerState markerBuilder: (context, day, events) { if (events.isEmpty) return null; - final visitRecords = events.cast(); - final confirmedCount = visitRecords - .where((r) => r.isConfirmed) - .length; - final unconfirmedCount = - visitRecords.length - confirmedCount; + final calendarEvents = events.cast<_CalendarEvent>(); + final confirmedVisits = calendarEvents.where( + (e) => e.visitRecord?.isConfirmed == true, + ); + final recommendedOnly = calendarEvents.where( + (e) => e.recommendationRecord != null, + ); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (confirmedCount > 0) + if (confirmedVisits.isNotEmpty) Container( width: 6, height: 6, @@ -152,7 +151,7 @@ class _CalendarScreenState extends ConsumerState shape: BoxShape.circle, ), ), - if (unconfirmedCount > 0) + if (recommendedOnly.isNotEmpty) Container( width: 6, height: 6, @@ -239,6 +238,7 @@ class _CalendarScreenState extends ConsumerState Widget _buildDayRecords(DateTime day, bool isDark) { final events = _getEventsForDay(day); + events.sort((a, b) => b.sortDate.compareTo(a.sortDate)); if (events.isEmpty) { return Center( @@ -294,18 +294,71 @@ class _CalendarScreenState extends ConsumerState child: ListView.builder( itemCount: events.length, itemBuilder: (context, index) { - final sortedEvents = events - ..sort((a, b) => b.visitDate.compareTo(a.visitDate)); - return VisitRecordCard( - visitRecord: sortedEvents[index], - onTap: () { - // TODO: 맛집 상세 페이지로 이동 - }, - ); + final event = events[index]; + if (event.visitRecord != null) { + return VisitRecordCard( + visitRecord: event.visitRecord!, + onTap: () {}, + ); + } + if (event.recommendationRecord != null) { + return RecommendationRecordCard( + recommendation: event.recommendationRecord!, + onConfirmVisit: () async { + await ref + .read(recommendationNotifierProvider.notifier) + .confirmVisit(event.recommendationRecord!.id); + }, + ); + } + return const SizedBox.shrink(); }, ), ), ], ); } + + Map> _buildEvents( + List visits, + List recommendations, + ) { + final Map> events = {}; + + for (final visit in visits) { + final day = DateTime( + visit.visitDate.year, + visit.visitDate.month, + visit.visitDate.day, + ); + events[day] = [ + ...(events[day] ?? []), + _CalendarEvent(visitRecord: visit), + ]; + } + + for (final reco in recommendations.where((r) => !r.visited)) { + final day = DateTime( + reco.recommendationDate.year, + reco.recommendationDate.month, + reco.recommendationDate.day, + ); + events[day] = [ + ...(events[day] ?? []), + _CalendarEvent(recommendationRecord: reco), + ]; + } + + return events; + } +} + +class _CalendarEvent { + final VisitRecord? visitRecord; + final RecommendationRecord? recommendationRecord; + + _CalendarEvent({this.visitRecord, this.recommendationRecord}); + + DateTime get sortDate => + visitRecord?.visitDate ?? recommendationRecord!.recommendationDate; } diff --git a/lib/presentation/pages/calendar/widgets/recommendation_record_card.dart b/lib/presentation/pages/calendar/widgets/recommendation_record_card.dart new file mode 100644 index 0000000..107daf2 --- /dev/null +++ b/lib/presentation/pages/calendar/widgets/recommendation_record_card.dart @@ -0,0 +1,151 @@ +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/domain/entities/recommendation_record.dart'; +import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; + +class RecommendationRecordCard extends ConsumerWidget { + final RecommendationRecord recommendation; + final VoidCallback onConfirmVisit; + + const RecommendationRecordCard({ + super.key, + required this.recommendation, + required this.onConfirmVisit, + }); + + String _formatTime(DateTime dateTime) { + final hour = dateTime.hour.toString().padLeft(2, '0'); + final minute = dateTime.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final restaurantAsync = ref.watch( + restaurantProvider(recommendation.restaurantId), + ); + + return restaurantAsync.when( + data: (restaurant) { + if (restaurant == null) { + return const SizedBox.shrink(); + } + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.whatshot, + color: Colors.orange, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + restaurant.name, + style: AppTypography.body1( + isDark, + ).copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.category_outlined, + size: 14, + color: isDark + ? AppColors.darkTextSecondary + : AppColors.lightTextSecondary, + ), + const SizedBox(width: 4), + Text( + restaurant.category, + style: AppTypography.caption(isDark), + ), + const SizedBox(width: 8), + Icon( + Icons.access_time, + size: 14, + color: isDark + ? AppColors.darkTextSecondary + : AppColors.lightTextSecondary, + ), + const SizedBox(width: 4), + Text( + _formatTime(recommendation.recommendationDate), + style: AppTypography.caption(isDark), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.info_outline, + size: 16, + color: Colors.orange, + ), + const SizedBox(width: 6), + Text( + '추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.', + style: AppTypography.caption(isDark).copyWith( + color: Colors.orange, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: onConfirmVisit, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.lightPrimary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 40), + ), + child: const Text('방문 확인'), + ), + ], + ), + ), + ); + }, + loading: () => const Card( + margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ), + error: (_, __) => const SizedBox.shrink(), + ); + } +} diff --git a/lib/presentation/pages/random_selection/random_selection_screen.dart b/lib/presentation/pages/random_selection/random_selection_screen.dart index c1c7a5e..6e76196 100644 --- a/lib/presentation/pages/random_selection/random_selection_screen.dart +++ b/lib/presentation/pages/random_selection/random_selection_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; @@ -28,6 +29,7 @@ class RandomSelectionScreen extends ConsumerStatefulWidget { class _RandomSelectionScreenState extends ConsumerState { double _distanceValue = 500; final List _selectedCategories = []; + final List _excludedRestaurantIds = []; bool _isProcessingRecommendation = false; @override @@ -459,18 +461,28 @@ class _RandomSelectionScreenState extends ConsumerState { return count > 0; } - Future _startRecommendation({bool skipAd = false}) async { + Future _startRecommendation({ + bool skipAd = false, + bool isReroll = false, + }) async { if (_isProcessingRecommendation) return; + if (!isReroll) { + _excludedRestaurantIds.clear(); + } + setState(() { _isProcessingRecommendation = true; }); try { - final candidate = await _generateRecommendationCandidate(); + final candidate = await _generateRecommendationCandidate( + excludedRestaurantIds: _excludedRestaurantIds, + ); if (candidate == null) { return; } + final recommendedAt = DateTime.now(); if (!skipAd) { final adService = ref.read(adServiceProvider); @@ -488,7 +500,7 @@ class _RandomSelectionScreenState extends ConsumerState { } if (!mounted) return; - _showRecommendationDialog(candidate); + await _showRecommendationDialog(candidate, recommendedAt: recommendedAt); } catch (_) { _showSnack( '추천을 준비하는 중 문제가 발생했습니다.', @@ -503,12 +515,16 @@ class _RandomSelectionScreenState extends ConsumerState { } } - Future _generateRecommendationCandidate() async { + Future _generateRecommendationCandidate({ + List excludedRestaurantIds = const [], + }) async { final notifier = ref.read(recommendationNotifierProvider.notifier); - await notifier.getRandomRecommendation( + final recommendation = await notifier.getRandomRecommendation( maxDistance: _distanceValue, selectedCategories: _selectedCategories, + excludedRestaurantIds: excludedRestaurantIds, + shouldSaveRecord: false, ); final result = ref.read(recommendationNotifierProvider); @@ -522,49 +538,82 @@ class _RandomSelectionScreenState extends ConsumerState { return null; } - final restaurant = result.asData?.value; - if (restaurant == null) { - _showSnack('조건에 맞는 식당이 존재하지 않습니다', backgroundColor: AppColors.lightError); + if (recommendation == null) { + _showSnack( + '조건에 맞는 식당이 존재하지 않습니다. 광고는 재생되지 않았습니다.', + backgroundColor: AppColors.lightError, + ); } - return restaurant; + return recommendation; } - void _showRecommendationDialog(Restaurant restaurant) { - showDialog( + Future _showRecommendationDialog( + Restaurant restaurant, { + DateTime? recommendedAt, + }) async { + final result = await showDialog( context: context, barrierDismissible: false, - builder: (dialogContext) => RecommendationResultDialog( - restaurant: restaurant, - onReroll: () async { - Navigator.pop(dialogContext); - await _startRecommendation(skipAd: true); - }, - onClose: () async { - Navigator.pop(dialogContext); - await _handleRecommendationAccepted(restaurant); - }, - ), + builder: (dialogContext) => + RecommendationResultDialog(restaurant: restaurant), ); + + if (!mounted) return; + + switch (result) { + case RecommendationDialogResult.reroll: + setState(() { + _excludedRestaurantIds.add(restaurant.id); + }); + await _startRecommendation(skipAd: true, isReroll: true); + break; + case RecommendationDialogResult.confirm: + case RecommendationDialogResult.autoConfirm: + default: + await _handleRecommendationAccepted( + restaurant, + recommendedAt ?? DateTime.now(), + ); + break; + } } - Future _handleRecommendationAccepted(Restaurant restaurant) async { - final recommendationTime = DateTime.now(); - + Future _handleRecommendationAccepted( + Restaurant restaurant, + DateTime recommendationTime, + ) async { try { + final recommendationNotifier = ref.read( + recommendationNotifierProvider.notifier, + ); + await recommendationNotifier.saveRecommendationRecord( + restaurant, + recommendationTime: recommendationTime, + ); + final notificationEnabled = await ref.read( notificationEnabledProvider.future, ); - if (notificationEnabled) { - final delayMinutes = await ref.read( - notificationDelayMinutesProvider.future, - ); + bool notificationScheduled = false; + if (notificationEnabled && !kIsWeb) { final notificationService = ref.read(notificationServiceProvider); - await notificationService.scheduleVisitReminder( - restaurantId: restaurant.id, - restaurantName: restaurant.name, - recommendationTime: recommendationTime, - delayMinutes: delayMinutes, + final notificationReady = await notificationService.ensureInitialized( + requestPermission: true, ); + + if (notificationReady) { + final delayMinutes = await ref.read( + notificationDelayMinutesProvider.future, + ); + await notificationService.scheduleVisitReminder( + restaurantId: restaurant.id, + restaurantName: restaurant.name, + recommendationTime: recommendationTime, + delayMinutes: delayMinutes, + ); + notificationScheduled = await notificationService + .hasVisitReminderScheduled(); + } } await ref @@ -574,7 +623,19 @@ class _RandomSelectionScreenState extends ConsumerState { recommendationTime: recommendationTime, ); - _showSnack('맛있게 드세요! 🍴'); + if (notificationEnabled && !notificationScheduled && !kIsWeb) { + _showSnack( + '방문 기록은 저장됐지만 알림 권한이나 설정을 확인해 주세요. 방문 알림을 예약하지 못했습니다.', + backgroundColor: AppColors.lightError, + ); + } else { + _showSnack('맛있게 드세요! 🍴'); + } + if (mounted) { + setState(() { + _excludedRestaurantIds.clear(); + }); + } } catch (_) { _showSnack( '방문 기록 또는 알림 예약에 실패했습니다.', @@ -588,10 +649,22 @@ class _RandomSelectionScreenState extends ConsumerState { Color backgroundColor = AppColors.lightPrimary, }) { if (!mounted) return; + final topInset = MediaQuery.of(context).viewPadding.top; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - SnackBar(content: Text(message), backgroundColor: backgroundColor), + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.fromLTRB( + 16, + (topInset > 0 ? topInset : 16) + 8, + 16, + 0, + ), + duration: const Duration(seconds: 3), + ), ); } } diff --git a/lib/presentation/pages/random_selection/widgets/recommendation_result_dialog.dart b/lib/presentation/pages/random_selection/widgets/recommendation_result_dialog.dart index ab0e164..c96251e 100644 --- a/lib/presentation/pages/random_selection/widgets/recommendation_result_dialog.dart +++ b/lib/presentation/pages/random_selection/widgets/recommendation_result_dialog.dart @@ -1,215 +1,258 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:lunchpick/core/constants/app_colors.dart'; import 'package:lunchpick/core/constants/app_typography.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; -class RecommendationResultDialog extends StatelessWidget { +enum RecommendationDialogResult { confirm, reroll, autoConfirm } + +class RecommendationResultDialog extends StatefulWidget { final Restaurant restaurant; - final Future Function() onReroll; - final Future Function() onClose; + final Duration autoConfirmDuration; const RecommendationResultDialog({ super.key, required this.restaurant, - required this.onReroll, - required this.onClose, + this.autoConfirmDuration = const Duration(seconds: 12), }); + @override + State createState() => + _RecommendationResultDialogState(); +} + +class _RecommendationResultDialogState + extends State { + Timer? _autoConfirmTimer; + bool _didComplete = false; + + @override + void initState() { + super.initState(); + _startAutoConfirmTimer(); + } + + @override + void dispose() { + _autoConfirmTimer?.cancel(); + super.dispose(); + } + + void _startAutoConfirmTimer() { + _autoConfirmTimer = Timer(widget.autoConfirmDuration, () { + if (!mounted || _didComplete) return; + _didComplete = true; + Navigator.of(context).pop(RecommendationDialogResult.autoConfirm); + }); + } + + Future _handleResult(RecommendationDialogResult result) async { + if (_didComplete) return; + _didComplete = true; + _autoConfirmTimer?.cancel(); + Navigator.of(context).pop(result); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - return Dialog( - backgroundColor: Colors.transparent, - child: Container( - decoration: BoxDecoration( - color: isDark ? AppColors.darkSurface : AppColors.lightSurface, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 상단 이미지 영역 - Container( - height: 150, - decoration: BoxDecoration( - color: AppColors.lightPrimary, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), + return WillPopScope( + onWillPop: () async { + await _handleResult(RecommendationDialogResult.confirm); + return true; + }, + child: Dialog( + backgroundColor: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 150, + decoration: BoxDecoration( + color: AppColors.lightPrimary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), ), - ), - child: Stack( - children: [ - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.restaurant_menu, - size: 64, - color: Colors.white, - ), - const SizedBox(height: 8), - Text( - '오늘의 추천!', - style: AppTypography.heading2( - false, - ).copyWith(color: Colors.white), - ), - ], - ), - ), - Positioned( - top: 8, - right: 8, - child: IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () async { - await onClose(); - }, - ), - ), - ], - ), - ), - - // 맛집 정보 - Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 가게 이름 - Center( - child: Text( - restaurant.name, - style: AppTypography.heading1(isDark), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 8), - - // 카테고리 - Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: AppColors.lightPrimary.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${restaurant.category} > ${restaurant.subCategory}', - style: AppTypography.body2( - isDark, - ).copyWith(color: AppColors.lightPrimary), + child: Stack( + children: [ + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.restaurant_menu, + size: 64, + color: Colors.white, + ), + const SizedBox(height: 8), + Text( + '오늘의 추천!', + style: AppTypography.heading2( + false, + ).copyWith(color: Colors.white), + ), + ], ), ), - ), - - if (restaurant.description != null) ...[ - const SizedBox(height: 16), - Text( - restaurant.description!, - style: AppTypography.body2(isDark), - textAlign: TextAlign.center, + Positioned( + top: 8, + right: 8, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () async { + await _handleResult( + RecommendationDialogResult.confirm, + ); + }, + ), ), ], - - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 16), - - // 주소 - Row( - children: [ - Icon( - Icons.location_on, - size: 20, - color: isDark - ? AppColors.darkTextSecondary - : AppColors.lightTextSecondary, + ), + ), + Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Text( + widget.restaurant.name, + style: AppTypography.heading1(isDark), + textAlign: TextAlign.center, ), - const SizedBox(width: 8), - Expanded( + ), + const SizedBox(height: 8), + Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.lightPrimary.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), child: Text( - restaurant.roadAddress, - style: AppTypography.body2(isDark), + '${widget.restaurant.category} > ${widget.restaurant.subCategory}', + style: AppTypography.body2( + isDark, + ).copyWith(color: AppColors.lightPrimary), ), ), + ), + if (widget.restaurant.description != null) ...[ + const SizedBox(height: 16), + Text( + widget.restaurant.description!, + style: AppTypography.body2(isDark), + textAlign: TextAlign.center, + ), ], - ), - - if (restaurant.phoneNumber != null) ...[ - const SizedBox(height: 8), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), Row( children: [ Icon( - Icons.phone, + Icons.location_on, size: 20, color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, ), const SizedBox(width: 8), - Text( - restaurant.phoneNumber!, - style: AppTypography.body2(isDark), + Expanded( + child: Text( + widget.restaurant.roadAddress, + style: AppTypography.body2(isDark), + ), ), ], ), - ], - - const SizedBox(height: 24), - - // 버튼들 - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () async { - await onReroll(); - }, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - side: const BorderSide( - color: AppColors.lightPrimary, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + if (widget.restaurant.phoneNumber != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.phone, + size: 20, + color: isDark + ? AppColors.darkTextSecondary + : AppColors.lightTextSecondary, ), - child: const Text( - '다시 뽑기', - style: TextStyle(color: AppColors.lightPrimary), + const SizedBox(width: 8), + Text( + widget.restaurant.phoneNumber!, + style: AppTypography.body2(isDark), ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton( - onPressed: () async { - await onClose(); - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - backgroundColor: AppColors.lightPrimary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: const Text('닫기'), - ), + ], ), ], - ), - ], + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () async { + await _handleResult( + RecommendationDialogResult.reroll, + ); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + side: const BorderSide( + color: AppColors.lightPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '다시 뽑기', + style: TextStyle(color: AppColors.lightPrimary), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () async { + await _handleResult( + RecommendationDialogResult.confirm, + ); + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + backgroundColor: AppColors.lightPrimary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('닫기'), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '조용히 두면 자동으로 방문 처리되고 알림이 예약됩니다.', + style: AppTypography.caption(isDark), + textAlign: TextAlign.center, + ), + ], + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/presentation/pages/share/share_screen.dart b/lib/presentation/pages/share/share_screen.dart index 4ec61b6..34a55a3 100644 --- a/lib/presentation/pages/share/share_screen.dart +++ b/lib/presentation/pages/share/share_screen.dart @@ -290,7 +290,16 @@ class _ShareScreenState extends ConsumerState { } Future _generateShareCode() async { + final hasPermission = + await PermissionService.checkAndRequestBluetoothPermission(); + if (!hasPermission) { + if (!mounted) return; + _showErrorSnackBar('블루투스 권한을 허용해야 공유 코드를 생성할 수 있어요.'); + return; + } + final adService = ref.read(adServiceProvider); + if (!mounted) return; final adWatched = await adService.showInterstitialAd(context); if (!mounted) return; if (!adWatched) { @@ -301,12 +310,17 @@ class _ShareScreenState extends ConsumerState { final random = Random(); final code = List.generate(6, (_) => random.nextInt(10)).join(); - setState(() { - _shareCode = code; - }); - - await ref.read(bluetoothServiceProvider).startListening(code); - _showSuccessSnackBar('공유 코드가 생성되었습니다.'); + try { + await ref.read(bluetoothServiceProvider).startListening(code); + if (!mounted) return; + setState(() { + _shareCode = code; + }); + _showSuccessSnackBar('공유 코드가 생성되었습니다.'); + } catch (_) { + if (!mounted) return; + _showErrorSnackBar('코드를 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.'); + } } Future _scanDevices() async { diff --git a/lib/presentation/providers/di_providers.dart b/lib/presentation/providers/di_providers.dart index 0e0051e..f946238 100644 --- a/lib/presentation/providers/di_providers.dart +++ b/lib/presentation/providers/di_providers.dart @@ -4,6 +4,9 @@ import 'package:lunchpick/data/repositories/visit_repository_impl.dart'; import 'package:lunchpick/data/repositories/settings_repository_impl.dart'; import 'package:lunchpick/data/repositories/weather_repository_impl.dart'; import 'package:lunchpick/data/repositories/recommendation_repository_impl.dart'; +import 'package:lunchpick/data/datasources/remote/naver_url_processor.dart'; +import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; +import 'package:lunchpick/data/api/naver_api_client.dart'; import 'package:lunchpick/domain/repositories/restaurant_repository.dart'; import 'package:lunchpick/domain/repositories/visit_repository.dart'; import 'package:lunchpick/domain/repositories/settings_repository.dart'; @@ -36,3 +39,21 @@ final recommendationRepositoryProvider = Provider(( ) { return RecommendationRepositoryImpl(); }); + +/// NaverApiClient Provider +final naverApiClientProvider = Provider((ref) { + return NaverApiClient(); +}); + +/// NaverMapParser Provider +final naverMapParserProvider = Provider((ref) { + final apiClient = ref.watch(naverApiClientProvider); + return NaverMapParser(apiClient: apiClient); +}); + +/// NaverUrlProcessor Provider +final naverUrlProcessorProvider = Provider((ref) { + final apiClient = ref.watch(naverApiClientProvider); + final parser = ref.watch(naverMapParserProvider); + return NaverUrlProcessor(apiClient: apiClient, mapParser: parser); +}); diff --git a/lib/presentation/providers/location_provider.dart b/lib/presentation/providers/location_provider.dart index 4aeeeeb..e48a007 100644 --- a/lib/presentation/providers/location_provider.dart +++ b/lib/presentation/providers/location_provider.dart @@ -1,7 +1,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:permission_handler/permission_handler.dart'; +const double _defaultLatitude = 37.5666805; +const double _defaultLongitude = 126.9784147; + +/// 위치 정보를 사용할 수 없을 때 활용하는 기본 좌표(서울 시청). +Position defaultPosition() { + return Position( + latitude: _defaultLatitude, + longitude: _defaultLongitude, + timestamp: DateTime.now(), + accuracy: 0, + altitude: 0, + altitudeAccuracy: 0, + heading: 0, + headingAccuracy: 0, + speed: 0, + speedAccuracy: 0, + isMocked: false, + ); +} + /// 위치 권한 상태 Provider final locationPermissionProvider = FutureProvider(( ref, @@ -18,14 +39,16 @@ final currentLocationProvider = FutureProvider((ref) async { // 권한이 없으면 요청 final result = await Permission.location.request(); if (!result.isGranted) { - return null; + AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청) 사용'); + return defaultPosition(); } } // 위치 서비스 활성화 확인 final serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { - throw Exception('위치 서비스가 비활성화되어 있습니다'); + AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청) 사용'); + return defaultPosition(); } // 현재 위치 가져오기 @@ -36,7 +59,12 @@ final currentLocationProvider = FutureProvider((ref) async { ); } catch (e) { // 타임아웃이나 오류 발생 시 마지막 알려진 위치 반환 - return await Geolocator.getLastKnownPosition(); + final lastPosition = await Geolocator.getLastKnownPosition(); + if (lastPosition != null) { + return lastPosition; + } + AppLogger.debug('현재 위치를 가져오지 못해 기본 좌표(서울 시청)를 반환'); + return defaultPosition(); } }); @@ -83,7 +111,8 @@ class LocationNotifier extends StateNotifier> { if (!permissionStatus.isGranted) { final granted = await requestLocationPermission(); if (!granted) { - state = const AsyncValue.data(null); + AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청)로 대체'); + state = AsyncValue.data(defaultPosition()); return; } } @@ -91,7 +120,8 @@ class LocationNotifier extends StateNotifier> { // 위치 서비스 확인 final serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { - state = AsyncValue.error('위치 서비스가 비활성화되어 있습니다', StackTrace.current); + AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청)로 대체'); + state = AsyncValue.data(defaultPosition()); return; } @@ -102,13 +132,18 @@ class LocationNotifier extends StateNotifier> { ); state = AsyncValue.data(position); - } catch (e, stack) { + } catch (e) { // 오류 발생 시 마지막 알려진 위치 시도 try { final lastPosition = await Geolocator.getLastKnownPosition(); - state = AsyncValue.data(lastPosition); + if (lastPosition != null) { + state = AsyncValue.data(lastPosition); + } else { + AppLogger.debug('마지막 위치도 없어 기본 좌표(서울 시청)로 대체'); + state = AsyncValue.data(defaultPosition()); + } } catch (_) { - state = AsyncValue.error(e, stack); + state = AsyncValue.data(defaultPosition()); } } } diff --git a/lib/presentation/providers/notification_handler_provider.dart b/lib/presentation/providers/notification_handler_provider.dart index 66bcdc3..3500659 100644 --- a/lib/presentation/providers/notification_handler_provider.dart +++ b/lib/presentation/providers/notification_handler_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:lunchpick/presentation/pages/calendar/widgets/visit_confirmation_dialog.dart'; import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; @@ -54,8 +55,8 @@ class NotificationPayload { ); } catch (e) { // 더 상세한 오류 정보 제공 - print('NotificationPayload parsing error: $e'); - print('Original payload: $payload'); + AppLogger.error('NotificationPayload parsing error', error: e); + AppLogger.debug('Original payload: $payload'); rethrow; } } @@ -77,17 +78,17 @@ class NotificationHandlerNotifier extends StateNotifier> { String? payload, ) async { if (payload == null || payload.isEmpty) { - print('Notification payload is null or empty'); + AppLogger.debug('Notification payload is null or empty'); return; } - print('Handling notification with payload: $payload'); + AppLogger.debug('Handling notification with payload: $payload'); try { // 기존 형식 (visit_reminder:restaurantName) 처리 if (payload.startsWith('visit_reminder:')) { final restaurantName = payload.substring(15); - print('Legacy format - Restaurant name: $restaurantName'); + AppLogger.debug('Legacy format - Restaurant name: $restaurantName'); // 맛집 이름으로 ID 찾기 final restaurantsAsync = await _ref.read(restaurantListProvider.future); @@ -110,11 +111,11 @@ class NotificationHandlerNotifier extends StateNotifier> { } } else { // 새로운 형식의 payload 처리 - print('Attempting to parse new format payload'); + AppLogger.debug('Attempting to parse new format payload'); try { final notificationPayload = NotificationPayload.fromString(payload); - print( + AppLogger.debug( 'Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}', ); @@ -135,8 +136,10 @@ class NotificationHandlerNotifier extends StateNotifier> { } } } catch (parseError) { - print('Failed to parse new format, attempting fallback parsing'); - print('Parse error: $parseError'); + AppLogger.debug( + 'Failed to parse new format, attempting fallback parsing', + ); + AppLogger.debug('Parse error: $parseError'); // Fallback: 간단한 파싱 시도 if (payload.contains('|')) { @@ -158,8 +161,11 @@ class NotificationHandlerNotifier extends StateNotifier> { } } } catch (e, stackTrace) { - print('Error handling notification: $e'); - print('Stack trace: $stackTrace'); + AppLogger.error( + 'Error handling notification', + error: e, + stackTrace: stackTrace, + ); state = AsyncValue.error(e, stackTrace); // 에러 발생 시 기본적으로 캘린더 화면으로 이동 diff --git a/lib/presentation/providers/recommendation_provider.dart b/lib/presentation/providers/recommendation_provider.dart index 2c475f9..be260a3 100644 --- a/lib/presentation/providers/recommendation_provider.dart +++ b/lib/presentation/providers/recommendation_provider.dart @@ -50,74 +50,109 @@ class RecommendationNotifier extends StateNotifier> { : super(const AsyncValue.data(null)); /// 랜덤 추천 실행 - Future getRandomRecommendation({ + Future getRandomRecommendation({ required double maxDistance, required List selectedCategories, + List excludedRestaurantIds = const [], + bool shouldSaveRecord = true, }) async { state = const AsyncValue.loading(); try { - // 현재 위치 가져오기 - final location = await _ref.read(currentLocationProvider.future); - if (location == null) { - throw Exception('위치 정보를 가져올 수 없습니다'); - } - - // 날씨 정보 가져오기 - final weather = await _ref.read(weatherProvider.future); - - // 사용자 설정 가져오기 - final userSettings = await _ref.read(userSettingsProvider.future); - - // 모든 식당 가져오기 - final allRestaurants = await _ref.read(restaurantListProvider.future); - - // 방문 기록 가져오기 - final allVisitRecords = await _ref.read(visitRecordsProvider.future); - - // 추천 설정 구성 - final config = RecommendationConfig( - userLatitude: location.latitude, - userLongitude: location.longitude, + final selectedRestaurant = await _generateCandidate( maxDistance: maxDistance, selectedCategories: selectedCategories, - userSettings: userSettings, - weather: weather, + excludedRestaurantIds: excludedRestaurantIds, ); - // 추천 엔진 사용 - final selectedRestaurant = await _recommendationEngine - .generateRecommendation( - allRestaurants: allRestaurants, - recentVisits: allVisitRecords, - config: config, - ); - if (selectedRestaurant == null) { state = const AsyncValue.data(null); - return; + return null; } - // 추천 기록 저장 - await _saveRecommendationRecord(selectedRestaurant); + if (shouldSaveRecord) { + await saveRecommendationRecord(selectedRestaurant); + } state = AsyncValue.data(selectedRestaurant); + return selectedRestaurant; } catch (e, stack) { state = AsyncValue.error(e, stack); + return null; } } + Future _generateCandidate({ + required double maxDistance, + required List selectedCategories, + List excludedRestaurantIds = const [], + }) async { + // 현재 위치 가져오기 + final location = await _ref.read(currentLocationProvider.future); + if (location == null) { + throw Exception('위치 정보를 가져올 수 없습니다'); + } + + // 날씨 정보 가져오기 + final weather = await _ref.read(weatherProvider.future); + + // 사용자 설정 가져오기 + final userSettings = await _ref.read(userSettingsProvider.future); + + // 모든 식당 가져오기 + final allRestaurants = await _ref.read(restaurantListProvider.future); + + // 방문 기록 가져오기 + final allVisitRecords = await _ref.read(visitRecordsProvider.future); + + // 제외된 식당 제거 + final availableRestaurants = excludedRestaurantIds.isEmpty + ? allRestaurants + : allRestaurants + .where( + (restaurant) => !excludedRestaurantIds.contains(restaurant.id), + ) + .toList(); + + if (availableRestaurants.isEmpty) { + return null; + } + + // 추천 설정 구성 + final config = RecommendationConfig( + userLatitude: location.latitude, + userLongitude: location.longitude, + maxDistance: maxDistance, + selectedCategories: selectedCategories, + userSettings: userSettings, + weather: weather, + ); + + // 추천 엔진 사용 + return _recommendationEngine.generateRecommendation( + allRestaurants: availableRestaurants, + recentVisits: allVisitRecords, + config: config, + ); + } + /// 추천 기록 저장 - Future _saveRecommendationRecord(Restaurant restaurant) async { + Future saveRecommendationRecord( + Restaurant restaurant, { + DateTime? recommendationTime, + }) async { + final now = DateTime.now(); + final record = RecommendationRecord( id: const Uuid().v4(), restaurantId: restaurant.id, - recommendationDate: DateTime.now(), + recommendationDate: recommendationTime ?? now, visited: false, - createdAt: DateTime.now(), + createdAt: now, ); await _repository.addRecommendationRecord(record); + return record; } /// 추천 후 방문 확인 diff --git a/lib/presentation/providers/weather_provider.dart b/lib/presentation/providers/weather_provider.dart index e2ec72f..59744b0 100644 --- a/lib/presentation/providers/weather_provider.dart +++ b/lib/presentation/providers/weather_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:lunchpick/domain/entities/weather_info.dart'; import 'package:lunchpick/domain/repositories/weather_repository.dart'; import 'package:lunchpick/presentation/providers/di_providers.dart'; @@ -7,12 +8,17 @@ import 'package:lunchpick/presentation/providers/location_provider.dart'; /// 현재 날씨 Provider final weatherProvider = FutureProvider((ref) async { final repository = ref.watch(weatherRepositoryProvider); - final location = await ref.watch(currentLocationProvider.future); + Position? location; - if (location == null) { - throw Exception('위치 정보를 가져올 수 없습니다'); + try { + location = await ref.watch(currentLocationProvider.future); + } catch (_) { + // 위치 호출 실패 시 기본 좌표 사용 + location = defaultPosition(); } + location ??= defaultPosition(); + // 캐시된 날씨 정보 확인 final cached = await repository.getCachedWeather(); if (cached != null) { diff --git a/test/integration/naver_api_integration_test.dart b/test/integration/naver_api_integration_test.dart index 4ea8705..2a1b1a3 100644 --- a/test/integration/naver_api_integration_test.dart +++ b/test/integration/naver_api_integration_test.dart @@ -1,4 +1,8 @@ +// ignore_for_file: unnecessary_library_name + @Skip('Requires live Naver API responses') +library naver_api_integration_test; + import 'package:flutter_test/flutter_test.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import '../mocks/mock_naver_api_client.dart'; diff --git a/test/integration/naver_integration_test.dart b/test/integration/naver_integration_test.dart index a3daa8f..3c32516 100644 --- a/test/integration/naver_integration_test.dart +++ b/test/integration/naver_integration_test.dart @@ -1,4 +1,8 @@ +// ignore_for_file: unnecessary_library_name + @Skip('Requires live Naver API responses') +library naver_integration_test; + import 'package:flutter_test/flutter_test.dart'; import 'package:lunchpick/data/api/naver_api_client.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; diff --git a/test/unit/data/api/naver_api_client_test.dart b/test/unit/data/api/naver_api_client_test.dart index 333a52f..fa803eb 100644 --- a/test/unit/data/api/naver_api_client_test.dart +++ b/test/unit/data/api/naver_api_client_test.dart @@ -1,6 +1,10 @@ +// ignore_for_file: unnecessary_library_name + @Skip( 'NaverApiClient unit tests require mocking Dio behavior not yet implemented', ) +library naver_api_client_test; + import 'package:flutter_test/flutter_test.dart'; import 'package:dio/dio.dart'; import 'package:mocktail/mocktail.dart'; diff --git a/test/unit/data/datasources/remote/naver_map_parser_test.dart b/test/unit/data/datasources/remote/naver_map_parser_test.dart index c6145a8..772b5eb 100644 --- a/test/unit/data/datasources/remote/naver_map_parser_test.dart +++ b/test/unit/data/datasources/remote/naver_map_parser_test.dart @@ -1,4 +1,8 @@ +// ignore_for_file: unnecessary_library_name + @Skip('Integration-heavy parser tests are temporarily disabled') +library naver_map_parser_test; + import 'package:flutter_test/flutter_test.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; diff --git a/test/unit/data/datasources/remote/naver_parser_location_test.dart b/test/unit/data/datasources/remote/naver_parser_location_test.dart index acdf492..9395aff 100644 --- a/test/unit/data/datasources/remote/naver_parser_location_test.dart +++ b/test/unit/data/datasources/remote/naver_parser_location_test.dart @@ -1,4 +1,8 @@ +// ignore_for_file: unnecessary_library_name + @Skip('Integration-heavy parser tests are temporarily disabled') +library naver_parser_location_test; + import 'package:flutter_test/flutter_test.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/data/api/naver/naver_local_search_api.dart'; diff --git a/test/unit/data/datasources/remote/naver_parser_v2_test.dart b/test/unit/data/datasources/remote/naver_parser_v2_test.dart index e9700fc..cdd5470 100644 --- a/test/unit/data/datasources/remote/naver_parser_v2_test.dart +++ b/test/unit/data/datasources/remote/naver_parser_v2_test.dart @@ -1,4 +1,8 @@ +// ignore_for_file: unnecessary_library_name + @Skip('Integration-heavy parser tests are temporarily disabled') +library naver_parser_v2_test; + import 'package:flutter_test/flutter_test.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/core/errors/network_exceptions.dart'; diff --git a/test/unit/data/datasources/remote/naver_url_redirect_test.dart b/test/unit/data/datasources/remote/naver_url_redirect_test.dart index 9ed357b..c2c54a7 100644 --- a/test/unit/data/datasources/remote/naver_url_redirect_test.dart +++ b/test/unit/data/datasources/remote/naver_url_redirect_test.dart @@ -1,4 +1,8 @@ +// ignore_for_file: unnecessary_library_name + @Skip('Integration-heavy parser tests are temporarily disabled') +library naver_url_redirect_test; + import 'package:flutter_test/flutter_test.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; diff --git a/test/unit/data/repositories/restaurant_repository_impl_test.dart b/test/unit/data/repositories/restaurant_repository_impl_test.dart new file mode 100644 index 0000000..aeb2db5 --- /dev/null +++ b/test/unit/data/repositories/restaurant_repository_impl_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:lunchpick/data/repositories/restaurant_repository_impl.dart'; +import 'package:lunchpick/data/datasources/remote/naver_search_service.dart'; +import 'package:lunchpick/domain/entities/restaurant.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockNaverSearchService extends Mock implements NaverSearchService {} + +void main() { + late Directory tempDir; + late RestaurantRepositoryImpl repository; + late _MockNaverSearchService mockSearchService; + + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + if (!Hive.isAdapterRegistered(0)) { + Hive.registerAdapter(RestaurantAdapter()); + } + if (!Hive.isAdapterRegistered(1)) { + Hive.registerAdapter(DataSourceAdapter()); + } + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('hive_restaurant_test'); + Hive.init(tempDir.path); + mockSearchService = _MockNaverSearchService(); + repository = RestaurantRepositoryImpl( + naverSearchService: mockSearchService, + ); + }); + + tearDown(() async { + await Hive.deleteBoxFromDisk('restaurants'); + Hive.close(); + await tempDir.delete(recursive: true); + }); + + Restaurant buildDummyRestaurant({String id = 'r1'}) { + return Restaurant( + id: id, + name: 'Test Place $id', + category: 'korean', + subCategory: 'bbq', + description: 'great food', + phoneNumber: '010-0000-0000', + roadAddress: '서울시 중구 세종대로 110', + jibunAddress: '서울시 중구 태평로1가 31', + latitude: 37.5665, + longitude: 126.9780, + lastVisitDate: null, + source: DataSource.USER_INPUT, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + naverPlaceId: '123', + naverUrl: 'https://map.naver.com/p/restaurant/123', + businessHours: '00:00-24:00', + lastVisited: null, + visitCount: 0, + ); + } + + test('add/get/update/delete works with Hive storage', () async { + final restaurant = buildDummyRestaurant(); + + await repository.addRestaurant(restaurant); + final fetched = await repository.getRestaurantById(restaurant.id); + expect(fetched?.name, restaurant.name); + + final updated = restaurant.copyWith(name: 'Updated'); + await repository.updateRestaurant(updated); + final fetchedUpdated = await repository.getRestaurantById(restaurant.id); + expect(fetchedUpdated?.name, 'Updated'); + + await repository.deleteRestaurant(restaurant.id); + final deleted = await repository.getRestaurantById(restaurant.id); + expect(deleted, isNull); + }); + + test('addRestaurantFromUrl persists parsed restaurant', () async { + final restaurant = buildDummyRestaurant(id: 'r2'); + when( + () => mockSearchService.getRestaurantFromUrl(any()), + ).thenAnswer((_) async => restaurant); + + final result = await repository.addRestaurantFromUrl( + 'https://map.naver.com/p/123', + ); + + expect(result.id, restaurant.id); + final stored = await repository.getRestaurantById(restaurant.id); + expect(stored, isNotNull); + verify(() => mockSearchService.getRestaurantFromUrl(any())).called(1); + }); + + test('previewRestaurantFromUrl does not persist', () async { + final restaurant = buildDummyRestaurant(id: 'preview'); + when( + () => mockSearchService.getRestaurantFromUrl(any()), + ).thenAnswer((_) async => restaurant); + + final preview = await repository.previewRestaurantFromUrl( + 'https://naver.me/abc', + ); + + expect(preview.id, restaurant.id); + final stored = await repository.getRestaurantById(restaurant.id); + expect(stored, isNull); + }); +} diff --git a/test/unit/domain/usecases/recommendation_engine_test.dart b/test/unit/domain/usecases/recommendation_engine_test.dart index b8bac95..181f466 100644 --- a/test/unit/domain/usecases/recommendation_engine_test.dart +++ b/test/unit/domain/usecases/recommendation_engine_test.dart @@ -1,6 +1,10 @@ +// ignore_for_file: unnecessary_library_name + @Skip( 'RecommendationEngine tests temporarily disabled pending deterministic fixtures', ) +library recommendation_engine_test; + import 'package:flutter_test/flutter_test.dart'; import 'package:lunchpick/domain/usecases/recommendation_engine.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; diff --git a/test/widget/add_restaurant_dialog_test.dart b/test/widget/add_restaurant_dialog_test.dart index db8dfd7..3e49a9d 100644 --- a/test/widget/add_restaurant_dialog_test.dart +++ b/test/widget/add_restaurant_dialog_test.dart @@ -1,4 +1,8 @@ +// ignore_for_file: unnecessary_library_name + @Skip('AddRestaurantDialog layout changed; widget test disabled temporarily') +library add_restaurant_dialog_test; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';