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

@@ -1,7 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/payment_card_model.dart';
import '../models/subscription_model.dart';
import '../providers/payment_card_provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../utils/payment_card_utils.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/analysis/analysis_screen_spacer.dart';
import '../widgets/analysis/subscription_pie_chart_card.dart';
@@ -9,6 +14,8 @@ import '../widgets/analysis/total_expense_summary_card.dart';
import '../widgets/analysis/monthly_expense_chart_card.dart';
import '../widgets/analysis/event_analysis_card.dart';
enum AnalysisCardFilterType { all, unassigned, card }
class AnalysisScreen extends StatefulWidget {
const AnalysisScreen({super.key});
@@ -25,6 +32,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
List<Map<String, dynamic>> _monthlyData = [];
bool _isLoading = true;
String _lastDataHash = '';
AnalysisCardFilterType _filterType = AnalysisCardFilterType.all;
String? _selectedCardId;
@override
void initState() {
@@ -42,7 +51,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
super.didChangeDependencies();
// Provider 변경 감지
final provider = Provider.of<SubscriptionProvider>(context);
final currentHash = _calculateDataHash(provider);
final filtered = _filterSubscriptions(provider.subscriptions);
final currentHash = _calculateDataHash(provider, filtered: filtered);
debugPrint('[AnalysisScreen] didChangeDependencies: '
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
@@ -64,13 +74,16 @@ class _AnalysisScreenState extends State<AnalysisScreen>
}
/// 구독 데이터의 해시값을 계산하여 변경 감지
String _calculateDataHash(SubscriptionProvider provider) {
final subscriptions = provider.subscriptions;
final buffer = StringBuffer();
buffer.write(subscriptions.length);
buffer.write('_');
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
String _calculateDataHash(
SubscriptionProvider provider, {
List<SubscriptionModel>? filtered,
}) {
final subscriptions =
filtered ?? _filterSubscriptions(provider.subscriptions);
final buffer = StringBuffer()
..write(_filterType.name)
..write('_${_selectedCardId ?? 'all'}')
..write('_${subscriptions.length}');
for (final sub in subscriptions) {
buffer.write(
@@ -80,6 +93,38 @@ class _AnalysisScreenState extends State<AnalysisScreen>
return buffer.toString();
}
List<SubscriptionModel> _filterSubscriptions(
List<SubscriptionModel> subscriptions) {
switch (_filterType) {
case AnalysisCardFilterType.all:
return subscriptions;
case AnalysisCardFilterType.unassigned:
return subscriptions.where((sub) => sub.paymentCardId == null).toList();
case AnalysisCardFilterType.card:
final cardId = _selectedCardId;
if (cardId == null) return subscriptions;
return subscriptions
.where((sub) => sub.paymentCardId == cardId)
.toList();
}
}
Future<void> _onFilterChanged(AnalysisCardFilterType type,
{String? cardId}) async {
if (_filterType == type) {
if (type != AnalysisCardFilterType.card || _selectedCardId == cardId) {
return;
}
}
setState(() {
_filterType = type;
_selectedCardId = type == AnalysisCardFilterType.card ? cardId : null;
});
await _loadData();
}
Future<void> _loadData() async {
debugPrint('[AnalysisScreen] _loadData 호출됨');
setState(() {
@@ -89,17 +134,25 @@ class _AnalysisScreenState extends State<AnalysisScreen>
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode;
final filteredSubscriptions = _filterSubscriptions(provider.subscriptions);
// 총 지출 계산 (로케일별 기본 통화로 환산)
_totalExpense = await provider.calculateTotalExpense(locale: locale);
_totalExpense = await provider.calculateTotalExpense(
locale: locale,
subset: filteredSubscriptions,
);
debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense');
// 월별 데이터 계산 (로케일별 기본 통화로 환산)
_monthlyData = await provider.getMonthlyExpenseData(locale: locale);
_monthlyData = await provider.getMonthlyExpenseData(
locale: locale,
subset: filteredSubscriptions,
);
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
// 현재 데이터 해시값 저장
_lastDataHash = _calculateDataHash(provider);
_lastDataHash =
_calculateDataHash(provider, filtered: filteredSubscriptions);
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
setState(() {
@@ -130,6 +183,128 @@ class _AnalysisScreenState extends State<AnalysisScreen>
);
}
Widget _buildCardFilterSection(
BuildContext context, PaymentCardProvider cardProvider) {
final loc = AppLocalizations.of(context);
final chips = <Widget>[
_buildGenericFilterChip(
context: context,
label: loc.analysisCardFilterAll,
icon: Icons.credit_card,
selected: _filterType == AnalysisCardFilterType.all,
onTap: () => _onFilterChanged(AnalysisCardFilterType.all),
),
const SizedBox(width: 8),
_buildGenericFilterChip(
context: context,
label: loc.paymentCardUnassigned,
icon: Icons.credit_card_off_rounded,
selected: _filterType == AnalysisCardFilterType.unassigned,
onTap: () => _onFilterChanged(AnalysisCardFilterType.unassigned),
),
];
for (final card in cardProvider.cards) {
chips.add(const SizedBox(width: 8));
chips.add(_buildPaymentCardChip(context, card));
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.analysisCardFilterLabel,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: chips),
),
],
),
),
);
}
Widget _buildGenericFilterChip({
required BuildContext context,
required String label,
required IconData icon,
required bool selected,
required VoidCallback onTap,
}) {
final cs = Theme.of(context).colorScheme;
return Semantics(
selected: selected,
button: true,
label: label,
child: ChoiceChip(
label: Text(label),
avatar: Icon(
icon,
size: 16,
color: selected ? cs.onPrimary : cs.onSurfaceVariant,
),
selected: selected,
onSelected: (_) => onTap(),
selectedColor: cs.primary,
labelStyle: TextStyle(
color: selected ? cs.onPrimary : cs.onSurface,
fontWeight: FontWeight.w600,
),
backgroundColor: cs.surface,
side: BorderSide(
color:
selected ? Colors.transparent : cs.outline.withValues(alpha: 0.5),
),
),
);
}
Widget _buildPaymentCardChip(BuildContext context, PaymentCardModel card) {
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
final cs = Theme.of(context).colorScheme;
final selected = _filterType == AnalysisCardFilterType.card &&
_selectedCardId == card.id;
final labelText = '${card.issuerName} · ****${card.last4}';
return Semantics(
label: labelText,
selected: selected,
button: true,
child: ChoiceChip(
avatar: CircleAvatar(
backgroundColor:
selected ? cs.onPrimary : color.withValues(alpha: 0.15),
child: Icon(
icon,
size: 16,
color: selected ? color : cs.onSurface,
),
),
label: Text(labelText),
selected: selected,
onSelected: (_) =>
_onFilterChanged(AnalysisCardFilterType.card, cardId: card.id),
selectedColor: color,
backgroundColor: cs.surface,
labelStyle: TextStyle(
color: selected ? cs.onPrimary : cs.onSurface,
fontWeight: FontWeight.w600,
),
side: BorderSide(
color: selected ? Colors.transparent : color.withValues(alpha: 0.5),
),
),
);
}
@override
Widget build(BuildContext context) {
// Provider를 직접 사용하여 변경 감지
@@ -142,6 +317,9 @@ class _AnalysisScreenState extends State<AnalysisScreen>
);
}
final cardProvider = Provider.of<PaymentCardProvider>(context);
final filteredSubscriptions = _filterSubscriptions(subscriptions);
return CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
@@ -159,9 +337,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
const AnalysisScreenSpacer(),
_buildCardFilterSection(context, cardProvider),
const AnalysisScreenSpacer(),
// 1. 구독 비율 파이 차트
SubscriptionPieChartCard(
subscriptions: subscriptions,
subscriptions: filteredSubscriptions,
animationController: _animationController,
),
@@ -170,7 +352,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 2. 총 지출 요약 카드
TotalExpenseSummaryCard(
key: ValueKey('total_expense_$_lastDataHash'),
subscriptions: subscriptions,
subscriptions: filteredSubscriptions,
totalExpense: _totalExpense,
animationController: _animationController,
),
@@ -189,6 +371,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 4. 이벤트 분석
EventAnalysisCard(
animationController: _animationController,
subscriptions: filteredSubscriptions,
),
// FloatingNavigationBar를 위한 충분한 하단 여백