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
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.READ_SMS" />
|
<uses-permission android:name="android.permission.READ_SMS" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- 재부팅 후 예약 복구를 위해 필요 -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<!-- 정확 알람(결제/캘린더 등 정확 시각 필요시) 사용 -->
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<application
|
<application
|
||||||
android:label="구독 관리"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -7,7 +8,17 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 앱이 포그라운드일 때도 배너/사운드/배지 표시
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler([.banner, .sound, .badge])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.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 {
|
class NotificationService {
|
||||||
static final FlutterLocalNotificationsPlugin _notifications =
|
static final FlutterLocalNotificationsPlugin _notifications =
|
||||||
@@ -56,6 +59,33 @@ class NotificationService {
|
|||||||
InitializationSettings(android: androidSettings, iOS: iosSettings);
|
InitializationSettings(android: androidSettings, iOS: iosSettings);
|
||||||
|
|
||||||
await _notifications.initialize(initSettings);
|
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;
|
_initialized = true;
|
||||||
debugPrint('알림 서비스 초기화 완료');
|
debugPrint('알림 서비스 초기화 완료');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -232,15 +262,24 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const androidDetails = AndroidNotificationDetails(
|
final ctx = navigatorKey.currentContext;
|
||||||
|
final channelName = ctx != null
|
||||||
|
? AppLocalizations.of(ctx).notifications
|
||||||
|
: 'Subscription Notifications';
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
'subscription_channel',
|
'subscription_channel',
|
||||||
'구독 알림',
|
channelName,
|
||||||
channelDescription: '구독 관련 알림을 보여줍니다.',
|
channelDescription: channelName,
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
);
|
);
|
||||||
|
|
||||||
const iosDetails = DarwinNotificationDetails();
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// tz.local 초기화 확인 및 재시도
|
||||||
tz.Location location;
|
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(
|
await _notifications.zonedSchedule(
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
tz.TZDateTime.from(scheduledDate, location),
|
target,
|
||||||
const NotificationDetails(android: androidDetails, iOS: iosDetails),
|
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
@@ -307,23 +353,24 @@ class NotificationService {
|
|||||||
try {
|
try {
|
||||||
final notificationId = subscription.id.hashCode;
|
final notificationId = subscription.id.hashCode;
|
||||||
|
|
||||||
const androidDetails = AndroidNotificationDetails(
|
final ctx = navigatorKey.currentContext;
|
||||||
'subscription_channel',
|
final title = ctx != null
|
||||||
'구독 알림',
|
? AppLocalizations.of(ctx).expirationReminder
|
||||||
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
|
: 'Expiration Reminder';
|
||||||
importance: Importance.high,
|
|
||||||
priority: Priority.high,
|
|
||||||
);
|
|
||||||
|
|
||||||
const iosDetails = DarwinNotificationDetails(
|
final notificationDetails = NotificationDetails(
|
||||||
presentAlert: true,
|
android: AndroidNotificationDetails(
|
||||||
presentBadge: true,
|
'subscription_channel',
|
||||||
presentSound: true,
|
title,
|
||||||
);
|
channelDescription: title,
|
||||||
|
importance: Importance.high,
|
||||||
const notificationDetails = NotificationDetails(
|
priority: Priority.high,
|
||||||
android: androidDetails,
|
),
|
||||||
iOS: iosDetails,
|
iOS: const DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// 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(
|
await _notifications.zonedSchedule(
|
||||||
notificationId,
|
notificationId,
|
||||||
'구독 만료 알림',
|
title,
|
||||||
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
_buildExpirationBody(subscription),
|
||||||
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
fireAt,
|
||||||
notificationDetails,
|
notificationDetails,
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
@@ -373,55 +436,18 @@ class NotificationService {
|
|||||||
|
|
||||||
static Future<void> schedulePaymentNotification(
|
static Future<void> schedulePaymentNotification(
|
||||||
SubscriptionModel subscription) async {
|
SubscriptionModel subscription) async {
|
||||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
if (_isWeb || !_initialized) return;
|
||||||
if (_isWeb || !_initialized) {
|
final reminderDays = await getReminderDays();
|
||||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
final hour = await getReminderHour();
|
||||||
return;
|
final minute = await getReminderMinute();
|
||||||
}
|
final daily = await isDailyReminderEnabled();
|
||||||
|
await schedulePaymentReminder(
|
||||||
try {
|
subscription: subscription,
|
||||||
final paymentDate = subscription.nextBillingDate;
|
reminderDays: reminderDays,
|
||||||
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
reminderHour: hour,
|
||||||
|
reminderMinute: minute,
|
||||||
// tz.local 초기화 확인 및 재시도
|
isDailyReminder: daily,
|
||||||
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<void> scheduleExpirationNotification(
|
static Future<void> scheduleExpirationNotification(
|
||||||
@@ -467,6 +493,11 @@ class NotificationService {
|
|||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
),
|
),
|
||||||
|
iOS: DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
@@ -491,6 +522,9 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final locale = _getLocaleCode();
|
||||||
|
final title = _paymentReminderTitle(locale);
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// tz.local 초기화 확인 및 재시도
|
||||||
tz.Location location;
|
tz.Location location;
|
||||||
try {
|
try {
|
||||||
@@ -510,7 +544,7 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 기본 알림 예약 (지정된 일수 전)
|
// 기본 알림 예약 (지정된 일수 전)
|
||||||
final scheduledDate = subscription.nextBillingDate
|
final baseLocal = subscription.nextBillingDate
|
||||||
.subtract(Duration(days: reminderDays))
|
.subtract(Duration(days: reminderDays))
|
||||||
.copyWith(
|
.copyWith(
|
||||||
hour: reminderHour,
|
hour: reminderHour,
|
||||||
@@ -519,46 +553,48 @@ class NotificationService {
|
|||||||
millisecond: 0,
|
millisecond: 0,
|
||||||
microsecond: 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일 후';
|
final daysText = _daysInText(locale, reminderDays);
|
||||||
if (reminderDays == 1) {
|
|
||||||
daysText = '내일';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이벤트 종료로 인한 가격 변동 확인
|
// 이벤트 종료로 인한 가격 변동 확인
|
||||||
String notificationBody;
|
final body = await _buildPaymentBody(subscription, daysText);
|
||||||
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(
|
await _notifications.zonedSchedule(
|
||||||
subscription.id.hashCode,
|
subscription.id.hashCode,
|
||||||
'구독 결제 예정 알림',
|
title,
|
||||||
notificationBody,
|
body,
|
||||||
tz.TZDateTime.from(scheduledDate, location),
|
scheduledDate,
|
||||||
const NotificationDetails(
|
NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
'subscription_channel',
|
'subscription_channel',
|
||||||
'Subscription Notifications',
|
title,
|
||||||
channelDescription: 'Channel for subscription reminders',
|
channelDescription: title,
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
),
|
),
|
||||||
iOS: DarwinNotificationDetails(),
|
iOS: const DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
@@ -569,7 +605,7 @@ class NotificationService {
|
|||||||
if (isDailyReminder && reminderDays >= 2) {
|
if (isDailyReminder && reminderDays >= 2) {
|
||||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||||
for (int i = reminderDays - 1; i >= 1; i--) {
|
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||||
final dailyDate =
|
final dailyLocal =
|
||||||
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
||||||
hour: reminderHour,
|
hour: reminderHour,
|
||||||
minute: reminderMinute,
|
minute: reminderMinute,
|
||||||
@@ -577,46 +613,46 @@ class NotificationService {
|
|||||||
millisecond: 0,
|
millisecond: 0,
|
||||||
microsecond: 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일 후';
|
final remainingDaysText = _daysInText(locale, i);
|
||||||
if (i == 1) {
|
|
||||||
remainingDaysText = '내일';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각 날짜에 대한 이벤트 종료 확인
|
// 각 날짜에 대한 이벤트 종료 확인
|
||||||
String dailyNotificationBody;
|
final dailyNotificationBody =
|
||||||
if (subscription.isEventActive &&
|
await _buildPaymentBody(subscription, remainingDaysText);
|
||||||
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(
|
await _notifications.zonedSchedule(
|
||||||
subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
||||||
'구독 결제 예정 알림',
|
title,
|
||||||
dailyNotificationBody,
|
dailyNotificationBody,
|
||||||
tz.TZDateTime.from(dailyDate, location),
|
dailyDate,
|
||||||
const NotificationDetails(
|
NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
'subscription_channel',
|
'subscription_channel',
|
||||||
'Subscription Notifications',
|
title,
|
||||||
channelDescription: 'Channel for subscription reminders',
|
channelDescription: title,
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
),
|
),
|
||||||
iOS: DarwinNotificationDetails(),
|
iOS: const DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
@@ -629,7 +665,109 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 디버그 테스트용: 즉시 결제 알림을 보여줍니다.
|
||||||
|
static Future<void> 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) {
|
static String getNotificationBody(String serviceName, double amount) {
|
||||||
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
|
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String> _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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user