967 lines
32 KiB
Dart
967 lines
32 KiB
Dart
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<void> 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<bool> isNotificationEnabled() async {
|
|
final value = await _secureStorage.read(key: _notificationEnabledKey);
|
|
return value == 'true';
|
|
}
|
|
|
|
static Future<bool> isPaymentNotificationEnabled() async {
|
|
final value =
|
|
await _secureStorage.read(key: _paymentNotificationEnabledKey);
|
|
return value == 'true';
|
|
}
|
|
|
|
static Future<void> setNotificationEnabled(bool value) async {
|
|
await _secureStorage.write(
|
|
key: _notificationEnabledKey,
|
|
value: value.toString(),
|
|
);
|
|
}
|
|
|
|
static Future<void> setPaymentNotificationEnabled(bool value) async {
|
|
await _secureStorage.write(
|
|
key: _paymentNotificationEnabledKey,
|
|
value: value.toString(),
|
|
);
|
|
}
|
|
|
|
// 알림 시점 설정 (1일전, 2일전, 3일전) 가져오기
|
|
static Future<int> getReminderDays() async {
|
|
final value = await _secureStorage.read(key: _reminderDaysKey);
|
|
return value != null ? int.tryParse(value) ?? 3 : 3;
|
|
}
|
|
|
|
// 알림 시간 가져오기
|
|
static Future<int> getReminderHour() async {
|
|
final value = await _secureStorage.read(key: _reminderHourKey);
|
|
return value != null ? int.tryParse(value) ?? 10 : 10;
|
|
}
|
|
|
|
static Future<int> getReminderMinute() async {
|
|
final value = await _secureStorage.read(key: _reminderMinuteKey);
|
|
return value != null ? int.tryParse(value) ?? 0 : 0;
|
|
}
|
|
|
|
// 반복 알림 설정 가져오기
|
|
static Future<bool> isDailyReminderEnabled() async {
|
|
final value = await _secureStorage.read(key: _dailyReminderKey);
|
|
return value == 'true';
|
|
}
|
|
|
|
// 모든 구독의 알림 일정 다시 예약
|
|
static Future<void> reschedulAllNotifications(
|
|
List<SubscriptionModel> subscriptions) async {
|
|
try {
|
|
// 웹 플랫폼이거나 알림 초기화 실패한 경우 건너뛰기
|
|
if (_isWeb || !_initialized) {
|
|
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
final pendingRequests =
|
|
await _notifications.pendingNotificationRequests();
|
|
|
|
final isPaymentEnabled = await isPaymentNotificationEnabled();
|
|
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,
|
|
reminderHour: reminderHour,
|
|
reminderMinute: reminderMinute,
|
|
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;
|
|
|
|
// 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<bool> 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<bool> 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<bool> 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<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) {
|
|
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<void> cancelNotification(int id) async {
|
|
await _notifications.cancel(id);
|
|
}
|
|
|
|
// 모든 알림 취소
|
|
static Future<void> cancelAllNotifications() async {
|
|
try {
|
|
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
|
if (_isWeb || !_initialized) {
|
|
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 취소할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
await _notifications.cancelAll();
|
|
debugPrint('모든 알림이 취소되었습니다.');
|
|
} catch (e) {
|
|
debugPrint('알림 취소 중 오류 발생: $e');
|
|
}
|
|
}
|
|
|
|
static Future<void> 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<void> updateSubscriptionNotification(
|
|
SubscriptionModel subscription) async {
|
|
await cancelSubscriptionNotification(subscription);
|
|
await scheduleSubscriptionNotification(subscription);
|
|
}
|
|
|
|
static Future<void> cancelSubscriptionNotification(
|
|
SubscriptionModel subscription) async {
|
|
final notificationId = subscription.id.hashCode;
|
|
await _notifications.cancel(notificationId);
|
|
}
|
|
|
|
static Future<void> 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<void> scheduleExpirationNotification(
|
|
SubscriptionModel subscription) async {
|
|
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
|
if (_isWeb || !_initialized) {
|
|
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final expirationDate = subscription.nextBillingDate;
|
|
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
|
final ctx = navigatorKey.currentContext;
|
|
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
|
|
|
// 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,
|
|
loc?.expirationReminder ?? _paymentReminderTitle(_getLocaleCode()),
|
|
loc?.expirationReminderBody(subscription.serviceName, 7) ??
|
|
'${subscription.serviceName} subscription expires in 7 days.',
|
|
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<void> 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<void> showTestPaymentNotification() async {
|
|
if (_isWeb || !_initialized) return;
|
|
try {
|
|
final locale = _getLocaleCode();
|
|
final ctx = navigatorKey.currentContext;
|
|
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
|
final title = loc?.paymentReminder ?? _paymentReminderTitle(locale);
|
|
final amountText =
|
|
await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale);
|
|
|
|
final body = loc?.testSubscriptionBody(amountText) ??
|
|
'Test subscription • $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) {
|
|
final ctx = navigatorKey.currentContext;
|
|
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
|
final amountText = amount.toStringAsFixed(0);
|
|
return loc?.paymentChargeNotification(serviceName, amountText) ??
|
|
'$serviceName subscription charge $amountText was completed.';
|
|
}
|
|
|
|
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) {
|
|
final ctx = navigatorKey.currentContext;
|
|
if (ctx != null) {
|
|
return AppLocalizations.of(ctx).paymentReminder;
|
|
}
|
|
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)';
|
|
}
|
|
}
|
|
}
|