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

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../models/category_model.dart';
import 'package:uuid/uuid.dart';
import '../l10n/app_localizations.dart';
class CategoryProvider extends ChangeNotifier {
List<CategoryModel> _categories = [];
@@ -9,16 +10,16 @@ class CategoryProvider extends ChangeNotifier {
// 카테고리 표시 순서 정의
static const List<String> _categoryOrder = [
'음악',
'OTT(동영상)',
'저장/클라우드',
'통신 · 인터넷 · TV',
'생활/라이프스타일',
'쇼핑/이커머스',
'프로그래밍',
'협업/오피스',
'AI 서비스',
'기타',
'music',
'ottVideo',
'storageCloud',
'telecomInternetTv',
'lifestyle',
'shoppingEcommerce',
'programming',
'collaborationOffice',
'aiService',
'other',
];
List<CategoryModel> get categories {
@@ -53,16 +54,16 @@ class CategoryProvider extends ChangeNotifier {
// 기본 카테고리 초기화
Future<void> _initDefaultCategories() async {
final defaultCategories = [
{'name': '음악', 'color': '#E91E63', 'icon': 'music_note'},
{'name': 'OTT(동영상)', 'color': '#9C27B0', 'icon': 'movie_filter'},
{'name': '저장/클라우드', 'color': '#2196F3', 'icon': 'cloud'},
{'name': '통신 · 인터넷 · TV', 'color': '#00BCD4', 'icon': 'wifi'},
{'name': '생활/라이프스타일', 'color': '#4CAF50', 'icon': 'home'},
{'name': '쇼핑/이커머스', 'color': '#FF9800', 'icon': 'shopping_cart'},
{'name': '프로그래밍', 'color': '#795548', 'icon': 'code'},
{'name': '협업/오피스', 'color': '#607D8B', 'icon': 'business_center'},
{'name': 'AI 서비스', 'color': '#673AB7', 'icon': 'smart_toy'},
{'name': '기타', 'color': '#9E9E9E', 'icon': 'category'},
{'name': 'music', 'color': '#E91E63', 'icon': 'music_note'},
{'name': 'ottVideo', 'color': '#9C27B0', 'icon': 'movie_filter'},
{'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'},
{'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'},
{'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'},
{'name': 'shoppingEcommerce', 'color': '#FF9800', 'icon': 'shopping_cart'},
{'name': 'programming', 'color': '#795548', 'icon': 'code'},
{'name': 'collaborationOffice', 'color': '#607D8B', 'icon': 'business_center'},
{'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'},
{'name': 'other', 'color': '#9E9E9E', 'icon': 'category'},
];
for (final category in defaultCategories) {
@@ -116,4 +117,57 @@ class CategoryProvider extends ChangeNotifier {
return null;
}
}
// 카테고리 이름을 현재 언어에 맞게 반환
String getLocalizedCategoryName(BuildContext context, String categoryKey) {
final localizations = AppLocalizations.of(context);
switch (categoryKey) {
case 'music':
return localizations.categoryMusic;
case 'ottVideo':
return localizations.categoryOttVideo;
case 'storageCloud':
return localizations.categoryStorageCloud;
case 'telecomInternetTv':
return localizations.categoryTelecomInternetTv;
case 'lifestyle':
return localizations.categoryLifestyle;
case 'shoppingEcommerce':
return localizations.categoryShoppingEcommerce;
case 'programming':
return localizations.categoryProgramming;
case 'collaborationOffice':
return localizations.categoryCollaborationOffice;
case 'aiService':
return localizations.categoryAiService;
case 'other':
return localizations.categoryOther;
default:
// 이전 버전과의 호환성을 위해 한국어 카테고리 이름도 처리
switch (categoryKey) {
case '음악':
return localizations.categoryMusic;
case 'OTT(동영상)':
return localizations.categoryOttVideo;
case '저장/클라우드':
return localizations.categoryStorageCloud;
case '통신 · 인터넷 · TV':
return localizations.categoryTelecomInternetTv;
case '생활/라이프스타일':
return localizations.categoryLifestyle;
case '쇼핑/이커머스':
return localizations.categoryShoppingEcommerce;
case '프로그래밍':
return localizations.categoryProgramming;
case '협업/오피스':
return localizations.categoryCollaborationOffice;
case 'AI 서비스':
return localizations.categoryAiService;
case '기타':
return localizations.categoryOther;
default:
return categoryKey;
}
}
}
}

View File

@@ -1,22 +1,48 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'dart:ui' as ui;
class LocaleProvider extends ChangeNotifier {
late Box<String> _localeBox;
Locale _locale = const Locale('ko');
static const List<String> supportedLanguages = ['en', 'ko', 'ja', 'zh'];
Locale get locale => _locale;
Future<void> init() async {
_localeBox = await Hive.openBox<String>('locale');
final savedLocale = _localeBox.get('locale', defaultValue: 'ko');
_locale = Locale(savedLocale ?? 'ko');
// 저장된 언어 설정 확인
final savedLocale = _localeBox.get('locale');
if (savedLocale != null) {
// 저장된 언어가 있으면 사용
_locale = Locale(savedLocale);
} else {
// 저장된 언어가 없으면 시스템 언어 감지
final systemLocale = ui.PlatformDispatcher.instance.locale;
// 시스템 언어가 지원되는 언어인지 확인
if (supportedLanguages.contains(systemLocale.languageCode)) {
_locale = Locale(systemLocale.languageCode);
} else {
// 지원되지 않는 언어면 영어 사용
_locale = const Locale('en');
}
// 감지된 언어 저장
await _localeBox.put('locale', _locale.languageCode);
}
notifyListeners();
}
Future<void> setLocale(String languageCode) async {
_locale = Locale(languageCode);
await _localeBox.put('locale', languageCode);
notifyListeners();
if (_locale.languageCode != languageCode) {
_locale = Locale(languageCode);
await _localeBox.put('locale', languageCode);
notifyListeners();
}
}
}

View File

@@ -4,7 +4,7 @@ class NavigationProvider extends ChangeNotifier {
int _currentIndex = 0;
final List<int> _navigationHistory = [0];
String _currentRoute = '/';
String _currentTitle = '';
String _currentTitle = 'home';
int get currentIndex => _currentIndex;
List<int> get navigationHistory => List.unmodifiable(_navigationHistory);
@@ -28,10 +28,10 @@ class NavigationProvider extends ChangeNotifier {
};
static const Map<int, String> indexToTitle = {
0: '',
1: '분석',
3: 'SMS 스캔',
4: '설정',
0: 'home',
1: 'analysis',
3: 'smsScanLabel',
4: 'settings',
};
void updateCurrentIndex(int index, {bool addToHistory = true}) {
@@ -39,7 +39,7 @@ class NavigationProvider extends ChangeNotifier {
_currentIndex = index;
_currentRoute = indexToRoute[index] ?? '/';
_currentTitle = indexToTitle[index] ?? '';
_currentTitle = indexToTitle[index] ?? 'home';
if (addToHistory && index >= 0) {
_navigationHistory.add(index);
@@ -57,17 +57,17 @@ class NavigationProvider extends ChangeNotifier {
if (index >= 0) {
_currentIndex = index;
_currentTitle = indexToTitle[index] ?? '';
_currentTitle = indexToTitle[index] ?? 'home';
} else {
switch (route) {
case '/add-subscription':
_currentTitle = '구독 추가';
_currentTitle = 'addSubscription';
break;
case '/subscription-detail':
_currentTitle = '구독 상세';
_currentTitle = 'subscriptionDetail';
break;
default:
_currentTitle = '';
_currentTitle = 'home';
}
}
@@ -89,7 +89,7 @@ class NavigationProvider extends ChangeNotifier {
void reset() {
_currentIndex = 0;
_currentRoute = '/';
_currentTitle = '';
_currentTitle = 'home';
_navigationHistory.clear();
_navigationHistory.add(0);
notifyListeners();
@@ -98,7 +98,7 @@ class NavigationProvider extends ChangeNotifier {
void clearHistoryAndGoHome() {
_currentIndex = 0;
_currentRoute = '/';
_currentTitle = '';
_currentTitle = 'home';
_navigationHistory.clear();
_navigationHistory.add(0);
notifyListeners();

View File

@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
import '../models/subscription_model.dart';
import '../services/notification_service.dart';
import '../services/exchange_rate_service.dart';
import '../services/currency_util.dart';
import 'category_provider.dart';
class SubscriptionProvider extends ChangeNotifier {
@@ -20,16 +21,23 @@ class SubscriptionProvider extends ChangeNotifier {
final rate = exchangeRateService.cachedUsdToKrwRate ??
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
return _subscriptions.fold(
final total = _subscriptions.fold(
0.0,
(sum, subscription) {
final price = subscription.currentPrice;
if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$${price} ×$rate = ₩${price * rate}');
return sum + (price * rate);
}
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
return sum + price;
},
);
debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: '
'${_subscriptions.length}개 구독, 총액 ₩$total');
return total;
}
/// 월간 총 비용을 반환합니다.
@@ -81,6 +89,11 @@ class SubscriptionProvider extends ChangeNotifier {
try {
_subscriptions = _subscriptionBox.values.toList()
..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: '
'${_subscriptions.length}개 구독, '
'총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners();
} catch (e) {
debugPrint('구독 목록 새로고침 중 오류 발생: $e');
@@ -138,9 +151,13 @@ class SubscriptionProvider extends ChangeNotifier {
Future<void> updateSubscription(SubscriptionModel subscription) async {
try {
notifyListeners();
debugPrint('[SubscriptionProvider] updateSubscription 호출됨: '
'${subscription.serviceName}, '
'금액: ${subscription.monthlyCost} ${subscription.currency}, '
'현재가격: ${subscription.currentPrice} ${subscription.currency}');
await _subscriptionBox.put(subscription.id, subscription);
debugPrint('[SubscriptionProvider] Hive에 저장 완료');
// 이벤트 관련 알림 업데이트
if (subscription.isEventActive && subscription.eventEndDate != null) {
@@ -154,6 +171,8 @@ class SubscriptionProvider extends ChangeNotifier {
await refreshSubscriptions();
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners();
} catch (e) {
debugPrint('구독 업데이트 중 오류 발생: $e');
@@ -230,17 +249,59 @@ class SubscriptionProvider extends ChangeNotifier {
}
}
/// 총 월간 지출을 계산합니다.
Future<double> calculateTotalExpense() async {
// 이미 존재하는 totalMonthlyExpense getter를 사용
return totalMonthlyExpense;
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
Future<double> calculateTotalExpense({String? locale}) async {
if (_subscriptions.isEmpty) return 0.0;
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = locale != null
? CurrencyUtil.getDefaultCurrency(locale)
: 'KRW'; // 기본값
double total = 0.0;
for (final subscription in _subscriptions) {
final currentPrice = subscription.currentPrice;
if (subscription.currency == targetCurrency) {
// 이미 타겟 통화인 경우
total += currentPrice;
} else if (subscription.currency == 'USD') {
// USD를 타겟 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(currentPrice, targetCurrency);
total += converted ?? currentPrice;
} else if (targetCurrency == 'USD') {
// 타겟이 USD인 경우 다른 통화를 USD로 변환
final converted = await ExchangeRateService().convertTargetToUsd(currentPrice, subscription.currency);
total += converted ?? currentPrice;
} else {
// USD를 거쳐서 변환 (예: KRW → USD → JPY)
// 1단계: 구독 통화를 USD로 변환
final usdAmount = await ExchangeRateService().convertTargetToUsd(currentPrice, subscription.currency);
if (usdAmount != null) {
// 2단계: USD를 타겟 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(usdAmount, targetCurrency);
total += converted ?? currentPrice;
} else {
// 변환 실패 시 원래 값 사용
total += currentPrice;
}
}
}
return total;
}
/// 최근 6개월의 월별 지출 데이터를 반환합니다.
Future<List<Map<String, dynamic>>> getMonthlyExpenseData() async {
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({String? locale}) async {
final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = [];
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = locale != null
? CurrencyUtil.getDefaultCurrency(locale)
: 'KRW'; // 기본값
// 최근 6개월 데이터 생성
for (int i = 5; i >= 0; i--) {
final month = DateTime(now.year, now.month - i, 1);
@@ -256,14 +317,38 @@ class SubscriptionProvider extends ChangeNotifier {
if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.nextBillingDate.isAfter(month)) {
// 해당 월의 비용 계산 (이벤트 가격 고려)
double cost;
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
month.isAfter(subscription.eventStartDate!) &&
month.isBefore(subscription.eventEndDate!)) {
monthTotal += subscription.eventPrice ?? subscription.monthlyCost;
cost = subscription.eventPrice ?? subscription.monthlyCost;
} else {
monthTotal += subscription.monthlyCost;
cost = subscription.monthlyCost;
}
// 통화 변환
if (subscription.currency == targetCurrency) {
monthTotal += cost;
} else if (subscription.currency == 'USD') {
final converted = await ExchangeRateService().convertUsdToTarget(cost, targetCurrency);
monthTotal += converted ?? cost;
} else if (targetCurrency == 'USD') {
final converted = await ExchangeRateService().convertTargetToUsd(cost, subscription.currency);
monthTotal += converted ?? cost;
} else {
// USD를 거쳐서 변환 (예: KRW → USD → JPY)
// 1단계: 구독 통화를 USD로 변환
final usdAmount = await ExchangeRateService().convertTargetToUsd(cost, subscription.currency);
if (usdAmount != null) {
// 2단계: USD를 타겟 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(usdAmount, targetCurrency);
monthTotal += converted ?? cost;
} else {
// 변환 실패 시 원래 값 사용
monthTotal += cost;
}
}
}
}
@@ -347,7 +432,7 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('플로') ||
serviceName.contains('벡스')) {
categoryId = categories.firstWhere(
(cat) => cat.name == '음악 서비스',
(cat) => cat.name == 'music',
orElse: () => categories.first,
).id;
}
@@ -357,7 +442,7 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('midjourney') ||
serviceName.contains('copilot')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'AI 서비스',
(cat) => cat.name == 'aiService',
orElse: () => categories.first,
).id;
}
@@ -367,7 +452,7 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('webstorm') ||
serviceName.contains('jetbrains')) {
categoryId = categories.firstWhere(
(cat) => cat.name == '프로그래밍/개발',
(cat) => cat.name == 'programming',
orElse: () => categories.first,
).id;
}
@@ -380,14 +465,14 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('icloud') ||
serviceName.contains('아이클라우드')) {
categoryId = categories.firstWhere(
(cat) => cat.name == '오피스/협업 툴',
(cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first,
).id;
}
// 기타 서비스 (기본값)
else {
categoryId = categories.firstWhere(
(cat) => cat.name == '기타 서비스',
(cat) => cat.name == 'other',
orElse: () => categories.first,
).id;
}