fix: ensure notifications use correct channels and dates

This commit is contained in:
JiWoong Sul
2025-09-19 01:06:36 +09:00
parent 44850a53cc
commit 3af9a1f839
4 changed files with 196 additions and 35 deletions

View File

@@ -1,6 +1,7 @@
<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.VIBRATE" />
<!-- 재부팅 후 예약 복구를 위해 필요 --> <!-- 재부팅 후 예약 복구를 위해 필요 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 정확 알람(결제/캘린더 등 정확 시각 필요시) 사용 --> <!-- 정확 알람(결제/캘린더 등 정확 시각 필요시) 사용 -->
@@ -40,6 +41,20 @@
<meta-data <meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID" android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-6691216385521068~6638409932" /> android:value="ca-app-pub-6691216385521068~6638409932" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -9,7 +9,6 @@ import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart'; import '../utils/billing_date_util.dart';
import '../utils/business_day_util.dart';
import 'package:permission_handler/permission_handler.dart' as permission; import 'package:permission_handler/permission_handler.dart' as permission;
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
@@ -495,7 +494,6 @@ class AddSubscriptionController {
); );
var adjustedNext = var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle); BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext);
await Provider.of<SubscriptionProvider>(context, listen: false) await Provider.of<SubscriptionProvider>(context, listen: false)
.addSubscription( .addSubscription(

View File

@@ -13,7 +13,6 @@ import '../widgets/dialogs/delete_confirmation_dialog.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart'; import '../utils/billing_date_util.dart';
import '../utils/business_day_util.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller /// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController extends ChangeNotifier { class DetailScreenController extends ChangeNotifier {
@@ -414,8 +413,6 @@ class DetailScreenController extends ChangeNotifier {
_nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day); _nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day);
var adjustedNext = var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle); BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
// 주말/고정 공휴일 보정 → 다음 영업일로 이월
adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext);
subscription.nextBillingDate = adjustedNext; subscription.nextBillingDate = adjustedNext;
subscription.categoryId = _selectedCategoryId; subscription.categoryId = _selectedCategoryId;
subscription.currency = _currency; subscription.currency = _currency;

View File

@@ -20,6 +20,24 @@ class NotificationService {
static const _reminderHourKey = 'reminder_hour'; static const _reminderHourKey = 'reminder_hour';
static const _reminderMinuteKey = 'reminder_minute'; static const _reminderMinuteKey = 'reminder_minute';
static const _dailyReminderKey = 'daily_reminder_enabled'; 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; static bool _initialized = false;
@@ -69,14 +87,14 @@ class NotificationService {
try { try {
await androidImpl await androidImpl
.createNotificationChannel(const AndroidNotificationChannel( .createNotificationChannel(const AndroidNotificationChannel(
'subscription_channel', _paymentChannelId,
'Subscription Notifications', 'Subscription Notifications',
description: 'Channel for subscription reminders', description: 'Channel for subscription reminders',
importance: Importance.high, importance: Importance.high,
)); ));
await androidImpl await androidImpl
.createNotificationChannel(const AndroidNotificationChannel( .createNotificationChannel(const AndroidNotificationChannel(
'expiration_channel', _expirationChannelId,
'Expiration Notifications', 'Expiration Notifications',
description: 'Channel for subscription expiration reminders', description: 'Channel for subscription expiration reminders',
importance: Importance.high, importance: Importance.high,
@@ -152,20 +170,32 @@ class NotificationService {
return; return;
} }
// 기존 알림 모두 취소 final pendingRequests =
await cancelAllNotifications(); await _notifications.pendingNotificationRequests();
// 알림 설정 가져오기
final isPaymentEnabled = await isPaymentNotificationEnabled(); final isPaymentEnabled = await isPaymentNotificationEnabled();
if (!isPaymentEnabled) return; if (!isPaymentEnabled) {
await _cancelOrphanedPaymentReminderNotifications(
const <String>{},
pendingRequests,
);
return;
}
final reminderDays = await getReminderDays(); final reminderDays = await getReminderDays();
final reminderHour = await getReminderHour(); final reminderHour = await getReminderHour();
final reminderMinute = await getReminderMinute(); final reminderMinute = await getReminderMinute();
final isDailyReminder = await isDailyReminderEnabled(); final isDailyReminder = await isDailyReminderEnabled();
// 각 구독에 대해 알림 재설정 final activeSubscriptionIds =
subscriptions.map((subscription) => subscription.id).toSet();
for (final subscription in subscriptions) { for (final subscription in subscriptions) {
await _cancelPaymentReminderNotificationsForSubscription(
subscription,
pendingRequests,
);
await schedulePaymentReminder( await schedulePaymentReminder(
subscription: subscription, subscription: subscription,
reminderDays: reminderDays, reminderDays: reminderDays,
@@ -174,11 +204,78 @@ class NotificationService {
isDailyReminder: isDailyReminder, isDailyReminder: isDailyReminder,
); );
} }
await _cancelOrphanedPaymentReminderNotifications(
activeSubscriptionIds,
pendingRequests,
);
} catch (e) { } catch (e) {
debugPrint('알림 일정 재설정 중 오류 발생: $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 { static Future<bool> requestPermission() async {
// 웹 플랫폼인 경우 false 반환 // 웹 플랫폼인 경우 false 반환
if (_isWeb) return false; if (_isWeb) return false;
@@ -276,12 +373,42 @@ class NotificationService {
return false; 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({ static Future<void> scheduleNotification({
required int id, required int id,
required String title, required String title,
required String body, required String body,
required DateTime scheduledDate, required DateTime scheduledDate,
String? payload,
String? channelId,
}) async { }) async {
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기 // 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
if (_isWeb || !_initialized) { if (_isWeb || !_initialized) {
@@ -291,16 +418,26 @@ class NotificationService {
try { try {
final ctx = navigatorKey.currentContext; final ctx = navigatorKey.currentContext;
final channelName = ctx != null String channelName;
? AppLocalizations.of(ctx).notifications if (channelId == _expirationChannelId) {
: 'Subscription Notifications'; 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( final androidDetails = AndroidNotificationDetails(
'subscription_channel', effectiveChannelId,
channelName, channelName,
channelDescription: channelName, channelDescription: channelName,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
autoCancel: false,
); );
const iosDetails = DarwinNotificationDetails( const iosDetails = DarwinNotificationDetails(
@@ -334,15 +471,19 @@ class NotificationService {
target = nowTz.add(const Duration(minutes: 1)); target = nowTz.add(const Duration(minutes: 1));
} }
final scheduleMode = await _resolveAndroidScheduleMode();
if (kDebugMode) {
debugPrint(
'[NotificationService] scheduleNotification scheduleMode=$scheduleMode');
}
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
id, id,
title, title,
body, body,
target, target,
NotificationDetails(android: androidDetails, iOS: iosDetails), NotificationDetails(android: androidDetails, iOS: iosDetails),
uiLocalNotificationDateInterpretation: androidScheduleMode: scheduleMode,
UILocalNotificationDateInterpretation.absoluteTime, payload: payload,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('알림 예약 중 오류 발생: $e'); debugPrint('알림 예약 중 오류 발생: $e');
@@ -388,11 +529,12 @@ class NotificationService {
final notificationDetails = NotificationDetails( final notificationDetails = NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
'subscription_channel', _paymentChannelId,
title, title,
channelDescription: title, channelDescription: title,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
autoCancel: false,
), ),
iOS: const DarwinNotificationDetails( iOS: const DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
@@ -435,15 +577,19 @@ class NotificationService {
return; return;
} }
final scheduleMode = await _resolveAndroidScheduleMode();
if (kDebugMode) {
debugPrint(
'[NotificationService] scheduleSubscriptionNotification scheduleMode=$scheduleMode');
}
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
notificationId, notificationId,
title, title,
_buildExpirationBody(subscription), _buildExpirationBody(subscription),
fireAt, fireAt,
notificationDetails, notificationDetails,
uiLocalNotificationDateInterpretation: androidScheduleMode: scheduleMode,
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('구독 알림 예약 중 오류 발생: $e'); debugPrint('구독 알림 예약 중 오류 발생: $e');
@@ -515,11 +661,12 @@ class NotificationService {
tz.TZDateTime.from(reminderDate, location), tz.TZDateTime.from(reminderDate, location),
const NotificationDetails( const NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
'expiration_channel', _expirationChannelId,
'Expiration Notifications', 'Expiration Notifications',
channelDescription: 'Channel for subscription expiration reminders', channelDescription: 'Channel for subscription expiration reminders',
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
autoCancel: false,
), ),
iOS: DarwinNotificationDetails( iOS: DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
@@ -527,9 +674,7 @@ class NotificationService {
presentSound: true, presentSound: true,
), ),
), ),
uiLocalNotificationDateInterpretation: androidScheduleMode: await _resolveAndroidScheduleMode(),
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('만료 알림 예약 중 오류 발생: $e'); debugPrint('만료 알림 예약 중 오류 발생: $e');
@@ -605,6 +750,12 @@ class NotificationService {
// 이벤트 종료로 인한 가격 변동 확인 // 이벤트 종료로 인한 가격 변동 확인
final body = await _buildPaymentBody(subscription, daysText); final body = await _buildPaymentBody(subscription, daysText);
final scheduleMode = await _resolveAndroidScheduleMode();
if (kDebugMode) {
debugPrint(
'[NotificationService] schedulePaymentReminder(base) scheduleMode=$scheduleMode');
}
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
subscription.id.hashCode, subscription.id.hashCode,
title, title,
@@ -612,11 +763,12 @@ class NotificationService {
scheduledDate, scheduledDate,
NotificationDetails( NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
'subscription_channel', _paymentChannelId,
title, title,
channelDescription: title, channelDescription: title,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
autoCancel: false,
), ),
iOS: const DarwinNotificationDetails( iOS: const DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
@@ -624,9 +776,8 @@ class NotificationService {
presentSound: true, presentSound: true,
), ),
), ),
uiLocalNotificationDateInterpretation: androidScheduleMode: scheduleMode,
UILocalNotificationDateInterpretation.absoluteTime, payload: _paymentPayload(subscription.id),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우) // 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
@@ -670,11 +821,12 @@ class NotificationService {
dailyDate, dailyDate,
NotificationDetails( NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
'subscription_channel', _paymentChannelId,
title, title,
channelDescription: title, channelDescription: title,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
autoCancel: false,
), ),
iOS: const DarwinNotificationDetails( iOS: const DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
@@ -682,9 +834,8 @@ class NotificationService {
presentSound: true, presentSound: true,
), ),
), ),
uiLocalNotificationDateInterpretation: androidScheduleMode: scheduleMode,
UILocalNotificationDateInterpretation.absoluteTime, payload: _paymentPayload(subscription.id),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} }
} }