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:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../controllers/detail_screen_controller.dart';
|
||||
import '../common/buttons/primary_button.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 상세 화면 액션 버튼 섹션
|
||||
/// 저장 버튼을 포함하는 섹션입니다.
|
||||
@@ -33,7 +34,7 @@ class DetailActionButtons extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 80),
|
||||
child: PrimaryButton(
|
||||
text: '변경사항 저장',
|
||||
text: AppLocalizations.of(context).saveChanges,
|
||||
icon: Icons.save_rounded,
|
||||
onPressed: controller.updateSubscription,
|
||||
isLoading: controller.isLoading,
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../controllers/detail_screen_controller.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../common/form_fields/currency_input_field.dart';
|
||||
import '../common/form_fields/date_picker_field.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 이벤트 가격 섹션
|
||||
/// 할인 이벤트 정보를 관리하는 섹션입니다.
|
||||
@@ -75,9 +76,9 @@ class DetailEventSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'이벤트 가격',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(context).eventPrice,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkNavy,
|
||||
@@ -125,7 +126,7 @@ class DetailEventSection extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'할인 또는 프로모션 가격을 설정하세요',
|
||||
AppLocalizations.of(context).eventPriceHint,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
@@ -151,8 +152,8 @@ class DetailEventSection extends StatelessWidget {
|
||||
onEndDateSelected: (date) {
|
||||
controller.eventEndDate = date;
|
||||
},
|
||||
startLabel: '시작일',
|
||||
endLabel: '종료일',
|
||||
startLabel: AppLocalizations.of(context).startDate,
|
||||
endLabel: AppLocalizations.of(context).endDate,
|
||||
primaryColor: baseColor,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
@@ -160,16 +161,16 @@ class DetailEventSection extends StatelessWidget {
|
||||
CurrencyInputField(
|
||||
controller: controller.eventPriceController,
|
||||
currency: controller.currency,
|
||||
label: '이벤트 가격',
|
||||
hintText: '할인된 가격을 입력하세요',
|
||||
label: AppLocalizations.of(context).eventPrice,
|
||||
hintText: AppLocalizations.of(context).eventPriceHint,
|
||||
validator: controller.isEventActive
|
||||
? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '이벤트 가격을 입력해주세요';
|
||||
return AppLocalizations.of(context).eventPriceRequired;
|
||||
}
|
||||
final price = double.tryParse(value.replaceAll(',', ''));
|
||||
if (price == null || price <= 0) {
|
||||
return '올바른 가격을 입력해주세요';
|
||||
return AppLocalizations.of(context).invalidPrice;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -233,7 +234,7 @@ class _DiscountBadge extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$discountPercentage% 할인',
|
||||
AppLocalizations.of(context).discountPercent.replaceAll('@', discountPercentage.toString()),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
@@ -243,9 +244,7 @@ class _DiscountBadge extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
currency == 'KRW'
|
||||
? '₩${discountAmount.toInt().toString()}원 절약'
|
||||
: '\$${discountAmount.toStringAsFixed(2)} 절약',
|
||||
_getLocalizedDiscountAmount(context, currency, discountAmount),
|
||||
style: TextStyle(
|
||||
color: const Color(0xFF15803D),
|
||||
fontSize: 14,
|
||||
@@ -256,4 +255,18 @@ class _DiscountBadge extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getLocalizedDiscountAmount(BuildContext context, String currency, double amount) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
switch (currency) {
|
||||
case 'KRW':
|
||||
return loc.discountAmountWon.replaceAll('@', amount.toInt().toString());
|
||||
case 'JPY':
|
||||
return loc.discountAmountYen.replaceAll('@', amount.toInt().toString());
|
||||
case 'CNY':
|
||||
return loc.discountAmountYuan.replaceAll('@', amount.toStringAsFixed(2));
|
||||
default: // USD
|
||||
return loc.discountAmountDollar.replaceAll('@', amount.toStringAsFixed(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../common/form_fields/date_picker_field.dart';
|
||||
import '../common/form_fields/currency_selector.dart';
|
||||
import '../common/form_fields/billing_cycle_selector.dart';
|
||||
import '../common/form_fields/category_selector.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 상세 화면 폼 섹션
|
||||
/// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다.
|
||||
@@ -66,8 +67,8 @@ class DetailFormSection extends StatelessWidget {
|
||||
BaseTextField(
|
||||
controller: controller.serviceNameController,
|
||||
focusNode: controller.serviceNameFocus,
|
||||
label: '서비스명',
|
||||
hintText: '예: Netflix, Spotify',
|
||||
label: AppLocalizations.of(context).subscriptionName,
|
||||
hintText: AppLocalizations.of(context).serviceNameExample,
|
||||
textInputAction: TextInputAction.next,
|
||||
onEditingComplete: () {
|
||||
controller.monthlyCostFocus.requestFocus();
|
||||
@@ -84,7 +85,7 @@ class DetailFormSection extends StatelessWidget {
|
||||
child: CurrencyInputField(
|
||||
controller: controller.monthlyCostController,
|
||||
currency: controller.currency,
|
||||
label: '월 지출',
|
||||
label: AppLocalizations.of(context).monthlyExpense,
|
||||
focusNode: controller.monthlyCostFocus,
|
||||
textInputAction: TextInputAction.next,
|
||||
onEditingComplete: () {
|
||||
@@ -97,9 +98,9 @@ class DetailFormSection extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'통화',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(context).currency,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
@@ -134,9 +135,9 @@ class DetailFormSection extends StatelessWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'결제 주기',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(context).billingCycle,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
@@ -161,7 +162,7 @@ class DetailFormSection extends StatelessWidget {
|
||||
onDateSelected: (date) {
|
||||
controller.nextBillingDate = date;
|
||||
},
|
||||
label: '다음 결제일',
|
||||
label: AppLocalizations.of(context).nextBillingDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
|
||||
primaryColor: baseColor,
|
||||
@@ -174,9 +175,9 @@ class DetailFormSection extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'카테고리',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(context).category,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
|
||||
@@ -3,7 +3,10 @@ import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../controllers/detail_screen_controller.dart';
|
||||
import '../../providers/locale_provider.dart';
|
||||
import '../../services/currency_util.dart';
|
||||
import '../website_icon.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 상세 화면 상단 헤더 섹션
|
||||
/// 서비스 아이콘, 이름, 결제 정보를 표시합니다.
|
||||
@@ -134,7 +137,7 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
controller.serviceNameController.text,
|
||||
controller.displayName ?? controller.serviceNameController.text,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
@@ -151,7 +154,8 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${controller.billingCycle} 결제',
|
||||
AppLocalizations.of(context).billingCyclePayment.replaceAll('@',
|
||||
_getLocalizedBillingCycle(context, controller.billingCycle)),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -175,25 +179,28 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_InfoColumn(
|
||||
label: '다음 결제일',
|
||||
value: DateFormat('yyyy년 MM월 dd일')
|
||||
.format(controller.nextBillingDate),
|
||||
label: AppLocalizations.of(context).nextBillingDate,
|
||||
value: AppLocalizations.of(context).formatDate(controller.nextBillingDate),
|
||||
),
|
||||
_InfoColumn(
|
||||
label: '월 지출',
|
||||
value: NumberFormat.currency(
|
||||
locale: controller.currency == 'KRW'
|
||||
? 'ko_KR'
|
||||
: 'en_US',
|
||||
symbol: controller.currency == 'KRW'
|
||||
? '₩'
|
||||
: '\$',
|
||||
decimalDigits:
|
||||
controller.currency == 'KRW' ? 0 : 2,
|
||||
).format(double.tryParse(
|
||||
controller.monthlyCostController.text.replaceAll(',', '')
|
||||
) ?? 0),
|
||||
alignment: CrossAxisAlignment.end,
|
||||
FutureBuilder<String>(
|
||||
future: () async {
|
||||
final locale = context.read<LocaleProvider>().locale.languageCode;
|
||||
final amount = double.tryParse(
|
||||
controller.monthlyCostController.text.replaceAll(',', '')
|
||||
) ?? 0;
|
||||
return CurrencyUtil.formatAmountWithLocale(
|
||||
amount,
|
||||
controller.currency,
|
||||
locale,
|
||||
);
|
||||
}(),
|
||||
builder: (context, snapshot) {
|
||||
return _InfoColumn(
|
||||
label: AppLocalizations.of(context).monthlyExpense,
|
||||
value: snapshot.data ?? '-',
|
||||
alignment: CrossAxisAlignment.end,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -212,6 +219,33 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
String _getLocalizedBillingCycle(BuildContext context, String cycle) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
switch (cycle.toLowerCase()) {
|
||||
case '매월':
|
||||
case 'monthly':
|
||||
case '毎月':
|
||||
case '每月':
|
||||
return loc.billingCycleMonthly;
|
||||
case '분기별':
|
||||
case 'quarterly':
|
||||
case '四半期':
|
||||
case '每季度':
|
||||
return loc.billingCycleQuarterly;
|
||||
case '반기별':
|
||||
case 'half-yearly':
|
||||
case '半年ごと':
|
||||
case '每半年':
|
||||
return loc.billingCycleHalfYearly;
|
||||
case '매년':
|
||||
case 'yearly':
|
||||
case '年間':
|
||||
case '每年':
|
||||
return loc.billingCycleYearly;
|
||||
default:
|
||||
return cycle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 정보 표시 컬럼
|
||||
|
||||
@@ -3,6 +3,7 @@ import '../../controllers/detail_screen_controller.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../common/form_fields/base_text_field.dart';
|
||||
import '../common/buttons/secondary_button.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 웹사이트 URL 섹션
|
||||
/// 서비스 웹사이트 URL과 해지 관련 정보를 관리하는 섹션입니다.
|
||||
@@ -69,9 +70,9 @@ class DetailUrlSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'웹사이트 정보',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(context).websiteInfo,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkNavy,
|
||||
@@ -85,8 +86,8 @@ class DetailUrlSection extends StatelessWidget {
|
||||
BaseTextField(
|
||||
controller: controller.websiteUrlController,
|
||||
focusNode: controller.websiteUrlFocus,
|
||||
label: '웹사이트 URL',
|
||||
hintText: 'https://example.com',
|
||||
label: AppLocalizations.of(context).websiteUrl,
|
||||
hintText: AppLocalizations.of(context).urlExample,
|
||||
keyboardType: TextInputType.url,
|
||||
prefixIcon: Icon(
|
||||
Icons.link_rounded,
|
||||
@@ -120,7 +121,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'해지 안내',
|
||||
AppLocalizations.of(context).cancelGuide,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -131,7 +132,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.',
|
||||
AppLocalizations.of(context).cancelServiceGuide,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
@@ -141,7 +142,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextLinkButton(
|
||||
text: '해지 페이지로 이동',
|
||||
text: AppLocalizations.of(context).goToCancelPage,
|
||||
icon: Icons.open_in_new_rounded,
|
||||
onPressed: controller.openCancellationPage,
|
||||
color: AppColors.warningColor,
|
||||
@@ -174,7 +175,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다',
|
||||
AppLocalizations.of(context).urlAutoMatchInfo,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
|
||||
Reference in New Issue
Block a user