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">
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- 재부팅 후 예약 복구를 위해 필요 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 정확 알람(결제/캘린더 등 정확 시각 필요시) 사용 -->
@@ -40,6 +41,20 @@
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
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>
<!-- Required to query activities that can process text, see:
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 '../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(

View File

@@ -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;

View File

@@ -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
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),
);
}
}