Initial commit: SubManager Flutter App
주요 구현 완료 기능: - 구독 관리 (추가/편집/삭제/카테고리 분류) - 이벤트 할인 시스템 (기본값 자동 설정) - SMS 자동 스캔 및 구독 정보 추출 - 알림 시스템 (타임존 처리 안정화) - 환율 변환 지원 (KRW/USD) - 반응형 UI 및 애니메이션 - 다국어 지원 (한국어/영어) 버그 수정: - NotificationService tz.local 초기화 오류 해결 - MainScreenSummaryCard 레이아웃 오버플로우 수정 - 구독 추가 시 LateInitializationError 완전 해결 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
548
lib/services/notification_service.dart
Normal file
548
lib/services/notification_service.dart
Normal file
@@ -0,0 +1,548 @@
|
||||
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/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
static final _secureStorage = const 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 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);
|
||||
_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;
|
||||
}
|
||||
|
||||
// 기존 알림 모두 취소
|
||||
await cancelAllNotifications();
|
||||
|
||||
// 알림 설정 가져오기
|
||||
final isPaymentEnabled = await isPaymentNotificationEnabled();
|
||||
if (!isPaymentEnabled) return;
|
||||
|
||||
final reminderDays = await getReminderDays();
|
||||
final reminderHour = await getReminderHour();
|
||||
final reminderMinute = await getReminderMinute();
|
||||
final isDailyReminder = await isDailyReminderEnabled();
|
||||
|
||||
// 각 구독에 대해 알림 재설정
|
||||
for (final subscription in subscriptions) {
|
||||
await schedulePaymentReminder(
|
||||
id: subscription.id.hashCode,
|
||||
serviceName: subscription.serviceName,
|
||||
amount: subscription.monthlyCost,
|
||||
billingDate: subscription.nextBillingDate,
|
||||
reminderDays: reminderDays,
|
||||
reminderHour: reminderHour,
|
||||
reminderMinute: reminderMinute,
|
||||
isDailyReminder: isDailyReminder,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('알림 일정 재설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 서비스가 초기화되었는지 확인하는 메서드
|
||||
static bool _isInitialized() {
|
||||
// 웹 플랫폼인 경우 항상 false 반환
|
||||
if (_isWeb) return false;
|
||||
// 초기화 플래그 확인
|
||||
return _initialized;
|
||||
}
|
||||
|
||||
static Future<bool> requestPermission() async {
|
||||
final result = await _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermission();
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
// 알림 스케줄 설정
|
||||
static Future<void> scheduleNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledDate,
|
||||
}) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'구독 알림',
|
||||
channelDescription: '구독 관련 알림을 보여줍니다.',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
final iosDetails = const DarwinNotificationDetails();
|
||||
|
||||
// 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(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tz.TZDateTime.from(scheduledDate, location),
|
||||
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} 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 androidDetails = AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'구독 알림',
|
||||
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
final iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
// 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(
|
||||
notificationId,
|
||||
'구독 만료 알림',
|
||||
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
||||
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
||||
notificationDetails,
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} 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) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final paymentDate = subscription.nextBillingDate;
|
||||
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
||||
|
||||
// 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.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,
|
||||
),
|
||||
),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
// 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,
|
||||
'구독 만료 예정 알림',
|
||||
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
|
||||
tz.TZDateTime.from(reminderDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'expiration_channel',
|
||||
'Expiration Notifications',
|
||||
channelDescription: 'Channel for subscription expiration reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> schedulePaymentReminder({
|
||||
required int id,
|
||||
required String serviceName,
|
||||
required double amount,
|
||||
required DateTime billingDate,
|
||||
int reminderDays = 3,
|
||||
int reminderHour = 10,
|
||||
int reminderMinute = 0,
|
||||
bool isDailyReminder = false,
|
||||
}) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 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 scheduledDate =
|
||||
billingDate.subtract(Duration(days: reminderDays)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String daysText = '$reminderDays일 후';
|
||||
if (reminderDays == 1) {
|
||||
daysText = '내일';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id,
|
||||
'구독 결제 예정 알림',
|
||||
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.',
|
||||
tz.TZDateTime.from(scheduledDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'Subscription Notifications',
|
||||
channelDescription: 'Channel for subscription reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
|
||||
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
|
||||
if (isDailyReminder && reminderDays >= 2) {
|
||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||
final dailyDate = billingDate.subtract(Duration(days: i)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String remainingDaysText = '$i일 후';
|
||||
if (i == 1) {
|
||||
remainingDaysText = '내일';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
||||
'구독 결제 예정 알림',
|
||||
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.',
|
||||
tz.TZDateTime.from(dailyDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'Subscription Notifications',
|
||||
channelDescription: 'Channel for subscription reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static String getNotificationBody(String serviceName, double amount) {
|
||||
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user