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

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