fix: ensure notifications use correct channels and dates
This commit is contained in:
@@ -9,7 +9,6 @@ import '../services/subscription_url_matcher.dart';
|
||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/billing_date_util.dart';
|
||||
import '../utils/business_day_util.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
|
||||
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
|
||||
@@ -495,7 +494,6 @@ class AddSubscriptionController {
|
||||
);
|
||||
var adjustedNext =
|
||||
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
|
||||
adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext);
|
||||
|
||||
await Provider.of<SubscriptionProvider>(context, listen: false)
|
||||
.addSubscription(
|
||||
|
||||
@@ -13,7 +13,6 @@ import '../widgets/dialogs/delete_confirmation_dialog.dart';
|
||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/billing_date_util.dart';
|
||||
import '../utils/business_day_util.dart';
|
||||
|
||||
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
|
||||
class DetailScreenController extends ChangeNotifier {
|
||||
@@ -414,8 +413,6 @@ class DetailScreenController extends ChangeNotifier {
|
||||
_nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day);
|
||||
var adjustedNext =
|
||||
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
|
||||
// 주말/고정 공휴일 보정 → 다음 영업일로 이월
|
||||
adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext);
|
||||
subscription.nextBillingDate = adjustedNext;
|
||||
subscription.categoryId = _selectedCategoryId;
|
||||
subscription.currency = _currency;
|
||||
|
||||
@@ -20,6 +20,24 @@ class NotificationService {
|
||||
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;
|
||||
@@ -69,14 +87,14 @@ class NotificationService {
|
||||
try {
|
||||
await androidImpl
|
||||
.createNotificationChannel(const AndroidNotificationChannel(
|
||||
'subscription_channel',
|
||||
_paymentChannelId,
|
||||
'Subscription Notifications',
|
||||
description: 'Channel for subscription reminders',
|
||||
importance: Importance.high,
|
||||
));
|
||||
await androidImpl
|
||||
.createNotificationChannel(const AndroidNotificationChannel(
|
||||
'expiration_channel',
|
||||
_expirationChannelId,
|
||||
'Expiration Notifications',
|
||||
description: 'Channel for subscription expiration reminders',
|
||||
importance: Importance.high,
|
||||
@@ -152,20 +170,32 @@ class NotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 알림 모두 취소
|
||||
await cancelAllNotifications();
|
||||
final pendingRequests =
|
||||
await _notifications.pendingNotificationRequests();
|
||||
|
||||
// 알림 설정 가져오기
|
||||
final isPaymentEnabled = await isPaymentNotificationEnabled();
|
||||
if (!isPaymentEnabled) return;
|
||||
if (!isPaymentEnabled) {
|
||||
await _cancelOrphanedPaymentReminderNotifications(
|
||||
const <String>{},
|
||||
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,
|
||||
@@ -174,11 +204,78 @@ class NotificationService {
|
||||
isDailyReminder: isDailyReminder,
|
||||
);
|
||||
}
|
||||
|
||||
await _cancelOrphanedPaymentReminderNotifications(
|
||||
activeSubscriptionIds,
|
||||
pendingRequests,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('알림 일정 재설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _cancelPaymentReminderNotificationsForSubscription(
|
||||
SubscriptionModel subscription,
|
||||
List<PendingNotificationRequest> pendingRequests,
|
||||
) async {
|
||||
final baseId = subscription.id.hashCode;
|
||||
final payload = _paymentPayload(subscription.id);
|
||||
|
||||
final idsToCancel = <int>{};
|
||||
|
||||
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<void> _cancelOrphanedPaymentReminderNotifications(
|
||||
Set<String> activeSubscriptionIds,
|
||||
List<PendingNotificationRequest> pendingRequests,
|
||||
) async {
|
||||
final idsToCancel = <int>{};
|
||||
|
||||
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<bool> requestPermission() async {
|
||||
// 웹 플랫폼인 경우 false 반환
|
||||
if (_isWeb) return false;
|
||||
@@ -276,12 +373,42 @@ class NotificationService {
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<AndroidScheduleMode> _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<void> scheduleNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledDate,
|
||||
String? payload,
|
||||
String? channelId,
|
||||
}) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
@@ -291,16 +418,26 @@ class NotificationService {
|
||||
|
||||
try {
|
||||
final ctx = navigatorKey.currentContext;
|
||||
final channelName = ctx != null
|
||||
? AppLocalizations.of(ctx).notifications
|
||||
: 'Subscription Notifications';
|
||||
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(
|
||||
'subscription_channel',
|
||||
effectiveChannelId,
|
||||
channelName,
|
||||
channelDescription: channelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
autoCancel: false,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
@@ -334,15 +471,19 @@ class NotificationService {
|
||||
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),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
androidScheduleMode: scheduleMode,
|
||||
payload: payload,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('알림 예약 중 오류 발생: $e');
|
||||
@@ -388,11 +529,12 @@ class NotificationService {
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
_paymentChannelId,
|
||||
title,
|
||||
channelDescription: title,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
autoCancel: false,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
@@ -435,15 +577,19 @@ class NotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
final scheduleMode = await _resolveAndroidScheduleMode();
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[NotificationService] scheduleSubscriptionNotification scheduleMode=$scheduleMode');
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
notificationId,
|
||||
title,
|
||||
_buildExpirationBody(subscription),
|
||||
fireAt,
|
||||
notificationDetails,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
androidScheduleMode: scheduleMode,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
||||
@@ -515,11 +661,12 @@ class NotificationService {
|
||||
tz.TZDateTime.from(reminderDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'expiration_channel',
|
||||
_expirationChannelId,
|
||||
'Expiration Notifications',
|
||||
channelDescription: 'Channel for subscription expiration reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
autoCancel: false,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
@@ -527,9 +674,7 @@ class NotificationService {
|
||||
presentSound: true,
|
||||
),
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
androidScheduleMode: await _resolveAndroidScheduleMode(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
||||
@@ -605,6 +750,12 @@ class NotificationService {
|
||||
// 이벤트 종료로 인한 가격 변동 확인
|
||||
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,
|
||||
@@ -612,11 +763,12 @@ class NotificationService {
|
||||
scheduledDate,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
_paymentChannelId,
|
||||
title,
|
||||
channelDescription: title,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
autoCancel: false,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
@@ -624,9 +776,8 @@ class NotificationService {
|
||||
presentSound: true,
|
||||
),
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
androidScheduleMode: scheduleMode,
|
||||
payload: _paymentPayload(subscription.id),
|
||||
);
|
||||
|
||||
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
|
||||
@@ -670,11 +821,12 @@ class NotificationService {
|
||||
dailyDate,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
_paymentChannelId,
|
||||
title,
|
||||
channelDescription: title,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
autoCancel: false,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
@@ -682,9 +834,8 @@ class NotificationService {
|
||||
presentSound: true,
|
||||
),
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
androidScheduleMode: scheduleMode,
|
||||
payload: _paymentPayload(subscription.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user