Files
submanager/lib/providers/subscription_provider.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

528 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:uuid/uuid.dart';
import 'package:intl/intl.dart';
import '../models/subscription_model.dart';
import '../services/notification_service.dart';
import '../services/exchange_rate_service.dart';
import '../services/currency_util.dart';
import 'category_provider.dart';
class SubscriptionProvider extends ChangeNotifier {
late Box<SubscriptionModel> _subscriptionBox;
List<SubscriptionModel> _subscriptions = [];
bool _isLoading = true;
List<SubscriptionModel> get subscriptions => _subscriptions;
bool get isLoading => _isLoading;
double get totalMonthlyExpense {
final exchangeRateService = ExchangeRateService();
final rate = exchangeRateService.cachedUsdToKrwRate ??
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
final total = _subscriptions.fold(
0.0,
(sum, subscription) {
final price = subscription.currentPrice;
if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$$price ×$rate = ₩${price * rate}');
return sum + (price * rate);
}
debugPrint(
'[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
return sum + price;
},
);
debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: '
'${_subscriptions.length}개 구독, 총액 ₩$total');
return total;
}
/// 월간 총 비용을 반환합니다.
double getTotalMonthlyCost() {
return totalMonthlyExpense;
}
/// 이벤트로 인한 총 절약액을 반환합니다.
double get totalEventSavings {
return _subscriptions.fold(
0.0,
(sum, subscription) => sum + subscription.eventSavings,
);
}
/// 현재 이벤트 중인 구독 목록을 반환합니다.
List<SubscriptionModel> get activeEventSubscriptions {
return _subscriptions.where((sub) => sub.isCurrentlyInEvent).toList();
}
Future<void> init() async {
try {
_isLoading = true;
notifyListeners();
// 환율 정보 미리 로드
await ExchangeRateService().getUsdToKrwRate();
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
await refreshSubscriptions();
// categoryId 마이그레이션
await _migrateCategoryIds();
// 앱 시작 시 이벤트 상태 확인
await checkAndUpdateEventStatus();
_isLoading = false;
notifyListeners();
} catch (e) {
debugPrint('구독 초기화 중 오류 발생: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
Future<void> refreshSubscriptions() async {
try {
_subscriptions = _subscriptionBox.values.toList()
..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: '
'${_subscriptions.length}개 구독, '
'총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners();
} catch (e) {
debugPrint('구독 목록 새로고침 중 오류 발생: $e');
rethrow;
}
}
Future<void> addSubscription({
required String serviceName,
required double monthlyCost,
required String billingCycle,
required DateTime nextBillingDate,
String? websiteUrl,
String? categoryId,
bool isAutoDetected = false,
int repeatCount = 1,
DateTime? lastPaymentDate,
String currency = 'KRW',
bool isEventActive = false,
DateTime? eventStartDate,
DateTime? eventEndDate,
double? eventPrice,
}) async {
try {
final subscription = SubscriptionModel(
id: const Uuid().v4(),
serviceName: serviceName,
monthlyCost: monthlyCost,
billingCycle: billingCycle,
nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl,
categoryId: categoryId,
isAutoDetected: isAutoDetected,
repeatCount: repeatCount,
lastPaymentDate: lastPaymentDate,
currency: currency,
isEventActive: isEventActive,
eventStartDate: eventStartDate,
eventEndDate: eventEndDate,
eventPrice: eventPrice,
);
await _subscriptionBox.put(subscription.id, subscription);
await refreshSubscriptions();
// 이벤트가 활성화된 경우 알림 스케줄 재설정
if (isEventActive && eventEndDate != null) {
await _scheduleEventEndNotification(subscription);
}
} catch (e) {
debugPrint('구독 추가 중 오류 발생: $e');
rethrow;
}
}
Future<void> updateSubscription(SubscriptionModel subscription) async {
try {
debugPrint('[SubscriptionProvider] updateSubscription 호출됨: '
'${subscription.serviceName}, '
'금액: ${subscription.monthlyCost} ${subscription.currency}, '
'현재가격: ${subscription.currentPrice} ${subscription.currency}');
await _subscriptionBox.put(subscription.id, subscription);
debugPrint('[SubscriptionProvider] Hive에 저장 완료');
// 이벤트 관련 알림 업데이트
if (subscription.isEventActive && subscription.eventEndDate != null) {
await _scheduleEventEndNotification(subscription);
} else {
// 이벤트가 비활성화된 경우 이벤트 종료 알림 취소
await NotificationService.cancelNotification(
'${subscription.id}_event_end'.hashCode,
);
}
await refreshSubscriptions();
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners();
} catch (e) {
debugPrint('구독 업데이트 중 오류 발생: $e');
rethrow;
}
}
Future<void> deleteSubscription(String id) async {
try {
await _subscriptionBox.delete(id);
await refreshSubscriptions();
} catch (e) {
debugPrint('구독 삭제 중 오류 발생: $e');
rethrow;
}
}
Future<void> clearAllSubscriptions() async {
_isLoading = true;
notifyListeners();
try {
// 모든 알림 취소
for (var subscription in _subscriptions) {
await NotificationService.cancelSubscriptionNotification(subscription);
}
// 모든 데이터 삭제
await _subscriptionBox.clear();
_subscriptions = [];
notifyListeners();
} catch (e) {
debugPrint('모든 구독 정보 삭제 중 오류 발생: $e');
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 이벤트 종료 알림을 스케줄링합니다.
Future<void> _scheduleEventEndNotification(
SubscriptionModel subscription) async {
if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
await NotificationService.scheduleNotification(
id: '${subscription.id}_event_end'.hashCode,
title: '이벤트 종료 알림',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
scheduledDate: subscription.eventEndDate!,
);
}
}
/// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다.
Future<void> checkAndUpdateEventStatus() async {
bool hasChanges = false;
for (var subscription in _subscriptions) {
// 이벤트가 종료되었지만 아직 활성화되어 있는 경우
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(DateTime.now())) {
subscription.isEventActive = false;
await _subscriptionBox.put(subscription.id, subscription);
hasChanges = true;
}
}
if (hasChanges) {
await refreshSubscriptions();
}
}
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
Future<double> calculateTotalExpense({String? locale}) async {
if (_subscriptions.isEmpty) return 0.0;
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
double total = 0.0;
for (final subscription in _subscriptions) {
final currentPrice = subscription.currentPrice;
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
final converted = await ExchangeRateService().convertBetweenCurrencies(
currentPrice,
subscription.currency,
targetCurrency,
);
total += converted ?? currentPrice;
}
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency');
return total;
}
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData(
{String? locale}) async {
final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = [];
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
// 최근 6개월 데이터 생성
for (int i = 5; i >= 0; i--) {
final month = DateTime(now.year, now.month - i, 1);
double monthTotal = 0.0;
// 현재 월인지 확인
final isCurrentMonth =
(month.year == now.year && month.month == now.month);
if (isCurrentMonth) {
debugPrint(
'[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
}
// 해당 월에 활성화된 구독 계산
for (final subscription in _subscriptions) {
if (isCurrentMonth) {
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
final cost = subscription.currentPrice;
debugPrint(
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
// 통화 변환
final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
} else {
// 과거 월인 경우: 기존 로직 유지
// 구독이 해당 월에 활성화되어 있었는지 확인
final subscriptionStartDate = subscription.nextBillingDate.subtract(
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
);
if (subscriptionStartDate
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.nextBillingDate.isAfter(month)) {
// 해당 월의 비용 계산 (이벤트 가격 고려)
double cost;
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
// 이벤트 기간과 해당 월이 겹치는지 확인
subscription.eventStartDate!
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.eventEndDate!.isAfter(month)) {
cost = subscription.eventPrice ?? subscription.monthlyCost;
} else {
cost = subscription.monthlyCost;
}
// 통화 변환
final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
}
}
}
if (isCurrentMonth) {
debugPrint(
'[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
}
monthlyData.add({
'month': month,
'totalExpense': monthTotal,
'monthName': _getMonthLabel(month, locale ?? 'en'),
});
}
return monthlyData;
}
/// 이벤트로 인한 총 절약액을 계산합니다.
double calculateTotalSavings() {
// 이미 존재하는 totalEventSavings getter를 사용
return totalEventSavings;
}
/// 결제 주기를 일 단위로 변환합니다.
int _getBillingCycleDays(String billingCycle) {
switch (billingCycle) {
case 'monthly':
return 30;
case 'yearly':
return 365;
case 'weekly':
return 7;
case 'quarterly':
return 90;
default:
return 30;
}
}
/// 월 라벨을 생성합니다.
String _getMonthLabel(DateTime month, String locale) {
if (locale == 'ko') {
// 한국어는 기존 형식 유지 (1월, 2월 등)
return '${month.month}';
} else if (locale == 'ja') {
// 일본어
return '${month.month}';
} else if (locale == 'zh') {
// 중국어
return '${month.month}';
} else {
// 영어 및 기타 언어는 약식 월 이름 사용
return DateFormat('MMM', locale).format(month);
}
}
/// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당
Future<void> _migrateCategoryIds() async {
debugPrint('❎ CategoryId 마이그레이션 시작...');
final categoryProvider = CategoryProvider();
await categoryProvider.init();
final categories = categoryProvider.categories;
int migratedCount = 0;
for (var subscription in _subscriptions) {
if (subscription.categoryId == null) {
final serviceName = subscription.serviceName.toLowerCase();
String? categoryId;
debugPrint('🔍 ${subscription.serviceName} 카테고리 매칭 시도...');
// OTT 서비스
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
serviceName.contains('disney') ||
serviceName.contains('왓차') ||
serviceName.contains('티빙') ||
serviceName.contains('디즈니') ||
serviceName.contains('넷플릭스')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'OTT 서비스',
orElse: () => categories.first,
)
.id;
}
// 음악 서비스
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벡스')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'music',
orElse: () => categories.first,
)
.id;
}
// AI 서비스
else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') ||
serviceName.contains('midjourney') ||
serviceName.contains('copilot')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'aiService',
orElse: () => categories.first,
)
.id;
}
// 프로그래밍/개발
else if (serviceName.contains('github') ||
serviceName.contains('intellij') ||
serviceName.contains('webstorm') ||
serviceName.contains('jetbrains')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'programming',
orElse: () => categories.first,
)
.id;
}
// 오피스/협업 툴
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('slack') ||
serviceName.contains('figma') ||
serviceName.contains('icloud') ||
serviceName.contains('아이클라우드')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first,
)
.id;
}
// 기타 서비스 (기본값)
else {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categories.first,
)
.id;
}
subscription.categoryId = categoryId;
await subscription.save();
migratedCount++;
final categoryName =
categories.firstWhere((cat) => cat.id == categoryId).name;
debugPrint('${subscription.serviceName}$categoryName');
}
}
if (migratedCount > 0) {
debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료');
await refreshSubscriptions();
} else {
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');
}
}
}