feat(app): finalize ad gated flows and weather

- add AppLogger and replace scattered print logging\n- implement ad-gated recommendation flow with reminder handling and calendar link\n- complete Bluetooth share pipeline with ad gate and merge\n- integrate KMA weather API with caching and dart-define decoding\n- add NaverUrlProcessor refactor and restore restaurant repository tests
This commit is contained in:
JiWoong Sul
2025-11-22 00:10:51 +09:00
parent 947fe59486
commit 2a01fa50c6
43 changed files with 1777 additions and 571 deletions

View File

@@ -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. - 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`. - 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. - 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 ## Collaboration & Language
- 기본 응답은 한국어로 작성하고, 코드/로그/명령어는 원문을 유지합니다. - 기본 응답은 한국어로 작성하고, 코드/로그/명령어는 원문을 유지합니다.
@@ -45,6 +46,11 @@ Never commit API secrets. Instead, create `lib/core/constants/api_keys.dart` loc
- Create task branches as `codex/<type>-<slug>` (e.g., `codex/fix-search-null`). - Create task branches as `codex/<type>-<slug>` (e.g., `codex/fix-search-null`).
- Continue using `type(scope): summary` commit messages, but keep explanations short and focused on observable behavior changes. - 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 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 ## 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. - Every file/class should have a single reason to change; split widgets over ~400 lines and methods over ~60 lines into helpers.

View File

@@ -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] 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`에서 직접 입력을 처리합니다. - [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일/거리 조건을 만족하는 식당을 찾으면 팝업으로 노출하고, 다시 추천받기/닫기 버튼을 제공하며, 닫거나 추가 행동 없이 유지되면 방문 처리 및 옵션으로 지정한 시간 이후 푸시 알림을 예약해야 한다. 조건에 맞는 식당이 없으면 광고 없이 “조건에 맞는 식당이 존재하지 않습니다” 토스트만 출력한다. - [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). 임시 광고(닫기 포함) 재생 후 조건 충족 시 추천 팝업을 띄우고, 닫기/자동확정 시 방문 기록·알림 예약을 처리하며 다시 추천은 광고 없이 제외 목록을 적용한다. 조건 불충족 시 광고 없이 토스트를 노출한다.
- [ ] P2P 리스트 공유 기능(광고 시청(임시화면만 제공) → 코드 생성 → Bluetooth 스캔/수신 → JSON 병합)을 서비스 계층(bluetoothServiceProvider, adServiceProvider, PermissionService 등)과 함께 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1874-1978, lib/presentation/pages/share/share_screen.dart:13-218). 현재 화면은 단순 토글과 더미 코드만 제공하며 요구된 광고 게이팅·기기 리스트·데이터 병합 로직이 없습니다. - [x] P2P 리스트 공유 기능(광고 시청(임시화면만 제공) → 코드 생성 → Bluetooth 스캔/수신 → JSON 병합)을 서비스 계층(bluetoothServiceProvider, adServiceProvider, PermissionService 등)과 함께 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1874-1978, lib/presentation/pages/share/share_screen.dart:13-218). 공유 코드 생성 시 블루투스 권한 확인과 광고 게이팅을 적용하고, 수신 데이터는 광고 시청 후 중복 제거 병합하며, 스캔/전송/취소 흐름을 UI에 연결했습니다.
- [ ] 실시간 날씨 API(기상청) 연동 및 캐시 무효화 로직 구현하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:306-329, lib/data/repositories/weather_repository_impl.dart:16-92). 지금은 더미 WeatherInfo를 반환하고 있어 추천 화면의 날씨 카드가 실제 데이터를 사용하지 못합니다. - [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`으로 주입해야 동작합니다.
- [ ] 방문 캘린더에서 추천 이력(`recommendationHistoryProvider`)과 방문 기록을 함께 로딩하고 카드 액션(방문 확인)까지 연결하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:2055-2127, lib/presentation/pages/calendar/calendar_screen.dart:18-210). 구현본은 `visitRecordsProvider`만 사용하며 추천 기록, 방문 확인 버튼, 추천 이벤트 마커가 모두 빠져 있습니다. - [x] 방문 캘린더에서 추천 이력(`recommendationHistoryProvider`)과 방문 기록을 함께 로딩하고 카드 액션(방문 확인)까지 연결하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:2055-2127, lib/presentation/pages/calendar/calendar_screen.dart:18-210). 추천 기록을 함께 로딩해 마커/목록에 표시하고, 추천 카드에서 방문 확인 시 `RecommendationNotifier.confirmVisit`를 호출하도록 연계했습니다.
- [ ] 문서에서 요구한 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에 산재해 있어 요구된 책임 분리가 이뤄지지 않습니다. - [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을 처리하여 중복 호출을 줄입니다.
- [ ] `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] `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] RestaurantRepositoryImpl 단위 테스트를 복구하고 `path_provider` 초기화 문제를 해결하기 (doc/07_test_report_lunchpick.md:52-57, test/unit/data/repositories/restaurant_repository_impl_test.dart). Hive 임시 디렉터리 초기화와 어댑터 등록 후 CRUD/URL 추가/미리보기 흐름을 검증하는 단위 테스트를 복구했습니다.

View File

@@ -18,6 +18,14 @@ class ApiKeys {
static String get naverClientId => _decodeIfNeeded(_encodedClientId); static String get naverClientId => _decodeIfNeeded(_encodedClientId);
static String get naverClientSecret => _decodeIfNeeded(_encodedClientSecret); 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 = static const String naverLocalSearchEndpoint =
'https://openapi.naver.com/v1/search/local.json'; 'https://openapi.naver.com/v1/search/local.json';

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../../utils/app_logger.dart';
/// 로깅 인터셉터 /// 로깅 인터셉터
/// ///
@@ -13,16 +14,18 @@ class LoggingInterceptor extends Interceptor {
final method = options.method; final method = options.method;
final headers = options.headers; final headers = options.headers;
print('═══════════════════════════════════════════════════════════════'); AppLogger.debug(
print('>>> REQUEST [$method] $uri'); '═══════════════════════════════════════════════════════════════',
print('>>> Headers: $headers'); );
AppLogger.debug('>>> REQUEST [$method] $uri');
AppLogger.debug('>>> Headers: $headers');
if (options.data != null) { if (options.data != null) {
print('>>> Body: ${options.data}'); AppLogger.debug('>>> Body: ${options.data}');
} }
if (options.queryParameters.isNotEmpty) { 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 statusCode = response.statusCode;
final uri = response.requestOptions.uri; final uri = response.requestOptions.uri;
print('<<< RESPONSE [$statusCode] $uri'); AppLogger.debug('<<< RESPONSE [$statusCode] $uri');
if (response.headers.map.isNotEmpty) { if (response.headers.map.isNotEmpty) {
print('<<< Headers: ${response.headers.map}'); AppLogger.debug('<<< Headers: ${response.headers.map}');
} }
// 응답 본문은 너무 길 수 있으므로 처음 500자만 출력 // 응답 본문은 너무 길 수 있으므로 처음 500자만 출력
final responseData = response.data.toString(); final responseData = response.data.toString();
if (responseData.length > 500) { if (responseData.length > 500) {
print('<<< Body: ${responseData.substring(0, 500)}...(truncated)'); AppLogger.debug(
'<<< Body: ${responseData.substring(0, 500)}...(truncated)',
);
} else { } else {
print('<<< Body: $responseData'); AppLogger.debug('<<< Body: $responseData');
} }
print('═══════════════════════════════════════════════════════════════'); AppLogger.debug(
'═══════════════════════════════════════════════════════════════',
);
} }
return handler.next(response); return handler.next(response);
@@ -61,17 +68,21 @@ class LoggingInterceptor extends Interceptor {
final uri = err.requestOptions.uri; final uri = err.requestOptions.uri;
final message = err.message; final message = err.message;
print('═══════════════════════════════════════════════════════════════'); AppLogger.debug(
print('!!! ERROR $uri'); '═══════════════════════════════════════════════════════════════',
print('!!! Message: $message'); );
AppLogger.debug('!!! ERROR $uri');
AppLogger.debug('!!! Message: $message');
if (err.response != null) { if (err.response != null) {
print('!!! Status Code: ${err.response!.statusCode}'); AppLogger.debug('!!! Status Code: ${err.response!.statusCode}');
print('!!! Response: ${err.response!.data}'); AppLogger.debug('!!! Response: ${err.response!.data}');
} }
print('!!! Error Type: ${err.type}'); AppLogger.debug('!!! Error Type: ${err.type}');
print('═══════════════════════════════════════════════════════════════'); AppLogger.debug(
'═══════════════════════════════════════════════════════════════',
);
} }
return handler.next(err); return handler.next(err);

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../network_config.dart'; import '../network_config.dart';
import '../../utils/app_logger.dart';
import '../../errors/network_exceptions.dart'; import '../../errors/network_exceptions.dart';
/// 재시도 인터셉터 /// 재시도 인터셉터
@@ -24,7 +25,7 @@ class RetryInterceptor extends Interceptor {
// 지수 백오프 계산 // 지수 백오프 계산
final delay = _calculateBackoffDelay(retryCount); final delay = _calculateBackoffDelay(retryCount);
print( AppLogger.debug(
'RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기', 'RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기',
); );
@@ -59,7 +60,7 @@ class RetryInterceptor extends Interceptor {
// 네이버 관련 요청은 재시도하지 않음 // 네이버 관련 요청은 재시도하지 않음
final url = err.requestOptions.uri.toString(); final url = err.requestOptions.uri.toString();
if (url.contains('naver.com') || url.contains('naver.me')) { if (url.contains('naver.com') || url.contains('naver.me')) {
print('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url'); AppLogger.debug('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
return false; return false;
} }

View File

@@ -1,9 +1,11 @@
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.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:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'network_config.dart'; import 'network_config.dart';
import '../errors/network_exceptions.dart'; import '../errors/network_exceptions.dart';
@@ -88,8 +90,12 @@ class NetworkClient {
); );
_dio.interceptors.add(DioCacheInterceptor(options: cacheOptions)); _dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
} catch (e) { } catch (e, stackTrace) {
debugPrint('NetworkClient: 캐시 설정 실패 - $e'); AppLogger.error(
'NetworkClient: 캐시 설정 실패 - $e',
error: e,
stackTrace: stackTrace,
);
// 캐시 실패해도 계속 진행 // 캐시 실패해도 계속 진행
} }
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz;
import '../utils/app_logger.dart';
/// 알림 서비스 싱글톤 클래스 /// 알림 서비스 싱글톤 클래스
class NotificationService { class NotificationService {
@@ -14,6 +15,7 @@ class NotificationService {
// Flutter Local Notifications 플러그인 // Flutter Local Notifications 플러그인
final FlutterLocalNotificationsPlugin _notifications = final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
bool _initialized = false;
// 알림 채널 정보 // 알림 채널 정보
static const String _channelId = 'lunchpick_visit_reminder'; static const String _channelId = 'lunchpick_visit_reminder';
@@ -22,11 +24,36 @@ class NotificationService {
// 알림 ID (방문 확인용) // 알림 ID (방문 확인용)
static const int _visitReminderNotificationId = 1; static const int _visitReminderNotificationId = 1;
bool _timezoneReady = false;
tz.Location? _cachedLocation;
/// 초기화 여부
bool get isInitialized => _initialized;
/// 초기화 및 권한 요청 보장
Future<bool> 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<bool> initialize() async { Future<bool> initialize() async {
if (_initialized) return true;
if (kIsWeb) return false;
// 시간대 초기화 // 시간대 초기화
tz.initializeTimeZones(); await _ensureLocalTimezone();
// Android 초기화 설정 // Android 초기화 설정
const androidInitSettings = AndroidInitializationSettings( const androidInitSettings = AndroidInitializationSettings(
@@ -58,18 +85,25 @@ class NotificationService {
); );
// 알림 플러그인 초기화 // 알림 플러그인 초기화
final initialized = await _notifications.initialize( try {
initSettings, final initialized = await _notifications.initialize(
onDidReceiveNotificationResponse: _onNotificationTap, initSettings,
onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap, onDidReceiveNotificationResponse: _onNotificationTap,
); onDidReceiveBackgroundNotificationResponse:
_onBackgroundNotificationTap,
);
_initialized = initialized ?? false;
} catch (e) {
_initialized = false;
AppLogger.debug('알림 초기화 실패: $e');
}
// Android 알림 채널 생성 (웹이 아닌 경우에만) // Android 알림 채널 생성 (웹이 아닌 경우에만)
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { if (_initialized && defaultTargetPlatform == TargetPlatform.android) {
await _createNotificationChannel(); await _createNotificationChannel();
} }
return initialized ?? false; return _initialized;
} }
/// Android 알림 채널 생성 /// Android 알림 채널 생성
@@ -92,25 +126,21 @@ class NotificationService {
/// 알림 권한 요청 /// 알림 권한 요청
Future<bool> requestPermission() async { Future<bool> requestPermission() async {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { if (kIsWeb) return false;
if (defaultTargetPlatform == TargetPlatform.android) {
final androidImplementation = _notifications final androidImplementation = _notifications
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin AndroidFlutterLocalNotificationsPlugin
>(); >();
if (androidImplementation != null) { if (androidImplementation != null) {
// Android 13 (API 33) 이상에서는 권한 요청이 필요 final granted = await androidImplementation
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) { .requestNotificationsPermission();
final granted = await androidImplementation return granted ?? false;
.requestNotificationsPermission();
return granted ?? false;
}
// Android 12 이하는 자동 허용
return true;
} }
} else if (!kIsWeb && } else if (defaultTargetPlatform == TargetPlatform.iOS ||
(defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS) {
defaultTargetPlatform == TargetPlatform.macOS)) {
final iosImplementation = _notifications final iosImplementation = _notifications
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin IOSFlutterLocalNotificationsPlugin
@@ -144,24 +174,43 @@ class NotificationService {
/// 권한 상태 확인 /// 권한 상태 확인
Future<bool> checkPermission() async { Future<bool> checkPermission() async {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { if (kIsWeb) return false;
if (defaultTargetPlatform == TargetPlatform.android) {
final androidImplementation = _notifications final androidImplementation = _notifications
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin AndroidFlutterLocalNotificationsPlugin
>(); >();
if (androidImplementation != null) { if (androidImplementation != null) {
// Android 13 이상에서만 권한 확인 final granted = await androidImplementation.areNotificationsEnabled();
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) { return granted ?? false;
final granted = await androidImplementation.areNotificationsEnabled();
return granted ?? false;
}
// Android 12 이하는 자동 허용
return true;
} }
} }
// 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; return true;
} }
@@ -176,9 +225,22 @@ class NotificationService {
int? delayMinutes, int? delayMinutes,
}) async { }) async {
try { 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 minutesToWait = delayMinutes ?? 90 + Random().nextInt(31);
final scheduledTime = tz.TZDateTime.now( final scheduledTime = tz.TZDateTime.now(
tz.local, location,
).add(Duration(minutes: minutesToWait)); ).add(Duration(minutes: minutesToWait));
// 알림 상세 설정 // 알림 상세 설정
@@ -215,45 +277,95 @@ class NotificationService {
'$restaurantName 어땠어요? 방문 기록을 남겨주세요!', '$restaurantName 어땠어요? 방문 기록을 남겨주세요!',
scheduledTime, scheduledTime,
notificationDetails, notificationDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, androidScheduleMode: await _resolveAndroidScheduleMode(),
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
payload: payload:
'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}', 'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
); );
if (kDebugMode) { AppLogger.debug('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
print('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
}
} catch (e) { } catch (e) {
if (kDebugMode) { AppLogger.debug('알림 예약 실패: $e');
print('알림 예약 실패: $e');
}
} }
} }
/// 예약된 방문 확인 알림 취소 /// 예약된 방문 확인 알림 취소
Future<void> cancelVisitReminder() async { Future<void> cancelVisitReminder() async {
if (!await ensureInitialized()) return;
await _notifications.cancel(_visitReminderNotificationId); await _notifications.cancel(_visitReminderNotificationId);
} }
/// 모든 알림 취소 /// 모든 알림 취소
Future<void> cancelAllNotifications() async { Future<void> cancelAllNotifications() async {
if (!await ensureInitialized()) return;
await _notifications.cancelAll(); await _notifications.cancelAll();
} }
/// 예약된 알림 목록 조회 /// 예약된 알림 목록 조회
Future<List<PendingNotificationRequest>> getPendingNotifications() async { Future<List<PendingNotificationRequest>> getPendingNotifications() async {
if (!await ensureInitialized()) return [];
return await _notifications.pendingNotificationRequests(); return await _notifications.pendingNotificationRequests();
} }
/// 방문 확인 알림이 예약되어 있는지 확인
Future<bool> hasVisitReminderScheduled() async {
if (!await ensureInitialized()) return false;
final pending = await getPendingNotifications();
return pending.any((item) => item.id == _visitReminderNotificationId);
}
/// 타임존을 안전하게 초기화하고 tz.local을 반환
Future<tz.Location> _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<AndroidScheduleMode> _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) { void _onNotificationTap(NotificationResponse response) {
if (onNotificationTap != null) { if (onNotificationTap != null) {
onNotificationTap!(response); onNotificationTap!(response);
} else if (response.payload != null) { } else if (response.payload != null) {
if (kDebugMode) { AppLogger.debug('알림 탭: ${response.payload}');
print('알림 탭: ${response.payload}');
}
} }
} }
@@ -263,9 +375,7 @@ class NotificationService {
if (onNotificationTap != null) { if (onNotificationTap != null) {
onNotificationTap!(response); onNotificationTap!(response);
} else if (response.payload != null) { } else if (response.payload != null) {
if (kDebugMode) { AppLogger.debug('백그라운드 알림 탭: ${response.payload}');
print('백그라운드 알림 탭: ${response.payload}');
}
} }
} }

View File

@@ -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());
}
}

View File

@@ -1,5 +1,5 @@
import 'package:dio/dio.dart'; 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_client.dart';
import '../../../core/errors/network_exceptions.dart'; import '../../../core/errors/network_exceptions.dart';
@@ -46,7 +46,11 @@ class NaverGraphQLApi {
return response.data!; return response.data!;
} on DioException catch (e) { } on DioException catch (e) {
debugPrint('fetchGraphQL error: $e'); AppLogger.error(
'fetchGraphQL error: $e',
error: e,
stackTrace: e.stackTrace,
);
throw ServerException( throw ServerException(
message: 'GraphQL 요청 중 오류가 발생했습니다', message: 'GraphQL 요청 중 오류가 발생했습니다',
statusCode: e.response?.statusCode ?? 500, statusCode: e.response?.statusCode ?? 500,
@@ -104,13 +108,17 @@ class NaverGraphQLApi {
); );
if (response['errors'] != null) { if (response['errors'] != null) {
debugPrint('GraphQL errors: ${response['errors']}'); AppLogger.error('GraphQL errors: ${response['errors']}');
throw ParseException(message: 'GraphQL 오류: ${response['errors']}'); throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
} }
return response['data']?['place'] ?? {}; return response['data']?['place'] ?? {};
} catch (e) { } catch (e, stackTrace) {
debugPrint('fetchKoreanTextsFromPcmap error: $e'); AppLogger.error(
'fetchKoreanTextsFromPcmap error: $e',
error: e,
stackTrace: stackTrace,
);
rethrow; rethrow;
} }
} }
@@ -150,8 +158,12 @@ class NaverGraphQLApi {
} }
return response['data']?['place'] ?? {}; return response['data']?['place'] ?? {};
} catch (e) { } catch (e, stackTrace) {
debugPrint('fetchPlaceBasicInfo error: $e'); AppLogger.error(
'fetchPlaceBasicInfo error: $e',
error: e,
stackTrace: stackTrace,
);
rethrow; rethrow;
} }
} }

View File

@@ -1,5 +1,5 @@
import 'package:dio/dio.dart'; 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/constants/api_keys.dart';
import '../../../core/network/network_client.dart'; import '../../../core/network/network_client.dart';
@@ -143,9 +143,13 @@ class NaverLocalSearchApi {
.map((item) => NaverLocalSearchResult.fromJson(item)) .map((item) => NaverLocalSearchResult.fromJson(item))
.toList(); .toList();
} on DioException catch (e) { } on DioException catch (e) {
debugPrint('NaverLocalSearchApi Error: ${e.message}'); AppLogger.error(
debugPrint('Error type: ${e.type}'); 'NaverLocalSearchApi error: ${e.message}',
debugPrint('Error response: ${e.response?.data}'); error: e,
stackTrace: e.stackTrace,
);
AppLogger.debug('Error type: ${e.type}');
AppLogger.debug('Error response: ${e.response?.data}');
if (e.error is NetworkException) { if (e.error is NetworkException) {
throw e.error!; throw e.error!;
@@ -189,8 +193,12 @@ class NaverLocalSearchApi {
// 정확한 매칭이 없으면 첫 번째 결과 반환 // 정확한 매칭이 없으면 첫 번째 결과 반환
return results.first; return results.first;
} catch (e) { } catch (e, stackTrace) {
debugPrint('searchRestaurantDetails error: $e'); AppLogger.error(
'searchRestaurantDetails error: $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
import '../../../core/network/network_client.dart'; import '../../../core/network/network_client.dart';
import '../../../core/network/network_config.dart'; import '../../../core/network/network_config.dart';
@@ -22,7 +23,7 @@ class NaverProxyClient {
try { try {
final proxyUrl = NetworkConfig.getCorsProxyUrl(url); final proxyUrl = NetworkConfig.getCorsProxyUrl(url);
debugPrint('Using proxy URL: $proxyUrl'); AppLogger.debug('Using proxy URL: $proxyUrl');
final response = await _networkClient.get<String>( final response = await _networkClient.get<String>(
proxyUrl, proxyUrl,
@@ -42,9 +43,13 @@ class NaverProxyClient {
return response.data!; return response.data!;
} on DioException catch (e) { } on DioException catch (e) {
debugPrint('Proxy fetch error: ${e.message}'); AppLogger.error(
debugPrint('Status code: ${e.response?.statusCode}'); 'Proxy fetch error: ${e.message}',
debugPrint('Response: ${e.response?.data}'); error: e,
stackTrace: e.stackTrace,
);
AppLogger.debug('Status code: ${e.response?.statusCode}');
AppLogger.debug('Response: ${e.response?.data}');
if (e.response?.statusCode == 403) { if (e.response?.statusCode == 403) {
throw ServerException( throw ServerException(
@@ -78,8 +83,12 @@ class NaverProxyClient {
); );
return response.statusCode == 200; return response.statusCode == 200;
} catch (e) { } catch (e, stackTrace) {
debugPrint('Proxy status check failed: $e'); AppLogger.error(
'Proxy status check failed: $e',
error: e,
stackTrace: stackTrace,
);
return false; return false;
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
import '../../../core/network/network_client.dart'; import '../../../core/network/network_client.dart';
import '../../../core/network/network_config.dart'; import '../../../core/network/network_config.dart';
@@ -39,7 +40,11 @@ class NaverUrlResolver {
// 리다이렉트가 없으면 원본 URL 반환 // 리다이렉트가 없으면 원본 URL 반환
return shortUrl; return shortUrl;
} on DioException catch (e) { } on DioException catch (e) {
debugPrint('resolveShortUrl error: $e'); AppLogger.error(
'resolveShortUrl error: $e',
error: e,
stackTrace: e.stackTrace,
);
// 리다이렉트 응답인 경우 Location 헤더 확인 // 리다이렉트 응답인 경우 Location 헤더 확인
if (e.response?.statusCode == 301 || e.response?.statusCode == 302) { if (e.response?.statusCode == 301 || e.response?.statusCode == 302) {
@@ -98,8 +103,12 @@ class NaverUrlResolver {
} }
return shortUrl; return shortUrl;
} catch (e) { } catch (e, stackTrace) {
debugPrint('_resolveShortUrlViaProxy error: $e'); AppLogger.error(
'_resolveShortUrlViaProxy error: $e',
error: e,
stackTrace: stackTrace,
);
return shortUrl; return shortUrl;
} }
} }
@@ -139,8 +148,12 @@ class NaverUrlResolver {
} }
return currentUrl; return currentUrl;
} catch (e) { } catch (e, stackTrace) {
debugPrint('getFinalRedirectUrl error: $e'); AppLogger.error(
'getFinalRedirectUrl error: $e',
error: e,
stackTrace: stackTrace,
);
return url; return url;
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
import '../../core/network/network_client.dart'; import '../../core/network/network_client.dart';
import '../../core/errors/network_exceptions.dart'; import '../../core/errors/network_exceptions.dart';
@@ -88,7 +89,11 @@ class NaverApiClient {
return response.data!; return response.data!;
} on DioException catch (e) { } on DioException catch (e) {
debugPrint('fetchMapPageHtml error: $e'); AppLogger.error(
'fetchMapPageHtml error: $e',
error: e,
stackTrace: e.stackTrace,
);
if (e.error is NetworkException) { if (e.error is NetworkException) {
throw e.error!; throw e.error!;
@@ -123,9 +128,9 @@ class NaverApiClient {
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home'; final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
try { try {
debugPrint('========== 네이버 pcmap 한글 추출 시작 =========='); AppLogger.debug('========== 네이버 pcmap 한글 추출 시작 ==========');
debugPrint('요청 URL: $pcmapUrl'); AppLogger.debug('요청 URL: $pcmapUrl');
debugPrint('Place ID: $placeId'); AppLogger.debug('Place ID: $placeId');
String html; String html;
if (kIsWeb) { if (kIsWeb) {
@@ -148,7 +153,7 @@ class NaverApiClient {
); );
if (response.statusCode != 200 || response.data == null) { if (response.statusCode != 200 || response.data == null) {
debugPrint( AppLogger.error(
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}', 'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
); );
return { return {
@@ -172,11 +177,11 @@ class NaverApiClient {
html, html,
); );
debugPrint('========== 추출 결과 =========='); AppLogger.debug('========== 추출 결과 ==========');
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}'); AppLogger.debug('총 한글 텍스트 수: ${koreanTexts.length}');
debugPrint('JSON-LD 상호명: $jsonLdName'); AppLogger.debug('JSON-LD 상호명: $jsonLdName');
debugPrint('Apollo State 상호명: $apolloName'); AppLogger.debug('Apollo State 상호명: $apolloName');
debugPrint('====================================='); AppLogger.debug('=====================================');
return { return {
'success': true, 'success': true,
@@ -187,8 +192,12 @@ class NaverApiClient {
'apolloStateName': apolloName, 'apolloStateName': apolloName,
'extractedAt': DateTime.now().toIso8601String(), 'extractedAt': DateTime.now().toIso8601String(),
}; };
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e'); AppLogger.error(
'NaverApiClient: pcmap 페이지 파싱 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return { return {
'success': false, 'success': false,
'error': e.toString(), 'error': e.toString(),

View File

@@ -1,5 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:lunchpick/core/utils/app_logger.dart';
/// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스 /// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스
class NaverHtmlExtractor { class NaverHtmlExtractor {
@@ -323,11 +323,11 @@ class NaverHtmlExtractor {
// 리스트로 변환하여 반환 // 리스트로 변환하여 반환
final resultList = uniqueTexts.toList(); final resultList = uniqueTexts.toList();
debugPrint('========== 유효한 한글 텍스트 추출 결과 =========='); AppLogger.debug('========== 유효한 한글 텍스트 추출 결과 ==========');
for (int i = 0; i < resultList.length; i++) { 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; return resultList;
} }
@@ -377,8 +377,12 @@ class NaverHtmlExtractor {
continue; continue;
} }
} }
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlExtractor: JSON-LD 추출 실패 - $e'); AppLogger.error(
'NaverHtmlExtractor: JSON-LD 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
} }
return null; return null;
@@ -418,14 +422,21 @@ class NaverHtmlExtractor {
} }
} }
} }
} catch (e) { } catch (e, stackTrace) {
// JSON 파싱 실패 AppLogger.error(
debugPrint('NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e'); 'NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e',
error: e,
stackTrace: stackTrace,
);
} }
} }
} }
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlExtractor: Apollo State 추출 실패 - $e'); AppLogger.error(
'NaverHtmlExtractor: Apollo State 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
} }
return null; return null;
@@ -442,7 +453,7 @@ class NaverHtmlExtractor {
final match = ogUrlRegex.firstMatch(html); final match = ogUrlRegex.firstMatch(html);
if (match != null) { if (match != null) {
final url = match.group(1); final url = match.group(1);
debugPrint('NaverHtmlExtractor: og:url 추출 - $url'); AppLogger.debug('NaverHtmlExtractor: og:url 추출 - $url');
return url; return url;
} }
@@ -454,11 +465,15 @@ class NaverHtmlExtractor {
final canonicalMatch = canonicalRegex.firstMatch(html); final canonicalMatch = canonicalRegex.firstMatch(html);
if (canonicalMatch != null) { if (canonicalMatch != null) {
final url = canonicalMatch.group(1); final url = canonicalMatch.group(1);
debugPrint('NaverHtmlExtractor: canonical URL 추출 - $url'); AppLogger.debug('NaverHtmlExtractor: canonical URL 추출 - $url');
return url; return url;
} }
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e'); AppLogger.error(
'NaverHtmlExtractor: Place Link 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
} }
return null; return null;

View File

@@ -1,5 +1,5 @@
import 'package:html/dom.dart'; import 'package:html/dom.dart';
import 'package:flutter/foundation.dart'; import 'package:lunchpick/core/utils/app_logger.dart';
/// 네이버 지도 HTML 파서 /// 네이버 지도 HTML 파서
/// ///
@@ -77,8 +77,12 @@ class NaverHtmlParser {
} }
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 이름 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 이름 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }
@@ -97,8 +101,12 @@ class NaverHtmlParser {
} }
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 카테고리 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 카테고리 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }
@@ -115,8 +123,12 @@ class NaverHtmlParser {
} }
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 서브 카테고리 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 서브 카테고리 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }
@@ -137,8 +149,12 @@ class NaverHtmlParser {
} }
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 설명 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 설명 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }
@@ -159,8 +175,12 @@ class NaverHtmlParser {
} }
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 전화번호 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 전화번호 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }
@@ -179,8 +199,12 @@ class NaverHtmlParser {
} }
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 도로명 주소 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 도로명 주소 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }
@@ -201,8 +225,12 @@ class NaverHtmlParser {
} }
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 지번 주소 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 지번 주소 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }
@@ -238,8 +266,12 @@ class NaverHtmlParser {
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 위도 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }
@@ -275,8 +307,12 @@ class NaverHtmlParser {
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 경도 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }
@@ -297,8 +333,12 @@ class NaverHtmlParser {
} }
} }
return null; return null;
} catch (e) { } catch (e, stackTrace) {
debugPrint('NaverHtmlParser: 영업시간 추출 실패 - $e'); AppLogger.error(
'NaverHtmlParser: 영업시간 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
return null; return null;
} }
} }

View File

@@ -1,16 +1,18 @@
import 'dart:math'; import 'dart:math';
import 'package:dio/dio.dart'; 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 'package:flutter/foundation.dart';
import '../../api/naver_api_client.dart'; import 'package:html/parser.dart' as html_parser;
import '../../api/naver/naver_local_search_api.dart'; import 'package:lunchpick/core/utils/app_logger.dart';
import '../../../core/errors/network_exceptions.dart'; import 'package:lunchpick/domain/entities/restaurant.dart';
import 'naver_html_parser.dart'; import 'package:uuid/uuid.dart';
import '../../api/naver/naver_graphql_queries.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 '../../../core/utils/category_mapper.dart';
import 'naver_html_parser.dart';
/// 네이버 지도 URL 파서 /// 네이버 지도 URL 파서
/// 네이버 지도 URL에서 식당 정보를 추출합니다. /// 네이버 지도 URL에서 식당 정보를 추출합니다.
@@ -60,9 +62,7 @@ class NaverMapParser {
throw NaverMapParseException('이미 dispose된 파서입니다'); throw NaverMapParseException('이미 dispose된 파서입니다');
} }
try { try {
if (kDebugMode) { AppLogger.debug('NaverMapParser: Starting to parse URL: $url');
debugPrint('NaverMapParser: Starting to parse URL: $url');
}
// URL 유효성 검증 // URL 유효성 검증
if (!_isValidNaverUrl(url)) { if (!_isValidNaverUrl(url)) {
@@ -72,9 +72,7 @@ class NaverMapParser {
// 짧은 URL인 경우 리다이렉트 처리 // 짧은 URL인 경우 리다이렉트 처리
final String finalUrl = await _apiClient.resolveShortUrl(url); final String finalUrl = await _apiClient.resolveShortUrl(url);
if (kDebugMode) { AppLogger.debug('NaverMapParser: Final URL after redirect: $finalUrl');
debugPrint('NaverMapParser: Final URL after redirect: $finalUrl');
}
// Place ID 추출 (10자리 숫자) // Place ID 추출 (10자리 숫자)
final String? placeId = _extractPlaceId(finalUrl); final String? placeId = _extractPlaceId(finalUrl);
@@ -82,11 +80,9 @@ class NaverMapParser {
// 짧은 URL에서 직접 ID 추출 시도 // 짧은 URL에서 직접 ID 추출 시도
final shortUrlId = _extractShortUrlId(url); final shortUrlId = _extractShortUrlId(url);
if (shortUrlId != null) { if (shortUrlId != null) {
if (kDebugMode) { AppLogger.debug(
debugPrint( 'NaverMapParser: Using short URL ID as place ID: $shortUrlId',
'NaverMapParser: Using short URL ID as place ID: $shortUrlId', );
);
}
return _createFallbackRestaurant(shortUrlId, url); return _createFallbackRestaurant(shortUrlId, url);
} }
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url'); throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
@@ -96,9 +92,7 @@ class NaverMapParser {
final isShortUrl = url.contains('naver.me'); final isShortUrl = url.contains('naver.me');
if (isShortUrl) { if (isShortUrl) {
if (kDebugMode) { AppLogger.debug('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
debugPrint('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
}
try { try {
// 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득 // 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득
@@ -108,14 +102,14 @@ class NaverMapParser {
userLatitude, userLatitude,
userLongitude, userLongitude,
); );
if (kDebugMode) { AppLogger.debug('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
}
return restaurant; return restaurant;
} catch (e) { } catch (e, stackTrace) {
if (kDebugMode) { AppLogger.error(
debugPrint('NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e'); 'NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e',
} error: e,
stackTrace: stackTrace,
);
// 실패 시 기본 파싱으로 계속 진행 // 실패 시 기본 파싱으로 계속 진행
} }
} }
@@ -177,9 +171,7 @@ class NaverMapParser {
}) async { }) async {
// 심플한 접근: URL로 직접 검색 // 심플한 접근: URL로 직접 검색
try { try {
if (kDebugMode) { AppLogger.debug('NaverMapParser: URL 기반 검색 시작');
debugPrint('NaverMapParser: URL 기반 검색 시작');
}
// 네이버 지도 URL 구성 // 네이버 지도 URL 구성
final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId'; final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
@@ -201,27 +193,25 @@ class NaverMapParser {
// place ID가 포함된 결과 찾기 // place ID가 포함된 결과 찾기
for (final result in searchResults) { for (final result in searchResults) {
if (result.link.contains(placeId)) { if (result.link.contains(placeId)) {
if (kDebugMode) { AppLogger.debug(
debugPrint( 'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}', );
);
}
return _convertSearchResultToData(result); return _convertSearchResultToData(result);
} }
} }
// 정확한 매칭이 없으면 첫 번째 결과 사용 // 정확한 매칭이 없으면 첫 번째 결과 사용
if (kDebugMode) { AppLogger.debug(
debugPrint( 'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}', );
);
}
return _convertSearchResultToData(searchResults.first); return _convertSearchResultToData(searchResults.first);
} }
} catch (e) { } catch (e, stackTrace) {
if (kDebugMode) { AppLogger.error(
debugPrint('NaverMapParser: URL 검색 실패 - $e'); 'NaverMapParser: URL 검색 실패 - $e',
} error: e,
stackTrace: stackTrace,
);
} }
// Step 2: Place ID로 검색 // Step 2: Place ID로 검색
@@ -238,17 +228,17 @@ class NaverMapParser {
); );
if (searchResults.isNotEmpty) { if (searchResults.isNotEmpty) {
if (kDebugMode) { AppLogger.debug(
debugPrint( 'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}', );
);
}
return _convertSearchResultToData(searchResults.first); return _convertSearchResultToData(searchResults.first);
} }
} catch (e) { } catch (e, stackTrace) {
if (kDebugMode) { AppLogger.error(
debugPrint('NaverMapParser: Place ID 검색 실패 - $e'); 'NaverMapParser: Place ID 검색 실패 - $e',
} error: e,
stackTrace: stackTrace,
);
// 429 에러인 경우 즉시 예외 발생 // 429 에러인 경우 즉시 예외 발생
if (e is DioException && e.response?.statusCode == 429) { if (e is DioException && e.response?.statusCode == 429) {
@@ -258,10 +248,12 @@ class NaverMapParser {
); );
} }
} }
} catch (e) { } catch (e, stackTrace) {
if (kDebugMode) { AppLogger.error(
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e'); 'NaverMapParser: URL 기반 검색 실패 - $e',
} error: e,
stackTrace: stackTrace,
);
// 429 에러인 경우 즉시 예외 발생 // 429 에러인 경우 즉시 예외 발생
if (e is DioException && e.response?.statusCode == 429) { if (e is DioException && e.response?.statusCode == 429) {
@@ -275,9 +267,7 @@ class NaverMapParser {
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도) // 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
// 첫 번째 시도: places 쿼리 // 첫 번째 시도: places 쿼리
try { try {
if (kDebugMode) { AppLogger.debug('NaverMapParser: Trying places query...');
debugPrint('NaverMapParser: Trying places query...');
}
final response = await _apiClient.fetchGraphQL( final response = await _apiClient.fetchGraphQL(
operationName: 'getPlaceDetail', operationName: 'getPlaceDetail',
variables: {'id': placeId}, variables: {'id': placeId},
@@ -293,17 +283,17 @@ class NaverMapParser {
return _extractPlaceData(placesData as Map<String, dynamic>); return _extractPlaceData(placesData as Map<String, dynamic>);
} }
} }
} catch (e) { } catch (e, stackTrace) {
if (kDebugMode) { AppLogger.error(
debugPrint('NaverMapParser: places query failed - $e'); 'NaverMapParser: places query failed - $e',
} error: e,
stackTrace: stackTrace,
);
} }
// 두 번째 시도: nxPlaces 쿼리 // 두 번째 시도: nxPlaces 쿼리
try { try {
if (kDebugMode) { AppLogger.debug('NaverMapParser: Trying nxPlaces query...');
debugPrint('NaverMapParser: Trying nxPlaces query...');
}
final response = await _apiClient.fetchGraphQL( final response = await _apiClient.fetchGraphQL(
operationName: 'getPlaceDetail', operationName: 'getPlaceDetail',
variables: {'id': placeId}, variables: {'id': placeId},
@@ -319,18 +309,18 @@ class NaverMapParser {
return _extractPlaceData(nxPlacesData as Map<String, dynamic>); return _extractPlaceData(nxPlacesData as Map<String, dynamic>);
} }
} }
} catch (e) { } catch (e, stackTrace) {
if (kDebugMode) { AppLogger.error(
debugPrint('NaverMapParser: nxPlaces query failed - $e'); 'NaverMapParser: nxPlaces query failed - $e',
} error: e,
stackTrace: stackTrace,
);
} }
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback // 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
if (kDebugMode) { AppLogger.debug(
debugPrint( 'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing',
'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing', );
);
}
return await _fallbackToHtmlParsing(placeId); return await _fallbackToHtmlParsing(placeId);
} }
@@ -508,7 +498,7 @@ class NaverMapParser {
double? userLongitude, double? userLongitude,
) async { ) async {
if (kDebugMode) { if (kDebugMode) {
debugPrint('NaverMapParser: 단축 URL 향상된 파싱 시작'); AppLogger.debug('NaverMapParser: 단축 URL 향상된 파싱 시작');
} }
// 1. 한글 텍스트 추출 // 1. 한글 텍스트 추출
@@ -525,17 +515,17 @@ class NaverMapParser {
if (koreanData['jsonLdName'] != null) { if (koreanData['jsonLdName'] != null) {
searchQuery = koreanData['jsonLdName'] as String; searchQuery = koreanData['jsonLdName'] as String;
if (kDebugMode) { if (kDebugMode) {
debugPrint('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery'); AppLogger.debug('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
} }
} else if (koreanData['apolloStateName'] != null) { } else if (koreanData['apolloStateName'] != null) {
searchQuery = koreanData['apolloStateName'] as String; searchQuery = koreanData['apolloStateName'] as String;
if (kDebugMode) { if (kDebugMode) {
debugPrint('NaverMapParser: Apollo State 상호명 사용 - $searchQuery'); AppLogger.debug('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
} }
} else if (koreanTexts.isNotEmpty) { } else if (koreanTexts.isNotEmpty) {
searchQuery = koreanTexts.first as String; searchQuery = koreanTexts.first as String;
if (kDebugMode) { if (kDebugMode) {
debugPrint('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery'); AppLogger.debug('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
} }
} else { } else {
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다'); throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
@@ -543,7 +533,7 @@ class NaverMapParser {
// 2. 로컬 검색 API 호출 // 2. 로컬 검색 API 호출
if (kDebugMode) { if (kDebugMode) {
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"'); AppLogger.debug('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
} }
await Future.delayed( await Future.delayed(
@@ -563,15 +553,15 @@ class NaverMapParser {
// 디버깅: 검색 결과 Place ID 분석 // 디버깅: 검색 결과 Place ID 분석
if (kDebugMode) { if (kDebugMode) {
debugPrint('=== 로컬 검색 결과 Place ID 분석 ==='); AppLogger.debug('=== 로컬 검색 결과 Place ID 분석 ===');
for (int i = 0; i < searchResults.length; i++) { for (int i = 0; i < searchResults.length; i++) {
final result = searchResults[i]; final result = searchResults[i];
final extractedId = result.extractPlaceId(); final extractedId = result.extractPlaceId();
debugPrint('[$i] ${result.title}'); AppLogger.debug('[$i] ${result.title}');
debugPrint(' 링크: ${result.link}'); AppLogger.debug(' 링크: ${result.link}');
debugPrint(' 추출된 Place ID: $extractedId (타겟: $placeId)'); AppLogger.debug(' 추출된 Place ID: $extractedId (타겟: $placeId)');
} }
debugPrint('====================================='); AppLogger.debug('=====================================');
} }
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘 // 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
@@ -583,7 +573,7 @@ class NaverMapParser {
if (extractedId == placeId) { if (extractedId == placeId) {
bestMatch = result; bestMatch = result;
if (kDebugMode) { if (kDebugMode) {
debugPrint('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}'); AppLogger.debug('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}');
} }
break; break;
} }
@@ -604,7 +594,7 @@ class NaverMapParser {
exactName.contains(result.title)) { exactName.contains(result.title)) {
bestMatch = result; bestMatch = result;
if (kDebugMode) { if (kDebugMode) {
debugPrint('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}'); AppLogger.debug('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
} }
break; break;
} }
@@ -620,7 +610,7 @@ class NaverMapParser {
userLongitude, userLongitude,
); );
if (bestMatch != null && kDebugMode) { if (bestMatch != null && kDebugMode) {
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}'); AppLogger.debug('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
} }
} }
@@ -628,7 +618,7 @@ class NaverMapParser {
if (bestMatch == null) { if (bestMatch == null) {
bestMatch = searchResults.first; bestMatch = searchResults.first;
if (kDebugMode) { if (kDebugMode) {
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}'); AppLogger.debug('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
} }
} }
@@ -670,7 +660,7 @@ class NaverMapParser {
} }
if (kDebugMode && nearest != null) { if (kDebugMode && nearest != null) {
debugPrint( AppLogger.debug(
'가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)', '가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)',
); );
} }

View File

@@ -1,10 +1,13 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../api/naver_api_client.dart'; import '../../api/naver_api_client.dart';
import '../../api/naver/naver_local_search_api.dart'; import '../../api/naver/naver_local_search_api.dart';
import '../../../domain/entities/restaurant.dart';
import '../../../core/errors/network_exceptions.dart'; import '../../../core/errors/network_exceptions.dart';
import '../../../domain/entities/restaurant.dart';
import 'naver_map_parser.dart'; import 'naver_map_parser.dart';
import 'naver_url_processor.dart';
/// 네이버 검색 서비스 /// 네이버 검색 서비스
/// ///
@@ -12,14 +15,21 @@ import 'naver_map_parser.dart';
class NaverSearchService { class NaverSearchService {
final NaverApiClient _apiClient; final NaverApiClient _apiClient;
final NaverMapParser _mapParser; final NaverMapParser _mapParser;
final NaverUrlProcessor _urlProcessor;
final Uuid _uuid = const Uuid(); final Uuid _uuid = const Uuid();
// 성능 최적화를 위한 정규식 캐싱 // 성능 최적화를 위한 정규식 캐싱
static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]'); static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]');
NaverSearchService({NaverApiClient? apiClient, NaverMapParser? mapParser}) NaverSearchService({
: _apiClient = apiClient ?? NaverApiClient(), NaverApiClient? apiClient,
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient); NaverMapParser? mapParser,
NaverUrlProcessor? urlProcessor,
}) : _apiClient = apiClient ?? NaverApiClient(),
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient),
_urlProcessor =
urlProcessor ??
NaverUrlProcessor(apiClient: apiClient, mapParser: mapParser);
/// URL에서 식당 정보 가져오기 /// URL에서 식당 정보 가져오기
/// ///
@@ -32,7 +42,7 @@ class NaverSearchService {
/// - [NetworkException] 네트워크 오류 발생 시 /// - [NetworkException] 네트워크 오류 발생 시
Future<Restaurant> getRestaurantFromUrl(String url) async { Future<Restaurant> getRestaurantFromUrl(String url) async {
try { try {
return await _mapParser.parseRestaurantFromUrl(url); return await _urlProcessor.processUrl(url);
} catch (e) { } catch (e) {
if (e is NaverMapParseException || e is NetworkException) { if (e is NaverMapParseException || e is NetworkException) {
rethrow; rethrow;
@@ -149,9 +159,9 @@ class NaverSearchService {
); );
} catch (e) { } catch (e) {
// 상세 파싱 실패해도 기본 정보 반환 // 상세 파싱 실패해도 기본 정보 반환
if (kDebugMode) { AppLogger.debug(
debugPrint('[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}'); '[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}',
} );
} }
} }

View File

@@ -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<String, Restaurant>();
NaverUrlProcessor({NaverApiClient? apiClient, NaverMapParser? mapParser})
: _apiClient = apiClient ?? NaverApiClient(),
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
Future<Restaurant> 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();
}

View File

@@ -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_search_service.dart';
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
import 'package:lunchpick/core/constants/api_keys.dart'; import 'package:lunchpick/core/constants/api_keys.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
class RestaurantRepositoryImpl implements RestaurantRepository { class RestaurantRepositoryImpl implements RestaurantRepository {
static const String _boxName = 'restaurants'; static const String _boxName = 'restaurants';
@@ -224,7 +225,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
); );
} }
} catch (e) { } catch (e) {
print('API 검색 실패, 스크래핑된 정보만 사용: $e'); AppLogger.debug('API 검색 실패, 스크래핑된 정보만 사용: $e');
} }
} }

View File

@@ -1,4 +1,10 @@
import 'dart:convert';
import 'dart:math';
import 'package:hive_flutter/hive_flutter.dart'; 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/entities/weather_info.dart';
import 'package:lunchpick/domain/repositories/weather_repository.dart'; import 'package:lunchpick/domain/repositories/weather_repository.dart';
@@ -15,18 +21,32 @@ class WeatherRepositoryImpl implements WeatherRepository {
required double latitude, required double latitude,
required double longitude, required double longitude,
}) async { }) async {
// TODO: 실제 날씨 API 호출 구현 final cached = await getCachedWeather();
// 여기서는 임시로 더미 데이터 반환
final dummyWeather = WeatherInfo( try {
current: WeatherData(temperature: 20, isRainy: false, description: '맑음'), final weather = await _fetchWeatherFromKma(
nextHour: WeatherData(temperature: 22, isRainy: false, description: '맑음'), latitude: latitude,
); longitude: longitude,
);
// 캐시에 저장 await cacheWeatherInfo(weather);
await cacheWeatherInfo(dummyWeather); return weather;
} catch (_) {
return dummyWeather; if (cached != null) {
return cached;
}
return WeatherInfo(
current: WeatherData(
temperature: 20,
isRainy: false,
description: '날씨 정보를 불러오지 못했어요',
),
nextHour: WeatherData(
temperature: 20,
isRainy: false,
description: '날씨 정보를 불러오지 못했어요',
),
);
}
} }
@override @override
@@ -48,7 +68,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
try { try {
// 안전한 타입 변환 // 안전한 타입 변환
if (cachedData is! Map) { if (cachedData is! Map) {
print( AppLogger.debug(
'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}', 'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}',
); );
await clearWeatherCache(); await clearWeatherCache();
@@ -62,7 +82,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
// Map 구조 검증 // Map 구조 검증
if (!weatherMap.containsKey('current') || if (!weatherMap.containsKey('current') ||
!weatherMap.containsKey('nextHour')) { !weatherMap.containsKey('nextHour')) {
print('WeatherCache: Missing required fields in weather data'); AppLogger.debug(
'WeatherCache: Missing required fields in weather data',
);
await clearWeatherCache(); await clearWeatherCache();
return null; return null;
} }
@@ -70,7 +92,10 @@ class WeatherRepositoryImpl implements WeatherRepository {
return _weatherInfoFromMap(weatherMap); return _weatherInfoFromMap(weatherMap);
} catch (e) { } catch (e) {
// 캐시 데이터가 손상된 경우 // 캐시 데이터가 손상된 경우
print('WeatherCache: Error parsing cached weather data: $e'); AppLogger.error(
'WeatherCache: Error parsing cached weather data',
error: e,
);
await clearWeatherCache(); await clearWeatherCache();
return null; return null;
} }
@@ -118,7 +143,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
// 날짜 파싱 시도 // 날짜 파싱 시도
final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr); final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr);
if (lastUpdateTime == null) { if (lastUpdateTime == null) {
print('WeatherCache: Invalid date format in cache: $lastUpdateTimeStr'); AppLogger.debug(
'WeatherCache: Invalid date format in cache: $lastUpdateTimeStr',
);
return false; return false;
} }
@@ -127,7 +154,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
return difference < _cacheValidDuration; return difference < _cacheValidDuration;
} catch (e) { } catch (e) {
print('WeatherCache: Error checking cache validity: $e'); AppLogger.error('WeatherCache: Error checking cache validity', error: e);
return false; return false;
} }
} }
@@ -183,9 +210,284 @@ class WeatherRepositoryImpl implements WeatherRepository {
), ),
); );
} catch (e) { } catch (e) {
print('WeatherCache: Error converting map to WeatherInfo: $e'); AppLogger.error(
print('WeatherCache: Map data: $map'); 'WeatherCache: Error converting map to WeatherInfo',
error: e,
stackTrace: StackTrace.current,
);
AppLogger.debug('WeatherCache: Map data: $map');
rethrow; rethrow;
} }
} }
Future<WeatherInfo> _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<List<dynamic>> _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<String, dynamic>;
final items = body['response']?['body']?['items']?['item'];
if (items is List<dynamic>) {
return items;
}
throw Exception('Weather API 응답 파싱 실패');
}
double? _extractLatestValue(List<dynamic> 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<dynamic> items, {
required DateTime after,
}) {
DateTime? targetTime;
double? temperature;
int? pty;
int? sky;
DateTime fcstDateTime(Map<String, dynamic> 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<String, dynamic>);
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<String, dynamic>;
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,
});
} }

View File

@@ -44,8 +44,7 @@ void main() async {
// Initialize Notification Service (only for non-web platforms) // Initialize Notification Service (only for non-web platforms)
if (!kIsWeb) { if (!kIsWeb) {
final notificationService = NotificationService(); final notificationService = NotificationService();
await notificationService.initialize(); await notificationService.ensureInitialized(requestPermission: true);
await notificationService.requestPermission();
} }
// Get saved theme mode // Get saved theme mode

View File

@@ -3,9 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:table_calendar/table_calendar.dart'; import 'package:table_calendar/table_calendar.dart';
import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_typography.dart'; import '../../../core/constants/app_typography.dart';
import '../../../domain/entities/recommendation_record.dart';
import '../../../domain/entities/visit_record.dart'; import '../../../domain/entities/visit_record.dart';
import '../../providers/recommendation_provider.dart';
import '../../providers/visit_provider.dart'; import '../../providers/visit_provider.dart';
import 'widgets/visit_record_card.dart'; import 'widgets/visit_record_card.dart';
import 'widgets/recommendation_record_card.dart';
import 'widgets/visit_statistics.dart'; import 'widgets/visit_statistics.dart';
class CalendarScreen extends ConsumerStatefulWidget { class CalendarScreen extends ConsumerStatefulWidget {
@@ -21,7 +24,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
late DateTime _focusedDay; late DateTime _focusedDay;
CalendarFormat _calendarFormat = CalendarFormat.month; CalendarFormat _calendarFormat = CalendarFormat.month;
late TabController _tabController; late TabController _tabController;
Map<DateTime, List<VisitRecord>> _visitRecordEvents = {}; Map<DateTime, List<_CalendarEvent>> _events = {};
@override @override
void initState() { void initState() {
@@ -37,9 +40,9 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
super.dispose(); super.dispose();
} }
List<VisitRecord> _getEventsForDay(DateTime day) { List<_CalendarEvent> _getEventsForDay(DateTime day) {
final normalizedDay = DateTime(day.year, day.month, day.day); final normalizedDay = DateTime(day.year, day.month, day.day);
return _visitRecordEvents[normalizedDay] ?? []; return _events[normalizedDay] ?? [];
} }
@override @override
@@ -83,22 +86,17 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final visitRecordsAsync = ref.watch(visitRecordsProvider); final visitRecordsAsync = ref.watch(visitRecordsProvider);
final recommendationRecordsAsync = ref.watch(
recommendationRecordsProvider,
);
// 방문 기록을 날짜별로 그룹화 if (visitRecordsAsync.hasValue && recommendationRecordsAsync.hasValue) {
visitRecordsAsync.whenData((records) { final visits = visitRecordsAsync.value ?? [];
_visitRecordEvents = {}; final recommendations =
for (final record in records) { recommendationRecordsAsync.valueOrNull ??
final normalizedDate = DateTime( <RecommendationRecord>[];
record.visitDate.year, _events = _buildEvents(visits, recommendations);
record.visitDate.month, }
record.visitDate.day,
);
_visitRecordEvents[normalizedDate] = [
...(_visitRecordEvents[normalizedDate] ?? []),
record,
];
}
});
return Column( return Column(
children: [ children: [
@@ -132,17 +130,18 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
markerBuilder: (context, day, events) { markerBuilder: (context, day, events) {
if (events.isEmpty) return null; if (events.isEmpty) return null;
final visitRecords = events.cast<VisitRecord>(); final calendarEvents = events.cast<_CalendarEvent>();
final confirmedCount = visitRecords final confirmedVisits = calendarEvents.where(
.where((r) => r.isConfirmed) (e) => e.visitRecord?.isConfirmed == true,
.length; );
final unconfirmedCount = final recommendedOnly = calendarEvents.where(
visitRecords.length - confirmedCount; (e) => e.recommendationRecord != null,
);
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (confirmedCount > 0) if (confirmedVisits.isNotEmpty)
Container( Container(
width: 6, width: 6,
height: 6, height: 6,
@@ -152,7 +151,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
if (unconfirmedCount > 0) if (recommendedOnly.isNotEmpty)
Container( Container(
width: 6, width: 6,
height: 6, height: 6,
@@ -239,6 +238,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
Widget _buildDayRecords(DateTime day, bool isDark) { Widget _buildDayRecords(DateTime day, bool isDark) {
final events = _getEventsForDay(day); final events = _getEventsForDay(day);
events.sort((a, b) => b.sortDate.compareTo(a.sortDate));
if (events.isEmpty) { if (events.isEmpty) {
return Center( return Center(
@@ -294,18 +294,71 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
child: ListView.builder( child: ListView.builder(
itemCount: events.length, itemCount: events.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final sortedEvents = events final event = events[index];
..sort((a, b) => b.visitDate.compareTo(a.visitDate)); if (event.visitRecord != null) {
return VisitRecordCard( return VisitRecordCard(
visitRecord: sortedEvents[index], visitRecord: event.visitRecord!,
onTap: () { onTap: () {},
// TODO: 맛집 상세 페이지로 이동 );
}, }
); 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<DateTime, List<_CalendarEvent>> _buildEvents(
List<VisitRecord> visits,
List<RecommendationRecord> recommendations,
) {
final Map<DateTime, List<_CalendarEvent>> 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;
} }

View File

@@ -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(),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@@ -28,6 +29,7 @@ class RandomSelectionScreen extends ConsumerStatefulWidget {
class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> { class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
double _distanceValue = 500; double _distanceValue = 500;
final List<String> _selectedCategories = []; final List<String> _selectedCategories = [];
final List<String> _excludedRestaurantIds = [];
bool _isProcessingRecommendation = false; bool _isProcessingRecommendation = false;
@override @override
@@ -459,18 +461,28 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
return count > 0; return count > 0;
} }
Future<void> _startRecommendation({bool skipAd = false}) async { Future<void> _startRecommendation({
bool skipAd = false,
bool isReroll = false,
}) async {
if (_isProcessingRecommendation) return; if (_isProcessingRecommendation) return;
if (!isReroll) {
_excludedRestaurantIds.clear();
}
setState(() { setState(() {
_isProcessingRecommendation = true; _isProcessingRecommendation = true;
}); });
try { try {
final candidate = await _generateRecommendationCandidate(); final candidate = await _generateRecommendationCandidate(
excludedRestaurantIds: _excludedRestaurantIds,
);
if (candidate == null) { if (candidate == null) {
return; return;
} }
final recommendedAt = DateTime.now();
if (!skipAd) { if (!skipAd) {
final adService = ref.read(adServiceProvider); final adService = ref.read(adServiceProvider);
@@ -488,7 +500,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
} }
if (!mounted) return; if (!mounted) return;
_showRecommendationDialog(candidate); await _showRecommendationDialog(candidate, recommendedAt: recommendedAt);
} catch (_) { } catch (_) {
_showSnack( _showSnack(
'추천을 준비하는 중 문제가 발생했습니다.', '추천을 준비하는 중 문제가 발생했습니다.',
@@ -503,12 +515,16 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
} }
} }
Future<Restaurant?> _generateRecommendationCandidate() async { Future<Restaurant?> _generateRecommendationCandidate({
List<String> excludedRestaurantIds = const [],
}) async {
final notifier = ref.read(recommendationNotifierProvider.notifier); final notifier = ref.read(recommendationNotifierProvider.notifier);
await notifier.getRandomRecommendation( final recommendation = await notifier.getRandomRecommendation(
maxDistance: _distanceValue, maxDistance: _distanceValue,
selectedCategories: _selectedCategories, selectedCategories: _selectedCategories,
excludedRestaurantIds: excludedRestaurantIds,
shouldSaveRecord: false,
); );
final result = ref.read(recommendationNotifierProvider); final result = ref.read(recommendationNotifierProvider);
@@ -522,49 +538,82 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
return null; return null;
} }
final restaurant = result.asData?.value; if (recommendation == null) {
if (restaurant == null) { _showSnack(
_showSnack('조건에 맞는 식당이 존재하지 않습니다', backgroundColor: AppColors.lightError); '조건에 맞는 식당이 존재하지 않습니다. 광고는 재생되지 않았습니다.',
backgroundColor: AppColors.lightError,
);
} }
return restaurant; return recommendation;
} }
void _showRecommendationDialog(Restaurant restaurant) { Future<void> _showRecommendationDialog(
showDialog( Restaurant restaurant, {
DateTime? recommendedAt,
}) async {
final result = await showDialog<RecommendationDialogResult>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) => RecommendationResultDialog( builder: (dialogContext) =>
restaurant: restaurant, RecommendationResultDialog(restaurant: restaurant),
onReroll: () async {
Navigator.pop(dialogContext);
await _startRecommendation(skipAd: true);
},
onClose: () async {
Navigator.pop(dialogContext);
await _handleRecommendationAccepted(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<void> _handleRecommendationAccepted(Restaurant restaurant) async { Future<void> _handleRecommendationAccepted(
final recommendationTime = DateTime.now(); Restaurant restaurant,
DateTime recommendationTime,
) async {
try { try {
final recommendationNotifier = ref.read(
recommendationNotifierProvider.notifier,
);
await recommendationNotifier.saveRecommendationRecord(
restaurant,
recommendationTime: recommendationTime,
);
final notificationEnabled = await ref.read( final notificationEnabled = await ref.read(
notificationEnabledProvider.future, notificationEnabledProvider.future,
); );
if (notificationEnabled) { bool notificationScheduled = false;
final delayMinutes = await ref.read( if (notificationEnabled && !kIsWeb) {
notificationDelayMinutesProvider.future,
);
final notificationService = ref.read(notificationServiceProvider); final notificationService = ref.read(notificationServiceProvider);
await notificationService.scheduleVisitReminder( final notificationReady = await notificationService.ensureInitialized(
restaurantId: restaurant.id, requestPermission: true,
restaurantName: restaurant.name,
recommendationTime: recommendationTime,
delayMinutes: delayMinutes,
); );
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 await ref
@@ -574,7 +623,19 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
recommendationTime: recommendationTime, recommendationTime: recommendationTime,
); );
_showSnack('맛있게 드세요! 🍴'); if (notificationEnabled && !notificationScheduled && !kIsWeb) {
_showSnack(
'방문 기록은 저장됐지만 알림 권한이나 설정을 확인해 주세요. 방문 알림을 예약하지 못했습니다.',
backgroundColor: AppColors.lightError,
);
} else {
_showSnack('맛있게 드세요! 🍴');
}
if (mounted) {
setState(() {
_excludedRestaurantIds.clear();
});
}
} catch (_) { } catch (_) {
_showSnack( _showSnack(
'방문 기록 또는 알림 예약에 실패했습니다.', '방문 기록 또는 알림 예약에 실패했습니다.',
@@ -588,10 +649,22 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
Color backgroundColor = AppColors.lightPrimary, Color backgroundColor = AppColors.lightPrimary,
}) { }) {
if (!mounted) return; if (!mounted) return;
final topInset = MediaQuery.of(context).viewPadding.top;
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
..hideCurrentSnackBar() ..hideCurrentSnackBar()
..showSnackBar( ..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),
),
); );
} }
} }

View File

@@ -1,215 +1,258 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lunchpick/core/constants/app_colors.dart'; import 'package:lunchpick/core/constants/app_colors.dart';
import 'package:lunchpick/core/constants/app_typography.dart'; import 'package:lunchpick/core/constants/app_typography.dart';
import 'package:lunchpick/domain/entities/restaurant.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 Restaurant restaurant;
final Future<void> Function() onReroll; final Duration autoConfirmDuration;
final Future<void> Function() onClose;
const RecommendationResultDialog({ const RecommendationResultDialog({
super.key, super.key,
required this.restaurant, required this.restaurant,
required this.onReroll, this.autoConfirmDuration = const Duration(seconds: 12),
required this.onClose,
}); });
@override
State<RecommendationResultDialog> createState() =>
_RecommendationResultDialogState();
}
class _RecommendationResultDialogState
extends State<RecommendationResultDialog> {
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<void> _handleResult(RecommendationDialogResult result) async {
if (_didComplete) return;
_didComplete = true;
_autoConfirmTimer?.cancel();
Navigator.of(context).pop(result);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return Dialog( return WillPopScope(
backgroundColor: Colors.transparent, onWillPop: () async {
child: Container( await _handleResult(RecommendationDialogResult.confirm);
decoration: BoxDecoration( return true;
color: isDark ? AppColors.darkSurface : AppColors.lightSurface, },
borderRadius: BorderRadius.circular(20), child: Dialog(
), backgroundColor: Colors.transparent,
child: Column( child: Container(
mainAxisSize: MainAxisSize.min, decoration: BoxDecoration(
children: [ color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
// 상단 이미지 영역 borderRadius: BorderRadius.circular(20),
Container( ),
height: 150, child: Column(
decoration: BoxDecoration( mainAxisSize: MainAxisSize.min,
color: AppColors.lightPrimary, children: [
borderRadius: const BorderRadius.vertical( Container(
top: Radius.circular(20), height: 150,
decoration: BoxDecoration(
color: AppColors.lightPrimary,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
), ),
), child: Stack(
child: Stack( children: [
children: [ Center(
Center( child: Column(
child: Column( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ const Icon(
const Icon( Icons.restaurant_menu,
Icons.restaurant_menu, size: 64,
size: 64, color: Colors.white,
color: Colors.white, ),
), const SizedBox(height: 8),
const SizedBox(height: 8), Text(
Text( '오늘의 추천!',
'오늘의 추천!', style: AppTypography.heading2(
style: AppTypography.heading2( false,
false, ).copyWith(color: Colors.white),
).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),
), ),
), ),
), Positioned(
top: 8,
if (restaurant.description != null) ...[ right: 8,
const SizedBox(height: 16), child: IconButton(
Text( icon: const Icon(Icons.close, color: Colors.white),
restaurant.description!, onPressed: () async {
style: AppTypography.body2(isDark), await _handleResult(
textAlign: TextAlign.center, RecommendationDialogResult.confirm,
);
},
),
), ),
], ],
),
const SizedBox(height: 16), ),
const Divider(), Padding(
const SizedBox(height: 16), padding: const EdgeInsets.all(24),
child: Column(
// 주소 crossAxisAlignment: CrossAxisAlignment.start,
Row( children: [
children: [ Center(
Icon( child: Text(
Icons.location_on, widget.restaurant.name,
size: 20, style: AppTypography.heading1(isDark),
color: isDark textAlign: TextAlign.center,
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
), ),
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( child: Text(
restaurant.roadAddress, '${widget.restaurant.category} > ${widget.restaurant.subCategory}',
style: AppTypography.body2(isDark), 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,
),
], ],
), const SizedBox(height: 16),
const Divider(),
if (restaurant.phoneNumber != null) ...[ const SizedBox(height: 16),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
Icon( Icon(
Icons.phone, Icons.location_on,
size: 20, size: 20,
color: isDark color: isDark
? AppColors.darkTextSecondary ? AppColors.darkTextSecondary
: AppColors.lightTextSecondary, : AppColors.lightTextSecondary,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
restaurant.phoneNumber!, child: Text(
style: AppTypography.body2(isDark), widget.restaurant.roadAddress,
style: AppTypography.body2(isDark),
),
), ),
], ],
), ),
], if (widget.restaurant.phoneNumber != null) ...[
const SizedBox(height: 8),
const SizedBox(height: 24), Row(
children: [
// 버튼들 Icon(
Row( Icons.phone,
children: [ size: 20,
Expanded( color: isDark
child: OutlinedButton( ? AppColors.darkTextSecondary
onPressed: () async { : AppColors.lightTextSecondary,
await onReroll();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: const BorderSide(
color: AppColors.lightPrimary,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
), ),
child: const Text( const SizedBox(width: 8),
'다시 뽑기', Text(
style: TextStyle(color: AppColors.lightPrimary), 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,
),
],
),
), ),
), ],
], ),
), ),
), ),
); );

View File

@@ -290,7 +290,16 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
} }
Future<void> _generateShareCode() async { Future<void> _generateShareCode() async {
final hasPermission =
await PermissionService.checkAndRequestBluetoothPermission();
if (!hasPermission) {
if (!mounted) return;
_showErrorSnackBar('블루투스 권한을 허용해야 공유 코드를 생성할 수 있어요.');
return;
}
final adService = ref.read(adServiceProvider); final adService = ref.read(adServiceProvider);
if (!mounted) return;
final adWatched = await adService.showInterstitialAd(context); final adWatched = await adService.showInterstitialAd(context);
if (!mounted) return; if (!mounted) return;
if (!adWatched) { if (!adWatched) {
@@ -301,12 +310,17 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
final random = Random(); final random = Random();
final code = List.generate(6, (_) => random.nextInt(10)).join(); final code = List.generate(6, (_) => random.nextInt(10)).join();
setState(() { try {
_shareCode = code; await ref.read(bluetoothServiceProvider).startListening(code);
}); if (!mounted) return;
setState(() {
await ref.read(bluetoothServiceProvider).startListening(code); _shareCode = code;
_showSuccessSnackBar('공유 코드가 생성되었습니다.'); });
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
} catch (_) {
if (!mounted) return;
_showErrorSnackBar('코드를 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.');
}
} }
Future<void> _scanDevices() async { Future<void> _scanDevices() async {

View File

@@ -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/settings_repository_impl.dart';
import 'package:lunchpick/data/repositories/weather_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/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/restaurant_repository.dart';
import 'package:lunchpick/domain/repositories/visit_repository.dart'; import 'package:lunchpick/domain/repositories/visit_repository.dart';
import 'package:lunchpick/domain/repositories/settings_repository.dart'; import 'package:lunchpick/domain/repositories/settings_repository.dart';
@@ -36,3 +39,21 @@ final recommendationRepositoryProvider = Provider<RecommendationRepository>((
) { ) {
return RecommendationRepositoryImpl(); return RecommendationRepositoryImpl();
}); });
/// NaverApiClient Provider
final naverApiClientProvider = Provider<NaverApiClient>((ref) {
return NaverApiClient();
});
/// NaverMapParser Provider
final naverMapParserProvider = Provider<NaverMapParser>((ref) {
final apiClient = ref.watch(naverApiClientProvider);
return NaverMapParser(apiClient: apiClient);
});
/// NaverUrlProcessor Provider
final naverUrlProcessorProvider = Provider<NaverUrlProcessor>((ref) {
final apiClient = ref.watch(naverApiClientProvider);
final parser = ref.watch(naverMapParserProvider);
return NaverUrlProcessor(apiClient: apiClient, mapParser: parser);
});

View File

@@ -1,7 +1,28 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
import 'package:permission_handler/permission_handler.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 /// 위치 권한 상태 Provider
final locationPermissionProvider = FutureProvider<PermissionStatus>(( final locationPermissionProvider = FutureProvider<PermissionStatus>((
ref, ref,
@@ -18,14 +39,16 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
// 권한이 없으면 요청 // 권한이 없으면 요청
final result = await Permission.location.request(); final result = await Permission.location.request();
if (!result.isGranted) { if (!result.isGranted) {
return null; AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청) 사용');
return defaultPosition();
} }
} }
// 위치 서비스 활성화 확인 // 위치 서비스 활성화 확인
final serviceEnabled = await Geolocator.isLocationServiceEnabled(); final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) { if (!serviceEnabled) {
throw Exception('위치 서비스 비활성화되어 있습니다'); AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청) 사용');
return defaultPosition();
} }
// 현재 위치 가져오기 // 현재 위치 가져오기
@@ -36,7 +59,12 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
); );
} catch (e) { } 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<AsyncValue<Position?>> {
if (!permissionStatus.isGranted) { if (!permissionStatus.isGranted) {
final granted = await requestLocationPermission(); final granted = await requestLocationPermission();
if (!granted) { if (!granted) {
state = const AsyncValue.data(null); AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청)로 대체');
state = AsyncValue.data(defaultPosition());
return; return;
} }
} }
@@ -91,7 +120,8 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
// 위치 서비스 확인 // 위치 서비스 확인
final serviceEnabled = await Geolocator.isLocationServiceEnabled(); final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) { if (!serviceEnabled) {
state = AsyncValue.error('위치 서비스 비활성화되어 있습니다', StackTrace.current); AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청)로 대체');
state = AsyncValue.data(defaultPosition());
return; return;
} }
@@ -102,13 +132,18 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
); );
state = AsyncValue.data(position); state = AsyncValue.data(position);
} catch (e, stack) { } catch (e) {
// 오류 발생 시 마지막 알려진 위치 시도 // 오류 발생 시 마지막 알려진 위치 시도
try { try {
final lastPosition = await Geolocator.getLastKnownPosition(); final lastPosition = await Geolocator.getLastKnownPosition();
state = AsyncValue.data(lastPosition); if (lastPosition != null) {
state = AsyncValue.data(lastPosition);
} else {
AppLogger.debug('마지막 위치도 없어 기본 좌표(서울 시청)로 대체');
state = AsyncValue.data(defaultPosition());
}
} catch (_) { } catch (_) {
state = AsyncValue.error(e, stack); state = AsyncValue.data(defaultPosition());
} }
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/pages/calendar/widgets/visit_confirmation_dialog.dart';
import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
@@ -54,8 +55,8 @@ class NotificationPayload {
); );
} catch (e) { } catch (e) {
// 더 상세한 오류 정보 제공 // 더 상세한 오류 정보 제공
print('NotificationPayload parsing error: $e'); AppLogger.error('NotificationPayload parsing error', error: e);
print('Original payload: $payload'); AppLogger.debug('Original payload: $payload');
rethrow; rethrow;
} }
} }
@@ -77,17 +78,17 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
String? payload, String? payload,
) async { ) async {
if (payload == null || payload.isEmpty) { if (payload == null || payload.isEmpty) {
print('Notification payload is null or empty'); AppLogger.debug('Notification payload is null or empty');
return; return;
} }
print('Handling notification with payload: $payload'); AppLogger.debug('Handling notification with payload: $payload');
try { try {
// 기존 형식 (visit_reminder:restaurantName) 처리 // 기존 형식 (visit_reminder:restaurantName) 처리
if (payload.startsWith('visit_reminder:')) { if (payload.startsWith('visit_reminder:')) {
final restaurantName = payload.substring(15); final restaurantName = payload.substring(15);
print('Legacy format - Restaurant name: $restaurantName'); AppLogger.debug('Legacy format - Restaurant name: $restaurantName');
// 맛집 이름으로 ID 찾기 // 맛집 이름으로 ID 찾기
final restaurantsAsync = await _ref.read(restaurantListProvider.future); final restaurantsAsync = await _ref.read(restaurantListProvider.future);
@@ -110,11 +111,11 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
} }
} else { } else {
// 새로운 형식의 payload 처리 // 새로운 형식의 payload 처리
print('Attempting to parse new format payload'); AppLogger.debug('Attempting to parse new format payload');
try { try {
final notificationPayload = NotificationPayload.fromString(payload); final notificationPayload = NotificationPayload.fromString(payload);
print( AppLogger.debug(
'Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}', 'Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}',
); );
@@ -135,8 +136,10 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
} }
} }
} catch (parseError) { } catch (parseError) {
print('Failed to parse new format, attempting fallback parsing'); AppLogger.debug(
print('Parse error: $parseError'); 'Failed to parse new format, attempting fallback parsing',
);
AppLogger.debug('Parse error: $parseError');
// Fallback: 간단한 파싱 시도 // Fallback: 간단한 파싱 시도
if (payload.contains('|')) { if (payload.contains('|')) {
@@ -158,8 +161,11 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
} }
} }
} catch (e, stackTrace) { } catch (e, stackTrace) {
print('Error handling notification: $e'); AppLogger.error(
print('Stack trace: $stackTrace'); 'Error handling notification',
error: e,
stackTrace: stackTrace,
);
state = AsyncValue.error(e, stackTrace); state = AsyncValue.error(e, stackTrace);
// 에러 발생 시 기본적으로 캘린더 화면으로 이동 // 에러 발생 시 기본적으로 캘린더 화면으로 이동

View File

@@ -50,74 +50,109 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
: super(const AsyncValue.data(null)); : super(const AsyncValue.data(null));
/// 랜덤 추천 실행 /// 랜덤 추천 실행
Future<void> getRandomRecommendation({ Future<Restaurant?> getRandomRecommendation({
required double maxDistance, required double maxDistance,
required List<String> selectedCategories, required List<String> selectedCategories,
List<String> excludedRestaurantIds = const [],
bool shouldSaveRecord = true,
}) async { }) async {
state = const AsyncValue.loading(); state = const AsyncValue.loading();
try { try {
// 현재 위치 가져오기 final selectedRestaurant = await _generateCandidate(
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,
maxDistance: maxDistance, maxDistance: maxDistance,
selectedCategories: selectedCategories, selectedCategories: selectedCategories,
userSettings: userSettings, excludedRestaurantIds: excludedRestaurantIds,
weather: weather,
); );
// 추천 엔진 사용
final selectedRestaurant = await _recommendationEngine
.generateRecommendation(
allRestaurants: allRestaurants,
recentVisits: allVisitRecords,
config: config,
);
if (selectedRestaurant == null) { if (selectedRestaurant == null) {
state = const AsyncValue.data(null); state = const AsyncValue.data(null);
return; return null;
} }
// 추천 기록 저장 if (shouldSaveRecord) {
await _saveRecommendationRecord(selectedRestaurant); await saveRecommendationRecord(selectedRestaurant);
}
state = AsyncValue.data(selectedRestaurant); state = AsyncValue.data(selectedRestaurant);
return selectedRestaurant;
} catch (e, stack) { } catch (e, stack) {
state = AsyncValue.error(e, stack); state = AsyncValue.error(e, stack);
return null;
} }
} }
Future<Restaurant?> _generateCandidate({
required double maxDistance,
required List<String> selectedCategories,
List<String> 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<void> _saveRecommendationRecord(Restaurant restaurant) async { Future<RecommendationRecord> saveRecommendationRecord(
Restaurant restaurant, {
DateTime? recommendationTime,
}) async {
final now = DateTime.now();
final record = RecommendationRecord( final record = RecommendationRecord(
id: const Uuid().v4(), id: const Uuid().v4(),
restaurantId: restaurant.id, restaurantId: restaurant.id,
recommendationDate: DateTime.now(), recommendationDate: recommendationTime ?? now,
visited: false, visited: false,
createdAt: DateTime.now(), createdAt: now,
); );
await _repository.addRecommendationRecord(record); await _repository.addRecommendationRecord(record);
return record;
} }
/// 추천 후 방문 확인 /// 추천 후 방문 확인

View File

@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:lunchpick/domain/entities/weather_info.dart'; import 'package:lunchpick/domain/entities/weather_info.dart';
import 'package:lunchpick/domain/repositories/weather_repository.dart'; import 'package:lunchpick/domain/repositories/weather_repository.dart';
import 'package:lunchpick/presentation/providers/di_providers.dart'; import 'package:lunchpick/presentation/providers/di_providers.dart';
@@ -7,12 +8,17 @@ import 'package:lunchpick/presentation/providers/location_provider.dart';
/// 현재 날씨 Provider /// 현재 날씨 Provider
final weatherProvider = FutureProvider<WeatherInfo>((ref) async { final weatherProvider = FutureProvider<WeatherInfo>((ref) async {
final repository = ref.watch(weatherRepositoryProvider); final repository = ref.watch(weatherRepositoryProvider);
final location = await ref.watch(currentLocationProvider.future); Position? location;
if (location == null) { try {
throw Exception('위치 정보를 가져올 수 없습니다'); location = await ref.watch(currentLocationProvider.future);
} catch (_) {
// 위치 호출 실패 시 기본 좌표 사용
location = defaultPosition();
} }
location ??= defaultPosition();
// 캐시된 날씨 정보 확인 // 캐시된 날씨 정보 확인
final cached = await repository.getCachedWeather(); final cached = await repository.getCachedWeather();
if (cached != null) { if (cached != null) {

View File

@@ -1,4 +1,8 @@
// ignore_for_file: unnecessary_library_name
@Skip('Requires live Naver API responses') @Skip('Requires live Naver API responses')
library naver_api_integration_test;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
import '../mocks/mock_naver_api_client.dart'; import '../mocks/mock_naver_api_client.dart';

View File

@@ -1,4 +1,8 @@
// ignore_for_file: unnecessary_library_name
@Skip('Requires live Naver API responses') @Skip('Requires live Naver API responses')
library naver_integration_test;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lunchpick/data/api/naver_api_client.dart'; import 'package:lunchpick/data/api/naver_api_client.dart';
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';

View File

@@ -1,6 +1,10 @@
// ignore_for_file: unnecessary_library_name
@Skip( @Skip(
'NaverApiClient unit tests require mocking Dio behavior not yet implemented', 'NaverApiClient unit tests require mocking Dio behavior not yet implemented',
) )
library naver_api_client_test;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';

View File

@@ -1,4 +1,8 @@
// ignore_for_file: unnecessary_library_name
@Skip('Integration-heavy parser tests are temporarily disabled') @Skip('Integration-heavy parser tests are temporarily disabled')
library naver_map_parser_test;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/entities/restaurant.dart';

View File

@@ -1,4 +1,8 @@
// ignore_for_file: unnecessary_library_name
@Skip('Integration-heavy parser tests are temporarily disabled') @Skip('Integration-heavy parser tests are temporarily disabled')
library naver_parser_location_test;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
import 'package:lunchpick/data/api/naver/naver_local_search_api.dart'; import 'package:lunchpick/data/api/naver/naver_local_search_api.dart';

View File

@@ -1,4 +1,8 @@
// ignore_for_file: unnecessary_library_name
@Skip('Integration-heavy parser tests are temporarily disabled') @Skip('Integration-heavy parser tests are temporarily disabled')
library naver_parser_v2_test;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
import 'package:lunchpick/core/errors/network_exceptions.dart'; import 'package:lunchpick/core/errors/network_exceptions.dart';

View File

@@ -1,4 +1,8 @@
// ignore_for_file: unnecessary_library_name
@Skip('Integration-heavy parser tests are temporarily disabled') @Skip('Integration-heavy parser tests are temporarily disabled')
library naver_url_redirect_test;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/entities/restaurant.dart';

View File

@@ -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);
});
}

View File

@@ -1,6 +1,10 @@
// ignore_for_file: unnecessary_library_name
@Skip( @Skip(
'RecommendationEngine tests temporarily disabled pending deterministic fixtures', 'RecommendationEngine tests temporarily disabled pending deterministic fixtures',
) )
library recommendation_engine_test;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lunchpick/domain/usecases/recommendation_engine.dart'; import 'package:lunchpick/domain/usecases/recommendation_engine.dart';
import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/entities/restaurant.dart';

View File

@@ -1,4 +1,8 @@
// ignore_for_file: unnecessary_library_name
@Skip('AddRestaurantDialog layout changed; widget test disabled temporarily') @Skip('AddRestaurantDialog layout changed; widget test disabled temporarily')
library add_restaurant_dialog_test;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';