import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:uuid/uuid.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 _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 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 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(); // 앱 시작 시 이벤트 상태 확인 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 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 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 deleteSubscription(String id) async { try { await _subscriptionBox.delete(id); await refreshSubscriptions(); } 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(); } } /// 이벤트 종료 알림을 스케줄링합니다. Future _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 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}) async { if (_subscriptions.isEmpty) return 0.0; // locale이 제공되지 않으면 현재 로케일 사용 final targetCurrency = locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 double total = 0.0; for (final subscription in _subscriptions) { final currentPrice = subscription.currentPrice; if (subscription.currency == targetCurrency) { // 이미 타겟 통화인 경우 total += currentPrice; } else if (subscription.currency == 'USD') { // USD를 타겟 통화로 변환 final converted = await ExchangeRateService().convertUsdToTarget(currentPrice, targetCurrency); total += converted ?? currentPrice; } else if (targetCurrency == 'USD') { // 타겟이 USD인 경우 다른 통화를 USD로 변환 final converted = await ExchangeRateService().convertTargetToUsd(currentPrice, subscription.currency); total += converted ?? currentPrice; } else { // USD를 거쳐서 변환 (예: KRW → USD → JPY) // 1단계: 구독 통화를 USD로 변환 final usdAmount = await ExchangeRateService().convertTargetToUsd(currentPrice, subscription.currency); if (usdAmount != null) { // 2단계: USD를 타겟 통화로 변환 final converted = await ExchangeRateService().convertUsdToTarget(usdAmount, targetCurrency); total += converted ?? currentPrice; } else { // 변환 실패 시 원래 값 사용 total += currentPrice; } } } return total; } /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) Future>> getMonthlyExpenseData({String? locale}) async { final now = DateTime.now(); final List> 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; // 해당 월에 활성화된 구독 계산 for (final subscription in _subscriptions) { // 구독이 해당 월에 활성화되어 있었는지 확인 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 && month.isAfter(subscription.eventStartDate!) && month.isBefore(subscription.eventEndDate!)) { cost = subscription.eventPrice ?? subscription.monthlyCost; } else { cost = subscription.monthlyCost; } // 통화 변환 if (subscription.currency == targetCurrency) { monthTotal += cost; } else if (subscription.currency == 'USD') { final converted = await ExchangeRateService().convertUsdToTarget(cost, targetCurrency); monthTotal += converted ?? cost; } else if (targetCurrency == 'USD') { final converted = await ExchangeRateService().convertTargetToUsd(cost, subscription.currency); monthTotal += converted ?? cost; } else { // USD를 거쳐서 변환 (예: KRW → USD → JPY) // 1단계: 구독 통화를 USD로 변환 final usdAmount = await ExchangeRateService().convertTargetToUsd(cost, subscription.currency); if (usdAmount != null) { // 2단계: USD를 타겟 통화로 변환 final converted = await ExchangeRateService().convertUsdToTarget(usdAmount, targetCurrency); monthTotal += converted ?? cost; } else { // 변환 실패 시 원래 값 사용 monthTotal += cost; } } } } monthlyData.add({ 'month': month, 'totalExpense': monthTotal, 'monthName': _getMonthLabel(month), }); } 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) { final months = [ '1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월' ]; return months[month.month - 1]; } /// 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; } if (categoryId != null) { 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를 가지고 있습니다'); } } }