feat: 다국어 지원 및 다중 통화 환율 변환 기능 확대

- ExchangeRateService에 JPY, CNY 환율 지원 추가
- 구독 서비스별 다국어 표시 이름 지원
- 분석 화면 차트 및 UI/UX 개선
- 설정 화면 전면 리팩토링
- SMS 스캔 기능 사용성 개선
- 전체 앱 다국어 번역 확대

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-16 17:34:32 +09:00
parent 4d1c0f5dab
commit 0f0b02bf08
55 changed files with 4100 additions and 1197 deletions

View File

@@ -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,
),
],
),

View File

@@ -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,
);
},
),
],
),

View File

@@ -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 {
);
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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),
),
],
),

View File

@@ -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) {
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
},

View File

@@ -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),
),
),
],
],
),
),
),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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));
}
}
}

View File

@@ -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,

View File

@@ -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;
}
}
}
/// 정보 표시 컬럼

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),
),

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,
),
),
],
);
},
),
],
),

View File

@@ -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 가이드: 서브 텍스트

View File

@@ -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,
);
}

View File

@@ -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(),
),
],