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:
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../../controllers/add_subscription_controller.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 구독 추가 화면의 App Bar
|
||||
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
@@ -49,7 +50,7 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
'구독 추가',
|
||||
AppLocalizations.of(context).addSubscription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Montserrat',
|
||||
fontSize: 24,
|
||||
@@ -93,7 +94,7 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
|
||||
color: Color(0xFF3B82F6),
|
||||
),
|
||||
onPressed: onScanSMS,
|
||||
tooltip: 'SMS에서 구독 정보 스캔',
|
||||
tooltip: AppLocalizations.of(context).scanTextMessages,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import '../../controllers/add_subscription_controller.dart';
|
||||
import '../common/form_fields/currency_input_field.dart';
|
||||
import '../common/form_fields/date_picker_field.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 구독 추가 화면의 이벤트/할인 섹션
|
||||
class AddSubscriptionEventSection extends StatelessWidget {
|
||||
@@ -75,13 +76,32 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'이벤트 가격',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final locale = Localizations.localeOf(context);
|
||||
String titleText;
|
||||
switch (locale.languageCode) {
|
||||
case 'ko':
|
||||
titleText = '이벤트 가격';
|
||||
break;
|
||||
case 'ja':
|
||||
titleText = 'イベント価格';
|
||||
break;
|
||||
case 'zh':
|
||||
titleText = '活动价格';
|
||||
break;
|
||||
default:
|
||||
titleText = 'Event Price';
|
||||
}
|
||||
return Text(
|
||||
titleText,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -133,13 +153,32 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'할인 또는 프로모션 가격을 설정하세요',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final locale = Localizations.localeOf(context);
|
||||
String infoText;
|
||||
switch (locale.languageCode) {
|
||||
case 'ko':
|
||||
infoText = '할인 또는 프로모션 가격을 설정하세요';
|
||||
break;
|
||||
case 'ja':
|
||||
infoText = '割引またはプロモーション価格を設定してください';
|
||||
break;
|
||||
case 'zh':
|
||||
infoText = '设置折扣或促销价格';
|
||||
break;
|
||||
default:
|
||||
infoText = 'Set up discount or promotion price';
|
||||
}
|
||||
return Text(
|
||||
infoText,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -148,35 +187,88 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 이벤트 기간
|
||||
DateRangePickerField(
|
||||
startDate: controller.eventStartDate,
|
||||
endDate: controller.eventEndDate,
|
||||
onStartDateSelected: (date) {
|
||||
setState(() {
|
||||
controller.eventStartDate = date;
|
||||
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
||||
if (date != null && controller.eventEndDate == null) {
|
||||
controller.eventEndDate = date.add(const Duration(days: 30));
|
||||
}
|
||||
});
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final locale = Localizations.localeOf(context);
|
||||
String startLabel;
|
||||
String endLabel;
|
||||
switch (locale.languageCode) {
|
||||
case 'ko':
|
||||
startLabel = '시작일';
|
||||
endLabel = '종료일';
|
||||
break;
|
||||
case 'ja':
|
||||
startLabel = '開始日';
|
||||
endLabel = '終了日';
|
||||
break;
|
||||
case 'zh':
|
||||
startLabel = '开始日期';
|
||||
endLabel = '结束日期';
|
||||
break;
|
||||
default:
|
||||
startLabel = 'Start Date';
|
||||
endLabel = 'End Date';
|
||||
}
|
||||
return DateRangePickerField(
|
||||
startDate: controller.eventStartDate,
|
||||
endDate: controller.eventEndDate,
|
||||
onStartDateSelected: (date) {
|
||||
setState(() {
|
||||
controller.eventStartDate = date;
|
||||
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
||||
if (date != null && controller.eventEndDate == null) {
|
||||
controller.eventEndDate = date.add(const Duration(days: 30));
|
||||
}
|
||||
});
|
||||
},
|
||||
onEndDateSelected: (date) {
|
||||
setState(() {
|
||||
controller.eventEndDate = date;
|
||||
});
|
||||
},
|
||||
startLabel: startLabel,
|
||||
endLabel: endLabel,
|
||||
primaryColor: controller.gradientColors[0],
|
||||
);
|
||||
},
|
||||
onEndDateSelected: (date) {
|
||||
setState(() {
|
||||
controller.eventEndDate = date;
|
||||
});
|
||||
},
|
||||
startLabel: '시작일',
|
||||
endLabel: '종료일',
|
||||
primaryColor: controller.gradientColors[0],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 이벤트 가격
|
||||
CurrencyInputField(
|
||||
controller: controller.eventPriceController,
|
||||
currency: controller.currency,
|
||||
label: '이벤트 가격',
|
||||
hintText: '할인된 가격을 입력하세요',
|
||||
Builder(
|
||||
builder: (BuildContext innerContext) {
|
||||
// 현재 로케일 확인
|
||||
final currentLocale = Localizations.localeOf(innerContext);
|
||||
|
||||
// 로케일에 따라 직접 텍스트 설정
|
||||
String eventPriceLabel;
|
||||
String eventPriceHint;
|
||||
|
||||
switch (currentLocale.languageCode) {
|
||||
case 'ko':
|
||||
eventPriceLabel = '이벤트 가격';
|
||||
eventPriceHint = '할인된 가격을 입력하세요';
|
||||
break;
|
||||
case 'ja':
|
||||
eventPriceLabel = 'イベント価格';
|
||||
eventPriceHint = '割引価格を入力してください';
|
||||
break;
|
||||
case 'zh':
|
||||
eventPriceLabel = '活动价格';
|
||||
eventPriceHint = '输入折扣价格';
|
||||
break;
|
||||
default:
|
||||
eventPriceLabel = 'Event Price';
|
||||
eventPriceHint = 'Enter discounted price';
|
||||
}
|
||||
|
||||
return CurrencyInputField(
|
||||
controller: controller.eventPriceController,
|
||||
currency: controller.currency,
|
||||
label: eventPriceLabel,
|
||||
hintText: eventPriceHint,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import '../../controllers/add_subscription_controller.dart';
|
||||
import '../../providers/category_provider.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../common/form_fields/base_text_field.dart';
|
||||
import '../common/form_fields/currency_input_field.dart';
|
||||
import '../common/form_fields/date_picker_field.dart';
|
||||
@@ -67,9 +68,9 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'서비스 정보',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(context).serviceInfo,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
@@ -84,15 +85,15 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
BaseTextField(
|
||||
controller: controller.serviceNameController,
|
||||
focusNode: controller.serviceNameFocus,
|
||||
label: '서비스명',
|
||||
hintText: '예: Netflix, Spotify',
|
||||
label: AppLocalizations.of(context).labelServiceName,
|
||||
hintText: AppLocalizations.of(context).hintServiceName,
|
||||
textInputAction: TextInputAction.next,
|
||||
onEditingComplete: () {
|
||||
controller.monthlyCostFocus.requestFocus();
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '서비스명을 입력해주세요';
|
||||
return AppLocalizations.of(context).serviceNameRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -108,7 +109,7 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
child: CurrencyInputField(
|
||||
controller: controller.monthlyCostController,
|
||||
currency: controller.currency,
|
||||
label: '월 지출',
|
||||
label: AppLocalizations.of(context).labelMonthlyExpense,
|
||||
focusNode: controller.monthlyCostFocus,
|
||||
textInputAction: TextInputAction.next,
|
||||
onEditingComplete: () {
|
||||
@@ -116,7 +117,7 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '금액을 입력해주세요';
|
||||
return AppLocalizations.of(context).amountRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -127,9 +128,9 @@ class AddSubscriptionForm 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,
|
||||
),
|
||||
@@ -155,9 +156,10 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'결제 주기',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(context).billingCycle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -185,7 +187,7 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
controller.nextBillingDate = date;
|
||||
});
|
||||
},
|
||||
label: '다음 결제일',
|
||||
label: AppLocalizations.of(context).nextBillingDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
|
||||
primaryColor: controller.gradientColors[0],
|
||||
@@ -196,8 +198,8 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
BaseTextField(
|
||||
controller: controller.websiteUrlController,
|
||||
focusNode: controller.websiteUrlFocus,
|
||||
label: '웹사이트 URL (선택)',
|
||||
hintText: 'https://example.com',
|
||||
label: AppLocalizations.of(context).websiteUrlOptional,
|
||||
hintText: AppLocalizations.of(context).hintWebsiteUrl,
|
||||
keyboardType: TextInputType.url,
|
||||
prefixIcon: Icon(
|
||||
Icons.link_rounded,
|
||||
@@ -212,9 +214,9 @@ class AddSubscriptionForm 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,
|
||||
),
|
||||
@@ -243,4 +245,3 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../controllers/add_subscription_controller.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 구독 추가 화면의 헤더 섹션
|
||||
class AddSubscriptionHeader extends StatelessWidget {
|
||||
@@ -54,23 +55,23 @@ class AddSubscriptionHeader extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'새 구독 추가',
|
||||
style: TextStyle(
|
||||
AppLocalizations.of(context).newSubscriptionAdd,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'서비스 정보를 입력해주세요',
|
||||
style: TextStyle(
|
||||
AppLocalizations.of(context).enterServiceInfo,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white70,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../controllers/add_subscription_controller.dart';
|
||||
import '../common/buttons/primary_button.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 구독 추가 화면의 저장 버튼
|
||||
class AddSubscriptionSaveButton extends StatelessWidget {
|
||||
@@ -37,7 +38,7 @@ class AddSubscriptionSaveButton extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 80),
|
||||
child: PrimaryButton(
|
||||
text: '구독 추가하기',
|
||||
text: AppLocalizations.of(context).addSubscriptionButton,
|
||||
icon: Icons.add_circle_outline,
|
||||
onPressed: controller.isLoading
|
||||
? null
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../services/currency_util.dart';
|
||||
import '../../providers/locale_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
|
||||
class AnalysisBadge extends StatelessWidget {
|
||||
@@ -54,17 +57,26 @@ class AnalysisBadge extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 0),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.formatAmount(
|
||||
future: CurrencyUtil.formatAmountWithLocale(
|
||||
subscription.monthlyCost,
|
||||
subscription.currency,
|
||||
context.read<LocaleProvider>().locale.languageCode,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final amountText = snapshot.data!;
|
||||
// 금액이 너무 길면 축약
|
||||
final displayText = amountText.length > 8
|
||||
? amountText.replaceAll('원', '').trim()
|
||||
: amountText;
|
||||
// 금액이 너무 길면 축약 (괄호 제거)
|
||||
String displayText = amountText;
|
||||
if (amountText.length > 12) {
|
||||
// 괄호 안의 내용 제거
|
||||
displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
|
||||
}
|
||||
if (displayText.length > 10) {
|
||||
// 통화 기호만 남기고 숫자만 표시
|
||||
final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency);
|
||||
displayText = displayText.replaceAll(currencySymbol, '').trim();
|
||||
displayText = '$currencySymbol${displayText.substring(0, 6)}...';
|
||||
}
|
||||
return Text(
|
||||
displayText,
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../services/currency_util.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../themed_text.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 이벤트 할인 현황을 보여주는 카드 위젯
|
||||
class EventAnalysisCard extends StatelessWidget {
|
||||
@@ -50,7 +51,7 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: '이벤트 할인 현황',
|
||||
text: AppLocalizations.of(context).eventDiscountStatus,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
@@ -78,7 +79,7 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${provider.activeEventSubscriptions.length}개 진행중',
|
||||
AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -119,9 +120,9 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ThemedText(
|
||||
'월간 절약 금액',
|
||||
style: TextStyle(
|
||||
ThemedText(
|
||||
AppLocalizations.of(context).monthlySavingAmount,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -144,9 +145,9 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const ThemedText(
|
||||
'진행중인 이벤트',
|
||||
style: TextStyle(
|
||||
ThemedText(
|
||||
AppLocalizations.of(context).eventsInProgress,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -246,7 +247,7 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'$discountRate% 할인',
|
||||
'$discountRate${AppLocalizations.of(context).discountPercent}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,72 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../services/currency_util.dart';
|
||||
import '../../services/exchange_rate_service.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../themed_text.dart';
|
||||
import 'analysis_badge.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../providers/locale_provider.dart';
|
||||
|
||||
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
||||
class SubscriptionPieChartCard extends StatelessWidget {
|
||||
class SubscriptionPieChartCard extends StatefulWidget {
|
||||
final List<SubscriptionModel> subscriptions;
|
||||
final AnimationController animationController;
|
||||
final int touchedIndex;
|
||||
final Function(int) onPieTouch;
|
||||
|
||||
const SubscriptionPieChartCard({
|
||||
super.key,
|
||||
required this.subscriptions,
|
||||
required this.animationController,
|
||||
required this.touchedIndex,
|
||||
required this.onPieTouch,
|
||||
});
|
||||
|
||||
// 파이 차트 섹션 데이터
|
||||
List<PieChartSectionData> _getPieSections() {
|
||||
if (subscriptions.isEmpty) return [];
|
||||
@override
|
||||
State<SubscriptionPieChartCard> createState() => _SubscriptionPieChartCardState();
|
||||
}
|
||||
|
||||
final colors = [
|
||||
const Color(0xFF3B82F6),
|
||||
const Color(0xFF10B981),
|
||||
const Color(0xFFF59E0B),
|
||||
const Color(0xFFEF4444),
|
||||
const Color(0xFF8B5CF6),
|
||||
const Color(0xFF0EA5E9),
|
||||
const Color(0xFFEC4899),
|
||||
];
|
||||
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
int _touchedIndex = -1;
|
||||
late Future<List<PieChartSectionData>> _pieSectionsFuture;
|
||||
String? _lastLocale;
|
||||
|
||||
static const _chartColors = [
|
||||
Color(0xFF3B82F6),
|
||||
Color(0xFF10B981),
|
||||
Color(0xFFF59E0B),
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFF8B5CF6),
|
||||
Color(0xFF0EA5E9),
|
||||
Color(0xFFEC4899),
|
||||
];
|
||||
|
||||
// 개별 구독의 비율 계산을 위한 값들
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeFuture();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SubscriptionPieChartCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// subscriptions나 locale이 변경된 경우만 Future 재생성
|
||||
final currentLocale = context.read<LocaleProvider>().locale.languageCode;
|
||||
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
|
||||
_lastLocale != currentLocale) {
|
||||
_initializeFuture();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeFuture() {
|
||||
_lastLocale = context.read<LocaleProvider>().locale.languageCode;
|
||||
_pieSectionsFuture = _getPieSections();
|
||||
}
|
||||
|
||||
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i].id != b[i].id ||
|
||||
a[i].currentPrice != b[i].currentPrice ||
|
||||
a[i].currency != b[i].currency ||
|
||||
a[i].serviceName != b[i].serviceName) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
|
||||
Future<List<PieChartSectionData>> _getPieSections() async {
|
||||
|
||||
if (widget.subscriptions.isEmpty) return [];
|
||||
|
||||
// 현재 locale 가져오기
|
||||
final locale = context.read<LocaleProvider>().locale.languageCode;
|
||||
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
|
||||
|
||||
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
|
||||
List<double> sectionValues = [];
|
||||
|
||||
// 각 구독의 원화 환산 금액 또는 원화 금액을 계산
|
||||
for (var subscription in subscriptions) {
|
||||
double value = subscription.monthlyCost;
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD의 경우 마지막으로 조회된 환율로 대략적인 계산
|
||||
// (정확한 계산은 비동기로 이루어지므로 UI 표시용으로만 사용)
|
||||
const rate = 1350.0; // 기본 환율 (실제 값은 API로 별도로 가져옴)
|
||||
value = value * rate;
|
||||
// 각 구독의 현재 가격을 언어별 기본 통화로 환산
|
||||
for (var subscription in widget.subscriptions) {
|
||||
double value = subscription.currentPrice;
|
||||
|
||||
if (subscription.currency == defaultCurrency) {
|
||||
// 이미 기본 통화인 경우 그대로 사용
|
||||
sectionValues.add(value);
|
||||
} else if (subscription.currency == 'USD') {
|
||||
// USD를 기본 통화로 변환
|
||||
final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency);
|
||||
sectionValues.add(converted ?? value);
|
||||
} else if (defaultCurrency == 'USD') {
|
||||
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환
|
||||
final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency);
|
||||
sectionValues.add(converted ?? value);
|
||||
} else {
|
||||
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
|
||||
sectionValues.add(value);
|
||||
}
|
||||
sectionValues.add(value);
|
||||
}
|
||||
|
||||
// 총합 계산
|
||||
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
|
||||
|
||||
// 총합이 0이면 빈 배열 반환
|
||||
if (sectionsTotal == 0) return [];
|
||||
|
||||
// 섹션 데이터 생성
|
||||
return List.generate(subscriptions.length, (i) {
|
||||
final subscription = subscriptions[i];
|
||||
// 섹션 데이터 생성 (터치 상태 제외)
|
||||
final sections = List.generate(widget.subscriptions.length, (i) {
|
||||
final percentage = (sectionValues[i] / sectionsTotal) * 100;
|
||||
final index = i % colors.length;
|
||||
final isTouched = touchedIndex == i;
|
||||
final fontSize = isTouched ? 16.0 : 12.0;
|
||||
final radius = isTouched ? 105.0 : 100.0;
|
||||
final index = i % _chartColors.length;
|
||||
|
||||
return PieChartSectionData(
|
||||
value: sectionValues[i],
|
||||
title: '${percentage.toStringAsFixed(1)}%',
|
||||
titleStyle: TextStyle(
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.pureWhite,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
||||
],
|
||||
),
|
||||
color: _chartColors[index],
|
||||
radius: 100.0,
|
||||
titlePositionPercentageOffset: 0.6,
|
||||
badgeWidget: null,
|
||||
badgePositionPercentageOffset: .98,
|
||||
);
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// 배지 위젯 생성
|
||||
Widget _createBadgeWidget(int index) {
|
||||
if (index >= widget.subscriptions.length) return const SizedBox.shrink();
|
||||
|
||||
final subscription = widget.subscriptions[index];
|
||||
final colorIndex = index % _chartColors.length;
|
||||
|
||||
return IgnorePointer(
|
||||
child: AnalysisBadge(
|
||||
size: 40,
|
||||
borderColor: _chartColors[colorIndex],
|
||||
subscription: subscription,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 터치 상태를 반영한 섹션 데이터 생성
|
||||
List<PieChartSectionData> _applyTouchedState(List<PieChartSectionData> sections) {
|
||||
return List.generate(sections.length, (i) {
|
||||
final section = sections[i];
|
||||
final isTouched = _touchedIndex == i;
|
||||
final fontSize = isTouched ? 16.0 : 12.0;
|
||||
final radius = isTouched ? 105.0 : 100.0;
|
||||
|
||||
return PieChartSectionData(
|
||||
value: section.value,
|
||||
title: section.title,
|
||||
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.pureWhite,
|
||||
@@ -74,17 +177,11 @@ class SubscriptionPieChartCard extends StatelessWidget {
|
||||
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
||||
],
|
||||
),
|
||||
color: colors[index],
|
||||
color: section.color,
|
||||
radius: radius,
|
||||
titlePositionPercentageOffset: 0.6,
|
||||
badgeWidget: isTouched
|
||||
? AnalysisBadge(
|
||||
size: 40,
|
||||
borderColor: colors[index],
|
||||
subscription: subscription,
|
||||
)
|
||||
: null,
|
||||
badgePositionPercentageOffset: .98,
|
||||
titlePositionPercentageOffset: section.titlePositionPercentageOffset,
|
||||
badgeWidget: isTouched ? _createBadgeWidget(i) : null,
|
||||
badgePositionPercentageOffset: section.badgePositionPercentageOffset,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -96,7 +193,7 @@ class SubscriptionPieChartCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: animationController,
|
||||
parent: widget.animationController,
|
||||
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
|
||||
),
|
||||
child: SlideTransition(
|
||||
@@ -104,7 +201,7 @@ class SubscriptionPieChartCard extends StatelessWidget {
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animationController,
|
||||
parent: widget.animationController,
|
||||
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
|
||||
)),
|
||||
child: GlassmorphismCard(
|
||||
@@ -120,13 +217,15 @@ class SubscriptionPieChartCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: '구독 서비스 비율',
|
||||
text: AppLocalizations.of(context).subscriptionServiceRatio,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.getExchangeRateInfo(),
|
||||
future: CurrencyUtil.getExchangeRateInfoForLocale(
|
||||
context.watch<LocaleProvider>().locale.languageCode
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData &&
|
||||
snapshot.data!.isNotEmpty) {
|
||||
@@ -145,7 +244,7 @@ class SubscriptionPieChartCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
snapshot.data!,
|
||||
AppLocalizations.of(context).exchangeRateFormat(snapshot.data!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -161,20 +260,20 @@ class SubscriptionPieChartCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ThemedText.subtitle(
|
||||
text: '월 지출 기준',
|
||||
text: AppLocalizations.of(context).monthlyExpenseBasis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: subscriptions.isEmpty
|
||||
? const SizedBox(
|
||||
child: widget.subscriptions.isEmpty
|
||||
? SizedBox(
|
||||
height: 250,
|
||||
child: Center(
|
||||
child: ThemedText(
|
||||
'구독중인 서비스가 없습니다',
|
||||
style: TextStyle(
|
||||
AppLocalizations.of(context).noSubscriptionServices,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -182,52 +281,90 @@ class SubscriptionPieChartCard extends StatelessWidget {
|
||||
)
|
||||
: SizedBox(
|
||||
height: 250,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
borderData: FlBorderData(show: false),
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 60,
|
||||
sections: _getPieSections(),
|
||||
pieTouchData: PieTouchData(
|
||||
touchCallback: (FlTouchEvent event,
|
||||
pieTouchResponse) {
|
||||
if (!event
|
||||
.isInterestedForInteractions ||
|
||||
pieTouchResponse == null ||
|
||||
pieTouchResponse
|
||||
.touchedSection ==
|
||||
null) {
|
||||
onPieTouch(-1);
|
||||
return;
|
||||
}
|
||||
onPieTouch(pieTouchResponse
|
||||
.touchedSection!
|
||||
.touchedSectionIndex);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: FutureBuilder<List<PieChartSectionData>>(
|
||||
future: _pieSectionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return Center(
|
||||
child: ThemedText(
|
||||
AppLocalizations.of(context).noSubscriptionServices,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return PieChart(
|
||||
PieChartData(
|
||||
borderData: FlBorderData(show: false),
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 60,
|
||||
sections: _applyTouchedState(snapshot.data!),
|
||||
pieTouchData: PieTouchData(
|
||||
enabled: true,
|
||||
touchCallback: (FlTouchEvent event,
|
||||
pieTouchResponse) {
|
||||
// 터치 응답이 없거나 섹션이 없는 경우
|
||||
if (pieTouchResponse == null ||
|
||||
pieTouchResponse.touchedSection == null) {
|
||||
// 차트 밖으로 나갔을 때만 리셋
|
||||
if (_touchedIndex != -1) {
|
||||
setState(() {
|
||||
_touchedIndex = -1;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final touchedIndex = pieTouchResponse
|
||||
.touchedSection!
|
||||
.touchedSectionIndex;
|
||||
|
||||
// 탭 이벤트 처리 (토글)
|
||||
if (event is FlTapUpEvent) {
|
||||
setState(() {
|
||||
// 동일 섹션 탭하면 선택 해제, 아니면 선택
|
||||
_touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// hover 이벤트 처리 (단순 표시)
|
||||
if (event is FlPointerHoverEvent ||
|
||||
event is FlPointerEnterEvent) {
|
||||
// 현재 인덱스와 다른 경우만 업데이트
|
||||
if (_touchedIndex != touchedIndex) {
|
||||
setState(() {
|
||||
_touchedIndex = touchedIndex;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 서비스 목록
|
||||
Column(
|
||||
children: subscriptions.isEmpty
|
||||
children: widget.subscriptions.isEmpty
|
||||
? []
|
||||
: List.generate(
|
||||
subscriptions.length,
|
||||
widget.subscriptions.length,
|
||||
(index) {
|
||||
final subscription =
|
||||
subscriptions[index];
|
||||
final color = [
|
||||
const Color(0xFF3B82F6),
|
||||
const Color(0xFF10B981),
|
||||
const Color(0xFFF59E0B),
|
||||
const Color(0xFFEF4444),
|
||||
const Color(0xFF8B5CF6),
|
||||
const Color(0xFF0EA5E9),
|
||||
const Color(0xFFEC4899),
|
||||
][index % 7];
|
||||
widget.subscriptions[index];
|
||||
final color = _chartColors[index % _chartColors.length];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 4.0),
|
||||
@@ -254,8 +391,9 @@ class SubscriptionPieChartCard extends StatelessWidget {
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil
|
||||
.formatSubscriptionAmount(
|
||||
subscription),
|
||||
.formatSubscriptionAmountWithLocale(
|
||||
subscription,
|
||||
context.read<LocaleProvider>().locale.languageCode),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ThemedText(
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../services/currency_util.dart';
|
||||
import '../../providers/locale_provider.dart';
|
||||
import '../../utils/haptic_feedback_helper.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../themed_text.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 총 지출 요약을 보여주는 카드 위젯
|
||||
class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
@@ -23,6 +26,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locale = context.watch<LocaleProvider>().locale.languageCode;
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
@@ -52,7 +56,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: '총 지출 요약',
|
||||
text: AppLocalizations.of(context).totalExpenseSummary,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
@@ -63,14 +67,14 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
final totalExpenseText = CurrencyUtil.formatTotalAmount(totalExpense);
|
||||
final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale);
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: totalExpenseText));
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('총 지출액이 복사되었습니다: $totalExpenseText'),
|
||||
content: Text(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -89,7 +93,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ThemedText.subtitle(
|
||||
text: '월 단위 총액',
|
||||
text: AppLocalizations.of(context).monthlyTotalAmount,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
@@ -103,7 +107,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: '총 지출',
|
||||
text: AppLocalizations.of(context).totalExpense,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -111,7 +115,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ThemedText(
|
||||
CurrencyUtil.formatTotalAmount(totalExpense),
|
||||
CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale),
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -148,7 +152,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: '총 서비스',
|
||||
text: AppLocalizations.of(context).totalServices,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -156,7 +160,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ThemedText(
|
||||
'${subscriptions.length}개',
|
||||
AppLocalizations.of(context).subscriptionCount(subscriptions.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -190,7 +194,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: '평균 요금',
|
||||
text: AppLocalizations.of(context).averageCost,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -198,10 +202,11 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ThemedText(
|
||||
CurrencyUtil.formatTotalAmount(
|
||||
CurrencyUtil.formatTotalAmountWithLocale(
|
||||
subscriptions.isEmpty
|
||||
? 0
|
||||
: totalExpense / subscriptions.length),
|
||||
: totalExpense / subscriptions.length,
|
||||
locale),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../models/subscription_model.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import 'animated_page_transitions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 앱 전체의 네비게이션을 관리하는 클래스
|
||||
class AppNavigator {
|
||||
@@ -118,16 +119,16 @@ class AppNavigator {
|
||||
final shouldExit = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('앱 종료'),
|
||||
content: const Text('SubManager를 종료하시겠습니까?'),
|
||||
title: Text(AppLocalizations.of(context).exitApp),
|
||||
content: Text(AppLocalizations.of(context).exitAppConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
child: Text(AppLocalizations.of(context).cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('종료'),
|
||||
child: Text(AppLocalizations.of(context).exit),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 카테고리별 구독 그룹의 헤더 위젯
|
||||
///
|
||||
@@ -10,6 +11,8 @@ class CategoryHeaderWidget extends StatelessWidget {
|
||||
final int subscriptionCount;
|
||||
final double totalCostUSD;
|
||||
final double totalCostKRW;
|
||||
final double totalCostJPY;
|
||||
final double totalCostCNY;
|
||||
|
||||
const CategoryHeaderWidget({
|
||||
Key? key,
|
||||
@@ -17,6 +20,8 @@ class CategoryHeaderWidget extends StatelessWidget {
|
||||
required this.subscriptionCount,
|
||||
required this.totalCostUSD,
|
||||
required this.totalCostKRW,
|
||||
required this.totalCostJPY,
|
||||
required this.totalCostCNY,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -38,7 +43,7 @@ class CategoryHeaderWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_buildCostDisplay(),
|
||||
_buildCostDisplay(context),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -59,11 +64,11 @@ class CategoryHeaderWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 통화별 합계를 표시하는 문자열을 생성합니다.
|
||||
String _buildCostDisplay() {
|
||||
String _buildCostDisplay(BuildContext context) {
|
||||
final parts = <String>[];
|
||||
|
||||
// 개수는 항상 표시
|
||||
parts.add('$subscriptionCount개');
|
||||
parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
|
||||
|
||||
// 통화 부분을 별도로 처리
|
||||
final currencyParts = <String>[];
|
||||
@@ -88,6 +93,26 @@ class CategoryHeaderWidget extends StatelessWidget {
|
||||
currencyParts.add(formatter.format(totalCostKRW));
|
||||
}
|
||||
|
||||
// 엔화가 있는 경우
|
||||
if (totalCostJPY > 0) {
|
||||
final formatter = NumberFormat.currency(
|
||||
locale: 'ja_JP',
|
||||
symbol: '¥',
|
||||
decimalDigits: 0,
|
||||
);
|
||||
currencyParts.add(formatter.format(totalCostJPY));
|
||||
}
|
||||
|
||||
// 위안화가 있는 경우
|
||||
if (totalCostCNY > 0) {
|
||||
final formatter = NumberFormat.currency(
|
||||
locale: 'zh_CN',
|
||||
symbol: '¥',
|
||||
decimalDigits: 2,
|
||||
);
|
||||
currencyParts.add(formatter.format(totalCostCNY));
|
||||
}
|
||||
|
||||
// 통화가 하나 이상 있는 경우
|
||||
if (currencyParts.isNotEmpty) {
|
||||
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 결제 주기 선택 위젯
|
||||
/// 월간, 분기별, 반기별, 연간 중 선택할 수 있습니다.
|
||||
@@ -21,10 +22,21 @@ class BillingCycleSelector extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localization = AppLocalizations.of(context);
|
||||
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
|
||||
final cycles = isGlassmorphism
|
||||
? ['매월', '분기별', '반기별', '매년']
|
||||
: ['월간', '분기별', '반기별', '연간'];
|
||||
? [
|
||||
localization.billingCycleMonthly,
|
||||
localization.billingCycleQuarterly,
|
||||
localization.billingCycleHalfYearly,
|
||||
localization.billingCycleYearly,
|
||||
]
|
||||
: [
|
||||
localization.monthly,
|
||||
localization.billingCycleQuarterly,
|
||||
localization.billingCycleHalfYearly,
|
||||
localization.yearly,
|
||||
];
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
import '../../../providers/category_provider.dart';
|
||||
|
||||
/// 카테고리 선택 위젯
|
||||
/// 구독 서비스의 카테고리를 선택할 수 있습니다.
|
||||
@@ -50,13 +52,17 @@ class CategorySelector extends StatelessWidget {
|
||||
color: _getTextColor(isSelected),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
category.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _getTextColor(isSelected),
|
||||
),
|
||||
Consumer<CategoryProvider>(
|
||||
builder: (context, categoryProvider, child) {
|
||||
return Text(
|
||||
categoryProvider.getLocalizedCategoryName(context, category.name),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _getTextColor(isSelected),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -69,25 +75,25 @@ class CategorySelector extends StatelessWidget {
|
||||
IconData _getCategoryIcon(dynamic category) {
|
||||
// 카테고리명에 따른 아이콘 반환
|
||||
switch (category.name) {
|
||||
case '음악':
|
||||
case 'music':
|
||||
return Icons.music_note_rounded;
|
||||
case 'OTT(동영상)':
|
||||
case 'ottVideo':
|
||||
return Icons.movie_filter_rounded;
|
||||
case '저장/클라우드':
|
||||
case 'storageCloud':
|
||||
return Icons.cloud_outlined;
|
||||
case '통신 · 인터넷 · TV':
|
||||
case 'telecomInternetTv':
|
||||
return Icons.wifi_rounded;
|
||||
case '생활/라이프스타일':
|
||||
case 'lifestyle':
|
||||
return Icons.home_outlined;
|
||||
case '쇼핑/이커머스':
|
||||
case 'shoppingEcommerce':
|
||||
return Icons.shopping_cart_outlined;
|
||||
case '프로그래밍':
|
||||
case 'programming':
|
||||
return Icons.code_rounded;
|
||||
case '협업/오피스':
|
||||
case 'collaborationOffice':
|
||||
return Icons.business_center_outlined;
|
||||
case 'AI 서비스':
|
||||
case 'aiService':
|
||||
return Icons.smart_toy_outlined;
|
||||
case '기타':
|
||||
case 'other':
|
||||
default:
|
||||
return Icons.category_outlined;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'base_text_field.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 통화 입력 필드 위젯
|
||||
/// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다.
|
||||
@@ -112,8 +113,8 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||
return widget.currency == 'KRW' ? '₩ ' : '\$ ';
|
||||
}
|
||||
|
||||
String get _defaultHintText {
|
||||
return widget.currency == 'KRW' ? '금액을 입력하세요' : 'Enter amount';
|
||||
String _getDefaultHintText(BuildContext context) {
|
||||
return AppLocalizations.of(context).enterAmount;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -122,7 +123,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
label: widget.label,
|
||||
hintText: widget.hintText ?? _defaultHintText,
|
||||
hintText: widget.hintText ?? _getDefaultHintText(context),
|
||||
textInputAction: widget.textInputAction,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
@@ -158,11 +159,11 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||
},
|
||||
validator: widget.validator ?? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '금액을 입력해주세요';
|
||||
return AppLocalizations.of(context).amountRequired;
|
||||
}
|
||||
final parsedValue = _parseValue(value);
|
||||
if (parsedValue == null || parsedValue <= 0) {
|
||||
return '올바른 금액을 입력해주세요';
|
||||
return AppLocalizations.of(context).invalidAmount;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// 통화 선택 위젯
|
||||
/// KRW(원화)와 USD(달러) 중 선택할 수 있습니다.
|
||||
/// KRW(원화), USD(달러), JPY(엔화), CNY(위안화) 중 선택할 수 있습니다.
|
||||
class CurrencySelector extends StatelessWidget {
|
||||
final String currency;
|
||||
final ValueChanged<String> onChanged;
|
||||
@@ -17,22 +17,48 @@ class CurrencySelector extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
return Column(
|
||||
children: [
|
||||
_CurrencyOption(
|
||||
label: '₩',
|
||||
value: 'KRW',
|
||||
isSelected: currency == 'KRW',
|
||||
onTap: () => onChanged('KRW'),
|
||||
isGlassmorphism: isGlassmorphism,
|
||||
Row(
|
||||
children: [
|
||||
_CurrencyOption(
|
||||
label: '₩',
|
||||
value: 'KRW',
|
||||
isSelected: currency == 'KRW',
|
||||
onTap: () => onChanged('KRW'),
|
||||
isGlassmorphism: isGlassmorphism,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CurrencyOption(
|
||||
label: '\$',
|
||||
value: 'USD',
|
||||
isSelected: currency == 'USD',
|
||||
onTap: () => onChanged('USD'),
|
||||
isGlassmorphism: isGlassmorphism,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CurrencyOption(
|
||||
label: '\$',
|
||||
value: 'USD',
|
||||
isSelected: currency == 'USD',
|
||||
onTap: () => onChanged('USD'),
|
||||
isGlassmorphism: isGlassmorphism,
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_CurrencyOption(
|
||||
label: '¥',
|
||||
value: 'JPY',
|
||||
subtitle: 'JPY',
|
||||
isSelected: currency == 'JPY',
|
||||
onTap: () => onChanged('JPY'),
|
||||
isGlassmorphism: isGlassmorphism,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CurrencyOption(
|
||||
label: '¥',
|
||||
value: 'CNY',
|
||||
subtitle: 'CNY',
|
||||
isSelected: currency == 'CNY',
|
||||
onTap: () => onChanged('CNY'),
|
||||
isGlassmorphism: isGlassmorphism,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -43,6 +69,7 @@ class CurrencySelector extends StatelessWidget {
|
||||
class _CurrencyOption extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final String? subtitle;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final bool isGlassmorphism;
|
||||
@@ -50,6 +77,7 @@ class _CurrencyOption extends StatelessWidget {
|
||||
const _CurrencyOption({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.subtitle,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.isGlassmorphism,
|
||||
@@ -71,13 +99,29 @@ class _CurrencyOption extends StatelessWidget {
|
||||
border: _getBorder(),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getTextColor(),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getTextColor(),
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _getTextColor().withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 날짜 선택 필드 위젯
|
||||
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
|
||||
@@ -38,7 +39,9 @@ class DatePickerField extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
|
||||
final effectiveDateFormat = dateFormat ?? 'yyyy년 MM월 dd일';
|
||||
final localizations = AppLocalizations.of(context);
|
||||
final effectiveDateFormat = dateFormat ?? localizations.dateFormatFull;
|
||||
final locale = Localizations.localeOf(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -94,7 +97,7 @@ class DatePickerField extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
DateFormat(effectiveDateFormat).format(selectedDate),
|
||||
DateFormat(effectiveDateFormat, locale.toString()).format(selectedDate),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: enabled
|
||||
@@ -249,8 +252,8 @@ class _DateRangeItem extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
date != null
|
||||
? DateFormat('MM/dd').format(date!)
|
||||
: '선택',
|
||||
? DateFormat(AppLocalizations.of(context).dateFormatShort).format(date!)
|
||||
: AppLocalizations.of(context).dateSelect,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:math' as math;
|
||||
import 'glassmorphism_card.dart';
|
||||
import 'themed_text.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
||||
///
|
||||
@@ -74,15 +75,15 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const ThemedText(
|
||||
'등록된 구독이 없습니다',
|
||||
ThemedText(
|
||||
AppLocalizations.of(context).noSubscriptions,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const ThemedText(
|
||||
'새로운 구독을 추가해보세요',
|
||||
ThemedText(
|
||||
AppLocalizations.of(context).addSubscriptionNow,
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
),
|
||||
@@ -107,8 +108,8 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
HapticFeedback.mediumImpact();
|
||||
onAddPressed();
|
||||
},
|
||||
child: const Text(
|
||||
'구독 추가하기',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).addSubscription,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class FloatingNavigationBar extends StatefulWidget {
|
||||
final int selectedIndex;
|
||||
@@ -103,13 +104,13 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
children: [
|
||||
_NavigationItem(
|
||||
icon: Icons.home_rounded,
|
||||
label: '홈',
|
||||
label: AppLocalizations.of(context).home,
|
||||
isSelected: widget.selectedIndex == 0,
|
||||
onTap: () => _onItemTapped(0),
|
||||
),
|
||||
_NavigationItem(
|
||||
icon: Icons.analytics_rounded,
|
||||
label: '분석',
|
||||
label: AppLocalizations.of(context).analysis,
|
||||
isSelected: widget.selectedIndex == 1,
|
||||
onTap: () => _onItemTapped(1),
|
||||
),
|
||||
@@ -118,13 +119,13 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
),
|
||||
_NavigationItem(
|
||||
icon: Icons.qr_code_scanner_rounded,
|
||||
label: 'SMS',
|
||||
label: AppLocalizations.of(context).smsScanLabel,
|
||||
isSelected: widget.selectedIndex == 3,
|
||||
onTap: () => _onItemTapped(3),
|
||||
),
|
||||
_NavigationItem(
|
||||
icon: Icons.settings_rounded,
|
||||
label: '설정',
|
||||
label: AppLocalizations.of(context).settings,
|
||||
isSelected: widget.selectedIndex == 4,
|
||||
onTap: () => _onItemTapped(4),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'dart:ui';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'themed_text.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 글래스모피즘 효과가 적용된 통일된 앱바
|
||||
class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
@@ -113,7 +114,7 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
splashRadius: 24,
|
||||
tooltip: '뒤로가기',
|
||||
tooltip: AppLocalizations.of(context).back,
|
||||
color: ThemedText.getContrastColor(context),
|
||||
);
|
||||
}
|
||||
@@ -221,7 +222,7 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
splashRadius: 24,
|
||||
tooltip: '뒤로가기',
|
||||
tooltip: AppLocalizations.of(context).back,
|
||||
)
|
||||
: null),
|
||||
actions: actions,
|
||||
|
||||
@@ -173,8 +173,23 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// onTap이 없으면 제스처 처리를 하지 않음
|
||||
if (widget.onTap == null) {
|
||||
return GlassmorphismCard(
|
||||
padding: widget.padding,
|
||||
margin: widget.margin,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
borderRadius: widget.borderRadius,
|
||||
blur: widget.blur,
|
||||
opacity: widget.opacity,
|
||||
onTap: null,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque, // translucent에서 opaque로 변경하여 이벤트 충돌 방지
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: (details) {
|
||||
_handleTapUp(details);
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../widgets/main_summary_card.dart';
|
||||
import '../widgets/subscription_list_widget.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class HomeContent extends StatelessWidget {
|
||||
final AnimationController fadeController;
|
||||
@@ -55,6 +56,7 @@ class HomeContent extends StatelessWidget {
|
||||
final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions(
|
||||
provider.subscriptions,
|
||||
categoryProvider,
|
||||
context,
|
||||
);
|
||||
|
||||
return RefreshIndicator(
|
||||
@@ -103,7 +105,7 @@ class HomeContent extends StatelessWidget {
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutCubic)),
|
||||
child: Text(
|
||||
'나의 구독 서비스',
|
||||
AppLocalizations.of(context).mySubscriptions,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
@@ -118,7 +120,7 @@ class HomeContent extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${provider.subscriptions.length}개',
|
||||
AppLocalizations.of(context).subscriptionCount(provider.subscriptions.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/locale_provider.dart';
|
||||
import '../services/currency_util.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'animated_wave_background.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 메인 화면 상단에 표시되는 요약 카드 위젯
|
||||
///
|
||||
@@ -26,10 +29,12 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double monthlyCost = provider.totalMonthlyExpense;
|
||||
final double yearlyCost = monthlyCost * 12;
|
||||
// 언어 설정 가져오기
|
||||
final locale = context.watch<LocaleProvider>().locale.languageCode;
|
||||
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
|
||||
final currencySymbol = CurrencyUtil.getCurrencySymbol(defaultCurrency);
|
||||
|
||||
final int totalSubscriptions = provider.subscriptions.length;
|
||||
final double eventSavings = provider.totalEventSavings;
|
||||
final int activeEvents = provider.activeEventSubscriptions.length;
|
||||
|
||||
return FadeTransition(
|
||||
@@ -83,7 +88,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'이번 달 총 구독 비용',
|
||||
AppLocalizations.of(context).monthlyTotalSubscriptionCost,
|
||||
style: TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
@@ -91,88 +96,123 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.getExchangeRateInfo(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F2FF),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFBFDBFE),
|
||||
width: 1,
|
||||
// 환율 정보 표시 (영어 사용자는 표시 안함)
|
||||
if (locale != 'en')
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.getExchangeRateInfoForLocale(locale),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
snapshot.data!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF3B82F6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F2FF),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFBFDBFE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).exchangeRateDisplay.replaceAll('@', snapshot.data!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF3B82F6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(monthlyCost),
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'원',
|
||||
style: TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
// 월별 총 비용 표시 (언어별 기본 통화)
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
final monthlyCost = snapshot.data!;
|
||||
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW' ? 'ko_KR' :
|
||||
defaultCurrency == 'JPY' ? 'ja_JP' :
|
||||
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
|
||||
symbol: '',
|
||||
decimalDigits: decimals,
|
||||
).format(monthlyCost),
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
currencySymbol,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '예상 연간 구독 비용',
|
||||
value: '${NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(yearlyCost)}원',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '총 구독 서비스',
|
||||
value: '$totalSubscriptions개',
|
||||
),
|
||||
],
|
||||
// 연간 비용 및 총 구독 수 표시
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final monthlyCost = snapshot.data!;
|
||||
final yearlyCost = monthlyCost * 12;
|
||||
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: AppLocalizations.of(context).estimatedAnnualCost,
|
||||
value: '${NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW' ? 'ko_KR' :
|
||||
defaultCurrency == 'JPY' ? 'ja_JP' :
|
||||
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: decimals,
|
||||
).format(yearlyCost)}',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: AppLocalizations.of(context).totalSubscriptionServices,
|
||||
value: '$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
// 이벤트 절약액 표시
|
||||
if (activeEvents > 0) ...[
|
||||
@@ -215,7 +255,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'이벤트 할인 중',
|
||||
AppLocalizations.of(context).eventDiscountActive,
|
||||
style: TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
@@ -224,31 +264,46 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(eventSavings),
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' 절약 ($activeEvents개)',
|
||||
style: TextStyle(
|
||||
color: AppColors
|
||||
.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
// 이벤트 절약액 표시 (언어별 기본 통화)
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil.calculateTotalEventSavingsInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final eventSavings = snapshot.data!;
|
||||
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW' ? 'ko_KR' :
|
||||
defaultCurrency == 'JPY' ? 'ja_JP' :
|
||||
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: decimals,
|
||||
).format(eventSavings),
|
||||
style: const TextStyle(
|
||||
color: AppColors.primaryColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})',
|
||||
style: const TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,10 +3,14 @@ import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../providers/locale_provider.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import '../services/currency_util.dart';
|
||||
import 'website_icon.dart';
|
||||
import 'app_navigator.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class SubscriptionCard extends StatefulWidget {
|
||||
final SubscriptionModel subscription;
|
||||
@@ -26,6 +30,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _hoverController;
|
||||
bool _isHovering = false;
|
||||
String? _displayName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -34,9 +39,36 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
_loadDisplayName();
|
||||
}
|
||||
|
||||
Future<void> _loadDisplayName() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final localeProvider = context.read<LocaleProvider>();
|
||||
final locale = localeProvider.locale.languageCode;
|
||||
|
||||
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
||||
serviceName: widget.subscription.serviceName,
|
||||
locale: locale,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_displayName = displayName;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SubscriptionCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.subscription.serviceName != widget.subscription.serviceName) {
|
||||
_loadDisplayName();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hoverController.dispose();
|
||||
@@ -66,20 +98,20 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
|
||||
// 오늘이 결제일인 경우
|
||||
if (dateOnlyNow.isAtSameMomentAs(dateOnlyBilling)) {
|
||||
return '오늘 결제 예정';
|
||||
return AppLocalizations.of(context).paymentDueToday;
|
||||
}
|
||||
|
||||
// 미래 날짜인 경우 남은 일수 계산
|
||||
if (dateOnlyBilling.isAfter(dateOnlyNow)) {
|
||||
final difference = dateOnlyBilling.difference(dateOnlyNow).inDays;
|
||||
return '$difference일 후 결제 예정';
|
||||
return AppLocalizations.of(context).paymentDueInDays(difference);
|
||||
}
|
||||
|
||||
// 과거 날짜인 경우, 다음 결제일 계산
|
||||
final billingCycle = widget.subscription.billingCycle;
|
||||
|
||||
// 월간 구독인 경우
|
||||
if (billingCycle == '월간') {
|
||||
if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'monthly') {
|
||||
// 결제일에 해당하는 날짜 가져오기
|
||||
int day = nextBillingDate.day;
|
||||
int nextMonth = now.month;
|
||||
@@ -109,12 +141,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
final nextDate = DateTime(nextYear, nextMonth, day);
|
||||
final days = nextDate.difference(dateOnlyNow).inDays;
|
||||
|
||||
if (days == 0) return '오늘 결제 예정';
|
||||
return '$days일 후 결제 예정';
|
||||
if (days == 0) return AppLocalizations.of(context).paymentDueToday;
|
||||
return AppLocalizations.of(context).paymentDueInDays(days);
|
||||
}
|
||||
|
||||
// 연간 구독인 경우
|
||||
if (billingCycle == '연간') {
|
||||
if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'yearly') {
|
||||
// 결제일에 해당하는 날짜와 월 가져오기
|
||||
int day = nextBillingDate.day;
|
||||
int month = nextBillingDate.month;
|
||||
@@ -143,18 +175,18 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
final nextYearDate = DateTime(year, month, day);
|
||||
final days = nextYearDate.difference(dateOnlyNow).inDays;
|
||||
|
||||
if (days == 0) return '오늘 결제 예정';
|
||||
return '$days일 후 결제 예정';
|
||||
if (days == 0) return AppLocalizations.of(context).paymentDueToday;
|
||||
return AppLocalizations.of(context).paymentDueInDays(days);
|
||||
} else {
|
||||
final days = thisYearDate.difference(dateOnlyNow).inDays;
|
||||
|
||||
if (days == 0) return '오늘 결제 예정';
|
||||
return '$days일 후 결제 예정';
|
||||
if (days == 0) return AppLocalizations.of(context).paymentDueToday;
|
||||
return AppLocalizations.of(context).paymentDueInDays(days);
|
||||
}
|
||||
}
|
||||
|
||||
// 주간 구독인 경우
|
||||
if (billingCycle == '주간') {
|
||||
if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'weekly') {
|
||||
// 결제 요일 가져오기
|
||||
final billingWeekday = nextBillingDate.weekday;
|
||||
// 현재 요일
|
||||
@@ -171,20 +203,20 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
daysUntilNext = 7; // 다음 주 같은 요일
|
||||
}
|
||||
|
||||
if (daysUntilNext == 0) return '오늘 결제 예정';
|
||||
return '$daysUntilNext일 후 결제 예정';
|
||||
if (daysUntilNext == 0) return AppLocalizations.of(context).paymentDueToday;
|
||||
return AppLocalizations.of(context).paymentDueInDays(daysUntilNext);
|
||||
}
|
||||
|
||||
// 기본값 - 예상할 수 없는 경우
|
||||
return '결제일 정보 필요';
|
||||
return AppLocalizations.of(context).paymentInfoNeeded;
|
||||
}
|
||||
|
||||
// 결제일이 가까운지 확인 (7일 이내)
|
||||
bool _isNearBilling() {
|
||||
final text = _getNextBillingText();
|
||||
if (text == '오늘 결제 예정') return true;
|
||||
if (text == AppLocalizations.of(context).paymentDueToday) return true;
|
||||
|
||||
final regex = RegExp(r'(\d+)일 후');
|
||||
final regex = RegExp(r'(\d+)');
|
||||
final match = regex.firstMatch(text);
|
||||
if (match != null) {
|
||||
final days = int.parse(match.group(1) ?? '0');
|
||||
@@ -222,9 +254,41 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
}
|
||||
}
|
||||
|
||||
// 가격 포맷팅 함수 (언어별 통화)
|
||||
Future<String> _getFormattedPrice() async {
|
||||
final locale = context.read<LocaleProvider>().locale.languageCode;
|
||||
if (widget.subscription.isCurrentlyInEvent) {
|
||||
// 이벤트 중인 경우 원래 가격과 현재 가격 모두 표시
|
||||
final originalPrice = await CurrencyUtil.formatAmountWithLocale(
|
||||
widget.subscription.monthlyCost,
|
||||
widget.subscription.currency,
|
||||
locale,
|
||||
);
|
||||
final currentPrice = await CurrencyUtil.formatAmountWithLocale(
|
||||
widget.subscription.currentPrice,
|
||||
widget.subscription.currency,
|
||||
locale,
|
||||
);
|
||||
return '$originalPrice|$currentPrice';
|
||||
} else {
|
||||
return CurrencyUtil.formatAmountWithLocale(
|
||||
widget.subscription.currentPrice,
|
||||
widget.subscription.currency,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// LocaleProvider를 watch하여 언어 변경시 자동 업데이트
|
||||
final localeProvider = context.watch<LocaleProvider>();
|
||||
|
||||
// 언어가 변경되면 displayName 다시 로드
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadDisplayName();
|
||||
});
|
||||
|
||||
final isNearBilling = _isNearBilling();
|
||||
|
||||
return Hero(
|
||||
@@ -238,6 +302,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
blur: _isHovering ? 15 : 10,
|
||||
width: double.infinity, // 전체 너비를 차지하도록 설정
|
||||
onTap: widget.onTap ?? () async {
|
||||
print('[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}');
|
||||
await AppNavigator.toDetail(context, widget.subscription);
|
||||
},
|
||||
child: Column(
|
||||
@@ -290,7 +355,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
// 서비스명
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.subscription.serviceName,
|
||||
_displayName ?? widget.subscription.serviceName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
@@ -322,18 +387,18 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 11,
|
||||
color: AppColors.pureWhite,
|
||||
),
|
||||
SizedBox(width: 3),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'이벤트',
|
||||
style: TextStyle(
|
||||
AppLocalizations.of(context).event,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.pureWhite,
|
||||
@@ -361,7 +426,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.subscription.billingCycle,
|
||||
AppLocalizations.of(context).getBillingCycleName(widget.subscription.billingCycle),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -382,57 +447,51 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 가격 표시 (이벤트 가격 반영)
|
||||
Row(
|
||||
children: [
|
||||
// 이벤트 중인 경우 원래 가격을 취소선으로 표시
|
||||
if (widget.subscription.isCurrentlyInEvent) ...[
|
||||
Text(
|
||||
widget.subscription.currency == 'USD'
|
||||
? NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(widget
|
||||
.subscription.monthlyCost)
|
||||
: NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(widget
|
||||
.subscription.monthlyCost),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||
decoration: TextDecoration.lineThrough,
|
||||
// 가격 표시 (언어별 통화)
|
||||
FutureBuilder<String>(
|
||||
future: _getFormattedPrice(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
if (widget.subscription.isCurrentlyInEvent && snapshot.data!.contains('|')) {
|
||||
final prices = snapshot.data!.split('|');
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
prices[0],
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.navyGray,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
prices[1],
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFFFF6B6B),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
snapshot.data!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.subscription.isCurrentlyInEvent
|
||||
? const Color(0xFFFF6B6B)
|
||||
: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// 현재 가격 (이벤트 또는 정상 가격)
|
||||
Text(
|
||||
widget.subscription.currency == 'USD'
|
||||
? NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(widget
|
||||
.subscription.currentPrice)
|
||||
: NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(widget
|
||||
.subscription.currentPrice),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.subscription.isCurrentlyInEvent
|
||||
? const Color(0xFFFF6B6B)
|
||||
: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// 결제 예정일 정보
|
||||
@@ -505,23 +564,25 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
color: Color(0xFFFF6B6B),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.subscription.currency == 'USD'
|
||||
? '${NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(widget.subscription.eventSavings)} 절약'
|
||||
: '${NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(widget.subscription.eventSavings)} 절약',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFFF6B6B),
|
||||
// 이벤트 절약액 표시 (언어별 통화)
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.formatEventSavingsWithLocale(
|
||||
widget.subscription,
|
||||
localeProvider.locale.languageCode,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return Text(
|
||||
'${snapshot.data!} ${AppLocalizations.of(context).saving}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFFF6B6B),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -530,7 +591,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
// 이벤트 종료일까지 남은 일수
|
||||
if (widget.subscription.eventEndDate != null) ...[
|
||||
Text(
|
||||
'${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음',
|
||||
AppLocalizations.of(context).daysRemaining(widget.subscription.eventEndDate!.difference(DateTime.now()).inDays),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||
|
||||
@@ -6,8 +6,12 @@ import '../widgets/staggered_list_animation.dart';
|
||||
import '../widgets/app_navigator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/locale_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import './dialogs/delete_confirmation_dialog.dart';
|
||||
import './common/snackbar/app_snackbar.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 카테고리별로 구독 목록을 표시하는 위젯
|
||||
class SubscriptionListWidget extends StatelessWidget {
|
||||
@@ -39,11 +43,17 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
// 카테고리 헤더
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: CategoryHeaderWidget(
|
||||
categoryName: category,
|
||||
subscriptionCount: subscriptions.length,
|
||||
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
|
||||
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
|
||||
child: Consumer<CategoryProvider>(
|
||||
builder: (context, categoryProvider, child) {
|
||||
return CategoryHeaderWidget(
|
||||
categoryName: categoryProvider.getLocalizedCategoryName(context, category),
|
||||
subscriptionCount: subscriptions.length,
|
||||
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
|
||||
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
|
||||
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
|
||||
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// 카테고리별 구독 목록
|
||||
@@ -89,10 +99,21 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||
},
|
||||
onDelete: () async {
|
||||
// 현재 로케일에 맞는 서비스명 가져오기
|
||||
final localeProvider = Provider.of<LocaleProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final locale = localeProvider.locale.languageCode;
|
||||
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
||||
serviceName: subscriptions[subIndex].serviceName,
|
||||
locale: locale,
|
||||
);
|
||||
|
||||
// 삭제 확인 다이얼로그 표시
|
||||
final shouldDelete = await DeleteConfirmationDialog.show(
|
||||
context: context,
|
||||
serviceName: subscriptions[subIndex].serviceName,
|
||||
serviceName: displayName,
|
||||
);
|
||||
|
||||
if (shouldDelete && context.mounted) {
|
||||
@@ -108,7 +129,7 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.',
|
||||
message: AppLocalizations.of(context).subscriptionDeleted(displayName),
|
||||
icon: Icons.delete_forever_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,26 +122,17 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
}
|
||||
|
||||
void _handlePanEnd(DragEndDetails details) {
|
||||
final duration = DateTime.now().difference(_startTime!);
|
||||
final velocity = details.velocity.pixelsPerSecond.dx;
|
||||
|
||||
// 탭/스와이프 처리 분기
|
||||
|
||||
// 탭 처리 - 짧은 시간 내에 작은 움직임만 있었다면 탭으로 처리
|
||||
if (_isValidTap &&
|
||||
duration.inMilliseconds < _tapDurationMs &&
|
||||
_currentOffset.abs() < _tapTolerance) {
|
||||
_processTap();
|
||||
return;
|
||||
}
|
||||
|
||||
// 스와이프 처리
|
||||
// 스와이프 처리만 수행 (탭은 SubscriptionCard에서 처리)
|
||||
_processSwipe(velocity);
|
||||
}
|
||||
|
||||
// 헬퍼 메서드
|
||||
void _processTap() {
|
||||
print('[SwipeableSubscriptionCard] _processTap 호출됨');
|
||||
if (widget.onTap != null) {
|
||||
print('[SwipeableSubscriptionCard] onTap 콜백 실행');
|
||||
widget.onTap!();
|
||||
}
|
||||
_animateToOffset(0);
|
||||
@@ -151,6 +142,12 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
final extent = _currentOffset.abs();
|
||||
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
|
||||
|
||||
// 아주 작은 움직임은 무시하고 원위치로 복귀
|
||||
if (extent < _tapTolerance) {
|
||||
_animateToOffset(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) {
|
||||
// 삭제 실행
|
||||
if (widget.onDelete != null) {
|
||||
@@ -261,7 +258,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
angle: _currentOffset / 2000,
|
||||
child: SubscriptionCard(
|
||||
subscription: widget.subscription,
|
||||
onTap: widget.onTap,
|
||||
onTap: widget.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -279,6 +276,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
onPanStart: _handlePanStart,
|
||||
onPanUpdate: _handlePanUpdate,
|
||||
onPanEnd: _handlePanEnd,
|
||||
// onTap 제거 - SubscriptionCard의 AnimatedGlassmorphismCard에서 처리하도록 함
|
||||
child: _buildCard(),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user