Files
submanager/lib/services/notification_service.dart
JiWoong Sul d37f66d526 feat(settings): SMS 읽기 권한 상태/요청 위젯 추가 (Android)
- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지

feat(permissions): 최초 실행 시 SMS 권한 온보딩 화면 추가 및 Splash에서 라우팅 (Android)

- 권한 필요 이유/수집 범위 현지화 문구 추가\n- 거부/영구거부 케이스 처리 및 설정 이동

chore(codex): AGENTS.md/체크 스크립트/CI/프롬프트 템플릿 추가

- AGENTS.md, scripts/check.sh, scripts/fix.sh, .github/workflows/flutter_ci.yml, .claude/agents/codex.md, 문서 템플릿 추가

refactor(logging): 경로별 print 제거 후 경량 로거(Log) 도입

- SMS 스캐너/컨트롤러, URL 매처, 데이터 리포지토리, 내비게이션, 메모리/성능 유틸 등 핵심 경로 치환

feat(exchange): 환율 API URL을 --dart-define로 오버라이드 가능 + 폴백 로깅 강화

test: URL 매처/환율 스모크 테스트 추가

chore(android): RECEIVE_SMS 권한 제거 (READ_SMS만 유지)

fix(lints): dart fix + 수동 정리로 경고 대폭 감소, 비동기 context(mounted) 보강

fix(deprecations):\n- flutter_local_notifications의 androidAllowWhileIdle → androidScheduleMode 전환\n- WillPopScope → PopScope 교체

i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
2025-09-07 21:32:16 +09:00

636 lines
22 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';
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 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(
subscription: subscription,
reminderDays: reminderDays,
reminderHour: reminderHour,
reminderMinute: reminderMinute,
isDailyReminder: isDailyReminder,
);
}
} catch (e) {
debugPrint('알림 일정 재설정 중 오류 발생: $e');
}
}
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; // 기본값
}
// 알림 스케줄 설정
static Future<void> scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDate,
}) async {
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
if (_isWeb || !_initialized) {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return;
}
try {
const androidDetails = AndroidNotificationDetails(
'subscription_channel',
'구독 알림',
channelDescription: '구독 관련 알림을 보여줍니다.',
importance: Importance.high,
priority: Priority.high,
);
const iosDetails = 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),
const NotificationDetails(android: androidDetails, iOS: iosDetails),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} 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;
const androidDetails = AndroidNotificationDetails(
'subscription_channel',
'구독 알림',
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
importance: Importance.high,
priority: Priority.high,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const 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,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} 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,
),
),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} 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,
),
),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} 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 {
// 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 = subscription.nextBillingDate
.subtract(Duration(days: reminderDays))
.copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
// 남은 일수에 따른 메시지 생성
String daysText = '$reminderDays일';
if (reminderDays == 1) {
daysText = '내일';
}
// 이벤트 종료로 인한 가격 변동 확인
String notificationBody;
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
// 이벤트가 결제일 전에 종료되는 경우
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost;
notificationBody =
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else {
// 일반 알림
final currentPrice = subscription.currentPrice;
notificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
}
await _notifications.zonedSchedule(
subscription.id.hashCode,
'구독 결제 예정 알림',
notificationBody,
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 =
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
// 남은 일수에 따른 메시지 생성
String remainingDaysText = '$i일';
if (i == 1) {
remainingDaysText = '내일';
}
// 각 날짜에 대한 이벤트 종료 확인
String dailyNotificationBody;
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!
.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
final eventPrice =
subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost;
dailyNotificationBody =
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else {
final currentPrice = subscription.currentPrice;
dailyNotificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
}
await _notifications.zonedSchedule(
subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
'구독 결제 예정 알림',
dailyNotificationBody,
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)}원이 결제되었습니다.';
}
}