import 'dart:math'; 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 { // 싱글톤 인스턴스 static final NotificationService _instance = NotificationService._internal(); factory NotificationService() => _instance; NotificationService._internal(); // Flutter Local Notifications 플러그인 final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); bool _initialized = false; // 알림 채널 정보 static const String _channelId = 'lunchpick_visit_reminder'; static const String _channelName = '방문 확인 알림'; static const String _channelDescription = '점심 식사 후 방문을 확인하는 알림입니다.'; // 알림 ID (방문 확인용) static const int _visitReminderNotificationId = 1; bool _timezoneReady = false; tz.Location? _cachedLocation; /// 초기화 여부 bool get isInitialized => _initialized; /// 초기화 및 권한 요청 보장 Future ensureInitialized({bool requestPermission = false}) async { if (!_initialized) { _initialized = await initialize(); } if (!_initialized) return false; if (requestPermission) { final alreadyGranted = await checkPermission(); if (alreadyGranted) return true; return await this.requestPermission(); } return true; } /// 알림 서비스 초기화 Future initialize() async { if (_initialized) return true; if (kIsWeb) return false; // 시간대 초기화 await _ensureLocalTimezone(); // Android 초기화 설정 const androidInitSettings = AndroidInitializationSettings( '@mipmap/ic_launcher', ); // iOS 초기화 설정 final iosInitSettings = DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, requestSoundPermission: true, onDidReceiveLocalNotification: (id, title, body, payload) async { // iOS 9 이하 버전 대응 }, ); // macOS 초기화 설정 final macOSInitSettings = DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, requestSoundPermission: true, ); // 플랫폼별 초기화 설정 통합 final initSettings = InitializationSettings( android: androidInitSettings, iOS: iosInitSettings, macOS: macOSInitSettings, ); // 알림 플러그인 초기화 try { final initialized = await _notifications.initialize( initSettings, onDidReceiveNotificationResponse: _onNotificationTap, onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap, ); _initialized = initialized ?? false; } catch (e) { _initialized = false; AppLogger.debug('알림 초기화 실패: $e'); } // Android 알림 채널 생성 (웹이 아닌 경우에만) if (_initialized && defaultTargetPlatform == TargetPlatform.android) { await _createNotificationChannel(); } return _initialized; } /// Android 알림 채널 생성 Future _createNotificationChannel() async { const androidChannel = AndroidNotificationChannel( _channelId, _channelName, description: _channelDescription, importance: Importance.high, playSound: true, enableVibration: true, ); try { await _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >() ?.createNotificationChannel(androidChannel); } catch (e) { AppLogger.debug('안드로이드 채널 생성 실패: $e'); } } /// 알림 권한 요청 Future requestPermission() async { if (kIsWeb) return false; if (defaultTargetPlatform == TargetPlatform.android) { final androidImplementation = _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); if (androidImplementation != null) { final granted = await androidImplementation .requestNotificationsPermission(); return granted ?? false; } } else if (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS) { final iosImplementation = _notifications .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin >(); final macosImplementation = _notifications .resolvePlatformSpecificImplementation< MacOSFlutterLocalNotificationsPlugin >(); if (iosImplementation != null) { final granted = await iosImplementation.requestPermissions( alert: true, badge: true, sound: true, ); return granted ?? false; } if (macosImplementation != null) { final granted = await macosImplementation.requestPermissions( alert: true, badge: true, sound: true, ); return granted ?? false; } } return false; } /// 권한 상태 확인 Future checkPermission() async { if (kIsWeb) return false; if (defaultTargetPlatform == TargetPlatform.android) { final androidImplementation = _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); if (androidImplementation != null) { final granted = await androidImplementation.areNotificationsEnabled(); return granted ?? false; } } 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; } /// 정확 알람 권한 가능 여부 확인 (Android 12+) Future canScheduleExactAlarms() async { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { return true; } try { final androidImplementation = _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); final canExact = await androidImplementation ?.canScheduleExactNotifications(); return canExact ?? true; } catch (e) { AppLogger.debug('정확 알람 권한 확인 실패: $e'); return false; } } /// 정확 알람 권한 요청 (Android 12+ 설정 화면 이동) Future requestExactAlarmsPermission() async { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { return true; } try { final androidImplementation = _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); final granted = await androidImplementation ?.requestExactAlarmsPermission(); return granted ?? false; } catch (e) { AppLogger.debug('정확 알람 권한 요청 실패: $e'); return false; } } // 알림 탭 콜백 static void Function(NotificationResponse)? onNotificationTap; /// 방문 확인 알림 예약 Future scheduleVisitReminder({ required String restaurantId, required String restaurantName, required DateTime recommendationTime, 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 = max(delayMinutes ?? 90 + Random().nextInt(31), 1); final scheduledTime = tz.TZDateTime.now( location, ).add(Duration(minutes: minutesToWait)); // 알림 상세 설정 final androidDetails = AndroidNotificationDetails( _channelId, _channelName, channelDescription: _channelDescription, importance: Importance.high, priority: Priority.high, ticker: '방문 확인', icon: '@mipmap/ic_launcher', autoCancel: true, enableVibration: true, playSound: true, ); const iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, sound: 'default', ); final notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, macOS: iosDetails, ); // 알림 예약 await _notifications.zonedSchedule( _visitReminderNotificationId, '다녀왔음? 🍴', '$restaurantName 어땠어요? 방문 기록을 남겨주세요!', scheduledTime, notificationDetails, androidScheduleMode: await _resolveAndroidScheduleMode(), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, payload: 'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}', ); AppLogger.debug('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)'); } catch (e) { AppLogger.debug('알림 예약 실패: $e'); } } /// 예약된 방문 확인 알림 취소 Future cancelVisitReminder() async { if (!await ensureInitialized()) return; await _notifications.cancel(_visitReminderNotificationId); } /// 모든 알림 취소 Future cancelAllNotifications() async { if (!await ensureInitialized()) return; await _notifications.cancelAll(); } /// 예약된 알림 목록 조회 Future> getPendingNotifications() async { if (!await ensureInitialized()) return []; return await _notifications.pendingNotificationRequests(); } /// 방문 확인 알림이 예약되어 있는지 확인 Future hasVisitReminderScheduled() async { if (!await ensureInitialized()) return false; final pending = await getPendingNotifications(); return pending.any((item) => item.id == _visitReminderNotificationId); } /// 타임존을 안전하게 초기화하고 tz.local을 반환 Future _ensureLocalTimezone() async { if (_cachedLocation != null) return _cachedLocation!; if (!_timezoneReady) { try { tz.initializeTimeZones(); } catch (_) { // 초기화 실패 시에도 계속 진행 } _timezoneReady = true; } try { tz.setLocalLocation(tz.getLocation('Asia/Seoul')); _cachedLocation = tz.local; } catch (_) { // 로컬 타임존을 가져오지 못하면 UTC로 강제 설정 tz.setLocalLocation(tz.UTC); _cachedLocation = tz.UTC; } return _cachedLocation!; } /// 정확 알람 권한 여부에 따라 스케줄 모드 결정 Future _resolveAndroidScheduleMode() async { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { return AndroidScheduleMode.exactAllowWhileIdle; } final canExact = await canScheduleExactAlarms(); if (!canExact) { AppLogger.debug('정확 알람 권한 없음 → 근사 모드로 예약'); return AndroidScheduleMode.inexactAllowWhileIdle; } return AndroidScheduleMode.exactAllowWhileIdle; } /// 알림 탭 이벤트 처리 void _onNotificationTap(NotificationResponse response) { if (onNotificationTap != null) { onNotificationTap!(response); } else if (response.payload != null) { AppLogger.debug('알림 탭: ${response.payload}'); } } /// 백그라운드 알림 탭 이벤트 처리 @pragma('vm:entry-point') static void _onBackgroundNotificationTap(NotificationResponse response) { if (onNotificationTap != null) { onNotificationTap!(response); } else if (response.payload != null) { AppLogger.debug('백그라운드 알림 탭: ${response.payload}'); } } /// 즉시 알림 표시 (테스트용) Future showImmediateNotification({ required String title, required String body, }) async { const androidDetails = AndroidNotificationDetails( _channelId, _channelName, channelDescription: _channelDescription, importance: Importance.high, priority: Priority.high, ); const iosDetails = DarwinNotificationDetails(); const notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, macOS: iosDetails, ); await _notifications.show(0, title, body, notificationDetails); } }