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 'package:flutter/foundation.dart'; import 'dart:io' show Platform; import '../models/subscription_model.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class NotificationService { static final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); static const _secureStorage = FlutterSecureStorage(); static const _notificationEnabledKey = 'notification_enabled'; static const _paymentNotificationEnabledKey = 'payment_notification_enabled'; static const _reminderDaysKey = 'reminder_days'; static const _reminderHourKey = 'reminder_hour'; static const _reminderMinuteKey = 'reminder_minute'; static const _dailyReminderKey = 'daily_reminder_enabled'; // 초기화 상태를 추적하기 위한 플래그 static bool _initialized = false; // 웹 플랫폼 여부 확인 (웹에서는 flutter_local_notifications가 지원되지 않음) static bool get _isWeb => kIsWeb; static const AndroidNotificationChannel channel = AndroidNotificationChannel( 'subscription_channel', 'Subscription Notifications', description: 'Channel for subscription reminders', importance: Importance.high, enableVibration: true, ); // 알림 초기화 static Future init() async { try { // 웹 플랫폼인 경우 초기화 건너뛰기 if (_isWeb) { debugPrint('웹 플랫폼에서는 로컬 알림이 지원되지 않습니다.'); return; } tz.initializeTimeZones(); try { tz.setLocalLocation(tz.getLocation('Asia/Seoul')); } catch (e) { // 타임존 찾기 실패시 UTC 사용 tz.setLocalLocation(tz.UTC); debugPrint('타임존 설정 실패, UTC 사용: $e'); } const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const iosSettings = DarwinInitializationSettings(); const initSettings = InitializationSettings(android: androidSettings, iOS: iosSettings); await _notifications.initialize(initSettings); _initialized = true; debugPrint('알림 서비스 초기화 완료'); } catch (e) { _initialized = false; debugPrint('알림 서비스 초기화 실패: $e'); } } static Future isNotificationEnabled() async { final value = await _secureStorage.read(key: _notificationEnabledKey); return value == 'true'; } static Future isPaymentNotificationEnabled() async { final value = await _secureStorage.read(key: _paymentNotificationEnabledKey); return value == 'true'; } static Future setNotificationEnabled(bool value) async { await _secureStorage.write( key: _notificationEnabledKey, value: value.toString(), ); } static Future setPaymentNotificationEnabled(bool value) async { await _secureStorage.write( key: _paymentNotificationEnabledKey, value: value.toString(), ); } // 알림 시점 설정 (1일전, 2일전, 3일전) 가져오기 static Future getReminderDays() async { final value = await _secureStorage.read(key: _reminderDaysKey); return value != null ? int.tryParse(value) ?? 3 : 3; } // 알림 시간 가져오기 static Future getReminderHour() async { final value = await _secureStorage.read(key: _reminderHourKey); return value != null ? int.tryParse(value) ?? 10 : 10; } static Future getReminderMinute() async { final value = await _secureStorage.read(key: _reminderMinuteKey); return value != null ? int.tryParse(value) ?? 0 : 0; } // 반복 알림 설정 가져오기 static Future isDailyReminderEnabled() async { final value = await _secureStorage.read(key: _dailyReminderKey); return value == 'true'; } // 모든 구독의 알림 일정 다시 예약 static Future reschedulAllNotifications( List subscriptions) async { try { // 웹 플랫폼이거나 알림 초기화 실패한 경우 건너뛰기 if (_isWeb || !_initialized) { debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } // 기존 알림 모두 취소 await cancelAllNotifications(); // 알림 설정 가져오기 final isPaymentEnabled = await isPaymentNotificationEnabled(); if (!isPaymentEnabled) return; final reminderDays = await getReminderDays(); final reminderHour = await getReminderHour(); final reminderMinute = await getReminderMinute(); final isDailyReminder = await isDailyReminderEnabled(); // 각 구독에 대해 알림 재설정 for (final subscription in subscriptions) { await schedulePaymentReminder( subscription: subscription, reminderDays: reminderDays, reminderHour: reminderHour, reminderMinute: reminderMinute, isDailyReminder: isDailyReminder, ); } } catch (e) { debugPrint('알림 일정 재설정 중 오류 발생: $e'); } } static Future requestPermission() async { // 웹 플랫폼인 경우 false 반환 if (_isWeb) return false; // iOS 처리 if (Platform.isIOS) { final iosImplementation = _notifications.resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>(); if (iosImplementation != null) { final granted = await iosImplementation.requestPermissions( alert: true, badge: true, sound: true, ); return granted ?? false; } } // Android 처리 if (Platform.isAndroid) { final androidImplementation = _notifications.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); if (androidImplementation != null) { final granted = await androidImplementation.requestNotificationsPermission(); return granted ?? false; } } return false; } // 권한 상태 확인 static Future checkPermission() async { // 웹 플랫폼인 경우 false 반환 if (_isWeb) return false; // Android 처리 if (Platform.isAndroid) { final androidImplementation = _notifications.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); if (androidImplementation != null) { // Android 13 이상에서만 권한 확인 필요 final isEnabled = await androidImplementation.areNotificationsEnabled(); return isEnabled ?? false; } } // iOS 처리 if (Platform.isIOS) { final iosImplementation = _notifications.resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>(); if (iosImplementation != null) { final settings = await iosImplementation.checkPermissions(); return settings?.isEnabled ?? false; } } return true; // 기본값 } // 알림 스케줄 설정 static Future scheduleNotification({ required int id, required String title, required String body, required DateTime scheduledDate, }) async { // 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기 if (_isWeb || !_initialized) { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } try { const androidDetails = AndroidNotificationDetails( 'subscription_channel', '구독 알림', channelDescription: '구독 관련 알림을 보여줍니다.', importance: Importance.high, priority: Priority.high, ); const iosDetails = DarwinNotificationDetails(); // tz.local 초기화 확인 및 재시도 tz.Location location; try { location = tz.local; } catch (e) { // tz.local이 초기화되지 않은 경우 재시도 debugPrint('tz.local 초기화되지 않음, 재시도 중...'); try { tz.setLocalLocation(tz.getLocation('Asia/Seoul')); location = tz.local; } catch (_) { // 그래도 실패하면 UTC 사용 debugPrint('타임존 설정 실패, UTC 사용'); tz.setLocalLocation(tz.UTC); location = tz.UTC; } } await _notifications.zonedSchedule( id, title, body, tz.TZDateTime.from(scheduledDate, location), const NotificationDetails(android: androidDetails, iOS: iosDetails), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('알림 예약 중 오류 발생: $e'); } } // 알림 취소 static Future cancelNotification(int id) async { await _notifications.cancel(id); } // 모든 알림 취소 static Future cancelAllNotifications() async { try { // 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기 if (_isWeb || !_initialized) { debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 취소할 수 없습니다.'); return; } await _notifications.cancelAll(); debugPrint('모든 알림이 취소되었습니다.'); } catch (e) { debugPrint('알림 취소 중 오류 발생: $e'); } } static Future scheduleSubscriptionNotification( SubscriptionModel subscription) async { // 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기 if (_isWeb || !_initialized) { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } try { final notificationId = subscription.id.hashCode; const androidDetails = AndroidNotificationDetails( 'subscription_channel', '구독 알림', channelDescription: '구독 만료 알림을 보내는 채널입니다.', importance: Importance.high, priority: Priority.high, ); const iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ); const notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, ); // tz.local 초기화 확인 및 재시도 tz.Location location; try { location = tz.local; } catch (e) { // tz.local이 초기화되지 않은 경우 재시도 debugPrint('tz.local 초기화되지 않음, 재시도 중...'); try { tz.setLocalLocation(tz.getLocation('Asia/Seoul')); location = tz.local; } catch (_) { // 그래도 실패하면 UTC 사용 debugPrint('타임존 설정 실패, UTC 사용'); tz.setLocalLocation(tz.UTC); location = tz.UTC; } } await _notifications.zonedSchedule( notificationId, '구독 만료 알림', '${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.', tz.TZDateTime.from(subscription.nextBillingDate, location), notificationDetails, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('구독 알림 예약 중 오류 발생: $e'); } } static Future updateSubscriptionNotification( SubscriptionModel subscription) async { await cancelSubscriptionNotification(subscription); await scheduleSubscriptionNotification(subscription); } static Future cancelSubscriptionNotification( SubscriptionModel subscription) async { final notificationId = subscription.id.hashCode; await _notifications.cancel(notificationId); } static Future schedulePaymentNotification( SubscriptionModel subscription) async { // 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기 if (_isWeb || !_initialized) { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } try { final paymentDate = subscription.nextBillingDate; final reminderDate = paymentDate.subtract(const Duration(days: 3)); // tz.local 초기화 확인 및 재시도 tz.Location location; try { location = tz.local; } catch (e) { // tz.local이 초기화되지 않은 경우 재시도 debugPrint('tz.local 초기화되지 않음, 재시도 중...'); try { tz.setLocalLocation(tz.getLocation('Asia/Seoul')); location = tz.local; } catch (_) { // 그래도 실패하면 UTC 사용 debugPrint('타임존 설정 실패, UTC 사용'); tz.setLocalLocation(tz.UTC); location = tz.UTC; } } await _notifications.zonedSchedule( subscription.id.hashCode, '구독 결제 예정 알림', '${subscription.serviceName} 결제가 3일 후 예정되어 있습니다.', tz.TZDateTime.from(reminderDate, location), const NotificationDetails( android: AndroidNotificationDetails( 'payment_channel', 'Payment Notifications', channelDescription: 'Channel for subscription payment reminders', importance: Importance.high, priority: Priority.high, ), ), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('결제 알림 예약 중 오류 발생: $e'); } } static Future scheduleExpirationNotification( SubscriptionModel subscription) async { // 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기 if (_isWeb || !_initialized) { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } try { final expirationDate = subscription.nextBillingDate; final reminderDate = expirationDate.subtract(const Duration(days: 7)); // tz.local 초기화 확인 및 재시도 tz.Location location; try { location = tz.local; } catch (e) { // tz.local이 초기화되지 않은 경우 재시도 debugPrint('tz.local 초기화되지 않음, 재시도 중...'); try { tz.setLocalLocation(tz.getLocation('Asia/Seoul')); location = tz.local; } catch (_) { // 그래도 실패하면 UTC 사용 debugPrint('타임존 설정 실패, UTC 사용'); tz.setLocalLocation(tz.UTC); location = tz.UTC; } } await _notifications.zonedSchedule( ('${subscription.id}_expiration').hashCode, '구독 만료 예정 알림', '${subscription.serviceName} 구독이 7일 후 만료됩니다.', tz.TZDateTime.from(reminderDate, location), const NotificationDetails( android: AndroidNotificationDetails( 'expiration_channel', 'Expiration Notifications', channelDescription: 'Channel for subscription expiration reminders', importance: Importance.high, priority: Priority.high, ), ), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('만료 알림 예약 중 오류 발생: $e'); } } static Future schedulePaymentReminder({ required SubscriptionModel subscription, int reminderDays = 3, int reminderHour = 10, int reminderMinute = 0, bool isDailyReminder = false, }) async { // 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기 if (_isWeb || !_initialized) { debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } try { // tz.local 초기화 확인 및 재시도 tz.Location location; try { location = tz.local; } catch (e) { // tz.local이 초기화되지 않은 경우 재시도 debugPrint('tz.local 초기화되지 않음, 재시도 중...'); try { tz.setLocalLocation(tz.getLocation('Asia/Seoul')); location = tz.local; } catch (_) { // 그래도 실패하면 UTC 사용 debugPrint('타임존 설정 실패, UTC 사용'); tz.setLocalLocation(tz.UTC); location = tz.UTC; } } // 기본 알림 예약 (지정된 일수 전) final scheduledDate = subscription.nextBillingDate .subtract(Duration(days: reminderDays)) .copyWith( hour: reminderHour, minute: reminderMinute, second: 0, millisecond: 0, microsecond: 0, ); // 남은 일수에 따른 메시지 생성 String daysText = '$reminderDays일 후'; if (reminderDays == 1) { daysText = '내일'; } // 이벤트 종료로 인한 가격 변동 확인 String notificationBody; if (subscription.isEventActive && subscription.eventEndDate != null && subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && subscription.eventEndDate!.isAfter(DateTime.now())) { // 이벤트가 결제일 전에 종료되는 경우 final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; final normalPrice = subscription.monthlyCost; notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n' '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; } else { // 일반 알림 final currentPrice = subscription.currentPrice; notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.'; } await _notifications.zonedSchedule( subscription.id.hashCode, '구독 결제 예정 알림', notificationBody, tz.TZDateTime.from(scheduledDate, location), const NotificationDetails( android: AndroidNotificationDetails( 'subscription_channel', 'Subscription Notifications', channelDescription: 'Channel for subscription reminders', importance: Importance.high, priority: Priority.high, ), iOS: DarwinNotificationDetails(), ), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); // 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우) if (isDailyReminder && reminderDays >= 2) { // 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약 for (int i = reminderDays - 1; i >= 1; i--) { final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( hour: reminderHour, minute: reminderMinute, second: 0, millisecond: 0, microsecond: 0, ); // 남은 일수에 따른 메시지 생성 String remainingDaysText = '$i일 후'; if (i == 1) { remainingDaysText = '내일'; } // 각 날짜에 대한 이벤트 종료 확인 String dailyNotificationBody; if (subscription.isEventActive && subscription.eventEndDate != null && subscription.eventEndDate! .isBefore(subscription.nextBillingDate) && subscription.eventEndDate!.isAfter(DateTime.now())) { final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; final normalPrice = subscription.monthlyCost; dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n' '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; } else { final currentPrice = subscription.currentPrice; dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.'; } await _notifications.zonedSchedule( subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함 '구독 결제 예정 알림', dailyNotificationBody, tz.TZDateTime.from(dailyDate, location), const NotificationDetails( android: AndroidNotificationDetails( 'subscription_channel', 'Subscription Notifications', channelDescription: 'Channel for subscription reminders', importance: Importance.high, priority: Priority.high, ), iOS: DarwinNotificationDetails(), ), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } } } catch (e) { debugPrint('결제 알림 예약 중 오류 발생: $e'); } } static String getNotificationBody(String serviceName, double amount) { return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.'; } }