feat: add payment card grouping and analysis

This commit is contained in:
JiWoong Sul
2025-11-14 16:53:41 +09:00
parent cba7d082bd
commit 132ae758de
40 changed files with 2846 additions and 522 deletions

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:uuid/uuid.dart';
import '../models/payment_card_model.dart';
class PaymentCardProvider extends ChangeNotifier {
late Box<PaymentCardModel> _cardBox;
final List<PaymentCardModel> _cards = [];
List<PaymentCardModel> get cards => List.unmodifiable(_cards);
PaymentCardModel? get defaultCard {
try {
return _cards.firstWhere((card) => card.isDefault);
} catch (_) {
return _cards.isNotEmpty ? _cards.first : null;
}
}
Future<void> init() async {
_cardBox = await Hive.openBox<PaymentCardModel>('payment_cards');
_cards
..clear()
..addAll(_cardBox.values);
_sortCards();
notifyListeners();
}
Future<PaymentCardModel> addCard({
required String issuerName,
required String last4,
required String colorHex,
required String iconName,
bool isDefault = false,
}) async {
if (isDefault) {
await _unsetDefaultCard();
}
final card = PaymentCardModel(
id: const Uuid().v4(),
issuerName: issuerName,
last4: last4,
colorHex: colorHex,
iconName: iconName,
isDefault: isDefault,
);
await _cardBox.put(card.id, card);
_cards.add(card);
_sortCards();
notifyListeners();
return card;
}
Future<void> updateCard(PaymentCardModel updated) async {
final index = _cards.indexWhere((card) => card.id == updated.id);
if (index == -1) return;
if (updated.isDefault) {
await _unsetDefaultCard(exceptId: updated.id);
}
_cards[index] = updated;
await _cardBox.put(updated.id, updated);
_sortCards();
notifyListeners();
}
Future<void> deleteCard(String id) async {
await _cardBox.delete(id);
_cards.removeWhere((card) => card.id == id);
if (!_cards.any((card) => card.isDefault) && _cards.isNotEmpty) {
_cards.first.isDefault = true;
await _cardBox.put(_cards.first.id, _cards.first);
}
_sortCards();
notifyListeners();
}
Future<void> setDefaultCard(String id) async {
final index = _cards.indexWhere((card) => card.id == id);
if (index == -1) return;
await _unsetDefaultCard(exceptId: id);
_cards[index].isDefault = true;
await _cardBox.put(id, _cards[index]);
_sortCards();
notifyListeners();
}
PaymentCardModel? getCardById(String? id) {
if (id == null) return null;
try {
return _cards.firstWhere((card) => card.id == id);
} catch (_) {
return null;
}
}
void _sortCards() {
_cards.sort((a, b) {
if (a.isDefault != b.isDefault) {
return a.isDefault ? -1 : 1;
}
final issuerCompare =
a.issuerName.toLowerCase().compareTo(b.issuerName.toLowerCase());
if (issuerCompare != 0) return issuerCompare;
return a.last4.compareTo(b.last4);
});
}
Future<void> _unsetDefaultCard({String? exceptId}) async {
for (final card in _cards) {
if (card.isDefault && card.id != exceptId) {
card.isDefault = false;
await _cardBox.put(card.id, card);
}
}
}
}

View File

@@ -118,6 +118,7 @@ class SubscriptionProvider extends ChangeNotifier {
required DateTime nextBillingDate,
String? websiteUrl,
String? categoryId,
String? paymentCardId,
bool isAutoDetected = false,
int repeatCount = 1,
DateTime? lastPaymentDate,
@@ -136,6 +137,7 @@ class SubscriptionProvider extends ChangeNotifier {
nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl,
categoryId: categoryId,
paymentCardId: paymentCardId,
isAutoDetected: isAutoDetected,
repeatCount: repeatCount,
lastPaymentDate: lastPaymentDate,
@@ -268,17 +270,22 @@ class SubscriptionProvider extends ChangeNotifier {
}
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
Future<double> calculateTotalExpense({String? locale}) async {
if (_subscriptions.isEmpty) return 0.0;
Future<double> calculateTotalExpense({
String? locale,
List<SubscriptionModel>? subset,
}) async {
final targetSubscriptions = subset ?? _subscriptions;
if (targetSubscriptions.isEmpty) return 0.0;
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, '
'대상 구독: ${targetSubscriptions.length}');
double total = 0.0;
for (final subscription in _subscriptions) {
for (final subscription in targetSubscriptions) {
final currentPrice = subscription.currentPrice;
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
@@ -292,15 +299,19 @@ class SubscriptionProvider extends ChangeNotifier {
total += converted ?? currentPrice;
}
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency');
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total '
'$targetCurrency (대상 ${targetSubscriptions.length}개)');
return total;
}
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData(
{String? locale}) async {
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 =
@@ -321,7 +332,7 @@ class SubscriptionProvider extends ChangeNotifier {
}
// 해당 월에 활성화된 구독 계산
for (final subscription in _subscriptions) {
for (final subscription in targetSubscriptions) {
if (isCurrentMonth) {
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
final cost = subscription.currentPrice;