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'; import '../navigator_key.dart'; import '../l10n/app_localizations.dart'; import '../services/currency_util.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 const int _maxDailyReminderSlots = 7; static const String _paymentPayloadPrefix = 'payment:'; static const String _paymentChannelId = 'subscription_channel_v2'; static const String _expirationChannelId = 'expiration_channel_v2'; static String get paymentChannelId => _paymentChannelId; static String get expirationChannelId => _expirationChannelId; static String _paymentPayload(String subscriptionId) => '$_paymentPayloadPrefix$subscriptionId'; static bool _matchesPaymentPayload(String? payload) => payload != null && payload.startsWith(_paymentPayloadPrefix); static String? _subscriptionIdFromPaymentPayload(String? payload) => _matchesPaymentPayload(payload) ? payload!.substring(_paymentPayloadPrefix.length) : null; // 초기화 상태를 추적하기 위한 플래그 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); // Android 채널을 선제적으로 생성하여 중요도/진동이 확실히 적용되도록 함 if (Platform.isAndroid) { final androidImpl = _notifications.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); if (androidImpl != null) { try { await androidImpl .createNotificationChannel(const AndroidNotificationChannel( _paymentChannelId, 'Subscription Notifications', description: 'Channel for subscription reminders', importance: Importance.high, )); await androidImpl .createNotificationChannel(const AndroidNotificationChannel( _expirationChannelId, 'Expiration Notifications', description: 'Channel for subscription expiration reminders', importance: Importance.high, )); } catch (e) { debugPrint('안드로이드 채널 생성 실패: $e'); } } } _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; } final pendingRequests = await _notifications.pendingNotificationRequests(); final isPaymentEnabled = await isPaymentNotificationEnabled(); if (!isPaymentEnabled) { await _cancelOrphanedPaymentReminderNotifications( const {}, pendingRequests, ); return; } final reminderDays = await getReminderDays(); final reminderHour = await getReminderHour(); final reminderMinute = await getReminderMinute(); final isDailyReminder = await isDailyReminderEnabled(); final activeSubscriptionIds = subscriptions.map((subscription) => subscription.id).toSet(); for (final subscription in subscriptions) { await _cancelPaymentReminderNotificationsForSubscription( subscription, pendingRequests, ); await schedulePaymentReminder( subscription: subscription, reminderDays: reminderDays, reminderHour: reminderHour, reminderMinute: reminderMinute, isDailyReminder: isDailyReminder, ); } await _cancelOrphanedPaymentReminderNotifications( activeSubscriptionIds, pendingRequests, ); } catch (e) { debugPrint('알림 일정 재설정 중 오류 발생: $e'); } } static Future _cancelPaymentReminderNotificationsForSubscription( SubscriptionModel subscription, List pendingRequests, ) async { final baseId = subscription.id.hashCode; final payload = _paymentPayload(subscription.id); final idsToCancel = {}; for (final request in pendingRequests) { final matchesPayload = request.payload == payload; final matchesIdPattern = request.id == baseId || (request.id > baseId && request.id <= baseId + _maxDailyReminderSlots); if (matchesPayload || matchesIdPattern) { idsToCancel.add(request.id); } } for (final id in idsToCancel) { try { await _notifications.cancel(id); } catch (e) { debugPrint('결제 알림 취소 중 오류 발생: $e'); } } if (idsToCancel.isNotEmpty) { pendingRequests .removeWhere((request) => idsToCancel.contains(request.id)); } } static Future _cancelOrphanedPaymentReminderNotifications( Set activeSubscriptionIds, List pendingRequests, ) async { final idsToCancel = {}; for (final request in pendingRequests) { final subscriptionId = _subscriptionIdFromPaymentPayload(request.payload); if (subscriptionId != null && !activeSubscriptionIds.contains(subscriptionId)) { idsToCancel.add(request.id); } } for (final id in idsToCancel) { try { await _notifications.cancel(id); } catch (e) { debugPrint('고아 결제 알림 취소 중 오류 발생: $e'); } } if (idsToCancel.isNotEmpty) { pendingRequests .removeWhere((request) => idsToCancel.contains(request.id)); } } 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; // 기본값 } // Android: 정확 알람 권한 가능 여부 확인 (S+) static Future canScheduleExactAlarms() async { if (_isWeb) return false; if (Platform.isAndroid) { final android = _notifications.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); if (android != null) { final can = await android.canScheduleExactNotifications(); return can ?? true; // 하위 버전은 true 간주 } } return true; } // Android: 정확 알람 권한 요청 (Android 12+에서 설정 화면으로 이동) static Future requestExactAlarmsPermission() async { if (_isWeb) return false; if (Platform.isAndroid) { final android = _notifications.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); if (android != null) { final granted = await android.requestExactAlarmsPermission(); return granted ?? false; } } return false; } static Future _resolveAndroidScheduleMode() async { if (_isWeb) { return AndroidScheduleMode.exactAllowWhileIdle; } if (Platform.isAndroid) { try { final canExact = await canScheduleExactAlarms(); if (kDebugMode) { debugPrint( '[NotificationService] canScheduleExactAlarms result: $canExact'); } if (!canExact) { if (kDebugMode) { debugPrint( '[NotificationService] exact alarm unavailable → use inexact mode'); } return AndroidScheduleMode.inexactAllowWhileIdle; } } catch (e) { debugPrint('정확 알람 권한 확인 중 오류 발생: $e'); return AndroidScheduleMode.inexactAllowWhileIdle; } } return AndroidScheduleMode.exactAllowWhileIdle; } // 알림 스케줄 설정 static Future scheduleNotification({ required int id, required String title, required String body, required DateTime scheduledDate, String? payload, String? channelId, }) async { // 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기 if (_isWeb || !_initialized) { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } try { final ctx = navigatorKey.currentContext; String channelName; if (channelId == _expirationChannelId) { channelName = ctx != null ? AppLocalizations.of(ctx).expirationReminder : 'Expiration Notifications'; } else { channelName = ctx != null ? AppLocalizations.of(ctx).notifications : 'Subscription Notifications'; } final effectiveChannelId = channelId ?? _paymentChannelId; final androidDetails = AndroidNotificationDetails( effectiveChannelId, channelName, channelDescription: channelName, importance: Importance.high, priority: Priority.high, autoCancel: false, ); const iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ); // 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; } } // 과거 시각 방지: 최소 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)); } final scheduleMode = await _resolveAndroidScheduleMode(); if (kDebugMode) { debugPrint( '[NotificationService] scheduleNotification scheduleMode=$scheduleMode'); } await _notifications.zonedSchedule( id, title, body, target, NotificationDetails(android: androidDetails, iOS: iosDetails), androidScheduleMode: scheduleMode, payload: payload, ); } 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; final ctx = navigatorKey.currentContext; final title = ctx != null ? AppLocalizations.of(ctx).expirationReminder : 'Expiration Reminder'; final notificationDetails = NotificationDetails( android: AndroidNotificationDetails( _paymentChannelId, title, channelDescription: title, importance: Importance.high, priority: Priority.high, autoCancel: false, ), iOS: const DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ), ); // 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 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; } final scheduleMode = await _resolveAndroidScheduleMode(); if (kDebugMode) { debugPrint( '[NotificationService] scheduleSubscriptionNotification scheduleMode=$scheduleMode'); } await _notifications.zonedSchedule( notificationId, title, _buildExpirationBody(subscription), fireAt, notificationDetails, androidScheduleMode: scheduleMode, ); } 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) 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( 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( _expirationChannelId, 'Expiration Notifications', channelDescription: 'Channel for subscription expiration reminders', importance: Importance.high, priority: Priority.high, autoCancel: false, ), iOS: DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ), ), androidScheduleMode: await _resolveAndroidScheduleMode(), ); } 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 { final locale = _getLocaleCode(); final title = _paymentReminderTitle(locale); // 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 baseLocal = subscription.nextBillingDate .subtract(Duration(days: reminderDays)) .copyWith( hour: reminderHour, minute: reminderMinute, second: 0, 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'); } } // 남은 일수에 따른 메시지 생성 final daysText = _daysInText(locale, reminderDays); // 이벤트 종료로 인한 가격 변동 확인 final body = await _buildPaymentBody(subscription, daysText); final scheduleMode = await _resolveAndroidScheduleMode(); if (kDebugMode) { debugPrint( '[NotificationService] schedulePaymentReminder(base) scheduleMode=$scheduleMode'); } await _notifications.zonedSchedule( subscription.id.hashCode, title, body, scheduledDate, NotificationDetails( android: AndroidNotificationDetails( _paymentChannelId, title, channelDescription: title, importance: Importance.high, priority: Priority.high, autoCancel: false, ), iOS: const DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ), ), androidScheduleMode: scheduleMode, payload: _paymentPayload(subscription.id), ); // 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우) if (isDailyReminder && reminderDays >= 2) { // 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약 for (int i = reminderDays - 1; i >= 1; i--) { final dailyLocal = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( hour: reminderHour, minute: reminderMinute, second: 0, 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; } // 남은 일수에 따른 메시지 생성 final remainingDaysText = _daysInText(locale, i); // 각 날짜에 대한 이벤트 종료 확인 final dailyNotificationBody = await _buildPaymentBody(subscription, remainingDaysText); await _notifications.zonedSchedule( subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함 title, dailyNotificationBody, dailyDate, NotificationDetails( android: AndroidNotificationDetails( _paymentChannelId, title, channelDescription: title, importance: Importance.high, priority: Priority.high, autoCancel: false, ), iOS: const DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ), ), androidScheduleMode: scheduleMode, payload: _paymentPayload(subscription.id), ); } } } catch (e) { debugPrint('결제 알림 예약 중 오류 발생: $e'); } } // 디버그 테스트용: 즉시 결제 알림을 보여줍니다. 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)'; } } }