feat: 다국어 지원 및 다중 통화 환율 변환 기능 확대

- ExchangeRateService에 JPY, CNY 환율 지원 추가
- 구독 서비스별 다국어 표시 이름 지원
- 분석 화면 차트 및 UI/UX 개선
- 설정 화면 전면 리팩토링
- SMS 스캔 기능 사용성 개선
- 전체 앱 다국어 번역 확대

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-16 17:34:32 +09:00
parent 4d1c0f5dab
commit 0f0b02bf08
55 changed files with 4100 additions and 1197 deletions

View File

@@ -1,10 +1,13 @@
import 'package:flutter/material.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';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
class MonthlyExpenseChartCard extends StatelessWidget {
@@ -17,12 +20,64 @@ class MonthlyExpenseChartCard extends StatelessWidget {
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() {
List<BarChartGroupData> _getMonthlyBarGroups(String locale) {
final List<BarChartGroupData> barGroups = [];
final calculatedMax = monthlyData.fold<double>(
0, (max, data) => math.max(max, data['totalExpense'] as double));
final maxAmount = calculatedMax > 0 ? calculatedMax : 100000.0; // 기본값 10만원
final maxAmount = _calculateChartMaxY(calculatedMax, locale);
for (int i = 0; i < monthlyData.length; i++) {
final data = monthlyData[i];
@@ -44,7 +99,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
borderRadius: BorderRadius.circular(4),
backDrawRodData: BackgroundBarChartRodData(
show: true,
toY: maxAmount + (maxAmount * 0.1),
toY: maxAmount,
color: AppColors.navyGray.withValues(alpha: 0.1),
),
),
@@ -58,6 +113,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final locale = context.watch<LocaleProvider>().locale.languageCode;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -84,14 +140,14 @@ class MonthlyExpenseChartCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText.headline(
text: '월별 지출 현황',
text: AppLocalizations.of(context).monthlyExpenseTitle,
style: const TextStyle(
fontSize: 18,
),
),
const SizedBox(height: 8),
ThemedText.subtitle(
text: '최근 6개월간 추이',
text: AppLocalizations.of(context).recentSixMonthsTrend,
style: const TextStyle(
fontSize: 14,
),
@@ -103,25 +159,26 @@ class MonthlyExpenseChartCard extends StatelessWidget {
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: math.max(
maxY: _calculateChartMaxY(
monthlyData.fold<double>(
0,
(max, data) => math.max(
max, data['totalExpense'] as double)) *
1.2,
100000.0 // 최소값 10만원
max, data['totalExpense'] as double)),
locale
),
barGroups: _getMonthlyBarGroups(),
barGroups: _getMonthlyBarGroups(locale),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: math.max(
monthlyData.fold<double>(
horizontalInterval: _calculateGridInterval(
_calculateChartMaxY(
monthlyData.fold<double>(
0,
(max, data) => math.max(max,
data['totalExpense'] as double)) /
4,
25000.0 // 최소 간격 2.5만원
data['totalExpense'] as double)),
locale
),
CurrencyUtil.getDefaultCurrency(locale)
),
getDrawingHorizontalLine: (value) {
return FlLine(
@@ -176,9 +233,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
),
children: [
TextSpan(
text: CurrencyUtil.formatTotalAmount(
text: CurrencyUtil.formatTotalAmountWithLocale(
monthlyData[group.x]['totalExpense']
as double),
as double,
locale),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontSize: 14,
@@ -196,7 +254,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
const SizedBox(height: 16),
Center(
child: ThemedText.caption(
text: '월 구독 지출',
text: AppLocalizations.of(context).monthlySubscriptionExpense,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,