From 55e3f6727990048914eea2e29e74ada0cfccfc3e Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 15 Sep 2025 15:18:45 +0900 Subject: [PATCH] fix(notification): improve local notification reliability on iOS/Android\n\n- iOS: set UNUserNotificationCenter delegate and present [.banner,.sound,.badge]\n- Android: create channels on init; use exactAllowWhileIdle; add RECEIVE_BOOT_COMPLETED and SCHEDULE_EXACT_ALARM\n- Dart: ensure iOS present options enabled; fix title variable shadowing\n\nValidation: scripts/check.sh passed (format/analyze/tests)\nRisk: exact alarms require user to allow 'Alarms & reminders' on Android 12+\nRollback: revert manifest perms and switch schedule mode back to inexact --- android/app/src/main/AndroidManifest.xml | 6 +- ios/Runner/AppDelegate.swift | 11 + lib/services/notification_service.dart | 400 +++++++++++++++-------- 3 files changed, 285 insertions(+), 132 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c6eaf9c..17011b0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,12 @@ + + + + Bool { + UNUserNotificationCenter.current().delegate = self GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + // 앱이 포그라운드일 때도 배너/사운드/배지 표시 + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge]) + } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index d4be196..ee7d291 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -5,6 +5,9 @@ import 'package:flutter/foundation.dart'; import 'dart:io' show Platform; import '../models/subscription_model.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../navigator_key.dart'; +import '../l10n/app_localizations.dart'; +import '../services/currency_util.dart'; class NotificationService { static final FlutterLocalNotificationsPlugin _notifications = @@ -56,6 +59,33 @@ class NotificationService { InitializationSettings(android: androidSettings, iOS: iosSettings); await _notifications.initialize(initSettings); + + // Android 채널을 선제적으로 생성하여 중요도/진동이 확실히 적용되도록 함 + if (Platform.isAndroid) { + final androidImpl = + _notifications.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + if (androidImpl != null) { + try { + await androidImpl + .createNotificationChannel(const AndroidNotificationChannel( + 'subscription_channel', + 'Subscription Notifications', + description: 'Channel for subscription reminders', + importance: Importance.high, + )); + await androidImpl + .createNotificationChannel(const AndroidNotificationChannel( + 'expiration_channel', + 'Expiration Notifications', + description: 'Channel for subscription expiration reminders', + importance: Importance.high, + )); + } catch (e) { + debugPrint('안드로이드 채널 생성 실패: $e'); + } + } + } _initialized = true; debugPrint('알림 서비스 초기화 완료'); } catch (e) { @@ -232,15 +262,24 @@ class NotificationService { } try { - const androidDetails = AndroidNotificationDetails( + final ctx = navigatorKey.currentContext; + final channelName = ctx != null + ? AppLocalizations.of(ctx).notifications + : 'Subscription Notifications'; + + final androidDetails = AndroidNotificationDetails( 'subscription_channel', - '구독 알림', - channelDescription: '구독 관련 알림을 보여줍니다.', + channelName, + channelDescription: channelName, importance: Importance.high, priority: Priority.high, ); - const iosDetails = DarwinNotificationDetails(); + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); // tz.local 초기화 확인 및 재시도 tz.Location location; @@ -260,12 +299,19 @@ class NotificationService { } } + // 과거 시각 방지: 최소 1분 뒤로 조정 + final nowTz = tz.TZDateTime.now(location); + var target = tz.TZDateTime.from(scheduledDate, location); + if (!target.isAfter(nowTz)) { + target = nowTz.add(const Duration(minutes: 1)); + } + await _notifications.zonedSchedule( id, title, body, - tz.TZDateTime.from(scheduledDate, location), - const NotificationDetails(android: androidDetails, iOS: iosDetails), + target, + NotificationDetails(android: androidDetails, iOS: iosDetails), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, @@ -307,23 +353,24 @@ class NotificationService { try { final notificationId = subscription.id.hashCode; - const androidDetails = AndroidNotificationDetails( - 'subscription_channel', - '구독 알림', - channelDescription: '구독 만료 알림을 보내는 채널입니다.', - importance: Importance.high, - priority: Priority.high, - ); + final ctx = navigatorKey.currentContext; + final title = ctx != null + ? AppLocalizations.of(ctx).expirationReminder + : 'Expiration Reminder'; - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - ); - - const notificationDetails = NotificationDetails( - android: androidDetails, - iOS: iosDetails, + final notificationDetails = NotificationDetails( + android: AndroidNotificationDetails( + 'subscription_channel', + title, + channelDescription: title, + importance: Importance.high, + priority: Priority.high, + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), ); // tz.local 초기화 확인 및 재시도 @@ -344,11 +391,27 @@ class NotificationService { } } + final nowTz = tz.TZDateTime.now(location); + var fireAt = tz.TZDateTime.from(subscription.nextBillingDate, location); + if (kDebugMode) { + debugPrint('[NotificationService] scheduleSubscriptionNotification' + ' id=${subscription.id.hashCode} tz=${location.name}' + ' now=$nowTz target=$fireAt service=${subscription.serviceName}'); + } + if (!fireAt.isAfter(nowTz)) { + // 이미 지난 시각이면 예약 생략 + if (kDebugMode) { + debugPrint( + '[NotificationService] skip scheduleSubscriptionNotification (past)'); + } + return; + } + await _notifications.zonedSchedule( notificationId, - '구독 만료 알림', - '${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.', - tz.TZDateTime.from(subscription.nextBillingDate, location), + title, + _buildExpirationBody(subscription), + fireAt, notificationDetails, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, @@ -373,55 +436,18 @@ class NotificationService { 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'); - } + if (_isWeb || !_initialized) return; + final reminderDays = await getReminderDays(); + final hour = await getReminderHour(); + final minute = await getReminderMinute(); + final daily = await isDailyReminderEnabled(); + await schedulePaymentReminder( + subscription: subscription, + reminderDays: reminderDays, + reminderHour: hour, + reminderMinute: minute, + isDailyReminder: daily, + ); } static Future scheduleExpirationNotification( @@ -467,6 +493,11 @@ class NotificationService { importance: Importance.high, priority: Priority.high, ), + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), ), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, @@ -491,6 +522,9 @@ class NotificationService { } try { + final locale = _getLocaleCode(); + final title = _paymentReminderTitle(locale); + // tz.local 초기화 확인 및 재시도 tz.Location location; try { @@ -510,7 +544,7 @@ class NotificationService { } // 기본 알림 예약 (지정된 일수 전) - final scheduledDate = subscription.nextBillingDate + final baseLocal = subscription.nextBillingDate .subtract(Duration(days: reminderDays)) .copyWith( hour: reminderHour, @@ -519,46 +553,48 @@ class NotificationService { millisecond: 0, microsecond: 0, ); + final nowTz = tz.TZDateTime.now(location); + var scheduledDate = tz.TZDateTime.from(baseLocal, location); + if (kDebugMode) { + debugPrint('[NotificationService] schedulePaymentReminder(base)' + ' id=${subscription.id.hashCode} tz=${location.name}' + ' now=$nowTz requested=$baseLocal scheduled=$scheduledDate' + ' days=$reminderDays time=${reminderHour.toString().padLeft(2, '0')}:${reminderMinute.toString().padLeft(2, '0')}' + ' service=${subscription.serviceName}'); + } + if (!scheduledDate.isAfter(nowTz)) { + // 지정일이 과거면 최소 1분 뒤로 + scheduledDate = nowTz.add(const Duration(minutes: 1)); + if (kDebugMode) { + debugPrint( + '[NotificationService] schedulePaymentReminder(base) adjusted to $scheduledDate'); + } + } // 남은 일수에 따른 메시지 생성 - String daysText = '$reminderDays일 후'; - if (reminderDays == 1) { - daysText = '내일'; - } + final daysText = _daysInText(locale, reminderDays); // 이벤트 종료로 인한 가격 변동 확인 - 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 결제 예정입니다.'; - } + final body = await _buildPaymentBody(subscription, daysText); await _notifications.zonedSchedule( subscription.id.hashCode, - '구독 결제 예정 알림', - notificationBody, - tz.TZDateTime.from(scheduledDate, location), - const NotificationDetails( + title, + body, + scheduledDate, + NotificationDetails( android: AndroidNotificationDetails( 'subscription_channel', - 'Subscription Notifications', - channelDescription: 'Channel for subscription reminders', + title, + channelDescription: title, importance: Importance.high, priority: Priority.high, ), - iOS: DarwinNotificationDetails(), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), ), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, @@ -569,7 +605,7 @@ class NotificationService { if (isDailyReminder && reminderDays >= 2) { // 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약 for (int i = reminderDays - 1; i >= 1; i--) { - final dailyDate = + final dailyLocal = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( hour: reminderHour, minute: reminderMinute, @@ -577,46 +613,46 @@ class NotificationService { millisecond: 0, microsecond: 0, ); + final dailyDate = tz.TZDateTime.from(dailyLocal, location); + if (kDebugMode) { + debugPrint('[NotificationService] schedulePaymentReminder(daily)' + ' id=${subscription.id.hashCode + i} tz=${location.name}' + ' now=$nowTz requested=$dailyLocal scheduled=$dailyDate' + ' daysLeft=$i'); + } + if (!dailyDate.isAfter(nowTz)) { + // 과거면 건너뜀 + if (kDebugMode) { + debugPrint('[NotificationService] skip daily (past)'); + } + continue; + } // 남은 일수에 따른 메시지 생성 - String remainingDaysText = '$i일 후'; - if (i == 1) { - remainingDaysText = '내일'; - } + final remainingDaysText = _daysInText(locale, i); // 각 날짜에 대한 이벤트 종료 확인 - 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 결제 예정입니다.'; - } + final dailyNotificationBody = + await _buildPaymentBody(subscription, remainingDaysText); await _notifications.zonedSchedule( subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함 - '구독 결제 예정 알림', + title, dailyNotificationBody, - tz.TZDateTime.from(dailyDate, location), - const NotificationDetails( + dailyDate, + NotificationDetails( android: AndroidNotificationDetails( 'subscription_channel', - 'Subscription Notifications', - channelDescription: 'Channel for subscription reminders', + title, + channelDescription: title, importance: Importance.high, priority: Priority.high, ), - iOS: DarwinNotificationDetails(), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), ), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, @@ -629,7 +665,109 @@ class NotificationService { } } + // 디버그 테스트용: 즉시 결제 알림을 보여줍니다. + static Future showTestPaymentNotification() async { + if (_isWeb || !_initialized) return; + try { + final locale = _getLocaleCode(); + final title = _paymentReminderTitle(locale); + final amountText = + await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale); + + final body = '테스트 구독 • $amountText'; + + await _notifications.show( + DateTime.now().millisecondsSinceEpoch.remainder(1 << 31), + title, + body, + NotificationDetails( + android: AndroidNotificationDetails( + 'subscription_channel', + title, + channelDescription: title, + importance: Importance.high, + priority: Priority.high, + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ), + ); + } catch (e) { + debugPrint('테스트 결제 알림 표시 실패: $e'); + } + } + static String getNotificationBody(String serviceName, double amount) { return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.'; } + + static Future _buildPaymentBody( + SubscriptionModel subscription, String daysText) async { + final ctx = navigatorKey.currentContext; + final locale = + ctx != null ? AppLocalizations.of(ctx).locale.languageCode : 'en'; + final warnText = ctx != null + ? AppLocalizations.of(ctx).eventDiscountEndsBeforeBilling + : 'Event discount ends before billing date'; + final amountText = await CurrencyUtil.formatAmountWithLocale( + subscription.currentPrice, subscription.currency, locale); + if (subscription.isEventActive && + subscription.eventEndDate != null && + subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && + subscription.eventEndDate!.isAfter(DateTime.now())) { + return '${subscription.serviceName} • $amountText • $daysText\n⚠️ $warnText'; + } + // 일반 알림 + if (ctx != null) { + return '${subscription.serviceName} • $amountText • $daysText'; + } + return '${subscription.serviceName} • $amountText • $daysText'; + } + + static String _buildExpirationBody(SubscriptionModel subscription) { + final ctx = navigatorKey.currentContext; + if (ctx != null) { + final date = + AppLocalizations.of(ctx).formatDate(subscription.nextBillingDate); + return '${subscription.serviceName} • $date'; + } + return '${subscription.serviceName} • ${subscription.nextBillingDate.toLocal()}'; + } + + static String _getLocaleCode() { + final ctx = navigatorKey.currentContext; + if (ctx != null) { + return AppLocalizations.of(ctx).locale.languageCode; + } + return 'en'; + } + + static String _paymentReminderTitle(String locale) { + switch (locale) { + case 'ko': + return '결제 예정 알림'; + case 'ja': + return '支払い予定の通知'; + case 'zh': + return '付款提醒'; + default: + return 'Payment Reminder'; + } + } + + static String _daysInText(String locale, int days) { + switch (locale) { + case 'ko': + return '$days일 후'; + case 'ja': + return '$days日後'; + case 'zh': + return '$days天后'; + default: + return 'in $days day(s)'; + } + } }