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 _subscriptionBox; List _subscriptions = []; bool _isLoading = true; List 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 get activeEventSubscriptions { return _subscriptions.where((sub) => sub.isCurrentlyInEvent).toList(); } Future init() async { try { _isLoading = true; notifyListeners(); // 환율 정보 미리 로드 await ExchangeRateService().getUsdToKrwRate(); _subscriptionBox = await Hive.openBox('subscriptions'); await refreshSubscriptions(); // categoryId 마이그레이션 await _migrateCategoryIds(); // billingCycle별 비용 마이그레이션 (연간/분기별 구독 월 비용 변환) await _migrateBillingCosts(); // 앱 시작 시 이벤트 상태 확인 await checkAndUpdateEventStatus(); _isLoading = false; notifyListeners(); } catch (e) { debugPrint('구독 초기화 중 오류 발생: $e'); _isLoading = false; notifyListeners(); rethrow; } } Future 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 _reschedulePaymentNotifications() async { try { await NotificationService.reschedulAllNotifications(_subscriptions); } catch (e) { debugPrint('결제 알림 재예약 중 오류 발생: $e'); } } Future 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 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 deleteSubscription(String id) async { try { await _subscriptionBox.delete(id); await refreshSubscriptions(); await _reschedulePaymentNotifications(); } catch (e) { debugPrint('구독 삭제 중 오류 발생: $e'); rethrow; } } Future 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 _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 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 calculateTotalExpense({ String? locale, List? 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>> getMonthlyExpenseData({ String? locale, List? subset, }) async { final now = DateTime.now(); final List> 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 _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 _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('💰 모든 구독이 이미 월 비용으로 저장되어 있습니다'); } } }