style: apply dart format across project

This commit is contained in:
JiWoong Sul
2025-09-07 19:33:11 +09:00
parent f812d4b9fd
commit d1a6cb9fe3
101 changed files with 3123 additions and 2574 deletions

View File

@@ -6,7 +6,8 @@ import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 App Bar
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget {
class AddSubscriptionAppBar extends StatelessWidget
implements PreferredSizeWidget {
final AddSubscriptionController controller;
final double scrollOffset;
final VoidCallback onScanSMS;
@@ -101,4 +102,4 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
),
);
}
}
}

View File

@@ -66,7 +66,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: controller.gradientColors[0].withValues(alpha: 0.1),
color: controller.gradientColors[0]
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
@@ -122,7 +123,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
),
],
),
// 이벤트 활성화 시 추가 필드 표시
AnimatedContainer(
duration: const Duration(milliseconds: 300),
@@ -155,7 +156,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Expanded(
child: Builder(
builder: (context) {
final locale = Localizations.localeOf(context);
final locale =
Localizations.localeOf(context);
String infoText;
switch (locale.languageCode) {
case 'ko':
@@ -168,7 +170,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
infoText = '设置折扣或促销价格';
break;
default:
infoText = 'Set up discount or promotion price';
infoText =
'Set up discount or promotion price';
}
return Text(
infoText,
@@ -185,7 +188,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
),
),
const SizedBox(height: 20),
// 이벤트 기간
Builder(
builder: (context) {
@@ -216,8 +219,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
setState(() {
controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30));
if (date != null &&
controller.eventEndDate == null) {
controller.eventEndDate =
date.add(const Duration(days: 30));
}
});
},
@@ -233,17 +238,18 @@ class AddSubscriptionEventSection extends StatelessWidget {
},
),
const SizedBox(height: 20),
// 이벤트 가격
Builder(
builder: (BuildContext innerContext) {
// 현재 로케일 확인
final currentLocale = Localizations.localeOf(innerContext);
final currentLocale =
Localizations.localeOf(innerContext);
// 로케일에 따라 직접 텍스트 설정
String eventPriceLabel;
String eventPriceHint;
switch (currentLocale.languageCode) {
case 'ko':
eventPriceLabel = '이벤트 가격';
@@ -261,7 +267,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
eventPriceLabel = 'Event Price';
eventPriceHint = 'Enter discounted price';
}
return CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
@@ -280,4 +286,4 @@ class AddSubscriptionEventSection extends StatelessWidget {
),
);
}
}
}

View File

@@ -86,4 +86,4 @@ class AddSubscriptionHeader extends StatelessWidget {
),
);
}
}
}

View File

@@ -40,8 +40,8 @@ class AddSubscriptionSaveButton extends StatelessWidget {
child: PrimaryButton(
text: AppLocalizations.of(context).addSubscriptionButton,
icon: Icons.add_circle_outline,
onPressed: controller.isLoading
? null
onPressed: controller.isLoading
? null
: () => controller.saveSubscription(setState: setState),
isLoading: controller.isLoading,
backgroundColor: const Color(0xFF3B82F6),
@@ -50,4 +50,4 @@ class AddSubscriptionSaveButton extends StatelessWidget {
),
);
}
}
}

View File

@@ -69,13 +69,17 @@ class AnalysisBadge extends StatelessWidget {
String displayText = amountText;
if (amountText.length > 12) {
// 괄호 안의 내용 제거
displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
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)}...';
final currencySymbol =
CurrencyUtil.getCurrencySymbol(subscription.currency);
displayText =
displayText.replaceAll(currencySymbol, '').trim();
displayText =
'$currencySymbol${displayText.substring(0, 6)}...';
}
return Text(
displayText,
@@ -93,4 +97,4 @@ class AnalysisBadge extends StatelessWidget {
),
);
}
}
}

View File

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
/// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리
class AnalysisScreenSpacer extends StatelessWidget {
final double height;
const AnalysisScreenSpacer({
super.key,
this.height = 24,
@@ -16,4 +16,4 @@ class AnalysisScreenSpacer extends StatelessWidget {
child: SizedBox(height: height),
);
}
}
}

View File

@@ -48,10 +48,12 @@ class EventAnalysisCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: AppLocalizations.of(context).eventDiscountStatus,
text: AppLocalizations.of(context)
.eventDiscountStatus,
style: const TextStyle(
fontSize: 18,
),
@@ -79,7 +81,10 @@ class EventAnalysisCard extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length),
AppLocalizations.of(context)
.servicesInProgress(provider
.activeEventSubscriptions
.length),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@@ -97,15 +102,18 @@ class EventAnalysisCard extends StatelessWidget {
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFFF6B6B).withValues(alpha: 0.1),
const Color(0xFFFF8787).withValues(alpha: 0.1),
const Color(0xFFFF6B6B)
.withValues(alpha: 0.1),
const Color(0xFFFF8787)
.withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFF6B6B).withValues(alpha: 0.3),
color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.3),
),
),
child: Row(
@@ -118,10 +126,12 @@ class EventAnalysisCard extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context).monthlySavingAmount,
AppLocalizations.of(context)
.monthlySavingAmount,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@@ -154,24 +164,29 @@ class EventAnalysisCard extends StatelessWidget {
),
const SizedBox(height: 12),
...provider.activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100).round();
final savings = sub.originalPrice -
(sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100)
.round();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.darkNavy.withValues(alpha: 0.05),
color: AppColors.darkNavy
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.darkNavy.withValues(alpha: 0.1),
color: AppColors.darkNavy
.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText(
sub.serviceName,
@@ -184,8 +199,8 @@ class EventAnalysisCard extends StatelessWidget {
Row(
children: [
FutureBuilder<String>(
future: CurrencyUtil
.formatAmount(
future:
CurrencyUtil.formatAmount(
sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
@@ -194,9 +209,11 @@ class EventAnalysisCard extends StatelessWidget {
snapshot.data!,
style: const TextStyle(
fontSize: 12,
decoration: TextDecoration
.lineThrough,
color: AppColors.navyGray,
decoration:
TextDecoration
.lineThrough,
color: AppColors
.navyGray,
),
);
}
@@ -211,9 +228,10 @@ class EventAnalysisCard extends StatelessWidget {
),
const SizedBox(width: 8),
FutureBuilder<String>(
future: CurrencyUtil
.formatAmount(
sub.eventPrice ?? sub.originalPrice,
future:
CurrencyUtil.formatAmount(
sub.eventPrice ??
sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
@@ -244,7 +262,8 @@ class EventAnalysisCard extends StatelessWidget {
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
borderRadius:
BorderRadius.circular(4),
),
child: Text(
'$discountRate${AppLocalizations.of(context).discountPercent}',
@@ -271,4 +290,4 @@ class EventAnalysisCard extends StatelessWidget {
),
);
}
}
}

View File

@@ -23,7 +23,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
/// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰)
double _calculateChartMaxY(double maxValue, String locale) {
final currency = CurrencyUtil.getDefaultCurrency(locale);
if (currency == 'KRW' || currency == 'JPY') {
// 소수점이 없는 통화 (원화, 엔화)
if (maxValue <= 0) return 100000;
@@ -33,9 +33,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
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();
final magnitude =
math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
return ((maxValue / magnitude).ceil() * magnitude).toDouble();
} else {
// 소수점이 있는 통화 (달러, 위안)
@@ -47,7 +48,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
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();
}
@@ -164,8 +165,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
0,
(max, data) => math.max(
max, data['totalExpense'] as double)),
locale
),
locale),
barGroups: _getMonthlyBarGroups(locale),
gridData: FlGridData(
show: true,
@@ -176,13 +176,12 @@ class MonthlyExpenseChartCard extends StatelessWidget {
0,
(max, data) => math.max(max,
data['totalExpense'] as double)),
locale
),
CurrencyUtil.getDefaultCurrency(locale)
),
locale),
CurrencyUtil.getDefaultCurrency(locale)),
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppColors.navyGray.withValues(alpha: 0.1),
color:
AppColors.navyGray.withValues(alpha: 0.1),
strokeWidth: 1,
);
},
@@ -233,10 +232,11 @@ class MonthlyExpenseChartCard extends StatelessWidget {
),
children: [
TextSpan(
text: CurrencyUtil.formatTotalAmountWithLocale(
monthlyData[group.x]['totalExpense']
as double,
locale),
text: CurrencyUtil
.formatTotalAmountWithLocale(
monthlyData[group.x]
['totalExpense'] as double,
locale),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontSize: 14,
@@ -254,7 +254,8 @@ class MonthlyExpenseChartCard extends StatelessWidget {
const SizedBox(height: 16),
Center(
child: ThemedText.caption(
text: AppLocalizations.of(context).monthlySubscriptionExpense,
text: AppLocalizations.of(context)
.monthlySubscriptionExpense,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@@ -270,4 +271,4 @@ class MonthlyExpenseChartCard extends StatelessWidget {
),
);
}
}
}

View File

@@ -23,14 +23,15 @@ class SubscriptionPieChartCard extends StatefulWidget {
});
@override
State<SubscriptionPieChartCard> createState() => _SubscriptionPieChartCardState();
State<SubscriptionPieChartCard> createState() =>
_SubscriptionPieChartCardState();
}
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
int _touchedIndex = -1;
late Future<List<PieChartSectionData>> _pieSectionsFuture;
String? _lastLocale;
static const _chartColors = [
Color(0xFF3B82F6),
Color(0xFF10B981),
@@ -52,7 +53,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
super.didUpdateWidget(oldWidget);
// subscriptions나 locale이 변경된 경우만 Future 재생성
final currentLocale = context.read<LocaleProvider>().locale.languageCode;
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
_lastLocale != currentLocale) {
_initializeFuture();
}
@@ -66,7 +67,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
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 ||
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) {
@@ -78,7 +79,6 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
Future<List<PieChartSectionData>> _getPieSections() async {
if (widget.subscriptions.isEmpty) return [];
// 현재 locale 가져오기
@@ -91,17 +91,19 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 각 구독의 현재 가격을 언어별 기본 통화로 환산
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);
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);
final converted = await ExchangeRateService()
.convertTargetToUsd(value, subscription.currency);
sectionValues.add(converted ?? value);
} else {
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
@@ -111,7 +113,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 총합 계산
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
// 총합이 0이면 빈 배열 반환
if (sectionsTotal == 0) return [];
@@ -138,17 +140,17 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
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,
@@ -159,24 +161,27 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
}
// 터치 상태를 반영한 섹션 데이터 생성
List<PieChartSectionData> _applyTouchedState(List<PieChartSectionData> sections) {
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,
shadows: const [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ??
TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
shadows: const [
Shadow(
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
color: section.color,
radius: radius,
titlePositionPercentageOffset: section.titlePositionPercentageOffset,
@@ -217,18 +222,20 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: AppLocalizations.of(context).subscriptionServiceRatio,
text: AppLocalizations.of(context)
.subscriptionServiceRatio,
style: const TextStyle(
fontSize: 18,
),
),
FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfoForLocale(
context.watch<LocaleProvider>().locale.languageCode
),
context
.watch<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) {
if (snapshot.hasData &&
snapshot.data!.isNotEmpty) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
@@ -236,15 +243,15 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
),
decoration: BoxDecoration(
color: const Color(0xFFE5F2FF),
borderRadius:
BorderRadius.circular(4),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFFBFDBFE),
width: 1,
),
),
child: Text(
AppLocalizations.of(context).exchangeRateFormat(snapshot.data!),
AppLocalizations.of(context)
.exchangeRateFormat(snapshot.data!),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@@ -272,7 +279,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
height: 250,
child: Center(
child: ThemedText(
AppLocalizations.of(context).noSubscriptionServices,
AppLocalizations.of(context)
.noSubscriptionServices,
style: const TextStyle(
fontSize: 16,
),
@@ -284,36 +292,41 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
child: FutureBuilder<List<PieChartSectionData>>(
future: _pieSectionsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
if (!snapshot.hasData ||
snapshot.data!.isEmpty) {
return Center(
child: ThemedText(
AppLocalizations.of(context).noSubscriptionServices,
AppLocalizations.of(context)
.noSubscriptionServices,
style: const TextStyle(
fontSize: 16,
),
),
);
}
return PieChart(
PieChartData(
borderData: FlBorderData(show: false),
sectionsSpace: 2,
centerSpaceRadius: 60,
sections: _applyTouchedState(snapshot.data!),
sections:
_applyTouchedState(snapshot.data!),
pieTouchData: PieTouchData(
enabled: true,
touchCallback: (FlTouchEvent event,
pieTouchResponse) {
// 터치 응답이 없거나 섹션이 없는 경우
if (pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
pieTouchResponse.touchedSection ==
null) {
// 차트 밖으로 나갔을 때만 리셋
if (_touchedIndex != -1) {
setState(() {
@@ -322,22 +335,25 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
}
return;
}
final touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
// 탭 이벤트 처리 (토글)
if (event is FlTapUpEvent) {
setState(() {
// 동일 섹션 탭하면 선택 해제, 아니면 선택
_touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex;
_touchedIndex = (_touchedIndex ==
touchedIndex)
? -1
: touchedIndex;
});
return;
}
// hover 이벤트 처리 (단순 표시)
if (event is FlPointerHoverEvent ||
if (event is FlPointerHoverEvent ||
event is FlPointerEnterEvent) {
// 현재 인덱스와 다른 경우만 업데이트
if (_touchedIndex != touchedIndex) {
@@ -364,10 +380,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
(index) {
final subscription =
widget.subscriptions[index];
final color = _chartColors[index % _chartColors.length];
final color =
_chartColors[index % _chartColors.length];
return Padding(
padding: const EdgeInsets.only(
bottom: 4.0),
padding: const EdgeInsets.only(bottom: 4.0),
child: Row(
children: [
Container(
@@ -385,31 +401,31 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
style: const TextStyle(
fontSize: 14,
),
overflow:
TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis,
),
),
FutureBuilder<String>(
future: CurrencyUtil
.formatSubscriptionAmountWithLocale(
subscription,
context.read<LocaleProvider>().locale.languageCode),
context
.read<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
fontSize: 14,
fontWeight:
FontWeight.bold,
fontWeight: FontWeight.bold,
),
);
}
return const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(
child: CircularProgressIndicator(
strokeWidth: 2,
),
);
@@ -430,4 +446,4 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
),
);
}
}
}

View File

@@ -56,7 +56,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: AppLocalizations.of(context).totalExpenseSummary,
text:
AppLocalizations.of(context).totalExpenseSummary,
style: const TextStyle(
fontSize: 18,
),
@@ -67,20 +68,24 @@ class TotalExpenseSummaryCard extends StatelessWidget {
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () async {
final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale);
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(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)),
content: Text(AppLocalizations.of(context)
.totalExpenseCopied(totalExpenseText)),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3),
backgroundColor: AppColors.glassBackground
.withValues(alpha: 0.3),
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
@@ -115,7 +120,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
),
const SizedBox(height: 4),
ThemedText(
CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale),
CurrencyUtil.formatTotalAmountWithLocale(
totalExpense, locale),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
@@ -134,10 +140,12 @@ class TotalExpenseSummaryCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.glassBackground.withValues(alpha: 0.3),
color: AppColors.glassBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.2),
color: AppColors.glassBorder
.withValues(alpha: 0.2),
),
),
child: const FaIcon(
@@ -152,7 +160,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
CrossAxisAlignment.start,
children: [
ThemedText.caption(
text: AppLocalizations.of(context).totalServices,
text: AppLocalizations.of(context)
.totalServices,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@@ -160,7 +169,9 @@ class TotalExpenseSummaryCard extends StatelessWidget {
),
const SizedBox(height: 2),
ThemedText(
AppLocalizations.of(context).subscriptionCount(subscriptions.length),
AppLocalizations.of(context)
.subscriptionCount(
subscriptions.length),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -176,10 +187,12 @@ class TotalExpenseSummaryCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.glassBackground.withValues(alpha: 0.3),
color: AppColors.glassBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.2),
color: AppColors.glassBorder
.withValues(alpha: 0.2),
),
),
child: const FaIcon(
@@ -194,7 +207,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
CrossAxisAlignment.start,
children: [
ThemedText.caption(
text: AppLocalizations.of(context).averageCost,
text: AppLocalizations.of(context)
.averageCost,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@@ -202,11 +216,13 @@ class TotalExpenseSummaryCard extends StatelessWidget {
),
const SizedBox(height: 2),
ThemedText(
CurrencyUtil.formatTotalAmountWithLocale(
subscriptions.isEmpty
? 0
: totalExpense / subscriptions.length,
locale),
CurrencyUtil
.formatTotalAmountWithLocale(
subscriptions.isEmpty
? 0
: totalExpense /
subscriptions.length,
locale),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -230,4 +246,4 @@ class TotalExpenseSummaryCard extends StatelessWidget {
),
);
}
}
}

View File

@@ -5,7 +5,7 @@ import 'dart:math' as math;
class SlidePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final AxisDirection direction;
SlidePageRoute({
required this.page,
this.direction = AxisDirection.right,
@@ -29,20 +29,20 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
begin = const Offset(0.0, -1.0);
break;
}
const end = Offset.zero;
const curve = Curves.easeOutCubic;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
var offsetAnimation = animation.drive(tween);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var fadeAnimation = animation.drive(fadeTween);
return SlideTransition(
position: offsetAnimation,
child: FadeTransition(
@@ -58,7 +58,7 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
class ScalePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final Alignment alignment;
ScalePageRoute({
required this.page,
this.alignment = Alignment.center,
@@ -68,17 +68,17 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.elasticOut;
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var scaleAnimation = animation.drive(scaleTween);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: Curves.easeIn),
);
var fadeAnimation = animation.drive(fadeTween);
return ScaleTransition(
scale: scaleAnimation,
alignment: alignment,
@@ -94,7 +94,7 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
/// 회전 + 스케일 전환
class RotatePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
RotatePageRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
@@ -102,17 +102,17 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
reverseTransitionDuration: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.easeInOut;
var rotateTween = Tween(begin: -0.5, end: 0.0).chain(
CurveTween(curve: curve),
);
var rotateAnimation = animation.drive(rotateTween);
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var scaleAnimation = animation.drive(scaleTween);
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
@@ -129,7 +129,7 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
class FlipPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final bool horizontal;
FlipPageRoute({
required this.page,
this.horizontal = true,
@@ -138,8 +138,9 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
transitionDuration: const Duration(milliseconds: 800),
reverseTransitionDuration: const Duration(milliseconds: 800),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final isAnimatingForward = animation.status == AnimationStatus.forward;
final isAnimatingForward =
animation.status == AnimationStatus.forward;
final flipAnimation = Tween(
begin: 0.0,
end: isAnimatingForward ? -math.pi : math.pi,
@@ -147,12 +148,12 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
parent: animation,
curve: Curves.easeInOut,
));
return AnimatedBuilder(
animation: flipAnimation,
builder: (context, child) {
final isShowingFront = flipAnimation.value.abs() < math.pi / 2;
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
@@ -181,7 +182,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final Widget startWidget;
final BorderRadius? borderRadius;
ContainerTransformPageRoute({
required this.page,
required this.startWidget,
@@ -208,7 +209,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
final scale = 0.5 + (0.5 * progress);
final radius = borderRadius?.topLeft.x ?? 0;
final currentRadius = radius * (1 - progress);
return Transform.scale(
scale: scale,
child: ClipRRect(
@@ -229,7 +230,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final String heroTag;
CustomHeroPageRoute({
required this.page,
required this.heroTag,
@@ -253,7 +254,7 @@ class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final SharedAxisTransitionType transitionType;
SharedAxisPageRoute({
required this.page,
required this.transitionType,
@@ -264,7 +265,7 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
transitionsBuilder: (context, animation, secondaryAnimation, child) {
late final Offset begin;
late final Offset end;
switch (transitionType) {
case SharedAxisTransitionType.horizontal:
begin = const Offset(1.0, 0.0);
@@ -279,17 +280,17 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
end = Offset.zero;
break;
}
final slideTween = Tween(begin: begin, end: end);
final fadeTween = Tween(begin: 0.0, end: 1.0);
final scaleTween = transitionType == SharedAxisTransitionType.scaled
? Tween(begin: 0.8, end: 1.0)
: Tween(begin: 1.0, end: 1.0);
final slideAnimation = animation.drive(slideTween);
final fadeAnimation = animation.drive(fadeTween);
final scaleAnimation = animation.drive(scaleTween);
return SlideTransition(
position: slideAnimation,
child: FadeTransition(
@@ -308,4 +309,4 @@ enum SharedAxisTransitionType {
horizontal,
vertical,
scaled,
}
}

View File

@@ -109,8 +109,8 @@ class AnimatedWaveBackground extends StatelessWidget {
width: 30,
height: 30,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha:
0.1 + 0.1 * pulseController.value,
color: Colors.white.withValues(
alpha: 0.1 + 0.1 * pulseController.value,
),
borderRadius: BorderRadius.circular(15),
),

View File

@@ -18,7 +18,7 @@ class AppNavigator {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.clearHistoryAndGoHome();
await Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.main,
(route) => false,
@@ -30,22 +30,23 @@ class AppNavigator {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(1);
await Navigator.of(context).pushNamed(AppRoutes.analysis);
}
/// 구독 추가 화면으로 네비게이션
static Future<void> toAddSubscription(BuildContext context) async {
HapticFeedback.mediumImpact();
await Navigator.of(context).pushNamed(AppRoutes.addSubscription);
}
/// 구독 상세 화면으로 네비게이션
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
static Future<void> toDetail(
BuildContext context, SubscriptionModel subscription) async {
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
HapticFeedback.lightImpact();
try {
await Navigator.of(context).pushNamed(
AppRoutes.subscriptionDetail,
@@ -62,7 +63,7 @@ class AppNavigator {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(3);
await Navigator.of(context).pushNamed(AppRoutes.smsScanner);
}
@@ -71,14 +72,14 @@ class AppNavigator {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(4);
await Navigator.of(context).pushNamed(AppRoutes.settings);
}
/// 카테고리 관리 화면으로 네비게이션
static Future<void> toCategoryManagement(BuildContext context) async {
HapticFeedback.lightImpact();
await Navigator.of(context).push(
SlidePageRoute(
page: const CategoryManagementScreen(),
@@ -101,20 +102,20 @@ class AppNavigator {
static Future<bool> handleBackButton(BuildContext context) async {
final navigator = Navigator.of(context);
final navigationProvider = context.read<NavigationProvider>();
// 네비게이션 스택이 있으면 팝
if (navigator.canPop()) {
HapticFeedback.lightImpact();
// NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원
if (navigationProvider.canPop()) {
navigationProvider.pop();
}
navigator.pop();
return false;
}
// 앱 종료 확인
final shouldExit = await showDialog<bool>(
context: context,
@@ -133,7 +134,7 @@ class AppNavigator {
],
),
);
return shouldExit ?? false;
}
@@ -141,17 +142,17 @@ class AppNavigator {
static void handleFloatingNavTap(BuildContext context, int index) {
final navigationProvider = context.read<NavigationProvider>();
final currentIndex = navigationProvider.currentIndex;
// 같은 탭을 다시 탭하면 아무 동작 안 함
if (currentIndex == index) {
return;
}
// 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기
if (Navigator.of(context).canPop()) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
// 선택된 인덱스에 따라 네비게이션
switch (index) {
case 0: // 홈
@@ -196,6 +197,7 @@ class AppNavigationObserver extends NavigatorObserver {
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
debugPrint(
'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
}
}
}

View File

@@ -66,13 +66,14 @@ class CategoryHeaderWidget extends StatelessWidget {
/// 통화별 합계를 표시하는 문자열을 생성합니다.
String _buildCostDisplay(BuildContext context) {
final parts = <String>[];
// 개수는 항상 표시
parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
// 통화 부분을 별도로 처리
final currencyParts = <String>[];
// 달러가 있는 경우
if (totalCostUSD > 0) {
final formatter = NumberFormat.currency(
@@ -82,7 +83,7 @@ class CategoryHeaderWidget extends StatelessWidget {
);
currencyParts.add(formatter.format(totalCostUSD));
}
// 원화가 있는 경우
if (totalCostKRW > 0) {
final formatter = NumberFormat.currency(
@@ -92,7 +93,7 @@ class CategoryHeaderWidget extends StatelessWidget {
);
currencyParts.add(formatter.format(totalCostKRW));
}
// 엔화가 있는 경우
if (totalCostJPY > 0) {
final formatter = NumberFormat.currency(
@@ -102,7 +103,7 @@ class CategoryHeaderWidget extends StatelessWidget {
);
currencyParts.add(formatter.format(totalCostJPY));
}
// 위안화가 있는 경우
if (totalCostCNY > 0) {
final formatter = NumberFormat.currency(
@@ -112,14 +113,14 @@ class CategoryHeaderWidget extends StatelessWidget {
);
currencyParts.add(formatter.format(totalCostCNY));
}
// 통화가 하나 이상 있는 경우
if (currencyParts.isNotEmpty) {
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
final currencyDisplay = currencyParts.join(' + ');
parts.add(currencyDisplay);
}
return parts.join(' · ');
}
}

View File

@@ -39,7 +39,7 @@ class DangerButton extends StatefulWidget {
class _DangerButtonState extends State<DangerButton> {
bool _isHovered = false;
static const Color _dangerColor = AppColors.dangerColor;
Future<void> _handlePress() async {
@@ -74,8 +74,7 @@ class _DangerButtonState extends State<DangerButton> {
),
const SizedBox(height: 16),
Text(
widget.confirmationMessage ??
'이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
widget.confirmationMessage ?? '이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
@@ -171,4 +170,4 @@ class _DangerButtonState extends State<DangerButton> {
return button;
}
}
}

View File

@@ -43,8 +43,10 @@ class _PrimaryButtonState extends State<PrimaryButton> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor;
final effectiveForegroundColor = widget.foregroundColor ?? AppColors.pureWhite;
final effectiveBackgroundColor =
widget.backgroundColor ?? theme.primaryColor;
final effectiveForegroundColor =
widget.foregroundColor ?? AppColors.pureWhite;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
@@ -64,7 +66,8 @@ class _PrimaryButtonState extends State<PrimaryButton> {
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08),
disabledBackgroundColor: effectiveBackgroundColor.withValues(alpha: 0.6),
disabledBackgroundColor:
effectiveBackgroundColor.withValues(alpha: 0.6),
),
child: widget.isLoading
? SizedBox(
@@ -110,4 +113,4 @@ class _PrimaryButtonState extends State<PrimaryButton> {
return button;
}
}
}

View File

@@ -61,18 +61,18 @@ class _SecondaryButtonState extends State<SecondaryButton> {
borderRadius: BorderRadius.circular(widget.borderRadius),
),
side: BorderSide(
color: _isHovered
color: _isHovered
? effectiveBorderColor.withValues(alpha: 0.4)
: effectiveBorderColor,
width: widget.borderWidth,
),
padding: widget.padding ?? const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
backgroundColor: _isHovered
? AppColors.glassBackground
: Colors.transparent,
padding: widget.padding ??
const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
backgroundColor:
_isHovered ? AppColors.glassBackground : Colors.transparent,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -146,7 +146,7 @@ class _TextLinkButtonState extends State<TextLinkButton> {
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _isHovered
color: _isHovered
? theme.colorScheme.onSurface.withValues(alpha: 0.05)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
@@ -179,9 +179,8 @@ class _TextLinkButtonState extends State<TextLinkButton> {
fontSize: widget.fontSize,
fontWeight: FontWeight.w500,
color: effectiveColor,
decoration: _isHovered
? TextDecoration.underline
: TextDecoration.none,
decoration:
_isHovered ? TextDecoration.underline : TextDecoration.none,
),
),
],
@@ -199,4 +198,4 @@ class _TextLinkButtonState extends State<TextLinkButton> {
return button;
}
}
}

View File

@@ -34,13 +34,14 @@ class SectionCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBackgroundColor = backgroundColor ?? Colors.white;
final effectiveShadow = boxShadow ?? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
];
final effectiveShadow = boxShadow ??
[
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
];
Widget card = Container(
height: height,
@@ -226,4 +227,4 @@ class InfoCard extends StatelessWidget {
),
);
}
}
}

View File

@@ -53,7 +53,8 @@ class ConfirmationDialog extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
color:
(iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
@@ -350,4 +351,4 @@ class ErrorDialog extends StatelessWidget {
),
);
}
}
}

View File

@@ -193,7 +193,8 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
width: widget.size / 5,
height: widget.size / 5,
decoration: BoxDecoration(
color: effectiveColor.withValues(alpha: 0.3 + value * 0.7),
color:
effectiveColor.withValues(alpha: 0.3 + value * 0.7),
shape: BoxShape.circle,
),
);
@@ -220,7 +221,8 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
height: widget.size * (0.3 + _animation.value * 0.5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: effectiveColor.withValues(alpha: 1 - _animation.value),
color:
effectiveColor.withValues(alpha: 1 - _animation.value),
),
),
),
@@ -235,4 +237,4 @@ enum LoadingStyle {
circular,
dots,
pulse,
}
}

View File

@@ -59,7 +59,7 @@ class BaseTextField extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -90,10 +90,11 @@ class BaseTextField extends StatelessWidget {
minLines: minLines,
readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor,
style: style ?? TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
),
style: style ??
TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(
@@ -146,4 +147,4 @@ class BaseTextField extends StatelessWidget {
],
);
}
}
}

View File

@@ -24,7 +24,7 @@ class BillingCycleSelector extends StatelessWidget {
Widget build(BuildContext context) {
final localization = AppLocalizations.of(context);
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
final cycles = isGlassmorphism
final cycles = isGlassmorphism
? [
localization.billingCycleMonthly,
localization.billingCycleQuarterly,
@@ -37,7 +37,7 @@ class BillingCycleSelector extends StatelessWidget {
localization.billingCycleHalfYearly,
localization.yearly,
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
@@ -76,7 +76,7 @@ class BillingCycleSelector extends StatelessWidget {
Color _getBackgroundColor(bool isSelected) {
if (!isSelected) {
return isGlassmorphism
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
@@ -84,11 +84,11 @@ class BillingCycleSelector extends StatelessWidget {
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
@@ -106,8 +106,6 @@ class BillingCycleSelector extends StatelessWidget {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.darkNavy
: Colors.grey[700]!;
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
}
}
}

View File

@@ -55,7 +55,8 @@ class CategorySelector extends StatelessWidget {
Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return Text(
categoryProvider.getLocalizedCategoryName(context, category.name),
categoryProvider.getLocalizedCategoryName(
context, category.name),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
@@ -101,7 +102,7 @@ class CategorySelector extends StatelessWidget {
Color _getBackgroundColor(bool isSelected) {
if (!isSelected) {
return isGlassmorphism
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
@@ -109,11 +110,11 @@ class CategorySelector extends StatelessWidget {
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
@@ -131,8 +132,6 @@ class CategorySelector extends StatelessWidget {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.darkNavy
: Colors.grey[700]!;
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
}
}
}

View File

@@ -45,7 +45,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onFocusChanged);
// 초기값이 있으면 포맷팅 적용
if (widget.controller.text.isNotEmpty) {
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
@@ -105,7 +105,11 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
}
double? _parseValue(String text) {
final cleanText = text.replaceAll(',', '').replaceAll('', '').replaceAll('\$', '').trim();
final cleanText = text
.replaceAll(',', '')
.replaceAll('', '')
.replaceAll('\$', '')
.trim();
return double.tryParse(cleanText);
}
@@ -128,16 +132,13 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
widget.currency == 'KRW'
? RegExp(r'[0-9]')
: RegExp(r'[0-9.]')
),
widget.currency == 'KRW' ? RegExp(r'[0-9]') : RegExp(r'[0-9.]')),
if (widget.currency == 'USD')
// USD의 경우 소수점 이하 2자리까지만 허용
TextInputFormatter.withFunction((oldValue, newValue) {
final text = newValue.text;
if (text.isEmpty) return newValue;
final parts = text.split('.');
if (parts.length > 2) {
// 소수점이 2개 이상인 경우 거부
@@ -157,16 +158,17 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
final parsedValue = _parseValue(value);
widget.onChanged?.call(parsedValue);
},
validator: widget.validator ?? (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).amountRequired;
}
final parsedValue = _parseValue(value);
if (parsedValue == null || parsedValue <= 0) {
return AppLocalizations.of(context).invalidAmount;
}
return null;
},
validator: widget.validator ??
(value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).amountRequired;
}
final parsedValue = _parseValue(value);
if (parsedValue == null || parsedValue <= 0) {
return AppLocalizations.of(context).invalidAmount;
}
return null;
},
);
}
}
}

View File

@@ -86,7 +86,7 @@ class _CurrencyOption extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Expanded(
child: InkWell(
onTap: onTap,
@@ -131,11 +131,9 @@ class _CurrencyOption extends StatelessWidget {
Color _getBackgroundColor(ThemeData theme) {
if (isSelected) {
return isGlassmorphism
? theme.primaryColor
: const Color(0xFF3B82F6);
return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6);
}
return isGlassmorphism
return isGlassmorphism
? AppColors.surfaceColorAlt
: Colors.grey.withValues(alpha: 0.1);
}
@@ -154,8 +152,6 @@ class _CurrencyOption extends StatelessWidget {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.navyGray
: Colors.grey[600]!;
return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!;
}
}
}

View File

@@ -42,7 +42,7 @@ class DatePickerField extends StatelessWidget {
final localizations = AppLocalizations.of(context);
final effectiveDateFormat = dateFormat ?? localizations.dateFormatFull;
final locale = Localizations.localeOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -57,31 +57,35 @@ class DatePickerField extends StatelessWidget {
const SizedBox(height: 8),
InkWell(
focusNode: focusNode,
onTap: enabled ? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: firstDate ?? DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)),
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null && picked != selectedDate) {
onDateSelected(picked);
}
} : null,
onTap: enabled
? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: firstDate ??
DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: lastDate ??
DateTime.now().add(const Duration(days: 365 * 10)),
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null && picked != selectedDate) {
onDateSelected(picked);
}
}
: null,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: contentPadding ?? const EdgeInsets.all(16),
@@ -97,21 +101,19 @@ class DatePickerField extends StatelessWidget {
children: [
Expanded(
child: Text(
DateFormat(effectiveDateFormat, locale.toString()).format(selectedDate),
DateFormat(effectiveDateFormat, locale.toString())
.format(selectedDate),
style: TextStyle(
fontSize: 16,
color: enabled
? AppColors.textPrimary
: AppColors.textMuted,
color:
enabled ? AppColors.textPrimary : AppColors.textMuted,
),
),
),
Icon(
Icons.calendar_today,
size: 20,
color: enabled
? AppColors.navyGray
: AppColors.textMuted,
color: enabled ? AppColors.navyGray : AppColors.textMuted,
),
],
),
@@ -158,7 +160,8 @@ class DateRangePickerField extends StatelessWidget {
primaryColor: primaryColor,
onDateSelected: onStartDateSelected,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: endDate ?? DateTime.now().add(const Duration(days: 365 * 2)),
lastDate:
endDate ?? DateTime.now().add(const Duration(days: 365 * 2)),
),
),
const SizedBox(width: 12),
@@ -203,31 +206,33 @@ class _DateRangeItem extends StatelessWidget {
final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
return InkWell(
onTap: enabled ? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: date ?? DateTime.now(),
firstDate: firstDate,
lastDate: lastDate,
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
onDateSelected(picked);
}
} : null,
onTap: enabled
? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: date ?? DateTime.now(),
firstDate: firstDate,
lastDate: lastDate,
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
onDateSelected(picked);
}
}
: null,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
@@ -252,14 +257,14 @@ class _DateRangeItem extends StatelessWidget {
const SizedBox(height: 4),
Text(
date != null
? DateFormat(AppLocalizations.of(context).dateFormatShort).format(date!)
? DateFormat(AppLocalizations.of(context).dateFormatShort)
.format(date!)
: AppLocalizations.of(context).dateSelect,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: date != null
? AppColors.textPrimary
: AppColors.textMuted,
color:
date != null ? AppColors.textPrimary : AppColors.textMuted,
),
),
],
@@ -267,4 +272,4 @@ class _DateRangeItem extends StatelessWidget {
),
);
}
}
}

View File

@@ -269,4 +269,4 @@ class AppSnackBar {
),
);
}
}
}

View File

@@ -44,4 +44,4 @@ class DetailActionButtons extends StatelessWidget {
),
);
}
}
}

View File

@@ -27,172 +27,177 @@ class DetailEventSection extends StatelessWidget {
final baseColor = controller.getCardColor();
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.8),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.8),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.local_offer_rounded,
color: baseColor,
size: 24,
),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.local_offer_rounded,
color: baseColor,
size: 24,
),
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).eventPrice,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
),
),
],
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).eventPrice,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
),
// 이벤트 활성화 스위치
Switch.adaptive(
value: controller.isEventActive,
onChanged: (value) {
controller.isEventActive = value;
if (!value) {
// 이벤트 비활성화시 관련 정보 초기화
controller.eventStartDate = null;
controller.eventEndDate = null;
controller.eventPriceController.clear();
}
},
activeColor: baseColor,
),
],
),
// 이벤트 활성화 스위치
Switch.adaptive(
value: controller.isEventActive,
onChanged: (value) {
controller.isEventActive = value;
if (!value) {
// 이벤트 비활성화시 관련 정보 초기화
controller.eventStartDate = null;
controller.eventEndDate = null;
controller.eventPriceController.clear();
}
},
activeColor: baseColor,
),
// 이벤트 활성화시 표시될 필드들
if (controller.isEventActive) ...[
const SizedBox(height: 20),
// 이벤트 설명
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context).eventPriceHint,
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(height: 20),
// 이벤트 기간
DateRangePickerField(
startDate: controller.eventStartDate,
endDate: controller.eventEndDate,
onStartDateSelected: (date) {
controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate =
date.add(const Duration(days: 30));
}
},
onEndDateSelected: (date) {
controller.eventEndDate = date;
},
startLabel: AppLocalizations.of(context).startDate,
endLabel: AppLocalizations.of(context).endDate,
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 이벤트 가격
CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: AppLocalizations.of(context).eventPrice,
hintText: AppLocalizations.of(context).eventPriceHint,
validator: controller.isEventActive
? (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context)
.eventPriceRequired;
}
final price =
double.tryParse(value.replaceAll(',', ''));
if (price == null || price <= 0) {
return AppLocalizations.of(context)
.invalidPrice;
}
return null;
}
: null,
),
const SizedBox(height: 16),
// 할인율 표시
if (controller.eventPriceController.text.isNotEmpty)
_DiscountBadge(
originalPrice: controller.subscription.monthlyCost,
eventPrice: double.tryParse(controller
.eventPriceController.text
.replaceAll(',', '')) ??
0,
currency: controller.currency,
),
],
],
),
// 이벤트 활성화시 표시될 필드들
if (controller.isEventActive) ...[
const SizedBox(height: 20),
// 이벤트 설명
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context).eventPriceHint,
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(height: 20),
// 이벤트 기간
DateRangePickerField(
startDate: controller.eventStartDate,
endDate: controller.eventEndDate,
onStartDateSelected: (date) {
controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30));
}
},
onEndDateSelected: (date) {
controller.eventEndDate = date;
},
startLabel: AppLocalizations.of(context).startDate,
endLabel: AppLocalizations.of(context).endDate,
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 이벤트 가격
CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: AppLocalizations.of(context).eventPrice,
hintText: AppLocalizations.of(context).eventPriceHint,
validator: controller.isEventActive
? (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).eventPriceRequired;
}
final price = double.tryParse(value.replaceAll(',', ''));
if (price == null || price <= 0) {
return AppLocalizations.of(context).invalidPrice;
}
return null;
}
: null,
),
const SizedBox(height: 16),
// 할인율 표시
if (controller.eventPriceController.text.isNotEmpty)
_DiscountBadge(
originalPrice: controller.subscription.monthlyCost,
eventPrice: double.tryParse(
controller.eventPriceController.text.replaceAll(',', '')
) ?? 0,
currency: controller.currency,
),
],
],
),
),
),
),
),
);
);
},
);
}
@@ -216,7 +221,8 @@ class _DiscountBadge extends StatelessWidget {
return const SizedBox.shrink();
}
final discountPercentage = ((originalPrice - eventPrice) / originalPrice * 100).round();
final discountPercentage =
((originalPrice - eventPrice) / originalPrice * 100).round();
final discountAmount = originalPrice - eventPrice;
return Container(
@@ -234,7 +240,9 @@ class _DiscountBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
AppLocalizations.of(context).discountPercent.replaceAll('@', discountPercentage.toString()),
AppLocalizations.of(context)
.discountPercent
.replaceAll('@', discountPercentage.toString()),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
@@ -256,7 +264,8 @@ class _DiscountBadge extends StatelessWidget {
);
}
String _getLocalizedDiscountAmount(BuildContext context, String currency, double amount) {
String _getLocalizedDiscountAmount(
BuildContext context, String currency, double amount) {
final loc = AppLocalizations.of(context);
switch (currency) {
case 'KRW':
@@ -264,9 +273,11 @@ class _DiscountBadge extends StatelessWidget {
case 'JPY':
return loc.discountAmountYen.replaceAll('@', amount.toInt().toString());
case 'CNY':
return loc.discountAmountYuan.replaceAll('@', amount.toStringAsFixed(2));
return loc.discountAmountYuan
.replaceAll('@', amount.toStringAsFixed(2));
default: // USD
return loc.discountAmountDollar.replaceAll('@', amount.toStringAsFixed(2));
return loc.discountAmountDollar
.replaceAll('@', amount.toStringAsFixed(2));
}
}
}
}

View File

@@ -32,151 +32,111 @@ class DetailFormSection extends StatelessWidget {
final baseColor = controller.getCardColor();
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.6),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.6),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 서비스명 필드
BaseTextField(
controller: controller.serviceNameController,
focusNode: controller.serviceNameFocus,
label: AppLocalizations.of(context).subscriptionName,
hintText: AppLocalizations.of(context).serviceNameExample,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.monthlyCostFocus.requestFocus();
},
),
const SizedBox(height: 20),
// 월 지출 및 통화 선택
Row(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
flex: 2,
child: CurrencyInputField(
controller: controller.monthlyCostController,
currency: controller.currency,
label: AppLocalizations.of(context).monthlyExpense,
focusNode: controller.monthlyCostFocus,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.billingCycleFocus.requestFocus();
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).currency,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
CurrencySelector(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
controller.currency = value;
// 통화 변경시 금액 포맷 업데이트
if (value == 'KRW') {
final amount = double.tryParse(
controller.monthlyCostController.text.replaceAll(',', '')
);
if (amount != null) {
controller.monthlyCostController.text =
amount.toInt().toString();
}
}
},
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 주기
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).billingCycle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (value) {
controller.billingCycle = value;
// 서비스명 필드
BaseTextField(
controller: controller.serviceNameController,
focusNode: controller.serviceNameFocus,
label: AppLocalizations.of(context).subscriptionName,
hintText: AppLocalizations.of(context).serviceNameExample,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.monthlyCostFocus.requestFocus();
},
),
],
),
const SizedBox(height: 20),
const SizedBox(height: 20),
// 다음 결제일
DatePickerField(
selectedDate: controller.nextBillingDate,
onDateSelected: (date) {
controller.nextBillingDate = date;
},
label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 월 지출 및 통화 선택
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: CurrencyInputField(
controller: controller.monthlyCostController,
currency: controller.currency,
label: AppLocalizations.of(context).monthlyExpense,
focusNode: controller.monthlyCostFocus,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.billingCycleFocus.requestFocus();
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).currency,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
CurrencySelector(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
controller.currency = value;
// 통화 변경시 금액 포맷 업데이트
if (value == 'KRW') {
final amount = double.tryParse(controller
.monthlyCostController.text
.replaceAll(',', ''));
if (amount != null) {
controller.monthlyCostController.text =
amount.toInt().toString();
}
}
},
),
],
),
),
],
),
const SizedBox(height: 20),
// 카테고리 선택
Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return Column(
// 결제 주기
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).category,
AppLocalizations.of(context).billingCycle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
@@ -184,27 +144,67 @@ class DetailFormSection extends StatelessWidget {
),
),
const SizedBox(height: 8),
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (categoryId) {
controller.selectedCategoryId = categoryId;
onChanged: (value) {
controller.billingCycle = value;
},
),
],
);
},
),
const SizedBox(height: 20),
// 다음 결제일
DatePickerField(
selectedDate: controller.nextBillingDate,
onDateSelected: (date) {
controller.nextBillingDate = date;
},
label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(),
lastDate:
DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 카테고리 선택
Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).category,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (categoryId) {
controller.selectedCategoryId = categoryId;
},
),
],
);
},
),
],
),
],
),
),
),
),
),
);
);
},
);
}
}

View File

@@ -34,191 +34,215 @@ class DetailHeaderSection extends StatelessWidget {
final gradient = controller.getGradient(baseColor);
return Container(
height: 320,
decoration: BoxDecoration(gradient: gradient),
child: Stack(
children: [
// 배경 패턴
Positioned(
top: -50,
right: -50,
child: RotationTransition(
turns: rotateAnimation,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.1),
height: 320,
decoration: BoxDecoration(gradient: gradient),
child: Stack(
children: [
// 배경 패턴
Positioned(
top: -50,
right: -50,
child: RotationTransition(
turns: rotateAnimation,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.1),
),
),
),
),
),
),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.08),
),
),
),
// 콘텐츠
SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 뒤로가기 버튼
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
color: Colors.white,
),
onPressed: () => Navigator.of(context).pop(),
),
IconButton(
icon: const Icon(
Icons.delete_outline_rounded,
color: Colors.white,
),
onPressed: controller.deleteSubscription,
),
],
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.08),
),
const Spacer(),
// 서비스 정보
FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
),
),
// 콘텐츠
SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 뒤로가기 버튼
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 서비스 아이콘과 이름
Row(
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
color: Colors.white,
),
onPressed: () => Navigator.of(context).pop(),
),
IconButton(
icon: const Icon(
Icons.delete_outline_rounded,
color: Colors.white,
),
onPressed: controller.deleteSubscription,
),
],
),
const Spacer(),
// 서비스 정보
FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'icon_${subscription.id}',
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: WebsiteIcon(
url: controller.websiteUrlController.text,
serviceName: controller.serviceNameController.text,
size: 48,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.displayName ?? controller.serviceNameController.text,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
// 서비스 아이콘과 이름
Row(
children: [
Hero(
tag: 'icon_${subscription.id}',
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
letterSpacing: -0.5,
shadows: [
Shadow(
color: Colors.black26,
offset: Offset(0, 2),
blurRadius: 4,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black
.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context).billingCyclePayment.replaceAll('@',
_getLocalizedBillingCycle(context, controller.billingCycle)),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: WebsiteIcon(
url: controller
.websiteUrlController.text,
serviceName: controller
.serviceNameController.text,
size: 48,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
controller.displayName ??
controller
.serviceNameController.text,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
shadows: [
Shadow(
color: Colors.black26,
offset: Offset(0, 2),
blurRadius: 4,
),
],
),
),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context)
.billingCyclePayment
.replaceAll(
'@',
_getLocalizedBillingCycle(
context,
controller.billingCycle)),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white
.withValues(alpha: 0.8),
),
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 정보 카드
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
_InfoColumn(
label: AppLocalizations.of(context)
.nextBillingDate,
value: AppLocalizations.of(context)
.formatDate(
controller.nextBillingDate),
),
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,
);
},
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 정보 카드
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_InfoColumn(
label: AppLocalizations.of(context).nextBillingDate,
value: AppLocalizations.of(context).formatDate(controller.nextBillingDate),
),
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,
);
},
),
],
),
),
],
),
),
),
],
),
],
),
),
),
],
),
],
),
);
);
},
);
}
String _getLocalizedBillingCycle(BuildContext context, String cycle) {
final loc = AppLocalizations.of(context);
switch (cycle.toLowerCase()) {
@@ -285,4 +309,4 @@ class _InfoColumn extends StatelessWidget {
],
);
}
}
}

View File

@@ -81,7 +81,7 @@ class DetailUrlSection extends StatelessWidget {
],
),
const SizedBox(height: 20),
// URL 입력 필드
BaseTextField(
controller: controller.websiteUrlController,
@@ -94,7 +94,7 @@ class DetailUrlSection extends StatelessWidget {
color: AppColors.navyGray,
),
),
// 해지 안내 섹션
if (controller.subscription.websiteUrl != null &&
controller.subscription.websiteUrl!.isNotEmpty) ...[
@@ -151,7 +151,7 @@ class DetailUrlSection extends StatelessWidget {
),
),
],
// URL 자동 매칭 정보
if (controller.websiteUrlController.text.isEmpty) ...[
const SizedBox(height: 16),
@@ -194,4 +194,4 @@ class DetailUrlSection extends StatelessWidget {
),
);
}
}
}

View File

@@ -56,7 +56,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 24),
// 타이틀
const Text(
'구독 삭제',
@@ -67,7 +67,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 12),
// 설명
RichText(
textAlign: TextAlign.center,
@@ -91,7 +91,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 8),
// 경고 메시지
Container(
padding: const EdgeInsets.symmetric(
@@ -127,7 +127,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 32),
// 버튼들
Row(
children: [
@@ -176,7 +176,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
serviceName: serviceName,
),
);
return result ?? false;
}
}
}

View File

@@ -58,7 +58,8 @@ class EmptyStateWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.primaryColor.withValues(alpha: 0.3),
color:
AppColors.primaryColor.withValues(alpha: 0.3),
spreadRadius: 0,
blurRadius: 16,
offset: const Offset(0, 8),

View File

@@ -7,7 +7,7 @@ import 'glassmorphism_card.dart';
class ExpandableFab extends StatefulWidget {
final List<FabAction> actions;
final double distance;
const ExpandableFab({
super.key,
required this.actions,
@@ -32,13 +32,13 @@ class _ExpandableFabState extends State<ExpandableFab>
duration: const Duration(milliseconds: 300),
vsync: this,
);
_expandAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeInBack,
);
_rotateAnimation = Tween<double>(
begin: 0.0,
end: math.pi / 4,
@@ -58,7 +58,7 @@ class _ExpandableFabState extends State<ExpandableFab>
setState(() {
_isExpanded = !_isExpanded;
});
if (_isExpanded) {
HapticFeedbackHelper.mediumImpact();
_controller.forward();
@@ -81,25 +81,26 @@ class _ExpandableFabState extends State<ExpandableFab>
animation: _expandAnimation,
builder: (context, child) {
return Container(
color: AppColors.shadowBlack.withValues(alpha: 3.75 * _expandAnimation.value),
color: AppColors.shadowBlack
.withValues(alpha: 3.75 * _expandAnimation.value),
);
},
),
),
// 액션 버튼들
...widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
return AnimatedBuilder(
animation: _expandAnimation,
builder: (context, child) {
final distance = widget.distance * _expandAnimation.value;
final x = distance * math.cos(angle);
final y = distance * math.sin(angle);
return Transform.translate(
offset: Offset(-x, -y),
child: ScaleTransition(
@@ -125,7 +126,7 @@ class _ExpandableFabState extends State<ExpandableFab>
},
);
}),
// 메인 FAB
AnimatedBuilder(
animation: _rotateAnimation,
@@ -144,21 +145,21 @@ class _ExpandableFabState extends State<ExpandableFab>
);
},
),
// 라벨 표시
if (_isExpanded)
...widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
return AnimatedBuilder(
animation: _expandAnimation,
builder: (context, child) {
final distance = widget.distance * _expandAnimation.value;
final x = distance * math.cos(angle);
final y = distance * math.sin(angle);
return Transform.translate(
offset: Offset(-x - 80, -y),
child: FadeTransition(
@@ -194,7 +195,7 @@ class FabAction {
final String label;
final VoidCallback onPressed;
final Color? color;
const FabAction({
required this.icon,
required this.label,
@@ -207,7 +208,7 @@ class FabAction {
class DraggableFab extends StatefulWidget {
final Widget child;
final EdgeInsets? padding;
const DraggableFab({
super.key,
required this.child,
@@ -226,7 +227,7 @@ class _DraggableFabState extends State<DraggableFab> {
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final padding = widget.padding ?? const EdgeInsets.all(20);
return Stack(
children: [
Positioned(
@@ -265,4 +266,4 @@ class _DraggableFabState extends State<DraggableFab> {
],
);
}
}
}

View File

@@ -124,8 +124,11 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
_NavigationItem(
icon: Icons.settings_rounded,
label: AppLocalizations.of(context).settings,
isSelected: PlatformHelper.isIOS ? widget.selectedIndex == 3 : widget.selectedIndex == 4,
onTap: () => _onItemTapped(PlatformHelper.isIOS ? 3 : 4),
isSelected: PlatformHelper.isIOS
? widget.selectedIndex == 3
: widget.selectedIndex == 4,
onTap: () =>
_onItemTapped(PlatformHelper.isIOS ? 3 : 4),
),
],
),

View File

@@ -6,7 +6,8 @@ import 'themed_text.dart';
import '../l10n/app_localizations.dart';
/// 글래스모피즘 효과가 적용된 통일된 앱바
class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget {
class GlassmorphicAppBar extends StatelessWidget
implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
final Widget? leading;
@@ -44,7 +45,7 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final canPop = Navigator.of(context).canPop();
return ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
@@ -54,17 +55,21 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
(backgroundColor ?? (isDarkMode
? AppColors.glassBackgroundDark
: AppColors.glassBackground)).withValues(alpha: opacity),
(backgroundColor ?? (isDarkMode
? AppColors.glassSurfaceDark
: AppColors.glassSurface)).withValues(alpha: opacity * 0.8),
(backgroundColor ??
(isDarkMode
? AppColors.glassBackgroundDark
: AppColors.glassBackground))
.withValues(alpha: opacity),
(backgroundColor ??
(isDarkMode
? AppColors.glassSurfaceDark
: AppColors.glassSurface))
.withValues(alpha: opacity * 0.8),
],
),
border: Border(
bottom: BorderSide(
color: isDarkMode
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.5),
width: 0.5,
@@ -77,26 +82,29 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: SizedBox(
height: kToolbarHeight,
child: NavigationToolbar(
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
? _buildBackButton(context)
: null),
middle: _buildTitle(context),
trailing: actions != null
? Row(
mainAxisSize: MainAxisSize.min,
children: actions!,
)
: null,
centerMiddle: centerTitle,
middleSpacing: titleSpacing ?? NavigationToolbar.kMiddleSpacing,
Flexible(
child: SizedBox(
height: kToolbarHeight,
child: NavigationToolbar(
leading: leading ??
(automaticallyImplyLeading &&
(canPop || onBackPressed != null)
? _buildBackButton(context)
: null),
middle: _buildTitle(context),
trailing: actions != null
? Row(
mainAxisSize: MainAxisSize.min,
children: actions!,
)
: null,
centerMiddle: centerTitle,
middleSpacing:
titleSpacing ?? NavigationToolbar.kMiddleSpacing,
),
),
),
),
if (bottom != null) bottom!,
if (bottom != null) bottom!,
],
),
),
@@ -109,10 +117,11 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
Widget _buildBackButton(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ?? () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
onPressed: onBackPressed ??
() {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
splashRadius: 24,
tooltip: AppLocalizations.of(context).back,
color: ThemedText.getContrastColor(context),
@@ -205,7 +214,7 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final canPop = Navigator.of(context).canPop();
return SliverAppBar(
expandedHeight: expandedHeight,
floating: floating,
@@ -214,26 +223,29 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
backgroundColor: Colors.transparent,
elevation: 0,
automaticallyImplyLeading: false,
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
? IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ?? () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
splashRadius: 24,
tooltip: AppLocalizations.of(context).back,
)
: null),
leading: leading ??
(automaticallyImplyLeading && (canPop || onBackPressed != null)
? IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ??
() {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
splashRadius: 24,
tooltip: AppLocalizations.of(context).back,
)
: null),
actions: actions,
centerTitle: centerTitle,
flexibleSpace: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final top = constraints.biggest.height;
final isCollapsed = top <= kToolbarHeight + MediaQuery.of(context).padding.top;
final isCollapsed =
top <= kToolbarHeight + MediaQuery.of(context).padding.top;
return FlexibleSpaceBar(
title: isCollapsed
title: isCollapsed
? ThemedText.headline(
text: title,
style: const TextStyle(
@@ -244,7 +256,8 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
)
: null,
centerTitle: centerTitle,
titlePadding: const EdgeInsets.only(left: 16, bottom: 16, right: 16),
titlePadding:
const EdgeInsets.only(left: 16, bottom: 16, right: 16),
background: Stack(
fit: StackFit.expand,
children: [
@@ -258,17 +271,19 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
(isDarkMode
? AppColors.glassBackgroundDark
: AppColors.glassBackground).withValues(alpha: opacity),
(isDarkMode
? AppColors.glassSurfaceDark
: AppColors.glassSurface).withValues(alpha: opacity * 0.8),
(isDarkMode
? AppColors.glassBackgroundDark
: AppColors.glassBackground)
.withValues(alpha: opacity),
(isDarkMode
? AppColors.glassSurfaceDark
: AppColors.glassSurface)
.withValues(alpha: opacity * 0.8),
],
),
border: Border(
bottom: BorderSide(
color: isDarkMode
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.5),
width: 0.5,
@@ -302,4 +317,4 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
),
);
}
}
}

View File

@@ -63,12 +63,12 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
duration: const Duration(seconds: 20),
vsync: this,
)..repeat();
_waveController = AnimationController(
duration: const Duration(seconds: 10),
vsync: this,
)..repeat();
if (widget.useFloatingNavBar) {
_scrollController = ScrollController();
_setupScrollListener();
@@ -78,13 +78,16 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
void _setupScrollListener() {
_scrollController?.addListener(() {
final currentScroll = _scrollController!.position.pixels;
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) {
if (currentScroll > 50 &&
_scrollController!.position.userScrollDirection ==
ScrollDirection.reverse) {
if (_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = false);
}
} else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) {
} else if (_scrollController!.position.userScrollDirection ==
ScrollDirection.forward) {
if (!_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = true);
}
@@ -104,7 +107,7 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
if (widget.backgroundGradient != null) {
return widget.backgroundGradient!;
}
// 디폴트 그라디언트
return AppColors.mainGradient;
}
@@ -112,18 +115,18 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
@override
Widget build(BuildContext context) {
final backgroundGradient = _getBackgroundGradient();
return Stack(
children: [
// 배경 그라디언트
_buildBackground(backgroundGradient),
// 파티클 효과 (선택적)
if (widget.enableParticles) _buildParticles(),
// 웨이브 애니메이션 (선택적)
if (widget.enableWaveAnimation) _buildWaveAnimation(),
// 메인 스캐폴드
Scaffold(
backgroundColor: widget.backgroundColor ?? Colors.transparent,
@@ -138,7 +141,7 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
drawer: widget.drawer,
endDrawer: widget.endDrawer,
),
// 플로팅 네비게이션 바 (선택적)
if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null)
FloatingNavigationBar(
@@ -159,7 +162,9 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradientColors.map((color) => color.withOpacity(0.3)).toList(),
colors: gradientColors
.map((color) => color.withOpacity(0.3))
.toList(),
),
),
),
@@ -233,11 +238,11 @@ class ParticlePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
for (final particle in particles) {
final progress = animation.value;
final y = (particle.y + progress * particle.speed) % 1.0;
paint.color = AppColors.pureWhite.withValues(alpha: particle.opacity);
canvas.drawCircle(
Offset(particle.x * size.width, y * size.height),
@@ -266,21 +271,23 @@ class WavePainter extends CustomPainter {
final paint = Paint()
..color = waveColor
..style = PaintingStyle.fill;
final path = Path();
final progress = animation.value;
path.moveTo(0, size.height);
for (double x = 0; x <= size.width; x++) {
final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 +
size.height * 0.5;
final y =
math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) *
20 +
size.height * 0.5;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@@ -303,4 +310,4 @@ class Particle {
required this.speed,
required this.opacity,
});
}
}

View File

@@ -38,7 +38,7 @@ class GlassmorphismCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
width: width,
height: height,
@@ -56,28 +56,32 @@ class GlassmorphismCard extends StatelessWidget {
padding: padding,
decoration: BoxDecoration(
color: backgroundColor ?? AppColors.glassCard,
gradient: gradient ?? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? AppColors.glassGradientDark
: AppColors.glassGradient,
),
gradient: gradient ??
LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? AppColors.glassGradientDark
: AppColors.glassGradient,
),
borderRadius: BorderRadius.circular(borderRadius),
border: border ?? Border.all(
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder,
width: 1,
),
boxShadow: boxShadow ?? [
BoxShadow(
color: AppColors.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
blurRadius: 20,
spreadRadius: -5,
offset: const Offset(0, 10),
),
],
border: border ??
Border.all(
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder,
width: 1,
),
boxShadow: boxShadow ??
[
BoxShadow(
color: AppColors
.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
blurRadius: 20,
spreadRadius: -5,
offset: const Offset(0, 10),
),
],
),
child: GlassmorphicIndicator(
child: child,
@@ -119,10 +123,11 @@ class AnimatedGlassmorphismCard extends StatefulWidget {
});
@override
State<AnimatedGlassmorphismCard> createState() => _AnimatedGlassmorphismCardState();
State<AnimatedGlassmorphismCard> createState() =>
_AnimatedGlassmorphismCardState();
}
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@@ -135,7 +140,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
duration: widget.animationDuration,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.98,
@@ -143,7 +148,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
parent: _controller,
curve: Curves.easeInOut,
));
_blurAnimation = Tween<double>(
begin: widget.blur,
end: widget.blur * 1.5,
@@ -187,7 +192,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
child: widget.child,
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown,
@@ -221,4 +226,4 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
),
);
}
}
}

View File

@@ -52,8 +52,10 @@ class HomeContent extends StatelessWidget {
}
// 카테고리별 구독 구분
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions(
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categorizedSubscriptions =
SubscriptionCategoryHelper.categorizeSubscriptions(
provider.subscriptions,
categoryProvider,
context,
@@ -107,8 +109,8 @@ class HomeContent extends StatelessWidget {
child: Text(
AppLocalizations.of(context).mySubscriptions,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.darkNavy,
),
color: AppColors.darkNavy,
),
),
),
SlideTransition(
@@ -120,7 +122,8 @@ class HomeContent extends StatelessWidget {
child: Row(
children: [
Text(
AppLocalizations.of(context).subscriptionCount(provider.subscriptions.length),
AppLocalizations.of(context)
.subscriptionCount(provider.subscriptions.length),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -153,4 +156,4 @@ class HomeContent extends StatelessWidget {
),
);
}
}
}

View File

@@ -33,7 +33,7 @@ class MainScreenSummaryCard extends StatelessWidget {
final locale = context.watch<LocaleProvider>().locale.languageCode;
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
final currencySymbol = CurrencyUtil.getCurrencySymbol(defaultCurrency);
final int totalSubscriptions = provider.subscriptions.length;
final int activeEvents = provider.activeEventSubscriptions.length;
@@ -88,7 +88,8 @@ class MainScreenSummaryCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context).monthlyTotalSubscriptionCost,
AppLocalizations.of(context)
.monthlyTotalSubscriptionCost,
style: TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
@@ -99,9 +100,12 @@ class MainScreenSummaryCard extends StatelessWidget {
// 환율 정보 표시 (영어 사용자는 표시 안함)
if (locale != 'en')
FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfoForLocale(locale),
future:
CurrencyUtil.getExchangeRateInfoForLocale(
locale),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
if (snapshot.hasData &&
snapshot.data!.isNotEmpty) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
@@ -116,7 +120,9 @@ class MainScreenSummaryCard extends StatelessWidget {
),
),
child: Text(
AppLocalizations.of(context).exchangeRateDisplay.replaceAll('@', snapshot.data!),
AppLocalizations.of(context)
.exchangeRateDisplay
.replaceAll('@', snapshot.data!),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@@ -133,7 +139,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
// 월별 총 비용 표시 (언어별 기본 통화)
FutureBuilder<double>(
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
future: CurrencyUtil
.calculateTotalMonthlyExpenseInDefaultCurrency(
provider.subscriptions,
locale,
),
@@ -142,17 +149,24 @@ class MainScreenSummaryCard extends StatelessWidget {
return const CircularProgressIndicator();
}
final monthlyCost = snapshot.data!;
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
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',
locale: defaultCurrency == 'KRW'
? 'ko_KR'
: defaultCurrency == 'JPY'
? 'ja_JP'
: defaultCurrency == 'CNY'
? 'zh_CN'
: 'en_US',
symbol: '',
decimalDigits: decimals,
).format(monthlyCost),
@@ -179,7 +193,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 16),
// 연간 비용 및 총 구독 수 표시
FutureBuilder<double>(
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
future: CurrencyUtil
.calculateTotalMonthlyExpenseInDefaultCurrency(
provider.subscriptions,
locale,
),
@@ -189,17 +204,25 @@ class MainScreenSummaryCard extends StatelessWidget {
}
final monthlyCost = snapshot.data!;
final yearlyCost = monthlyCost * 12;
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
final decimals = (defaultCurrency == 'KRW' ||
defaultCurrency == 'JPY')
? 0
: 2;
return Row(
children: [
_buildInfoBox(
context,
title: AppLocalizations.of(context).estimatedAnnualCost,
title: AppLocalizations.of(context)
.estimatedAnnualCost,
value: '${NumberFormat.currency(
locale: defaultCurrency == 'KRW' ? 'ko_KR' :
defaultCurrency == 'JPY' ? 'ja_JP' :
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
locale: defaultCurrency == 'KRW'
? 'ko_KR'
: defaultCurrency == 'JPY'
? 'ja_JP'
: defaultCurrency == 'CNY'
? 'zh_CN'
: 'en_US',
symbol: currencySymbol,
decimalDigits: decimals,
).format(yearlyCost)}',
@@ -207,8 +230,10 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(width: 16),
_buildInfoBox(
context,
title: AppLocalizations.of(context).totalSubscriptionServices,
value: '$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
title: AppLocalizations.of(context)
.totalSubscriptionServices,
value:
'$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
),
],
);
@@ -255,7 +280,8 @@ class MainScreenSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).eventDiscountActive,
AppLocalizations.of(context)
.eventDiscountActive,
style: TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
@@ -266,7 +292,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 2),
// 이벤트 절약액 표시 (언어별 기본 통화)
FutureBuilder<double>(
future: CurrencyUtil.calculateTotalEventSavingsInDefaultCurrency(
future: CurrencyUtil
.calculateTotalEventSavingsInDefaultCurrency(
provider.subscriptions,
locale,
),
@@ -275,15 +302,24 @@ class MainScreenSummaryCard extends StatelessWidget {
return const SizedBox();
}
final eventSavings = snapshot.data!;
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
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',
locale: defaultCurrency == 'KRW'
? 'ko_KR'
: defaultCurrency == 'JPY'
? 'ja_JP'
: defaultCurrency ==
'CNY'
? 'zh_CN'
: 'en_US',
symbol: currencySymbol,
decimalDigits: decimals,
).format(eventSavings),

View File

@@ -44,7 +44,7 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
}
_nativeAd = NativeAd(
adUnitId: _testAdUnitId(), // 실제 배포 시 교체 필요
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함
request: const AdRequest(),
listener: NativeAdListener(
@@ -63,15 +63,15 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
)..load();
}
/// 테스트 광고 단위 ID 반환 함수
/// 광고 단위 ID 반환 함수
/// Theme.of(context)를 사용하지 않고 Platform 클래스 직접 사용
String _testAdUnitId() {
if (Platform.isAndroid) {
// Android 테스트 네이티브 광고 ID
return 'ca-app-pub-3940256099942544/2247696110';
// Android 네이티브 광고 ID
return 'ca-app-pub-6691216385521068/4512709971';
} else if (Platform.isIOS) {
// iOS 테스트 네이티브 광고 ID
return 'ca-app-pub-3940256099942544/3986624511';
// iOS 네이티브 광고 ID
return 'ca-app-pub-6691216385521068/4512709971';
}
return '';
}

View File

@@ -5,7 +5,7 @@ class SkeletonLoading extends StatelessWidget {
final double? width;
final double? height;
final double borderRadius;
const SkeletonLoading({
Key? key,
this.width,
@@ -19,7 +19,7 @@ class SkeletonLoading extends StatelessWidget {
if (width != null || height != null) {
return _buildSingleSkeleton();
}
// 기본 전체 화면 스켈레톤
return Column(
children: [
@@ -30,25 +30,25 @@ class SkeletonLoading extends StatelessWidget {
blur: 10,
opacity: 0.1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 24,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 24,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildSkeletonColumn(),
_buildSkeletonColumn(),
],
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildSkeletonColumn(),
_buildSkeletonColumn(),
],
),
],
),
),
// 구독 목록 스켈레톤
@@ -156,4 +156,4 @@ class SkeletonLoading extends StatelessWidget {
],
);
}
}
}

View File

@@ -67,4 +67,4 @@ class ScanInitialWidget extends StatelessWidget {
],
);
}
}
}

View File

@@ -33,4 +33,4 @@ class ScanLoadingWidget extends StatelessWidget {
),
);
}
}
}

View File

@@ -35,4 +35,4 @@ class ScanProgressWidget extends StatelessWidget {
],
);
}
}
}

View File

@@ -43,7 +43,8 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
void initState() {
super.initState();
// URL 필드 자동 설정
if (widget.websiteUrlController.text.isEmpty && widget.subscription.websiteUrl != null) {
if (widget.websiteUrlController.text.isEmpty &&
widget.subscription.websiteUrl != null) {
widget.websiteUrlController.text = widget.subscription.websiteUrl!;
}
}
@@ -110,13 +111,13 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
),
),
),
// 구분선
Container(
height: 1,
color: AppColors.navyGray.withValues(alpha: 0.1),
),
// 클릭 불가능한 액션 영역
Padding(
padding: const EdgeInsets.all(16.0),
@@ -145,7 +146,7 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
forceDark: true,
),
const SizedBox(height: 24),
// 서비스명
ThemedText(
AppLocalizations.of(context).serviceName,
@@ -256,7 +257,8 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
const SizedBox(height: 8),
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: widget.selectedCategoryId ?? widget.subscription.category,
selectedCategoryId:
widget.selectedCategoryId ?? widget.subscription.category,
onChanged: widget.onCategoryChanged,
baseColor: _getCategoryColor(categoryProvider),
isGlassmorphism: true,
@@ -304,12 +306,13 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
}
Color? _getCategoryColor(CategoryProvider categoryProvider) {
final categoryId = widget.selectedCategoryId ?? widget.subscription.category;
final categoryId =
widget.selectedCategoryId ?? widget.subscription.category;
if (categoryId == null) return null;
final category = categoryProvider.getCategoryById(categoryId);
if (category == null) return null;
return CategoryIconMapper.getCategoryColor(category);
}
}
}

View File

@@ -8,7 +8,7 @@ class SpringAnimationWidget extends StatefulWidget {
final Offset? initialOffset;
final double? initialScale;
final double? initialRotation;
const SpringAnimationWidget({
super.key,
required this.child,
@@ -41,7 +41,7 @@ class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
vsync: this,
duration: const Duration(seconds: 2),
);
// 오프셋 애니메이션
_offsetAnimation = Tween<Offset>(
begin: widget.initialOffset ?? const Offset(0, 50),
@@ -50,7 +50,7 @@ class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
parent: _controller,
curve: Curves.elasticOut,
));
// 스케일 애니메이션
_scaleAnimation = Tween<double>(
begin: widget.initialScale ?? 0.5,
@@ -59,7 +59,7 @@ class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
parent: _controller,
curve: Curves.elasticOut,
));
// 회전 애니메이션
_rotationAnimation = Tween<double>(
begin: widget.initialRotation ?? 0.0,
@@ -68,7 +68,7 @@ class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
parent: _controller,
curve: Curves.elasticOut,
));
// 지연 후 애니메이션 시작
Future.delayed(widget.delay, () {
if (mounted) {
@@ -110,7 +110,7 @@ class BouncyButton extends StatefulWidget {
final VoidCallback? onPressed;
final EdgeInsetsGeometry? padding;
final BoxDecoration? decoration;
const BouncyButton({
super.key,
required this.child,
@@ -127,7 +127,7 @@ class _BouncyButtonState extends State<BouncyButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
@@ -135,7 +135,7 @@ class _BouncyButtonState extends State<BouncyButton>
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
@@ -144,26 +144,26 @@ class _BouncyButtonState extends State<BouncyButton>
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
_controller.forward();
}
void _handleTapUp(TapUpDetails details) {
_controller.reverse();
widget.onPressed?.call();
}
void _handleTapCancel() {
_controller.reverse();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
@@ -193,7 +193,7 @@ class GravityAnimation extends StatefulWidget {
final double gravity;
final double bounceFactor;
final double initialVelocity;
const GravityAnimation({
super.key,
required this.child,
@@ -221,7 +221,7 @@ class _GravityAnimationState extends State<GravityAnimation>
vsync: this,
duration: const Duration(seconds: 10),
)..addListener(_updatePhysics);
_controller.repeat();
}
@@ -229,15 +229,15 @@ class _GravityAnimationState extends State<GravityAnimation>
setState(() {
// 속도 업데이트 (중력 적용)
_velocity += widget.gravity * 0.016; // 60fps 가정
// 위치 업데이트
_position += _velocity;
// 바닥 충돌 감지
if (_position >= _floor) {
_position = _floor;
_velocity = -_velocity * widget.bounceFactor;
// 너무 작은 바운스는 멈춤
if (_velocity.abs() < 1) {
_velocity = 0;
@@ -266,7 +266,7 @@ class RippleAnimation extends StatefulWidget {
final Widget child;
final Color rippleColor;
final Duration duration;
const RippleAnimation({
super.key,
required this.child,
@@ -290,7 +290,7 @@ class _RippleAnimationState extends State<RippleAnimation>
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -325,8 +325,8 @@ class _RippleAnimationState extends State<RippleAnimation>
height: 100 + 200 * _animation.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.rippleColor.withValues(alpha:
(1 - _animation.value) * 0.3,
color: widget.rippleColor.withValues(
alpha: (1 - _animation.value) * 0.3,
),
),
);
@@ -337,4 +337,4 @@ class _RippleAnimationState extends State<RippleAnimation>
),
);
}
}
}

View File

@@ -8,7 +8,7 @@ class StaggeredListAnimation extends StatefulWidget {
final Duration animationDuration;
final Curve curve;
final Axis direction;
const StaggeredListAnimation({
super.key,
required this.children,
@@ -42,7 +42,7 @@ class _StaggeredListAnimationState extends State<StaggeredListAnimation>
duration: widget.animationDuration,
vsync: this,
);
final fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -50,7 +50,7 @@ class _StaggeredListAnimationState extends State<StaggeredListAnimation>
parent: controller,
curve: widget.curve,
));
final slideAnimation = Tween<Offset>(
begin: widget.direction == Axis.vertical
? const Offset(0, 0.3)
@@ -60,7 +60,7 @@ class _StaggeredListAnimationState extends State<StaggeredListAnimation>
parent: controller,
curve: widget.curve,
));
final scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
@@ -68,7 +68,7 @@ class _StaggeredListAnimationState extends State<StaggeredListAnimation>
parent: controller,
curve: widget.curve,
));
_controllers.add(controller);
_fadeAnimations.add(fadeAnimation);
_slideAnimations.add(slideAnimation);
@@ -132,7 +132,7 @@ class StaggeredAnimationItem extends StatefulWidget {
final Duration delay;
final Duration duration;
final Curve curve;
const StaggeredAnimationItem({
super.key,
required this.child,
@@ -160,7 +160,7 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
duration: widget.duration,
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -168,7 +168,7 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
parent: _controller,
curve: widget.curve,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
@@ -176,7 +176,7 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
parent: _controller,
curve: widget.curve,
));
_scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
@@ -184,7 +184,7 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
parent: _controller,
curve: widget.curve,
));
// 지연 후 애니메이션 시작
Future.delayed(widget.delay * widget.index, () {
if (mounted) {
@@ -224,7 +224,7 @@ class FlipAnimationCard extends StatefulWidget {
final Widget front;
final Widget back;
final Duration duration;
const FlipAnimationCard({
super.key,
required this.front,
@@ -249,7 +249,7 @@ class _FlipAnimationCardState extends State<FlipAnimationCard>
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -299,4 +299,4 @@ class _FlipAnimationCardState extends State<FlipAnimationCard>
),
);
}
}
}

View File

@@ -41,18 +41,18 @@ class _SubscriptionCardState extends State<SubscriptionCard>
);
_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;
@@ -60,7 +60,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
}
}
@override
void didUpdateWidget(SubscriptionCard oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -203,7 +202,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
daysUntilNext = 7; // 다음 주 같은 요일
}
if (daysUntilNext == 0) return AppLocalizations.of(context).paymentDueToday;
if (daysUntilNext == 0)
return AppLocalizations.of(context).paymentDueToday;
return AppLocalizations.of(context).paymentDueInDays(daysUntilNext);
}
@@ -232,18 +232,18 @@ class _SubscriptionCardState extends State<SubscriptionCard>
if (widget.subscription.categoryId == null) {
return AppColors.blueGradient;
}
final categoryProvider = context.watch<CategoryProvider>();
final category = categoryProvider.getCategoryById(widget.subscription.categoryId!);
final category =
categoryProvider.getCategoryById(widget.subscription.categoryId!);
if (category == null) {
return AppColors.blueGradient;
}
final categoryColor = Color(
int.parse(category.color.replaceAll('#', '0xFF'))
);
final categoryColor =
Color(int.parse(category.color.replaceAll('#', '0xFF')));
return [
categoryColor,
categoryColor.withValues(alpha: 0.8),
@@ -283,12 +283,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
Widget build(BuildContext context) {
// LocaleProvider를 watch하여 언어 변경시 자동 업데이트
final localeProvider = context.watch<LocaleProvider>();
// 언어가 변경되면 displayName 다시 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadDisplayName();
});
final isNearBilling = _isNearBilling();
return Hero(
@@ -300,11 +300,13 @@ class _SubscriptionCardState extends State<SubscriptionCard>
padding: EdgeInsets.zero,
borderRadius: 16,
blur: _isHovering ? 15 : 10,
width: double.infinity, // 전체 너비를 차지하도록 설정
onTap: widget.onTap ?? () async {
print('[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}');
await AppNavigator.toDetail(context, widget.subscription);
},
width: double.infinity, // 전체 너비를 차지하도록 설정
onTap: widget.onTap ??
() async {
print(
'[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}');
await AppNavigator.toDetail(context, widget.subscription);
},
child: Column(
children: [
// 그라데이션 상단 바 효과
@@ -330,281 +332,290 @@ class _SubscriptionCardState extends State<SubscriptionCard>
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 서비스 아이콘
WebsiteIcon(
key: ValueKey(
'subscription_icon_${widget.subscription.id}'),
url: widget.subscription.websiteUrl,
serviceName: widget.subscription.serviceName,
size: 48,
isHovered: _isHovering,
),
const SizedBox(width: 16),
// 서비스 정보
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 서비스 아이콘
WebsiteIcon(
key: ValueKey(
'subscription_icon_${widget.subscription.id}'),
url: widget.subscription.websiteUrl,
serviceName: widget.subscription.serviceName,
size: 48,
isHovered: _isHovering,
// 서비스
Flexible(
child: Text(
_displayName ??
widget.subscription.serviceName,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: AppColors
.darkNavy, // color.md 가이드: 메인 텍스트
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 16),
// 서비스 정보
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
// 서비스명
Flexible(
child: Text(
_displayName ?? widget.subscription.serviceName,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// 배지들
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 이벤트 배지
if (widget.subscription.isCurrentlyInEvent) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFFFF6B6B),
Color(0xFFFF8787),
],
),
borderRadius:
BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.local_offer_rounded,
size: 11,
color: AppColors.pureWhite,
),
const SizedBox(width: 3),
Text(
AppLocalizations.of(context).event,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.pureWhite,
),
),
],
),
),
const SizedBox(width: 6),
],
// 결제 주기 배지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
borderRadius:
BorderRadius.circular(12),
border: Border.all(
color: AppColors.borderColor,
width: 0.5,
),
),
child: Text(
AppLocalizations.of(context).getBillingCycleName(widget.subscription.billingCycle),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
),
),
),
// 배지들
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 이벤트 배지
if (widget
.subscription.isCurrentlyInEvent) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFFFF6B6B),
Color(0xFFFF8787),
],
),
],
),
const SizedBox(height: 6),
// 가격 정보
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
// 가격 표시 (이벤트 가격 반영)
// 가격 표시 (언어별 통화)
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,
),
);
}
},
),
// 결제 예정일 정보
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: isNearBilling
? AppColors.warningColor
.withValues(alpha: 0.1)
: AppColors.successColor
.withValues(alpha: 0.1),
borderRadius:
BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isNearBilling
? Icons
.access_time_filled_rounded
: Icons
.check_circle_rounded,
size: 12,
color: isNearBilling
? AppColors.warningColor
: AppColors.successColor,
),
const SizedBox(width: 4),
Text(
_getNextBillingText(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isNearBilling
? AppColors.warningColor
: AppColors.successColor,
),
),
],
),
),
],
),
// 이벤트 절약액 표시
if (widget.subscription.isCurrentlyInEvent &&
widget.subscription.eventSavings > 0) ...[
const SizedBox(height: 4),
Row(
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.savings_rounded,
size: 14,
color: Color(0xFFFF6B6B),
),
const SizedBox(width: 4),
// 이벤트 절약액 표시 (언어별 통화)
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),
),
);
},
),
],
const Icon(
Icons.local_offer_rounded,
size: 11,
color: AppColors.pureWhite,
),
const SizedBox(width: 3),
Text(
AppLocalizations.of(context).event,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.pureWhite,
),
),
const SizedBox(width: 8),
// 이벤트 종료일까지 남은 일수
if (widget.subscription.eventEndDate != null) ...[
Text(
AppLocalizations.of(context).daysRemaining(widget.subscription.eventEndDate!.difference(DateTime.now()).inDays),
style: const TextStyle(
fontSize: 11,
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
),
),
],
],
),
],
),
const SizedBox(width: 6),
],
// 결제 주기 배지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.borderColor,
width: 0.5,
),
),
child: Text(
AppLocalizations.of(context)
.getBillingCycleName(
widget.subscription.billingCycle),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors
.navyGray, // color.md 가이드: 서브 텍스트
),
),
),
],
),
],
),
const SizedBox(height: 6),
// 가격 정보
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 가격 표시 (이벤트 가격 반영)
// 가격 표시 (언어별 통화)
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,
),
);
}
},
),
// 결제 예정일 정보
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: isNearBilling
? AppColors.warningColor
.withValues(alpha: 0.1)
: AppColors.successColor
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isNearBilling
? Icons.access_time_filled_rounded
: Icons.check_circle_rounded,
size: 12,
color: isNearBilling
? AppColors.warningColor
: AppColors.successColor,
),
const SizedBox(width: 4),
Text(
_getNextBillingText(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isNearBilling
? AppColors.warningColor
: AppColors.successColor,
),
),
],
),
),
],
),
// 이벤트 절약액 표시
if (widget.subscription.isCurrentlyInEvent &&
widget.subscription.eventSavings > 0) ...[
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.savings_rounded,
size: 14,
color: Color(0xFFFF6B6B),
),
const SizedBox(width: 4),
// 이벤트 절약액 표시 (언어별 통화)
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),
),
);
},
),
],
),
),
const SizedBox(width: 8),
// 이벤트 종료일까지 남은 일수
if (widget.subscription.eventEndDate !=
null) ...[
Text(
AppLocalizations.of(context).daysRemaining(
widget.subscription.eventEndDate!
.difference(DateTime.now())
.inDays),
style: const TextStyle(
fontSize: 11,
color: AppColors
.navyGray, // color.md 가이드: 서브 텍스트
),
),
],
],
),
],
],
),
),
],
),
),
],

View File

@@ -46,12 +46,17 @@ class SubscriptionListWidget extends StatelessWidget {
child: Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return CategoryHeaderWidget(
categoryName: categoryProvider.getLocalizedCategoryName(context, category),
categoryName: categoryProvider.getLocalizedCategoryName(
context, category),
subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
totalCostUSD:
_calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW:
_calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY:
_calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY:
_calculateTotalByCurrency(subscriptions, 'CNY'),
);
},
),
@@ -95,41 +100,50 @@ class SubscriptionListWidget extends StatelessWidget {
child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex],
onTap: () {
print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(context, subscriptions[subIndex]);
print(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(
context, subscriptions[subIndex]);
},
onDelete: () async {
// 현재 로케일에 맞는 서비스명 가져오기
final localeProvider = Provider.of<LocaleProvider>(
final localeProvider =
Provider.of<LocaleProvider>(
context,
listen: false,
);
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscriptions[subIndex].serviceName,
final locale =
localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher
.getServiceDisplayName(
serviceName:
subscriptions[subIndex].serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show(
final shouldDelete =
await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
if (shouldDelete && context.mounted) {
// 사용자가 확인한 경우에만 삭제 진행
final provider = Provider.of<SubscriptionProvider>(
context,
final provider =
Provider.of<SubscriptionProvider>(
context,
listen: false,
);
await provider.deleteSubscription(
subscriptions[subIndex].id,
);
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).subscriptionDeleted(displayName),
message: AppLocalizations.of(context)
.subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded,
);
}
@@ -152,7 +166,8 @@ class SubscriptionListWidget extends StatelessWidget {
}
/// 특정 통화의 총 합계를 계산합니다.
double _calculateTotalByCurrency(List<SubscriptionModel> subscriptions, String currency) {
double _calculateTotalByCurrency(
List<SubscriptionModel> subscriptions, String currency) {
return subscriptions
.where((sub) => sub.currency == currency)
.fold(0.0, (sum, sub) => sum + sub.monthlyCost);

View File

@@ -258,7 +258,8 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
angle: _currentOffset / 2000,
child: SubscriptionCard(
subscription: widget.subscription,
onTap: widget.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함
onTap: widget
.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함
),
),
),

View File

@@ -35,47 +35,49 @@ class ThemedText extends StatelessWidget {
});
/// 배경 밝기에 따른 텍스트 색상 결정
static Color getContrastColor(BuildContext context, {
static Color getContrastColor(
BuildContext context, {
bool forceLight = false,
bool forceDark = false,
}) {
if (forceLight) return AppColors.pureWhite;
if (forceDark) return AppColors.darkNavy;
final brightness = Theme.of(context).brightness;
// 글래스모피즘 환경에서는 배경이 밝으므로 어두운 텍스트 사용
if (_isGlassmorphicContext(context)) {
return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트
return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트
}
// 일반 환경
return brightness == Brightness.dark
? AppColors.pureWhite
return brightness == Brightness.dark
? AppColors.pureWhite
: AppColors.darkNavy;
}
/// 글래스모피즘 컨텍스트인지 확인
static bool _isGlassmorphicContext(BuildContext context) {
// 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인
final glassmorphic = context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
final glassmorphic =
context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
return glassmorphic != null;
}
@override
Widget build(BuildContext context) {
final textColor = color ?? getContrastColor(
context,
forceLight: forceLight,
forceDark: forceDark,
);
final finalColor = opacity != null
? textColor.withValues(alpha: opacity!)
: textColor;
final textColor = color ??
getContrastColor(
context,
forceLight: forceLight,
forceDark: forceDark,
);
final finalColor =
opacity != null ? textColor.withValues(alpha: opacity!) : textColor;
final defaultStyle = DefaultTextStyle.of(context).style;
// 개별 스타일 속성들을 병합
final baseStyle = TextStyle(
fontSize: fontSize,
@@ -83,9 +85,9 @@ class ThemedText extends StatelessWidget {
letterSpacing: letterSpacing,
color: finalColor,
);
final effectiveStyle = defaultStyle.merge(baseStyle).merge(style);
return Text(
text,
style: effectiveStyle,
@@ -193,7 +195,7 @@ class GlassmorphicIndicator extends InheritedWidget {
/// 글래스모피즘 환경에서 텍스트 색상을 자동 조정하는 래퍼
class GlassmorphicTextWrapper extends StatelessWidget {
final Widget child;
const GlassmorphicTextWrapper({
super.key,
required this.child,
@@ -204,10 +206,10 @@ class GlassmorphicTextWrapper extends StatelessWidget {
return GlassmorphicIndicator(
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
color: ThemedText.getContrastColor(context),
),
color: ThemedText.getContrastColor(context),
),
child: child,
),
);
}
}
}

View File

@@ -104,8 +104,6 @@ class FaviconCache {
// 구글 파비콘 API 서비스
class GoogleFaviconService {
// 구글 파비콘 API URL 생성
static String getFaviconUrl(String domain, int size) {
final directUrl =
@@ -137,7 +135,8 @@ class GoogleFaviconService {
static String getBase64PlaceholderIcon(String serviceName, Color color) {
// 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시)
final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?';
final colorHex = color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2);
final colorHex =
color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2);
// 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생)
final svgContent =
@@ -568,7 +567,8 @@ class _WebsiteIconState extends State<WebsiteIcon>
boxShadow: widget.isHovered
? [
BoxShadow(
color: _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값
color:
_getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값
blurRadius: 12,
spreadRadius: 0,
offset: const Offset(0, 4),