Files
submanager/lib/providers/subscription_provider.dart

657 lines
22 KiB
Dart
Raw 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';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
import '../utils/billing_cost_util.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 now = DateTime.now();
final currentYear = now.year;
final currentMonth = now.month;
final total = _subscriptions.fold(
0.0,
(sum, subscription) {
// 이번 달에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
currentYear,
currentMonth,
);
if (!hasBilling) {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'이번 달 결제 없음, 제외');
return sum;
}
// 실제 결제 금액으로 역변환
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$$actualPrice ×$rate = ₩${actualPrice * rate}');
return sum + (actualPrice * rate);
}
debugPrint(
'[SubscriptionProvider] ${subscription.serviceName}: ₩$actualPrice');
return sum + actualPrice;
},
);
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();
// billingCycle별 비용 마이그레이션 (연간/분기별 구독 월 비용 변환)
await _migrateBillingCosts();
// 앱 시작 시 이벤트 상태 확인
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> _reschedulePaymentNotifications() async {
try {
await NotificationService.reschedulAllNotifications(_subscriptions);
} catch (e) {
debugPrint('결제 알림 재예약 중 오류 발생: $e');
}
}
Future<void> addSubscription({
required String serviceName,
required double monthlyCost,
required String billingCycle,
required DateTime nextBillingDate,
String? websiteUrl,
String? categoryId,
String? paymentCardId,
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,
paymentCardId: paymentCardId,
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);
}
await _reschedulePaymentNotifications();
} 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();
await _reschedulePaymentNotifications();
} catch (e) {
debugPrint('구독 업데이트 중 오류 발생: $e');
rethrow;
}
}
Future<void> deleteSubscription(String id) async {
try {
await _subscriptionBox.delete(id);
await refreshSubscriptions();
await _reschedulePaymentNotifications();
} 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();
await _reschedulePaymentNotifications();
}
}
/// 이벤트 종료 알림을 스케줄링합니다.
Future<void> _scheduleEventEndNotification(
SubscriptionModel subscription) async {
if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
await NotificationService.scheduleNotification(
id: '${subscription.id}_event_end'.hashCode,
title: loc?.eventEndNotificationTitle ?? 'Event end notification',
body: loc?.eventEndNotificationBody(subscription.serviceName) ??
"${subscription.serviceName}'s discount event has ended.",
scheduledDate: subscription.eventEndDate!,
channelId: NotificationService.expirationChannelId,
);
}
}
/// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다.
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,
List<SubscriptionModel>? subset,
}) async {
final targetSubscriptions = subset ?? _subscriptions;
if (targetSubscriptions.isEmpty) return 0.0;
final now = DateTime.now();
final currentYear = now.year;
final currentMonth = now.month;
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, '
'대상 구독: ${targetSubscriptions.length}개, 현재 월: $currentYear-$currentMonth');
double total = 0.0;
for (final subscription in targetSubscriptions) {
// 이번 달에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
currentYear,
currentMonth,
);
if (!hasBilling) {
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'이번 달 결제 없음 - 제외');
continue;
}
// 실제 결제 금액으로 변환 (월 비용 → 실제 결제 금액)
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'실제 결제 금액 $actualPrice ${subscription.currency} '
'(월 비용: ${subscription.currentPrice}, 주기: ${subscription.billingCycle})');
final converted = await ExchangeRateService().convertBetweenCurrencies(
actualPrice,
subscription.currency,
targetCurrency,
);
total += converted ?? actualPrice;
}
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total '
'$targetCurrency (대상 ${targetSubscriptions.length}개)');
return total;
}
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
/// - 각 월에 결제가 발생하는 구독만 포함
/// - 실제 결제 금액으로 계산 (연간이면 연간 금액)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({
String? locale,
List<SubscriptionModel>? subset,
}) async {
final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = [];
final targetSubscriptions = subset ?? _subscriptions;
// 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 targetSubscriptions) {
// 해당 월에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
month.year,
month.month,
);
if (!hasBilling) {
continue; // 해당 월에 결제가 없으면 제외
}
// 실제 결제 금액으로 변환 (월 비용 → 실제 결제 금액)
double actualCost;
if (isCurrentMonth) {
// 현재 월: 이벤트 가격 반영
actualCost = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
} else {
// 과거 월: 이벤트 기간 확인 후 적용
double monthlyCost;
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
subscription.eventStartDate!
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.eventEndDate!.isAfter(month)) {
monthlyCost = subscription.eventPrice ?? subscription.monthlyCost;
} else {
monthlyCost = subscription.monthlyCost;
}
actualCost = BillingCostUtil.convertFromMonthlyCost(
monthlyCost,
subscription.billingCycle,
);
}
if (isCurrentMonth) {
debugPrint(
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'실제 결제 금액 $actualCost ${subscription.currency}');
}
// 통화 변환
final converted =
await ExchangeRateService().convertBetweenCurrencies(
actualCost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? actualCost;
}
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;
}
/// 월 라벨을 생성합니다.
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를 가지고 있습니다');
}
}
/// billingCycle별 비용 마이그레이션
/// 기존 연간/분기별 구독의 monthlyCost를 월 환산 비용으로 변환
Future<void> _migrateBillingCosts() async {
debugPrint('💰 BillingCost 마이그레이션 시작...');
int migratedCount = 0;
for (var subscription in _subscriptions) {
final cycle = subscription.billingCycle.toLowerCase();
// 월간 구독이 아닌 경우에만 변환 필요
if (cycle != 'monthly' && cycle != '월간' && cycle != '매월') {
// 현재 monthlyCost가 실제 월 비용인지 확인
// 연간 구독인데 monthlyCost가 12배 이상 크면 변환 안됨 상태로 판단
final multiplier = BillingCostUtil.getBillingCycleMultiplier(cycle);
// 변환이 필요한 경우: monthlyCost가 비정상적으로 큰 경우
// (예: 연간 129,000원이 monthlyCost에 그대로 저장된 경우)
if (multiplier > 1.5) {
// 원래 monthlyCost를 백업
final originalCost = subscription.monthlyCost;
// 월 비용으로 변환
final convertedCost = BillingCostUtil.convertToMonthlyCost(
originalCost,
cycle,
);
// 이벤트 가격도 있다면 변환
if (subscription.eventPrice != null) {
final convertedEventPrice = BillingCostUtil.convertToMonthlyCost(
subscription.eventPrice!,
cycle,
);
subscription.eventPrice = convertedEventPrice;
}
subscription.monthlyCost = convertedCost;
await subscription.save();
migratedCount++;
debugPrint('${subscription.serviceName} ($cycle): '
'${originalCost.toInt()} → ₩${convertedCost.toInt()}/월');
}
}
}
if (migratedCount > 0) {
debugPrint('💰 총 $migratedCount개의 구독 비용 변환 완료');
await refreshSubscriptions();
} else {
debugPrint('💰 모든 구독이 이미 월 비용으로 저장되어 있습니다');
}
}
}