385 lines
12 KiB
Dart
385 lines
12 KiB
Dart
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';
|
|
import '../widgets/analysis/total_expense_summary_card.dart';
|
|
import '../widgets/analysis/monthly_expense_chart_card.dart';
|
|
import '../widgets/analysis/event_analysis_card.dart';
|
|
import '../theme/ui_constants.dart';
|
|
|
|
enum AnalysisCardFilterType { all, unassigned, card }
|
|
|
|
class AnalysisScreen extends StatefulWidget {
|
|
const AnalysisScreen({super.key});
|
|
|
|
@override
|
|
State<AnalysisScreen> createState() => _AnalysisScreenState();
|
|
}
|
|
|
|
class _AnalysisScreenState extends State<AnalysisScreen>
|
|
with TickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
late ScrollController _scrollController;
|
|
|
|
double _totalExpense = 0;
|
|
List<Map<String, dynamic>> _monthlyData = [];
|
|
bool _isLoading = true;
|
|
String _lastDataHash = '';
|
|
AnalysisCardFilterType _filterType = AnalysisCardFilterType.all;
|
|
String? _selectedCardId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(
|
|
duration: const Duration(milliseconds: 1500),
|
|
vsync: this,
|
|
);
|
|
_scrollController = ScrollController();
|
|
_loadData();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// Provider 변경 감지
|
|
final provider = Provider.of<SubscriptionProvider>(context);
|
|
final filtered = _filterSubscriptions(provider.subscriptions);
|
|
final currentHash = _calculateDataHash(provider, filtered: filtered);
|
|
|
|
debugPrint('[AnalysisScreen] didChangeDependencies: '
|
|
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
|
|
|
|
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
|
|
if (currentHash != _lastDataHash &&
|
|
!_isLoading &&
|
|
_lastDataHash.isNotEmpty) {
|
|
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
|
|
_loadData();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 구독 데이터의 해시값을 계산하여 변경 감지
|
|
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(
|
|
'_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
|
|
}
|
|
|
|
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(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
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,
|
|
subset: filteredSubscriptions,
|
|
);
|
|
debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense');
|
|
|
|
// 월별 데이터 계산 (로케일별 기본 통화로 환산)
|
|
_monthlyData = await provider.getMonthlyExpenseData(
|
|
locale: locale,
|
|
subset: filteredSubscriptions,
|
|
);
|
|
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
|
|
|
|
// 현재 데이터 해시값 저장
|
|
_lastDataHash =
|
|
_calculateDataHash(provider, filtered: filteredSubscriptions);
|
|
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
|
|
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
|
|
// 데이터 로드 완료 후 애니메이션 시작
|
|
_animationController.reset();
|
|
_animationController.forward();
|
|
}
|
|
|
|
Widget _buildAnimatedAd() {
|
|
return FadeTransition(
|
|
opacity: CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
|
),
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(
|
|
begin: const Offset(0, 0.2),
|
|
end: Offset.zero,
|
|
).animate(CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
|
)),
|
|
child: const NativeAdWidget(key: ValueKey('analysis_ad')),
|
|
),
|
|
);
|
|
}
|
|
|
|
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를 직접 사용하여 변경 감지
|
|
final provider = Provider.of<SubscriptionProvider>(context);
|
|
final subscriptions = provider.subscriptions;
|
|
|
|
if (_isLoading) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(),
|
|
);
|
|
}
|
|
|
|
final cardProvider = Provider.of<PaymentCardProvider>(context);
|
|
final filteredSubscriptions = _filterSubscriptions(subscriptions);
|
|
|
|
return CustomScrollView(
|
|
controller: _scrollController,
|
|
physics: const BouncingScrollPhysics(),
|
|
slivers: <Widget>[
|
|
SliverPadding(
|
|
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
|
|
sliver: _buildCardFilterSection(context, cardProvider),
|
|
),
|
|
|
|
const AnalysisScreenSpacer(),
|
|
|
|
// 1. 구독 비율 파이 차트
|
|
SubscriptionPieChartCard(
|
|
subscriptions: filteredSubscriptions,
|
|
animationController: _animationController,
|
|
),
|
|
|
|
const AnalysisScreenSpacer(),
|
|
|
|
// 네이티브 광고 위젯 (구독 비율 차트 하단)
|
|
SliverToBoxAdapter(
|
|
child: _buildAnimatedAd(),
|
|
),
|
|
|
|
const AnalysisScreenSpacer(),
|
|
|
|
// 2. 총 지출 요약 카드
|
|
TotalExpenseSummaryCard(
|
|
key: ValueKey('total_expense_$_lastDataHash'),
|
|
subscriptions: filteredSubscriptions,
|
|
totalExpense: _totalExpense,
|
|
animationController: _animationController,
|
|
),
|
|
|
|
const AnalysisScreenSpacer(),
|
|
|
|
// 3. 월별 지출 차트
|
|
MonthlyExpenseChartCard(
|
|
key: ValueKey('monthly_expense_$_lastDataHash'),
|
|
monthlyData: _monthlyData,
|
|
animationController: _animationController,
|
|
),
|
|
|
|
const AnalysisScreenSpacer(),
|
|
|
|
// 4. 이벤트 분석
|
|
EventAnalysisCard(
|
|
animationController: _animationController,
|
|
subscriptions: filteredSubscriptions,
|
|
),
|
|
|
|
// FloatingNavigationBar를 위한 충분한 하단 여백
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(
|
|
height: 120 + MediaQuery.of(context).padding.bottom,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|