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,11 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../models/subscription_model.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController {
@@ -23,7 +25,7 @@ class AddSubscriptionController {
final eventPriceController = TextEditingController();
// Form State
String billingCycle = '월간';
String billingCycle = 'monthly';
String currency = 'KRW';
DateTime? nextBillingDate;
bool isLoading = false;
@@ -172,7 +174,7 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showSuccess(
context: context,
message: '${serviceInfo.serviceName} 서비스가 자동으로 인식되었습니다.',
message: AppLocalizations.of(context).serviceRecognized(serviceInfo.serviceName),
);
}
}
@@ -215,7 +217,7 @@ class AddSubscriptionController {
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '음악',
(cat) => cat.name == 'music',
orElse: () => categories.first,
);
}
@@ -284,7 +286,7 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: 'SMS 권한이 필요합니다.',
message: AppLocalizations.of(context).smsPermissionRequired,
);
}
return;
@@ -296,7 +298,7 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showWarning(
context: context,
message: '구독 관련 SMS를 찾을 수 없습니다.',
message: AppLocalizations.of(context).noSubscriptionSmsFound,
);
}
return;
@@ -394,7 +396,7 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: 'SMS 스캔 중 오류 발생: $e',
message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()),
);
}
} finally {
@@ -450,7 +452,7 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: '저장 중 오류가 발생했습니다: $e',
message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
);
}
}

View File

@@ -5,11 +5,13 @@ import '../models/subscription_model.dart';
import '../models/category_model.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart';
import '../widgets/dialogs/delete_confirmation_dialog.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController extends ChangeNotifier {
@@ -22,6 +24,10 @@ class DetailScreenController extends ChangeNotifier {
late TextEditingController websiteUrlController;
late TextEditingController eventPriceController;
// Display Names
String? _displayName;
String? get displayName => _displayName;
// Form State
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
late String _billingCycle;
@@ -197,6 +203,9 @@ class DetailScreenController extends ChangeNotifier {
// 애니메이션 시작
animationController!.forward();
// 로케일에 맞는 서비스명 로드
_loadDisplayName();
// 서비스명 변경 감지 리스너
serviceNameController.addListener(onServiceNameChanged);
@@ -206,6 +215,20 @@ class DetailScreenController extends ChangeNotifier {
});
}
/// 로케일에 맞는 서비스명 로드
Future<void> _loadDisplayName() async {
final localeProvider = context.read<LocaleProvider>();
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscription.serviceName,
locale: locale,
);
_displayName = displayName;
notifyListeners();
}
/// 리소스 정리
@override
void dispose() {
@@ -282,7 +305,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '음악 서비스',
(cat) => cat.name == 'music',
orElse: () => categories.first,
);
}
@@ -295,7 +318,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '오피스/협업 툴',
(cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first,
);
}
@@ -306,7 +329,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('copilot') ||
serviceName.contains('midjourney')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'AI 서비스',
(cat) => cat.name == 'aiService',
orElse: () => categories.first,
);
}
@@ -317,7 +340,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '프로그래밍/개발',
(cat) => cat.name == 'programming',
orElse: () => categories.first,
);
}
@@ -328,7 +351,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '기타 서비스',
(cat) => cat.name == 'other',
orElse: () => categories.first,
);
}
@@ -344,7 +367,7 @@ class DetailScreenController extends ChangeNotifier {
if (formKey.currentState != null && !formKey.currentState!.validate()) {
AppSnackBar.showError(
context: context,
message: '필수 항목을 모두 입력해주세요',
message: AppLocalizations.of(context).requiredFieldsError,
);
return;
}
@@ -368,6 +391,10 @@ class DetailScreenController extends ChangeNotifier {
monthlyCost = subscription.monthlyCost;
}
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
'${subscription.serviceName}${serviceNameController.text}, '
'금액: ${subscription.monthlyCost}$monthlyCost ${_currency}');
subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost;
subscription.websiteUrl = websiteUrl;
@@ -393,13 +420,17 @@ class DetailScreenController extends ChangeNotifier {
subscription.eventPrice = null;
}
debugPrint('[DetailScreenController] 업데이트 정보: '
'현재가격=${subscription.currentPrice}, '
'이벤트활성=${subscription.isEventActive}');
// 구독 업데이트
await provider.updateSubscription(subscription);
if (context.mounted) {
AppSnackBar.showSuccess(
context: context,
message: '구독 정보가 업데이트되었습니다.',
message: AppLocalizations.of(context).subscriptionUpdated,
);
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
@@ -413,10 +444,18 @@ class DetailScreenController extends ChangeNotifier {
/// 구독 삭제
Future<void> deleteSubscription() async {
if (context.mounted) {
// 로케일에 맞는 서비스명 가져오기
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscription.serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show(
context: context,
serviceName: subscription.serviceName,
serviceName: displayName,
);
if (!shouldDelete) return;
@@ -429,7 +468,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: '구독이 삭제되었습니다.',
message: AppLocalizations.of(context).subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded,
);
Navigator.of(context).pop();
@@ -459,7 +498,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: '공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.',
message: AppLocalizations.of(context).officialCancelPageNotFound,
);
}
}
@@ -470,7 +509,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: '웹사이트를 열 수 없습니다.',
message: AppLocalizations.of(context).cannotOpenWebsite,
);
}
}
@@ -487,7 +526,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: '웹사이트를 열 수 없습니다.',
message: AppLocalizations.of(context).cannotOpenWebsite,
);
}
}
@@ -495,7 +534,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) {
AppSnackBar.showWarning(
context: context,
message: '웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.',
message: AppLocalizations.of(context).noWebsiteInfo,
);
}
}