Files
submanager/lib/providers/subscription_provider.dart

545 lines
18 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> _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,
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);
}
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())) {
await NotificationService.scheduleNotification(
id: '${subscription.id}_event_end'.hashCode,
title: '이벤트 종료 알림',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
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}) 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를 가지고 있습니다');
}
}
}