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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user