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:
@@ -18,6 +18,14 @@ class ApiKeys {
|
||||
static String get naverClientId => _decodeIfNeeded(_encodedClientId);
|
||||
static String get naverClientSecret => _decodeIfNeeded(_encodedClientSecret);
|
||||
|
||||
static const String _encodedWeatherServiceKey = String.fromEnvironment(
|
||||
'KMA_SERVICE_KEY',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
static String get weatherServiceKey =>
|
||||
_decodeIfNeeded(_encodedWeatherServiceKey);
|
||||
|
||||
static const String naverLocalSearchEndpoint =
|
||||
'https://openapi.naver.com/v1/search/local.json';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
/// 로깅 인터셉터
|
||||
///
|
||||
@@ -13,16 +14,18 @@ class LoggingInterceptor extends Interceptor {
|
||||
final method = options.method;
|
||||
final headers = options.headers;
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
print('>>> REQUEST [$method] $uri');
|
||||
print('>>> Headers: $headers');
|
||||
AppLogger.debug(
|
||||
'═══════════════════════════════════════════════════════════════',
|
||||
);
|
||||
AppLogger.debug('>>> REQUEST [$method] $uri');
|
||||
AppLogger.debug('>>> Headers: $headers');
|
||||
|
||||
if (options.data != null) {
|
||||
print('>>> Body: ${options.data}');
|
||||
AppLogger.debug('>>> Body: ${options.data}');
|
||||
}
|
||||
|
||||
if (options.queryParameters.isNotEmpty) {
|
||||
print('>>> Query Parameters: ${options.queryParameters}');
|
||||
AppLogger.debug('>>> Query Parameters: ${options.queryParameters}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,21 +38,25 @@ class LoggingInterceptor extends Interceptor {
|
||||
final statusCode = response.statusCode;
|
||||
final uri = response.requestOptions.uri;
|
||||
|
||||
print('<<< RESPONSE [$statusCode] $uri');
|
||||
AppLogger.debug('<<< RESPONSE [$statusCode] $uri');
|
||||
|
||||
if (response.headers.map.isNotEmpty) {
|
||||
print('<<< Headers: ${response.headers.map}');
|
||||
AppLogger.debug('<<< Headers: ${response.headers.map}');
|
||||
}
|
||||
|
||||
// 응답 본문은 너무 길 수 있으므로 처음 500자만 출력
|
||||
final responseData = response.data.toString();
|
||||
if (responseData.length > 500) {
|
||||
print('<<< Body: ${responseData.substring(0, 500)}...(truncated)');
|
||||
AppLogger.debug(
|
||||
'<<< Body: ${responseData.substring(0, 500)}...(truncated)',
|
||||
);
|
||||
} else {
|
||||
print('<<< Body: $responseData');
|
||||
AppLogger.debug('<<< Body: $responseData');
|
||||
}
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
AppLogger.debug(
|
||||
'═══════════════════════════════════════════════════════════════',
|
||||
);
|
||||
}
|
||||
|
||||
return handler.next(response);
|
||||
@@ -61,17 +68,21 @@ class LoggingInterceptor extends Interceptor {
|
||||
final uri = err.requestOptions.uri;
|
||||
final message = err.message;
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
print('!!! ERROR $uri');
|
||||
print('!!! Message: $message');
|
||||
AppLogger.debug(
|
||||
'═══════════════════════════════════════════════════════════════',
|
||||
);
|
||||
AppLogger.debug('!!! ERROR $uri');
|
||||
AppLogger.debug('!!! Message: $message');
|
||||
|
||||
if (err.response != null) {
|
||||
print('!!! Status Code: ${err.response!.statusCode}');
|
||||
print('!!! Response: ${err.response!.data}');
|
||||
AppLogger.debug('!!! Status Code: ${err.response!.statusCode}');
|
||||
AppLogger.debug('!!! Response: ${err.response!.data}');
|
||||
}
|
||||
|
||||
print('!!! Error Type: ${err.type}');
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
AppLogger.debug('!!! Error Type: ${err.type}');
|
||||
AppLogger.debug(
|
||||
'═══════════════════════════════════════════════════════════════',
|
||||
);
|
||||
}
|
||||
|
||||
return handler.next(err);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../network_config.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../errors/network_exceptions.dart';
|
||||
|
||||
/// 재시도 인터셉터
|
||||
@@ -24,7 +25,7 @@ class RetryInterceptor extends Interceptor {
|
||||
// 지수 백오프 계산
|
||||
final delay = _calculateBackoffDelay(retryCount);
|
||||
|
||||
print(
|
||||
AppLogger.debug(
|
||||
'RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기',
|
||||
);
|
||||
|
||||
@@ -59,7 +60,7 @@ class RetryInterceptor extends Interceptor {
|
||||
// 네이버 관련 요청은 재시도하지 않음
|
||||
final url = err.requestOptions.uri.toString();
|
||||
if (url.contains('naver.com') || url.contains('naver.me')) {
|
||||
print('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
||||
AppLogger.debug('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'network_config.dart';
|
||||
import '../errors/network_exceptions.dart';
|
||||
@@ -88,8 +90,12 @@ class NetworkClient {
|
||||
);
|
||||
|
||||
_dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
|
||||
} catch (e) {
|
||||
debugPrint('NetworkClient: 캐시 설정 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NetworkClient: 캐시 설정 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// 캐시 실패해도 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// 알림 서비스 싱글톤 클래스
|
||||
class NotificationService {
|
||||
@@ -14,6 +15,7 @@ class NotificationService {
|
||||
// Flutter Local Notifications 플러그인
|
||||
final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
bool _initialized = false;
|
||||
|
||||
// 알림 채널 정보
|
||||
static const String _channelId = 'lunchpick_visit_reminder';
|
||||
@@ -22,11 +24,36 @@ class NotificationService {
|
||||
|
||||
// 알림 ID (방문 확인용)
|
||||
static const int _visitReminderNotificationId = 1;
|
||||
bool _timezoneReady = false;
|
||||
tz.Location? _cachedLocation;
|
||||
|
||||
/// 초기화 여부
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// 초기화 및 권한 요청 보장
|
||||
Future<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 {
|
||||
if (_initialized) return true;
|
||||
if (kIsWeb) return false;
|
||||
|
||||
// 시간대 초기화
|
||||
tz.initializeTimeZones();
|
||||
await _ensureLocalTimezone();
|
||||
|
||||
// Android 초기화 설정
|
||||
const androidInitSettings = AndroidInitializationSettings(
|
||||
@@ -58,18 +85,25 @@ class NotificationService {
|
||||
);
|
||||
|
||||
// 알림 플러그인 초기화
|
||||
final initialized = await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap,
|
||||
);
|
||||
try {
|
||||
final initialized = await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
onDidReceiveBackgroundNotificationResponse:
|
||||
_onBackgroundNotificationTap,
|
||||
);
|
||||
_initialized = initialized ?? false;
|
||||
} catch (e) {
|
||||
_initialized = false;
|
||||
AppLogger.debug('알림 초기화 실패: $e');
|
||||
}
|
||||
|
||||
// Android 알림 채널 생성 (웹이 아닌 경우에만)
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
if (_initialized && defaultTargetPlatform == TargetPlatform.android) {
|
||||
await _createNotificationChannel();
|
||||
}
|
||||
|
||||
return initialized ?? false;
|
||||
return _initialized;
|
||||
}
|
||||
|
||||
/// Android 알림 채널 생성
|
||||
@@ -92,25 +126,21 @@ class NotificationService {
|
||||
|
||||
/// 알림 권한 요청
|
||||
Future<bool> requestPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
if (kIsWeb) return false;
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 (API 33) 이상에서는 권한 요청이 필요
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation
|
||||
.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
final granted = await androidImplementation
|
||||
.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
} else if (!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
} else if (defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
@@ -144,24 +174,43 @@ class NotificationService {
|
||||
|
||||
/// 권한 상태 확인
|
||||
Future<bool> checkPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
if (kIsWeb) return false;
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 이상에서만 권한 확인
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS/macOS는 설정에서 확인
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final macosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final settings = await iosImplementation.checkPermissions();
|
||||
return settings?.isEnabled ?? false;
|
||||
}
|
||||
|
||||
if (macosImplementation != null) {
|
||||
final settings = await macosImplementation.checkPermissions();
|
||||
return settings?.isEnabled ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS/macOS 외 플랫폼은 기본적으로 허용으로 간주
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -176,9 +225,22 @@ class NotificationService {
|
||||
int? delayMinutes,
|
||||
}) async {
|
||||
try {
|
||||
final ready = await ensureInitialized();
|
||||
if (!ready) {
|
||||
AppLogger.debug('알림 서비스가 초기화되지 않아 예약을 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final permissionGranted = await checkPermission();
|
||||
if (!permissionGranted) {
|
||||
AppLogger.debug('알림 권한이 없어 예약을 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final location = await _ensureLocalTimezone();
|
||||
final minutesToWait = delayMinutes ?? 90 + Random().nextInt(31);
|
||||
final scheduledTime = tz.TZDateTime.now(
|
||||
tz.local,
|
||||
location,
|
||||
).add(Duration(minutes: minutesToWait));
|
||||
|
||||
// 알림 상세 설정
|
||||
@@ -215,45 +277,95 @@ class NotificationService {
|
||||
'$restaurantName 어땠어요? 방문 기록을 남겨주세요!',
|
||||
scheduledTime,
|
||||
notificationDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
androidScheduleMode: await _resolveAndroidScheduleMode(),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload:
|
||||
'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
);
|
||||
if (kDebugMode) {
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
||||
}
|
||||
AppLogger.debug('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('알림 예약 실패: $e');
|
||||
}
|
||||
AppLogger.debug('알림 예약 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 예약된 방문 확인 알림 취소
|
||||
Future<void> cancelVisitReminder() async {
|
||||
if (!await ensureInitialized()) return;
|
||||
await _notifications.cancel(_visitReminderNotificationId);
|
||||
}
|
||||
|
||||
/// 모든 알림 취소
|
||||
Future<void> cancelAllNotifications() async {
|
||||
if (!await ensureInitialized()) return;
|
||||
await _notifications.cancelAll();
|
||||
}
|
||||
|
||||
/// 예약된 알림 목록 조회
|
||||
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||
if (!await ensureInitialized()) return [];
|
||||
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) {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('알림 탭: ${response.payload}');
|
||||
}
|
||||
AppLogger.debug('알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,9 +375,7 @@ class NotificationService {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('백그라운드 알림 탭: ${response.payload}');
|
||||
}
|
||||
AppLogger.debug('백그라운드 알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
lib/core/utils/app_logger.dart
Normal file
28
lib/core/utils/app_logger.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user