Files
submanager/lib/widgets/analysis/monthly_expense_chart_card.dart

292 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import '../../theme/color_scheme_ext.dart';
import 'package:fl_chart/fl_chart.dart';
import 'dart:math' as math;
import 'package:provider/provider.dart';
import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/reduce_motion.dart';
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
class MonthlyExpenseChartCard extends StatelessWidget {
final List<Map<String, dynamic>> monthlyData;
final AnimationController animationController;
const MonthlyExpenseChartCard({
super.key,
required this.monthlyData,
required this.animationController,
});
/// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰)
double _calculateChartMaxY(double maxValue, String locale) {
final currency = CurrencyUtil.getDefaultCurrency(locale);
if (currency == 'KRW' || currency == 'JPY') {
// 소수점이 없는 통화 (원화, 엔화)
if (maxValue <= 0) return 100000;
if (maxValue <= 10000) return 10000;
if (maxValue <= 50000) return 50000;
if (maxValue <= 100000) return 100000;
if (maxValue <= 200000) return 200000;
if (maxValue <= 500000) return 500000;
if (maxValue <= 1000000) return 1000000;
// 큰 금액은 자릿수에 맞춰 반올림
final magnitude =
math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
return ((maxValue / magnitude).ceil() * magnitude).toDouble();
} else {
// 소수점이 있는 통화 (달러, 위안)
if (maxValue <= 0) return 100.0;
if (maxValue <= 10) return 10.0;
if (maxValue <= 25) return 25.0;
if (maxValue <= 50) return 50.0;
if (maxValue <= 100) return 100.0;
if (maxValue <= 250) return 250.0;
if (maxValue <= 500) return 500.0;
if (maxValue <= 1000) return 1000.0;
// 큰 금액은 100 단위로 반올림
return ((maxValue / 100).ceil() * 100).toDouble();
}
}
/// 그리드 라인 간격을 계산합니다
double _calculateGridInterval(double maxY, String currency) {
if (currency == 'KRW' || currency == 'JPY') {
// 4등분하되 깔끔한 숫자로
if (maxY <= 40000) return 10000;
if (maxY <= 100000) return 25000;
if (maxY <= 200000) return 50000;
if (maxY <= 400000) return 100000;
return maxY / 4;
} else {
// 달러 등은 4등분
if (maxY <= 40) return 10;
if (maxY <= 100) return 25;
if (maxY <= 200) return 50;
if (maxY <= 400) return 100;
return maxY / 4;
}
}
// 월간 지출 차트 데이터
List<BarChartGroupData> _getMonthlyBarGroups(
BuildContext context, String locale) {
final List<BarChartGroupData> barGroups = [];
final calculatedMax = monthlyData.fold<double>(
0, (max, data) => math.max(max, data['totalExpense'] as double));
final maxAmount = _calculateChartMaxY(calculatedMax, locale);
final scheme = Theme.of(context).colorScheme;
for (int i = 0; i < monthlyData.length; i++) {
final data = monthlyData[i];
barGroups.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: data['totalExpense'],
color: scheme.primary,
width: 18,
borderRadius: BorderRadius.circular(4),
backDrawRodData: BackgroundBarChartRodData(
show: true,
toY: maxAmount,
color: scheme.onSurfaceVariant.withValues(alpha: 0.08),
),
),
],
),
);
}
return barGroups;
}
@override
Widget build(BuildContext context) {
final locale = context.watch<LocaleProvider>().locale.languageCode;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.4, 0.9, 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.4, 0.9, curve: Curves.easeOut),
)),
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText.headline(
text: AppLocalizations.of(context).monthlyExpenseTitle,
style: const TextStyle(
fontSize: 18,
),
),
const SizedBox(height: 8),
ThemedText.subtitle(
text: AppLocalizations.of(context).recentSixMonthsTrend,
style: const TextStyle(
fontSize: 14,
),
),
const SizedBox(height: 20),
// 바 차트 (RepaintBoundary로 페인트 분리)
RepaintBoundary(
child: AspectRatio(
aspectRatio: 1.6,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: _calculateChartMaxY(
monthlyData.fold<double>(
0,
(max, data) => math.max(
max, data['totalExpense'] as double)),
locale),
barGroups: _getMonthlyBarGroups(context, locale),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: _calculateGridInterval(
_calculateChartMaxY(
monthlyData.fold<double>(
0,
(max, data) => math.max(max,
data['totalExpense'] as double)),
locale),
CurrencyUtil.getDefaultCurrency(locale)),
getDrawingHorizontalLine: (value) {
return FlLine(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.1),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: ThemedText.caption(
text: monthlyData[value.toInt()]
['monthName'],
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBorderRadius: BorderRadius.circular(8),
getTooltipColor: (group) => Theme.of(context)
.colorScheme
.inverseSurface,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
TextStyle(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: CurrencyUtil
.formatTotalAmountWithLocale(
monthlyData[group.x]
['totalExpense'] as double,
locale),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.warning,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
);
},
),
),
),
duration: ReduceMotion.isEnabled(context)
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
),
),
const SizedBox(height: 16),
Center(
child: ThemedText.caption(
text: AppLocalizations.of(context)
.monthlySubscriptionExpense,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
),
),
),
);
}
}