feat: adopt material 3 theme and billing adjustments

This commit is contained in:
JiWoong Sul
2025-09-16 14:30:14 +09:00
parent a01d9092ba
commit 44850a53cc
85 changed files with 2957 additions and 2776 deletions

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
// import 'package:flutter/foundation.dart' show kIsWeb;
// import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'dart:math' as math;
import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart';
@@ -26,9 +26,11 @@ class AddSubscriptionAppBar extends StatelessWidget
Widget build(BuildContext context) {
final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100));
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: appBarOpacity),
// Color adapts to current theme (light/dark)
color: scheme.surface.withValues(alpha: appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
@@ -43,10 +45,10 @@ class AddSubscriptionAppBar extends StatelessWidget
child: SafeArea(
child: AppBar(
leading: IconButton(
icon: const Icon(
icon: Icon(
Icons.chevron_left,
size: 28,
color: Color(0xFF1E293B),
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () => Navigator.of(context).pop(),
),
@@ -57,7 +59,7 @@ class AddSubscriptionAppBar extends StatelessWidget
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: const Color(0xFF1E293B),
color: Theme.of(context).colorScheme.onSurface,
shadows: appBarOpacity > 0.6
? [
Shadow(
@@ -71,33 +73,8 @@ class AddSubscriptionAppBar extends StatelessWidget
),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
if (!kIsWeb)
controller.isLoading
? const Padding(
padding: EdgeInsets.only(right: 16.0),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF3B82F6)),
),
),
),
)
: IconButton(
icon: const FaIcon(
FontAwesomeIcons.message,
size: 20,
color: Color(0xFF3B82F6),
),
onPressed: onScanSMS,
tooltip: AppLocalizations.of(context).scanTextMessages,
),
],
// SMS 스캔 버튼 제거: 우측 액션 비움
actions: const [],
),
),
);

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../../theme/app_colors.dart';
// import '../../theme/app_colors.dart';
/// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget {
@@ -40,19 +40,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.glassCard,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -95,10 +89,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
}
return Text(
titleText,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
);
},
@@ -118,7 +112,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
}
});
},
activeColor: controller.gradientColors[0],
activeThumbColor: controller.gradientColors[0],
activeTrackColor:
controller.gradientColors[0].withValues(alpha: 0.5),
),
],
),
@@ -137,18 +133,24 @@ class AddSubscriptionEventSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08),
color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
const Icon(
Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
color: Theme.of(context).colorScheme.tertiary,
size: 20,
),
const SizedBox(width: 8),
@@ -174,9 +176,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
}
return Text(
infoText,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
color: Theme.of(context)
.colorScheme
.onSurface,
fontWeight: FontWeight.w500,
),
);
@@ -272,6 +276,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
currency: controller.currency,
label: eventPriceLabel,
hintText: eventPriceHint,
enabled: controller.isEventActive,
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
validator:
controller.isEventActive ? null : (_) => null,
);
},
),

View File

@@ -7,11 +7,11 @@ import '../../l10n/app_localizations.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_selector.dart';
import '../common/form_fields/currency_dropdown_field.dart';
import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../glassmorphism_card.dart';
import '../../theme/app_colors.dart';
// Glass 제거: Material 3 Card 사용
// Material colors only
/// 구독 추가 화면의 폼 섹션
class AddSubscriptionForm extends StatelessWidget {
@@ -45,8 +45,15 @@ class AddSubscriptionForm extends StatelessWidget {
parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
)),
child: GlassmorphismCard(
backgroundColor: AppColors.glassCard,
child: Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -55,26 +62,19 @@ class AddSubscriptionForm extends StatelessWidget {
// 헤더
Row(
children: [
ShaderMask(
shaderCallback: (bounds) => LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(bounds),
child: const Icon(
FontAwesomeIcons.fileLines,
size: 20,
color: Colors.white,
),
Icon(
FontAwesomeIcons.fileLines,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).serviceInfo,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: Color(0xFF1E293B),
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -136,9 +136,8 @@ class AddSubscriptionForm extends StatelessWidget {
),
),
const SizedBox(height: 8),
CurrencySelector(
CurrencyDropdownField(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
setState(() {
controller.currency = value;
@@ -158,8 +157,8 @@ class AddSubscriptionForm extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).billingCycle,
style: const TextStyle(
color: AppColors.textPrimary,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
@@ -168,7 +167,6 @@ class AddSubscriptionForm extends StatelessWidget {
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (value) {
setState(() {
controller.billingCycle = value;
@@ -203,7 +201,7 @@ class AddSubscriptionForm extends StatelessWidget {
keyboardType: TextInputType.url,
prefixIcon: Icon(
Icons.link_rounded,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
@@ -226,7 +224,6 @@ class AddSubscriptionForm extends StatelessWidget {
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (categoryId) {
setState(() {
controller.selectedCategoryId = categoryId;

View File

@@ -26,19 +26,7 @@ class AddSubscriptionHeader extends StatelessWidget {
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: controller.gradientColors[0].withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 0,
offset: const Offset(0, 8),
),
],
color: Theme.of(context).colorScheme.primary,
),
child: Row(
children: [
@@ -48,10 +36,10 @@ class AddSubscriptionHeader extends StatelessWidget {
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
child: Icon(
Icons.add_rounded,
size: 32,
color: Colors.white,
color: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 16),
@@ -61,20 +49,23 @@ class AddSubscriptionHeader extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).newSubscriptionAdd,
style: const TextStyle(
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
color: Theme.of(context).colorScheme.onPrimary,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context).enterServiceInfo,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white70,
color: Theme.of(context)
.colorScheme
.onPrimary
.withValues(alpha: 0.7),
),
),
],

View File

@@ -44,7 +44,7 @@ class AddSubscriptionSaveButton extends StatelessWidget {
? null
: () => controller.saveSubscription(setState: setState),
isLoading: controller.isLoading,
backgroundColor: const Color(0xFF3B82F6),
backgroundColor: Theme.of(context).colorScheme.primary,
),
),
),

View File

@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart';
// import '../../theme/app_colors.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget {
@@ -26,15 +26,15 @@ class AnalysisBadge extends StatelessWidget {
width: size,
height: size,
decoration: BoxDecoration(
color: AppColors.pureWhite,
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: 2,
),
boxShadow: const [
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10,
spreadRadius: 2,
),
@@ -48,10 +48,10 @@ class AnalysisBadge extends StatelessWidget {
subscription.serviceName.length > 5
? '${subscription.serviceName.substring(0, 5)}...'
: subscription.serviceName,
style: const TextStyle(
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 0),
@@ -82,9 +82,9 @@ class AnalysisBadge extends StatelessWidget {
}
return Text(
displayText,
style: const TextStyle(
style: TextStyle(
fontSize: 7,
color: AppColors.navyGray,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}

View File

@@ -3,10 +3,10 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart';
import '../../services/currency_util.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/color_scheme_ext.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget {
@@ -38,10 +38,17 @@ class EventAnalysisCard extends StatelessWidget {
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -64,20 +71,18 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFFFF6B6B),
Color(0xFFFE7E7E),
],
),
color:
Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const FaIcon(
FaIcon(
FontAwesomeIcons.fire,
size: 12,
color: AppColors.pureWhite,
color: Theme.of(context)
.colorScheme
.onError,
),
const SizedBox(width: 4),
Text(
@@ -85,10 +90,12 @@ class EventAnalysisCard extends StatelessWidget {
.servicesInProgress(provider
.activeEventSubscriptions
.length),
style: const TextStyle(
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
color: Theme.of(context)
.colorScheme
.onError,
),
),
],
@@ -100,27 +107,24 @@ class EventAnalysisCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFFF6B6B)
.withValues(alpha: 0.1),
const Color(0xFFFF8787)
.withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFF6B6B)
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.3),
),
),
child: Row(
children: [
const Icon(
Icon(
Icons.savings,
color: Color(0xFFFF6B6B),
color:
Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(width: 12),
@@ -142,10 +146,12 @@ class EventAnalysisCard extends StatelessWidget {
CurrencyUtil.formatTotalAmount(
provider.calculateTotalSavings(),
),
style: const TextStyle(
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B),
color: Theme.of(context)
.colorScheme
.error,
),
),
],
@@ -173,12 +179,16 @@ class EventAnalysisCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.darkNavy
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.darkNavy
.withValues(alpha: 0.1),
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.2),
),
),
child: Row(
@@ -207,13 +217,15 @@ class EventAnalysisCard extends StatelessWidget {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
decoration:
TextDecoration
.lineThrough,
color: AppColors
.navyGray,
color: Theme.of(
context)
.colorScheme
.onSurfaceVariant,
),
);
}
@@ -221,10 +233,12 @@ class EventAnalysisCard extends StatelessWidget {
},
),
const SizedBox(width: 8),
const Icon(
Icon(
Icons.arrow_forward,
size: 12,
color: AppColors.navyGray,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 8),
FutureBuilder<String>(
@@ -237,12 +251,14 @@ class EventAnalysisCard extends StatelessWidget {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
fontWeight:
FontWeight.bold,
color:
Color(0xFF10B981),
Theme.of(context)
.colorScheme
.success,
),
);
}
@@ -260,17 +276,22 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B)
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
borderRadius:
BorderRadius.circular(4),
),
child: Text(
'$discountRate${AppLocalizations.of(context).discountPercent}',
style: const TextStyle(
_formatDiscountPercent(
context, discountRate),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B),
color: Theme.of(context)
.colorScheme
.error,
),
),
),
@@ -291,3 +312,17 @@ class EventAnalysisCard extends StatelessWidget {
);
}
}
String _formatDiscountPercent(BuildContext context, int percent) {
final raw = AppLocalizations.of(context).discountPercent;
// 우선 @ 플레이스홀더가 있으면 치환
if (raw.contains('@')) {
return raw.replaceAll('@', percent.toString());
}
// % 마커가 있으면 첫 번째 %를 숫자%로 치환
if (raw.contains('%')) {
return raw.replaceFirst('%', '$percent%');
}
// 폴백: "99% text" 형태
return '$percent% $raw';
}

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import '../../theme/color_scheme_ext.dart';
import 'package:fl_chart/fl_chart.dart';
import 'dart:math' as math;
import 'package:provider/provider.dart';
import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/reduce_motion.dart';
@@ -75,11 +75,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
}
// 월간 지출 차트 데이터
List<BarChartGroupData> _getMonthlyBarGroups(String locale) {
List<BarChartGroupData> _getMonthlyBarGroups(
BuildContext context, String locale) {
final List<BarChartGroupData> barGroups = [];
final calculatedMax = monthlyData.fold<double>(
0, (max, data) => math.max(max, data['totalExpense'] as double));
final maxAmount = _calculateChartMaxY(calculatedMax, locale);
final scheme = Theme.of(context).colorScheme;
for (int i = 0; i < monthlyData.length; i++) {
final data = monthlyData[i];
@@ -89,20 +91,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
barRods: [
BarChartRodData(
toY: data['totalExpense'],
gradient: LinearGradient(
colors: [
const Color(0xFF3B82F6).withValues(alpha: 0.7),
const Color(0xFF60A5FA),
],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
),
color: scheme.primary,
width: 18,
borderRadius: BorderRadius.circular(4),
backDrawRodData: BackgroundBarChartRodData(
show: true,
toY: maxAmount,
color: AppColors.navyGray.withValues(alpha: 0.1),
color: scheme.onSurfaceVariant.withValues(alpha: 0.08),
),
),
],
@@ -132,10 +127,17 @@ class MonthlyExpenseChartCard extends StatelessWidget {
parent: animationController,
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -168,7 +170,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
(max, data) => math.max(
max, data['totalExpense'] as double)),
locale),
barGroups: _getMonthlyBarGroups(locale),
barGroups: _getMonthlyBarGroups(context, locale),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
@@ -182,8 +184,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
CurrencyUtil.getDefaultCurrency(locale)),
getDrawingHorizontalLine: (value) {
return FlLine(
color:
AppColors.navyGray.withValues(alpha: 0.1),
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.1),
strokeWidth: 1,
);
},
@@ -222,14 +226,18 @@ class MonthlyExpenseChartCard extends StatelessWidget {
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: AppColors.darkNavy,
tooltipBgColor: Theme.of(context)
.colorScheme
.inverseSurface,
tooltipRoundedRadius: 8,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
const TextStyle(
color: AppColors.pureWhite,
TextStyle(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
fontWeight: FontWeight.bold,
),
children: [
@@ -239,8 +247,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
monthlyData[group.x]
['totalExpense'] as double,
locale),
style: const TextStyle(
color: Color(0xFFFBBF24),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.warning,
fontSize: 14,
fontWeight: FontWeight.w500,
),

View File

@@ -4,8 +4,9 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../services/exchange_rate_service.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
// import '../../theme/app_colors.dart';
import '../../theme/color_scheme_ext.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
import 'analysis_badge.dart';
import '../../l10n/app_localizations.dart';
@@ -30,18 +31,19 @@ class SubscriptionPieChartCard extends StatefulWidget {
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
int _touchedIndex = -1;
late Future<List<PieChartSectionData>> _pieSectionsFuture;
// kept for compatibility previously; computation now happens per build
String? _lastLocale;
static const _chartColors = [
Color(0xFF3B82F6),
Color(0xFF10B981),
Color(0xFFF59E0B),
Color(0xFFEF4444),
Color(0xFF8B5CF6),
Color(0xFF0EA5E9),
Color(0xFFEC4899),
];
// 차트 팔레트: ColorScheme + 보조 상수(성공/경고/액센트)
List<Color> _getChartColors(ColorScheme scheme) => [
scheme.primary,
scheme.success,
scheme.warning,
scheme.error,
scheme.tertiary,
scheme.secondary,
const Color(0xFFEC4899), // accent
];
@override
void initState() {
@@ -62,7 +64,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
void _initializeFuture() {
_lastLocale = context.read<LocaleProvider>().locale.languageCode;
_pieSectionsFuture = _getPieSections();
// no-op: Future computed on demand in build
}
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
@@ -85,6 +87,9 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 현재 locale 가져오기
final locale = context.read<LocaleProvider>().locale.languageCode;
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
// Chart palette (capture scheme before any awaits)
final scheme = Theme.of(context).colorScheme;
final chartColors = _getChartColors(scheme);
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
List<double> sectionValues = [];
@@ -121,7 +126,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 섹션 데이터 생성 (터치 상태 제외)
final sections = List.generate(widget.subscriptions.length, (i) {
final percentage = (sectionValues[i] / sectionsTotal) * 100;
final index = i % _chartColors.length;
final index = i % chartColors.length;
return PieChartSectionData(
value: sectionValues[i],
@@ -129,12 +134,12 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
titleStyle: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
color: Colors.white,
shadows: [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
color: _chartColors[index],
color: chartColors[index],
radius: 100.0,
titlePositionPercentageOffset: 0.6,
badgeWidget: null,
@@ -150,12 +155,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
if (index >= widget.subscriptions.length) return const SizedBox.shrink();
final subscription = widget.subscriptions[index];
final colorIndex = index % _chartColors.length;
final chartColors = _getChartColors(Theme.of(context).colorScheme);
final colorIndex = index % chartColors.length;
return IgnorePointer(
child: AnalysisBadge(
size: 40,
borderColor: _chartColors[colorIndex],
borderColor: chartColors[colorIndex],
subscription: subscription,
),
);
@@ -177,7 +183,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
color: Colors.white,
shadows: const [
Shadow(
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
@@ -210,10 +216,17 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
parent: widget.animationController,
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -243,20 +256,27 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFE5F2FF),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFFBFDBFE),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
width: 1,
),
),
child: Text(
AppLocalizations.of(context)
.exchangeRateFormat(snapshot.data!),
style: const TextStyle(
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
color:
Theme.of(context).colorScheme.primary,
),
),
);
@@ -291,7 +311,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
: SizedBox(
height: 250,
child: FutureBuilder<List<PieChartSectionData>>(
future: _pieSectionsFuture,
future: _getPieSections(),
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
@@ -392,8 +412,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
(index) {
final subscription =
widget.subscriptions[index];
final chartColors = _getChartColors(
Theme.of(context).colorScheme);
final color =
_chartColors[index % _chartColors.length];
chartColors[index % chartColors.length];
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Row(

View File

@@ -6,8 +6,9 @@ import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart';
import '../../utils/haptic_feedback_helper.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
// import '../../theme/app_colors.dart';
import '../../theme/color_scheme_ext.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
@@ -44,10 +45,17 @@ class TotalExpenseSummaryCard extends StatelessWidget {
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
)),
child: RepaintBoundary(
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -85,8 +93,6 @@ class TotalExpenseSummaryCard extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
backgroundColor: AppColors.glassBackground
.withValues(alpha: 0.3),
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
@@ -142,18 +148,24 @@ class TotalExpenseSummaryCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.glassBackground
.withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.glassBorder
.withValues(alpha: 0.2),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
),
),
child: const FaIcon(
child: FaIcon(
FontAwesomeIcons.listCheck,
size: 16,
color: AppColors.primaryColor,
color: Theme.of(context)
.colorScheme
.primary,
),
),
const SizedBox(width: 12),
@@ -189,18 +201,24 @@ class TotalExpenseSummaryCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.glassBackground
.withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.glassBorder
.withValues(alpha: 0.2),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
),
),
child: const FaIcon(
child: FaIcon(
FontAwesomeIcons.chartLine,
size: 16,
color: AppColors.successColor,
color: Theme.of(context)
.colorScheme
.success,
),
),
const SizedBox(width: 12),

View File

@@ -130,9 +130,11 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateZ(rotateAnimation.value)
..scale(scaleAnimation.value),
child: child,
..rotateZ(rotateAnimation.value),
child: Transform.scale(
scale: scaleAnimation.value,
child: child,
),
);
},
);
@@ -219,7 +221,10 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
FadeTransition(
opacity: animation,
child: Container(
color: Colors.black.withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.scrim
.withValues(alpha: 0.3),
),
),
// 컨테이너 확장 애니메이션

View File

@@ -18,7 +18,21 @@ class AnimatedWaveBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
final reduce = ReduceMotion.isEnabled(context);
final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일
final amp = reduce ? 0.3 : 1.0; // 기본 효과 강도 스케일
// 원 크기에 따라 속도/진폭 스케일을 동적으로 계산
// size가 클수록 느리고(차분), 작을수록 빠르고(활발) 크게 움직이게 함
MotionParams paramsFor(double size) {
const ref = 160.0; // 기준 크기
// 진폭 스케일: 0.6 ~ 1.4 사이 (연속)
final ampScale = _clamp(ref / size, 0.6, 1.4) * (reduce ? 0.6 : 1.0);
// 속도 배수: 1~3의 정수로 제한하여 래핑 시 연속성 보장
final raw = 0.8 + (ref / size) * 0.6; // 약 0.8~1.4 범위
int speedMult = raw < 1.2 ? 1 : (raw < 1.6 ? 2 : 3);
if (reduce && speedMult > 2) speedMult = 2; // 감속 모드 상한
return MotionParams(ampScale: ampScale, speedMult: speedMult);
}
return Stack(
children: [
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
@@ -26,22 +40,26 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller,
builder: (context, child) {
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
final angle = controller.value * 2 * math.pi;
// 사인 함수를 사용하여 부드러운 움직임 생성
final xOffset = 20 * amp * math.sin(angle);
final yOffset = 10 * amp * math.cos(angle);
final p = paramsFor(200);
final angle = controller.value * 2 * math.pi * p.speedMult;
// 사인 함수를 사용하여 부드러운 움직임 생성 (큰 원: 차분)
final xOffset = 20 * amp * p.ampScale * math.sin(angle);
final yOffset = 10 * amp * p.ampScale * math.cos(angle);
return Positioned(
right: -40 + xOffset,
top: -60 + yOffset,
child: Transform.rotate(
// 회전도 선형적으로 변화하도록 수정
angle: 0.2 * amp * math.sin(angle * 0.5),
angle: 0.2 * amp * p.ampScale * math.sin(angle * 0.5),
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(100),
),
),
@@ -53,21 +71,26 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller,
builder: (context, child) {
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
final xOffset = 20 * amp * math.cos(angle);
final yOffset = 10 * amp * math.sin(angle);
final p = paramsFor(220);
final angle =
(controller.value * 2 * math.pi * p.speedMult) + (math.pi / 3);
final xOffset = 20 * amp * p.ampScale * math.cos(angle);
final yOffset = 10 * amp * p.ampScale * math.sin(angle);
return Positioned(
left: -80 + xOffset,
bottom: -70 + yOffset,
child: Transform.rotate(
// 반대 방향으로 회전하도록 설정
angle: -0.3 * amp * math.sin(angle * 0.5),
angle: -0.3 * amp * p.ampScale * math.sin(angle * 0.5),
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(110),
),
),
@@ -80,20 +103,25 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller,
builder: (context, child) {
// 세 번째 원은 다른 위상으로 움직이도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
final xOffset = 15 * amp * math.sin(angle * 0.7);
final yOffset = 8 * amp * math.cos(angle * 0.7);
final p = paramsFor(120);
final angle = (controller.value * 2 * math.pi * p.speedMult) +
(math.pi * 2 / 3);
final xOffset = 15 * amp * p.ampScale * math.sin(angle * 0.9);
final yOffset = 8 * amp * p.ampScale * math.cos(angle * 0.9);
return Positioned(
right: 40 + xOffset,
bottom: -40 + yOffset,
child: Transform.rotate(
angle: 0.4 * amp * math.cos(angle * 0.5),
angle: 0.4 * amp * p.ampScale * math.cos(angle * 0.5),
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(60),
),
),
@@ -112,7 +140,7 @@ class AnimatedWaveBackground extends StatelessWidget {
width: 30,
height: 30,
decoration: BoxDecoration(
color: Colors.white.withValues(
color: Theme.of(context).colorScheme.onSurface.withValues(
alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
borderRadius: BorderRadius.circular(15),
),
@@ -124,3 +152,13 @@ class AnimatedWaveBackground extends StatelessWidget {
);
}
}
// 내부 유틸리티: 값 범위 제한
double _clamp(double v, double min, double max) =>
v < min ? min : (v > max ? max : v);
class MotionParams {
final double ampScale;
final int speedMult;
MotionParams({required this.ampScale, required this.speedMult});
}

View File

@@ -36,27 +36,27 @@ class CategoryHeaderWidget extends StatelessWidget {
children: [
Text(
categoryName,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Color(0xFF374151),
color: Theme.of(context).colorScheme.onSurface,
),
),
Text(
_buildCostDisplay(context),
style: const TextStyle(
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF6B7280),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
const Divider(
Divider(
height: 1,
thickness: 1,
color: Color(0xFFEEEEEE),
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
),
],
),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 주요 액션에 사용되는 Primary 버튼
/// 저장, 추가, 확인 등의 주요 액션에 사용됩니다.
@@ -44,26 +43,30 @@ class _PrimaryButtonState extends State<PrimaryButton> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBackgroundColor =
widget.backgroundColor ?? theme.primaryColor;
widget.backgroundColor ?? theme.colorScheme.primary;
final effectiveForegroundColor =
widget.foregroundColor ?? AppColors.pureWhite;
widget.foregroundColor ?? theme.colorScheme.onPrimary;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: widget.width ?? double.infinity,
height: widget.height,
transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02))
? Matrix4.diagonal3Values(1.02, 1.02, 1.0)
: Matrix4.identity(),
child: ElevatedButton(
onPressed: widget.isLoading ? null : widget.onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: effectiveBackgroundColor,
foregroundColor: effectiveForegroundColor,
// 고정 높이와 텍스트 잘림 방지를 위해 최소 사이즈 지정
minimumSize: Size.fromHeight(widget.height),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.borderRadius),
),
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
// 컨테이너에서 높이를 관리하므로 수직 패딩은 0으로 두고
// 수평 여백만 부여하여 작은 높이(예: 48)에서 글자 잘림 방지
padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: 16),
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08),
disabledBackgroundColor:

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 부차적인 액션에 사용되는 Secondary 버튼
/// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다.
@@ -42,15 +41,17 @@ class _SecondaryButtonState extends State<SecondaryButton> {
@override
Widget build(BuildContext context) {
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;
final theme = Theme.of(context);
final effectiveBorderColor =
widget.borderColor ?? theme.colorScheme.outline;
final effectiveTextColor = widget.textColor ?? theme.colorScheme.primary;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: widget.width,
height: widget.height,
transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02))
? Matrix4.diagonal3Values(1.02, 1.02, 1.0)
: Matrix4.identity(),
child: OutlinedButton(
onPressed: widget.onPressed,
@@ -70,8 +71,9 @@ class _SecondaryButtonState extends State<SecondaryButton> {
vertical: 12,
horizontal: 24,
),
backgroundColor:
_isHovered ? AppColors.glassBackground : Colors.transparent,
backgroundColor: _isHovered
? theme.colorScheme.onSurface.withValues(alpha: 0.06)
: Colors.transparent,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -140,7 +142,7 @@ class _TextLinkButtonState extends State<TextLinkButton> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveColor = widget.color ?? AppColors.primaryColor;
final effectiveColor = widget.color ?? theme.colorScheme.primary;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../theme/color_scheme_ext.dart';
/// 확인 다이얼로그 위젯
/// 사용자에게 중요한 작업을 확인받을 때 사용합니다.
@@ -99,7 +100,9 @@ class ConfirmationDialog extends StatelessWidget {
),
child: Text(
confirmText,
style: const TextStyle(color: Colors.white),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
@@ -164,12 +167,13 @@ class SuccessDialog extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
color:
Theme.of(context).colorScheme.success.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
child: Icon(
Icons.check_circle,
color: Colors.green,
color: Theme.of(context).colorScheme.success,
size: 64,
),
),
@@ -188,7 +192,7 @@ class SuccessDialog extends StatelessWidget {
message!,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
@@ -203,7 +207,7 @@ class SuccessDialog extends StatelessWidget {
onPressed?.call();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
backgroundColor: Theme.of(context).colorScheme.success,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -214,8 +218,8 @@ class SuccessDialog extends StatelessWidget {
),
child: Text(
buttonText,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 16,
),
),
@@ -272,12 +276,12 @@ class ErrorDialog extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
child: Icon(
Icons.error_outline,
color: Colors.red,
color: Theme.of(context).colorScheme.error,
size: 64,
),
),
@@ -296,7 +300,7 @@ class ErrorDialog extends StatelessWidget {
message!,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
@@ -311,7 +315,7 @@ class ErrorDialog extends StatelessWidget {
onPressed?.call();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -322,8 +326,8 @@ class ErrorDialog extends StatelessWidget {
),
child: Text(
buttonText,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 16,
),
),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../theme/app_colors.dart';
// import '../../../theme/app_colors.dart';
/// 공통 텍스트 필드 위젯
/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다.
@@ -66,10 +66,10 @@ class BaseTextField extends StatelessWidget {
if (label != null) ...[
Text(
label!,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
@@ -89,22 +89,22 @@ class BaseTextField extends StatelessWidget {
maxLines: maxLines,
minLines: minLines,
readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor,
cursorColor: cursorColor ?? theme.colorScheme.primary,
style: style ??
const TextStyle(
TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
color: Theme.of(context).colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(
color: AppColors.textMuted,
hintStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
prefixIcon: prefixIcon,
prefixText: prefixText,
suffixIcon: suffixIcon,
filled: true,
fillColor: fillColor ?? AppColors.surfaceColorAlt,
fillColor: fillColor ?? Theme.of(context).colorScheme.surface,
contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
@@ -113,15 +113,15 @@ class BaseTextField extends StatelessWidget {
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.primaryColor,
color: theme.colorScheme.primary,
width: 2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
color: theme.colorScheme.outline.withValues(alpha: 0.6),
width: 1,
),
),
disabledBorder: OutlineInputBorder(

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
// import '../../../theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 결제 주기 선택 위젯
@@ -8,8 +8,8 @@ class BillingCycleSelector extends StatelessWidget {
final String billingCycle;
final ValueChanged<String> onChanged;
final Color? baseColor;
final List<Color>? gradientColors;
final bool isGlassmorphism;
final List<Color>? gradientColors; // deprecated: ignored
final bool isGlassmorphism; // deprecated: ignored
const BillingCycleSelector({
super.key,
@@ -24,19 +24,12 @@ class BillingCycleSelector extends StatelessWidget {
Widget build(BuildContext context) {
final localization = AppLocalizations.of(context);
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
final cycles = isGlassmorphism
? [
localization.billingCycleMonthly,
localization.billingCycleQuarterly,
localization.billingCycleHalfYearly,
localization.billingCycleYearly,
]
: [
localization.monthly,
localization.billingCycleQuarterly,
localization.billingCycleHalfYearly,
localization.yearly,
];
final cycles = [
localization.monthly,
localization.billingCycleQuarterly,
localization.billingCycleHalfYearly,
localization.yearly,
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
@@ -54,16 +47,16 @@ class BillingCycleSelector extends StatelessWidget {
vertical: 12,
),
decoration: BoxDecoration(
color: _getBackgroundColor(isSelected),
color: _getBackgroundColor(context, isSelected),
borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected),
border: _getBorder(context, isSelected),
),
child: Text(
cycle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _getTextColor(isSelected),
color: _getTextColor(context, isSelected),
),
),
),
@@ -74,38 +67,22 @@ class BillingCycleSelector extends StatelessWidget {
);
}
Color _getBackgroundColor(bool isSelected) {
if (!isSelected) {
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
Border? _getBorder(bool isSelected) {
if (isSelected || !isGlassmorphism) {
return null;
}
return Border.all(
color: AppColors.borderColor.withValues(alpha: 0.5),
width: 1.5,
);
}
Color _getTextColor(bool isSelected) {
Color _getBackgroundColor(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
if (isSelected) {
return Colors.white;
return baseColor ?? scheme.primary;
}
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
return scheme.surface;
}
Border? _getBorder(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
if (isSelected) return null;
return Border.all(color: scheme.outline.withValues(alpha: 0.6), width: 1);
}
Color _getTextColor(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
return isSelected ? scheme.onPrimary : scheme.onSurface;
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../theme/app_colors.dart';
// import '../../../theme/app_colors.dart';
import '../../../providers/category_provider.dart';
/// 카테고리 선택 위젯
@@ -10,8 +10,8 @@ class CategorySelector extends StatelessWidget {
final String? selectedCategoryId;
final ValueChanged<String?> onChanged;
final Color? baseColor;
final List<Color>? gradientColors;
final bool isGlassmorphism;
final List<Color>? gradientColors; // deprecated: ignored
final bool isGlassmorphism; // deprecated: ignored
const CategorySelector({
super.key,
@@ -39,9 +39,9 @@ class CategorySelector extends StatelessWidget {
vertical: 10,
),
decoration: BoxDecoration(
color: _getBackgroundColor(isSelected),
color: _getBackgroundColor(context, isSelected),
borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected),
border: _getBorder(context, isSelected),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -49,7 +49,7 @@ class CategorySelector extends StatelessWidget {
Icon(
_getCategoryIcon(category),
size: 18,
color: _getTextColor(isSelected),
color: _getTextColor(context, isSelected),
),
const SizedBox(width: 6),
Consumer<CategoryProvider>(
@@ -60,7 +60,7 @@ class CategorySelector extends StatelessWidget {
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _getTextColor(isSelected),
color: _getTextColor(context, isSelected),
),
);
},
@@ -100,38 +100,22 @@ class CategorySelector extends StatelessWidget {
}
}
Color _getBackgroundColor(bool isSelected) {
if (!isSelected) {
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
Border? _getBorder(bool isSelected) {
if (isSelected || !isGlassmorphism) {
return null;
}
return Border.all(
color: AppColors.borderColor.withValues(alpha: 0.5),
width: 1.5,
);
}
Color _getTextColor(bool isSelected) {
Color _getBackgroundColor(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
if (isSelected) {
return Colors.white;
return baseColor ?? scheme.primary;
}
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
return scheme.surface;
}
Border? _getBorder(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
if (isSelected) return null;
return Border.all(color: scheme.outline.withValues(alpha: 0.6), width: 1);
}
Color _getTextColor(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
return isSelected ? scheme.onPrimary : scheme.onSurface;
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
class CurrencyDropdownField extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
const CurrencyDropdownField({
super.key,
required this.currency,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DropdownButtonFormField<String>(
initialValue: currency,
isExpanded: true,
icon: const Icon(Icons.keyboard_arrow_down_rounded),
// 선택된 아이템은 코드만 간결하게 표시하여 오버플로우 방지
selectedItemBuilder: (context) {
final color = theme.colorScheme.onSurface;
return const [
'KRW',
'USD',
'JPY',
'CNY',
].map((code) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
code,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: color),
),
);
}).toList();
},
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.surface,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.6),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
),
items: const [
DropdownMenuItem(
value: 'KRW', child: _CurrencyItem(symbol: '', code: 'KRW')),
DropdownMenuItem(
value: 'USD', child: _CurrencyItem(symbol: '\$', code: 'USD')),
DropdownMenuItem(
value: 'JPY', child: _CurrencyItem(symbol: '¥', code: 'JPY')),
DropdownMenuItem(
value: 'CNY', child: _CurrencyItem(symbol: '¥', code: 'CNY')),
],
onChanged: (val) {
if (val != null) onChanged(val);
},
);
}
}
class _CurrencyItem extends StatelessWidget {
final String symbol;
final String code;
const _CurrencyItem({required this.symbol, required this.code});
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.onSurface;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
symbol,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(width: 8),
Text(
code,
style: TextStyle(
fontSize: 14,
color: color,
),
),
],
);
}
}

View File

@@ -5,10 +5,10 @@ import 'base_text_field.dart';
import '../../../l10n/app_localizations.dart';
/// 통화 입력 필드 위젯
/// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다.
/// KRW/JPY(정수), USD/CNY(소수점 2자리)를 지원하며 자동 포맷팅을 제공합니다.
class CurrencyInputField extends StatefulWidget {
final TextEditingController controller;
final String currency; // 'KRW' or 'USD'
final String currency; // 'KRW' | 'USD' | 'JPY' | 'CNY'
final String? label;
final String? hintText;
final Function(double?)? onChanged;
@@ -39,6 +39,7 @@ class CurrencyInputField extends StatefulWidget {
class _CurrencyInputFieldState extends State<CurrencyInputField> {
late FocusNode _focusNode;
bool _isFormatted = false;
bool _isPostFrameUpdating = false;
@override
void initState() {
@@ -66,6 +67,29 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
super.dispose();
}
@override
void didUpdateWidget(covariant CurrencyInputField oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currency != widget.currency) {
// 통화 변경 시 빌드 이후에 안전하게 재포맷 적용
if (_focusNode.hasFocus) return;
final value = _parseValue(widget.controller.text);
if (value == null) return;
final formatted = _formatCurrency(value);
if (widget.controller.text == formatted || _isPostFrameUpdating) return;
_isPostFrameUpdating = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
widget.controller.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
_isFormatted = true;
_isPostFrameUpdating = false;
});
}
}
void _onFocusChanged() {
if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) {
// 포커스를 잃었을 때 포맷팅 적용
@@ -81,7 +105,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
final value = _parseValue(widget.controller.text);
if (value != null) {
setState(() {
if (widget.currency == 'KRW') {
if (_isIntegerCurrency(widget.currency)) {
widget.controller.text = value.toInt().toString();
} else {
widget.controller.text = value.toString();
@@ -97,7 +121,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
}
String _formatCurrency(double value) {
if (widget.currency == 'KRW') {
if (_isIntegerCurrency(widget.currency)) {
return NumberFormat.decimalPattern().format(value.toInt());
} else {
return NumberFormat('#,##0.00').format(value);
@@ -108,13 +132,26 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
final cleanText = text
.replaceAll(',', '')
.replaceAll('', '')
.replaceAll('¥', '')
.replaceAll('', '')
.replaceAll('\$', '')
.trim();
return double.tryParse(cleanText);
}
// ignore: unused_element
String get _prefixText {
return widget.currency == 'KRW' ? '' : '\$ ';
switch (widget.currency) {
case 'KRW':
return '';
case 'JPY':
return '¥ ';
case 'CNY':
return '¥ ';
case 'USD':
default:
return '4 ';
}
}
String _getDefaultHintText(BuildContext context) {
@@ -132,26 +169,27 @@ 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.]')),
if (widget.currency == 'USD')
// USD의 경우 소수점 이하 2자리까지만 허용
_isIntegerCurrency(widget.currency)
? RegExp(r'[0-9]')
: RegExp(r'[0-9.]'),
),
if (!_isIntegerCurrency(widget.currency))
// 소수 통화(USD/CNY): 소수점 이하 2자리 제한
TextInputFormatter.withFunction((oldValue, newValue) {
final text = newValue.text;
if (text.isEmpty) return newValue;
final parts = text.split('.');
if (parts.length > 2) {
// 소수점이 2개 이상인 경우 거부
return oldValue;
return oldValue; // 소수점이 2개 이상인 경우 거부
}
if (parts.length == 2 && parts[1].length > 2) {
// 소수점 이하 2자리를 초과하는 경우 거부
return oldValue;
return oldValue; // 소수점 이하 2자 초과 거부
}
return newValue;
}),
],
prefixText: _prefixText,
prefixText: _getPrefixText(),
onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled,
onChanged: (value) {
@@ -172,3 +210,23 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
);
}
}
bool _isIntegerCurrency(String code) => code == 'KRW' || code == 'JPY';
// 안전한 프리픽스 계산 함수(모든 통화 지원)
String _currencySymbol(String code) {
switch (code) {
case 'KRW':
return '';
case 'JPY':
case 'CNY':
return '¥';
case 'USD':
default:
return '\$';
}
}
extension on _CurrencyInputFieldState {
String _getPrefixText() => '${_currencySymbol(widget.currency)} ';
}

View File

@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
// import '../../../theme/app_colors.dart';
/// 통화 선택 위젯
/// KRW(원화), USD(달러), JPY(엔화), CNY(위안화) 중 선택할 수 있습니다.
class CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
final bool isGlassmorphism;
final bool isGlassmorphism; // deprecated: ignored
const CurrencySelector({
super.key,
@@ -72,7 +72,7 @@ class _CurrencyOption extends StatelessWidget {
final String? subtitle;
final bool isSelected;
final VoidCallback onTap;
final bool isGlassmorphism;
final bool isGlassmorphism; // deprecated: ignored
const _CurrencyOption({
required this.label,
@@ -96,7 +96,7 @@ class _CurrencyOption extends StatelessWidget {
decoration: BoxDecoration(
color: _getBackgroundColor(theme),
borderRadius: BorderRadius.circular(12),
border: _getBorder(),
border: _getBorder(theme),
),
child: Center(
child: Column(
@@ -107,7 +107,7 @@ class _CurrencyOption extends StatelessWidget {
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: _getTextColor(),
color: _getTextColor(theme),
),
),
if (subtitle != null) ...[
@@ -117,7 +117,7 @@ class _CurrencyOption extends StatelessWidget {
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: _getTextColor().withValues(alpha: 0.8),
color: _getTextColor(theme).withValues(alpha: 0.8),
),
),
],
@@ -130,28 +130,20 @@ class _CurrencyOption extends StatelessWidget {
}
Color _getBackgroundColor(ThemeData theme) {
if (isSelected) {
return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6);
}
return isGlassmorphism
? AppColors.surfaceColorAlt
: Colors.grey.withValues(alpha: 0.1);
final scheme = theme.colorScheme;
return isSelected ? scheme.primary : scheme.surface;
}
Border? _getBorder() {
if (isSelected || !isGlassmorphism) {
return null;
}
Border? _getBorder(ThemeData theme) {
if (isSelected) return null;
return Border.all(
color: AppColors.borderColor,
width: 1.5,
color: theme.colorScheme.outline.withValues(alpha: 0.6),
width: 1,
);
}
Color _getTextColor() {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!;
Color _getTextColor(ThemeData theme) {
final scheme = theme.colorScheme;
return isSelected ? scheme.onPrimary : scheme.onSurface;
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../theme/app_colors.dart';
// import '../../../theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 날짜 선택 필드 위젯
@@ -48,10 +48,10 @@ class DatePickerField extends StatelessWidget {
children: [
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
@@ -67,13 +67,14 @@ class DatePickerField extends StatelessWidget {
lastDate: lastDate ??
DateTime.now().add(const Duration(days: 365 * 10)),
builder: (BuildContext context, Widget? child) {
final cs = Theme.of(context).colorScheme;
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
data: Theme.of(context).copyWith(
colorScheme: cs.copyWith(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
onPrimary: cs.onPrimary,
surface: cs.surface,
onSurface: cs.onSurface,
),
),
child: child!,
@@ -90,10 +91,13 @@ class DatePickerField extends StatelessWidget {
child: Container(
padding: contentPadding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: backgroundColor ?? AppColors.surfaceColorAlt,
color: backgroundColor ?? Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.borderColor.withValues(alpha: 0.7),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.6),
width: 1.5,
),
),
@@ -105,15 +109,18 @@ class DatePickerField extends StatelessWidget {
.format(selectedDate),
style: TextStyle(
fontSize: 16,
color:
enabled ? AppColors.textPrimary : AppColors.textMuted,
color: enabled
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Icon(
Icons.calendar_today,
size: 20,
color: enabled ? AppColors.navyGray : AppColors.textMuted,
color: enabled
? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
@@ -214,13 +221,14 @@ class _DateRangeItem extends StatelessWidget {
firstDate: firstDate,
lastDate: lastDate,
builder: (BuildContext context, Widget? child) {
final cs = Theme.of(context).colorScheme;
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
data: Theme.of(context).copyWith(
colorScheme: cs.copyWith(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
onPrimary: cs.onPrimary,
surface: cs.surface,
onSurface: cs.onSurface,
),
),
child: child!,
@@ -237,10 +245,10 @@ class _DateRangeItem extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.borderColor.withValues(alpha: 0.7),
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.6),
width: 1.5,
),
),
@@ -249,9 +257,9 @@ class _DateRangeItem extends StatelessWidget {
children: [
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
@@ -263,8 +271,9 @@ class _DateRangeItem extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color:
date != null ? AppColors.textPrimary : AppColors.textMuted,
color: date != null
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import '../../../theme/ui_constants.dart';
/// 페이지 공통 좌우 패딩과 최대 폭을 보장하는 래퍼
class PageContainer extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final double maxWidth;
const PageContainer({
super.key,
required this.child,
this.padding,
this.maxWidth = 720,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Padding(
padding: padding ??
const EdgeInsets.symmetric(
horizontal: UIConstants.pageHorizontalPadding,
),
child: child,
),
),
);
}
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
import '../../../theme/color_scheme_ext.dart';
/// 앱 전체에서 사용되는 통합 스낵바
/// 성공, 에러, 정보 등 다양한 타입의 메시지를 표시합니다.
@@ -16,9 +16,9 @@ class AppSnackBar {
context: context,
message: message,
icon: icon,
backgroundColor: AppColors.successColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
backgroundColor: Theme.of(context).colorScheme.success,
iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration,
showAtTop: showAtTop,
);
@@ -32,13 +32,14 @@ class AppSnackBar {
Duration duration = const Duration(seconds: 4),
bool showAtTop = true,
}) {
final cs = Theme.of(context).colorScheme;
_show(
context: context,
message: message,
icon: icon,
backgroundColor: AppColors.dangerColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
backgroundColor: cs.error,
iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration,
showAtTop: showAtTop,
);
@@ -56,9 +57,9 @@ class AppSnackBar {
context: context,
message: message,
icon: icon,
backgroundColor: AppColors.primaryColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
backgroundColor: Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration,
showAtTop: showAtTop,
);
@@ -76,9 +77,9 @@ class AppSnackBar {
context: context,
message: message,
icon: icon,
backgroundColor: AppColors.warningColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
backgroundColor: Theme.of(context).colorScheme.warning,
iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration,
showAtTop: showAtTop,
);
@@ -90,8 +91,8 @@ class AppSnackBar {
required String message,
required IconData icon,
required Color backgroundColor,
Color iconColor = AppColors.pureWhite,
Color textColor = AppColors.pureWhite,
Color iconColor = Colors.white,
Color textColor = Colors.white,
Duration duration = const Duration(seconds: 3),
bool showAtTop = true,
SnackBarAction? action,
@@ -200,25 +201,25 @@ class AppSnackBar {
width: 24,
height: 24,
margin: const EdgeInsets.only(right: 12),
child: const CircularProgressIndicator(
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: AppColors.pureWhite,
color: Theme.of(context).colorScheme.onPrimary,
),
),
// 메시지
Expanded(
child: Text(
message,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.pureWhite,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
backgroundColor: AppColors.primaryColor,
backgroundColor: Theme.of(context).colorScheme.primary,
behavior: SnackBarBehavior.floating,
margin: showAtTop
? EdgeInsets.only(
@@ -249,7 +250,7 @@ class AppSnackBar {
required String actionLabel,
required VoidCallback onActionPressed,
IconData icon = Icons.info_rounded,
Color backgroundColor = AppColors.primaryColor,
Color? backgroundColor,
Duration duration = const Duration(seconds: 4),
bool showAtTop = true,
}) {
@@ -257,14 +258,14 @@ class AppSnackBar {
context: context,
message: message,
icon: icon,
backgroundColor: backgroundColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration,
showAtTop: showAtTop,
action: SnackBarAction(
label: actionLabel,
textColor: AppColors.pureWhite,
textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: onActionPressed,
),
);

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../theme/app_colors.dart';
import '../../theme/color_scheme_ext.dart';
// import '../../theme/app_colors.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../../l10n/app_localizations.dart';
@@ -38,19 +39,15 @@ class DetailEventSection extends StatelessWidget {
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1,
),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
@@ -78,10 +75,10 @@ class DetailEventSection extends StatelessWidget {
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).eventPrice,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -98,7 +95,8 @@ class DetailEventSection extends StatelessWidget {
controller.eventPriceController.clear();
}
},
activeColor: baseColor,
activeThumbColor: baseColor,
activeTrackColor: baseColor.withValues(alpha: 0.5),
),
],
),
@@ -109,27 +107,34 @@ class DetailEventSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08),
color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
const Icon(
Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
color: Theme.of(context).colorScheme.tertiary,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context).eventPriceHint,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
color:
Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
@@ -228,7 +233,7 @@ class _DiscountBadge extends StatelessWidget {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
color: Theme.of(context).colorScheme.success.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
@@ -236,15 +241,15 @@ class _DiscountBadge extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green,
color: Theme.of(context).colorScheme.success,
borderRadius: BorderRadius.circular(8),
),
child: Text(
AppLocalizations.of(context)
.discountPercent
.replaceAll('@', discountPercentage.toString()),
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
@@ -253,8 +258,8 @@ class _DiscountBadge extends StatelessWidget {
const SizedBox(width: 12),
Text(
_getLocalizedDiscountAmount(context, currency, discountAmount),
style: const TextStyle(
color: Color(0xFF15803D),
style: TextStyle(
color: Theme.of(context).colorScheme.success,
fontSize: 14,
fontWeight: FontWeight.w500,
),

View File

@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../providers/category_provider.dart';
import '../../theme/app_colors.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_selector.dart';
// import '../common/form_fields/currency_selector.dart';
import '../common/form_fields/currency_dropdown_field.dart';
import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../../l10n/app_localizations.dart';
@@ -43,19 +43,15 @@ class DetailFormSection extends StatelessWidget {
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1,
),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
@@ -100,28 +96,18 @@ class DetailFormSection extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).currency,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
color:
Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
CurrencySelector(
CurrencyDropdownField(
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();
}
}
},
),
],
@@ -137,17 +123,16 @@ class DetailFormSection extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).billingCycle,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (value) {
controller.billingCycle = value;
},
@@ -163,7 +148,9 @@ class DetailFormSection extends StatelessWidget {
controller.nextBillingDate = date;
},
label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(),
// 과거 결제일을 가진 항목도 수정 가능하도록 범위 완화
firstDate: DateTime.now()
.subtract(const Duration(days: 365 * 10)),
lastDate:
DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor,
@@ -178,10 +165,10 @@ class DetailFormSection extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).category,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
@@ -189,7 +176,6 @@ class DetailFormSection extends StatelessWidget {
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (categoryId) {
controller.selectedCategoryId = categoryId;
},

View File

@@ -30,11 +30,10 @@ class DetailHeaderSection extends StatelessWidget {
return Consumer<DetailScreenController>(
builder: (context, controller, child) {
final baseColor = controller.getCardColor();
final gradient = controller.getGradient(baseColor);
return Container(
height: 320,
decoration: BoxDecoration(gradient: gradient),
decoration: BoxDecoration(color: baseColor),
child: Stack(
children: [
// 배경 패턴

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../theme/app_colors.dart';
import '../../theme/color_scheme_ext.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/buttons/secondary_button.dart';
import '../../l10n/app_localizations.dart';
@@ -35,19 +35,13 @@ class DetailUrlSection extends StatelessWidget {
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
@@ -72,10 +66,10 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).websiteInfo,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -89,9 +83,9 @@ class DetailUrlSection extends StatelessWidget {
label: AppLocalizations.of(context).websiteUrl,
hintText: AppLocalizations.of(context).urlExample,
keyboardType: TextInputType.url,
prefixIcon: const Icon(
prefixIcon: Icon(
Icons.link_rounded,
color: AppColors.navyGray,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
@@ -102,10 +96,16 @@ class DetailUrlSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.warningColor.withValues(alpha: 0.08),
color: Theme.of(context)
.colorScheme
.warning
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.warningColor.withValues(alpha: 0.4),
color: Theme.of(context)
.colorScheme
.warning
.withValues(alpha: 0.4),
width: 1,
),
),
@@ -114,18 +114,18 @@ class DetailUrlSection extends StatelessWidget {
children: [
Row(
children: [
const Icon(
Icon(
Icons.info_outline_rounded,
color: AppColors.warningColor,
color: Theme.of(context).colorScheme.warning,
size: 20,
),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).cancelGuide,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -133,9 +133,9 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).cancelServiceGuide,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.5,
),
@@ -145,7 +145,7 @@ class DetailUrlSection extends StatelessWidget {
text: AppLocalizations.of(context).goToCancelPage,
icon: Icons.open_in_new_rounded,
onPressed: controller.openCancellationPage,
color: AppColors.warningColor,
color: Theme.of(context).colorScheme.warning,
),
],
),
@@ -158,27 +158,33 @@ class DetailUrlSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08),
color: Theme.of(context)
.colorScheme
.info
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.info
.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
const Icon(
Icon(
Icons.auto_fix_high_rounded,
color: AppColors.infoColor,
color: Theme.of(context).colorScheme.info,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context).urlAutoMatchInfo,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),

View File

@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
import 'dart:ui';
import '../../utils/reduce_motion.dart';
import '../../theme/app_colors.dart';
// Material 3 기반 다이얼로그
import '../common/buttons/primary_button.dart';
import '../common/buttons/secondary_button.dart';
@@ -18,148 +16,133 @@ class DeleteConfirmationDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
child: Stack(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 글래스모피즘 배경
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: ReduceMotion.scale(context, normal: 10, reduced: 4),
sigmaY: ReduceMotion.scale(context, normal: 10, reduced: 4),
// 아이콘
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.error.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.delete_forever_rounded,
color: Theme.of(context).colorScheme.error,
size: 40,
),
),
const SizedBox(height: 24),
// 타이틀
Text(
'구독 삭제',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 12),
// 설명
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.5,
),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: AppColors.glassBorder,
width: 1,
children: [
const TextSpan(text: '정말로 '),
TextSpan(
text: serviceName,
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 아이콘
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_forever_rounded,
color: Colors.red,
size: 40,
),
),
const SizedBox(height: 24),
const TextSpan(text: ' 구독을\n삭제하시겠습니까?'),
],
),
),
const SizedBox(height: 8),
// 타이틀
const Text(
'구독 삭제',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 12),
// 설명
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
height: 1.5,
),
children: [
const TextSpan(text: '정말로 '),
TextSpan(
text: serviceName,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const TextSpan(text: ' 구독을\n삭제하시겠습니까?'),
],
),
),
const SizedBox(height: 8),
// 경고 메시지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.red.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.red.withValues(alpha: 0.8),
size: 20,
),
const SizedBox(width: 8),
const Text(
'이 작업은 되돌릴 수 없습니다',
style: TextStyle(
fontSize: 14,
color: Colors.red,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 32),
// 버튼들
Row(
children: [
Expanded(
child: SecondaryButton(
text: '취소',
onPressed: () {
Navigator.of(context).pop(false);
},
),
),
const SizedBox(width: 12),
Expanded(
child: PrimaryButton(
text: '삭제',
icon: Icons.delete_rounded,
onPressed: () {
Navigator.of(context).pop(true);
},
backgroundColor: Colors.red,
),
),
],
),
],
),
// 경고 메시지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.error.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.8),
size: 20,
),
const SizedBox(width: 8),
Text(
'이 작업은 되돌릴 수 없습니다',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 32),
// 버튼들
Row(
children: [
Expanded(
child: SecondaryButton(
text: '취소',
onPressed: () {
Navigator.of(context).pop(false);
},
),
),
const SizedBox(width: 12),
Expanded(
child: PrimaryButton(
text: '삭제',
icon: Icons.delete_rounded,
onPressed: () {
Navigator.of(context).pop(true);
},
backgroundColor: Theme.of(context).colorScheme.error,
),
),
],
),
],
),
@@ -175,7 +158,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.5),
barrierColor: Theme.of(context).colorScheme.scrim.withValues(alpha: 0.5),
builder: (context) => DeleteConfirmationDialog(
serviceName: serviceName,
),

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math' as math;
import 'glassmorphism_card.dart';
// Glass 제거: Material 3 Card로 대체
import 'themed_text.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart';
import '../utils/reduce_motion.dart';
@@ -29,106 +29,109 @@ class EmptyStateWidget extends StatelessWidget {
final beginOffset = ReduceMotion.isEnabled(context)
? const Offset(0, 0.05)
: const Offset(0, 0.2);
final fade = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: fadeController, curve: Curves.easeIn),
);
final slide = Tween<Offset>(begin: beginOffset, end: Offset.zero).animate(
CurvedAnimation(parent: slideController, curve: Curves.easeOutBack),
);
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
opacity: fade,
child: Center(
child: SlideTransition(
position: Tween<Offset>(
begin: beginOffset,
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutBack)),
position: slide,
child: RepaintBoundary(
child: GlassmorphismCard(
width: null,
child: Card(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: rotateController,
builder: (context, child) {
final angleScale =
ReduceMotion.isEnabled(context) ? 0.2 : 1.0;
return Transform.rotate(
angle:
angleScale * rotateController.value * 2 * math.pi,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.blueGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: rotateController,
builder: (context, child) {
final angleScale =
ReduceMotion.isEnabled(context) ? 0.2 : 1.0;
return Transform.rotate(
angle:
angleScale * rotateController.value * 2 * math.pi,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.subscriptions_outlined,
size: 48,
color: Theme.of(context).colorScheme.onPrimary,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.primaryColor
.withValues(alpha: 0.3),
spreadRadius: 0,
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: const Icon(
Icons.subscriptions_outlined,
size: 48,
color: AppColors.pureWhite,
),
),
);
},
),
const SizedBox(height: 32),
ThemedText(
AppLocalizations.of(context).noSubscriptions,
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
const SizedBox(height: 8),
ThemedText(
AppLocalizations.of(context).addSubscriptionNow,
fontSize: 16,
opacity: 0.7,
),
const SizedBox(height: 32),
MouseRegion(
onEnter: (_) => {},
onExit: (_) => {},
child: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 4,
backgroundColor: AppColors.primaryColor,
),
onPressed: () {
HapticFeedback.mediumImpact();
onAddPressed();
);
},
child: Text(
AppLocalizations.of(context).addSubscription,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: AppColors.pureWhite,
),
const SizedBox(height: 32),
ThemedText(
AppLocalizations.of(context).noSubscriptions,
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
const SizedBox(height: 8),
ThemedText(
AppLocalizations.of(context).addSubscriptionNow,
fontSize: 16,
opacity: 0.7,
),
const SizedBox(height: 32),
MouseRegion(
onEnter: (_) {},
onExit: (_) {},
child: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
backgroundColor:
Theme.of(context).colorScheme.primary,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
onPressed: () {
HapticFeedback.mediumImpact();
onAddPressed();
},
child: Text(
AppLocalizations.of(context).addSubscription,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
),
],
],
),
),
),
),

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/app_colors.dart';
import 'glassmorphism_card.dart';
// import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart';
import '../utils/platform_helper.dart';
import '../utils/reduce_motion.dart';
@@ -69,11 +68,12 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
final bottomInset = MediaQuery.of(context).padding.bottom;
return Positioned(
bottom: 20,
bottom: 0,
left: 16,
right: 16,
height: 88,
height: 88 + bottomInset,
child: Transform.translate(
offset: Offset(
0,
@@ -83,26 +83,15 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
child: Opacity(
opacity: ReduceMotion.isEnabled(context) ? 1 : _animation.value,
child: Container(
margin: const EdgeInsets.all(4), // 그림자 공간 확보
margin: EdgeInsets.zero,
decoration: BoxDecoration(
color: AppColors.surfaceColor,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 12,
spreadRadius: 0,
offset: Offset(0, 4),
),
],
boxShadow: const [],
),
child: GlassmorphismCard(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
borderRadius: 24,
blur: 10.0,
backgroundColor: Colors.transparent,
boxShadow: const [], // 그림자는 Container에서 처리
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
@@ -169,40 +158,50 @@ class _NavigationItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? AppColors.primaryColor.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: Icon(
icon,
color: isSelected ? AppColors.primaryColor : AppColors.navyGray,
size: isSelected ? 24 : 22,
return Material(
color: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: Icon(
icon,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
size: isSelected ? 24 : 22,
),
),
),
const SizedBox(height: 2),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
fontSize: 10,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? AppColors.primaryColor : AppColors.navyGray,
const SizedBox(height: 2),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
fontSize: 10,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
child: Text(label),
),
child: Text(label),
),
],
],
),
),
),
);
@@ -265,23 +264,12 @@ class _AddButtonState extends State<_AddButton>
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: AppColors.blueGradient,
),
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: const Icon(
child: Icon(
Icons.add_rounded,
color: AppColors.pureWhite,
color: Theme.of(context).colorScheme.onPrimary,
size: 28,
),
),

View File

@@ -1,316 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'dart:math' as math;
import '../theme/app_colors.dart';
import 'floating_navigation_bar.dart';
/// 글래스모피즘 디자인이 적용된 통일된 스캐폴드
class GlassmorphicScaffold extends StatefulWidget {
final PreferredSizeWidget? appBar;
final Widget body;
final Widget? floatingActionButton;
final FloatingActionButtonLocation? floatingActionButtonLocation;
final List<Color>? backgroundGradient;
final bool extendBodyBehindAppBar;
final bool extendBody;
final Widget? bottomNavigationBar;
final bool useFloatingNavBar;
final int? floatingNavBarIndex;
final Function(int)? onFloatingNavBarTapped;
final bool resizeToAvoidBottomInset;
final Widget? drawer;
final Widget? endDrawer;
final Color? backgroundColor;
final bool enableParticles;
final bool enableWaveAnimation;
const GlassmorphicScaffold({
super.key,
this.appBar,
required this.body,
this.floatingActionButton,
this.floatingActionButtonLocation,
this.backgroundGradient,
this.extendBodyBehindAppBar = true,
this.extendBody = true,
this.bottomNavigationBar,
this.useFloatingNavBar = false,
this.floatingNavBarIndex,
this.onFloatingNavBarTapped,
this.resizeToAvoidBottomInset = true,
this.drawer,
this.endDrawer,
this.backgroundColor,
this.enableParticles = false,
this.enableWaveAnimation = false,
});
@override
State<GlassmorphicScaffold> createState() => _GlassmorphicScaffoldState();
}
class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
with TickerProviderStateMixin {
late AnimationController _particleController;
late AnimationController _waveController;
ScrollController? _scrollController;
bool _isFloatingNavBarVisible = true;
@override
void initState() {
super.initState();
_particleController = AnimationController(
duration: const Duration(seconds: 20),
vsync: this,
)..repeat();
_waveController = AnimationController(
duration: const Duration(seconds: 10),
vsync: this,
)..repeat();
if (widget.useFloatingNavBar) {
_scrollController = ScrollController();
_setupScrollListener();
}
}
void _setupScrollListener() {
_scrollController?.addListener(() {
final currentScroll = _scrollController!.position.pixels;
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
if (currentScroll > 50 &&
_scrollController!.position.userScrollDirection ==
ScrollDirection.reverse) {
if (_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = false);
}
} else if (_scrollController!.position.userScrollDirection ==
ScrollDirection.forward) {
if (!_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = true);
}
}
});
}
@override
void dispose() {
_particleController.dispose();
_waveController.dispose();
_scrollController?.dispose();
super.dispose();
}
List<Color> _getBackgroundGradient() {
if (widget.backgroundGradient != null) {
return widget.backgroundGradient!;
}
// 디폴트 그라디언트
return AppColors.mainGradient;
}
@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,
appBar: widget.appBar,
body: widget.body,
floatingActionButton: widget.floatingActionButton,
floatingActionButtonLocation: widget.floatingActionButtonLocation,
bottomNavigationBar: widget.bottomNavigationBar,
extendBodyBehindAppBar: widget.extendBodyBehindAppBar,
extendBody: widget.extendBody,
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
drawer: widget.drawer,
endDrawer: widget.endDrawer,
),
// 플로팅 네비게이션 바 (선택적)
if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null)
FloatingNavigationBar(
selectedIndex: widget.floatingNavBarIndex!,
isVisible: _isFloatingNavBarVisible,
onItemTapped: widget.onFloatingNavBarTapped ?? (_) {},
),
],
);
}
Widget _buildBackground(List<Color> gradientColors) {
return Positioned.fill(
child: Container(
color: AppColors.backgroundColor, // 베이스 색상 추가
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradientColors
.map((color) => color.withValues(alpha: 0.3))
.toList(),
),
),
),
),
);
}
Widget _buildParticles() {
return Positioned.fill(
child: AnimatedBuilder(
animation: _particleController,
builder: (context, child) {
final media = MediaQuery.maybeOf(context);
final reduce = media?.disableAnimations ?? false;
final count = reduce ? 10 : 30;
return CustomPaint(
painter: ParticlePainter(
animation: _particleController,
particleCount: count,
),
);
},
),
);
}
Widget _buildWaveAnimation() {
return Positioned(
bottom: 0,
left: 0,
right: 0,
height: 200,
child: AnimatedBuilder(
animation: _waveController,
builder: (context, child) {
return CustomPaint(
painter: WavePainter(
animation: _waveController,
waveColor: AppColors.secondaryColor.withValues(alpha: 0.1),
),
);
},
),
);
}
}
/// 파티클 페인터
class ParticlePainter extends CustomPainter {
final Animation<double> animation;
final int particleCount;
final List<Particle> particles = [];
ParticlePainter({
required this.animation,
this.particleCount = 50,
}) : super(repaint: animation) {
_initParticles();
}
void _initParticles() {
final random = math.Random();
for (int i = 0; i < particleCount; i++) {
particles.add(Particle(
x: random.nextDouble(),
y: random.nextDouble(),
size: random.nextDouble() * 3 + 1,
speed: random.nextDouble() * 0.5 + 0.1,
opacity: random.nextDouble() * 0.5 + 0.1,
));
}
}
@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),
particle.size,
paint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
/// 웨이브 페인터
class WavePainter extends CustomPainter {
final Animation<double> animation;
final Color waveColor;
WavePainter({
required this.animation,
required this.waveColor,
}) : super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
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;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
/// 파티클 데이터 클래스
class Particle {
final double x;
final double y;
final double size;
final double speed;
final double opacity;
Particle({
required this.x,
required this.y,
required this.size,
required this.speed,
required this.opacity,
});
}

View File

@@ -1,240 +0,0 @@
import 'package:flutter/material.dart';
import '../utils/logger.dart';
import 'dart:ui';
import '../theme/app_colors.dart';
import '../utils/reduce_motion.dart';
import 'themed_text.dart';
class GlassmorphismCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? width;
final double? height;
final double borderRadius;
final double blur;
final double opacity;
final Color? backgroundColor;
final Gradient? gradient;
final Border? border;
final List<BoxShadow>? boxShadow;
final VoidCallback? onTap;
const GlassmorphismCard({
super.key,
required this.child,
this.padding,
this.margin,
this.width,
this.height,
this.borderRadius = 16.0,
this.blur = 10.0,
this.opacity = 0.1,
this.backgroundColor,
this.gradient,
this.border,
this.boxShadow,
this.onTap,
});
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
width: width,
height: height,
margin: margin,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(borderRadius),
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: ReduceMotion.scale(context,
normal: blur, reduced: blur * 0.4),
sigmaY: ReduceMotion.scale(context,
normal: blur, reduced: blur * 0.4),
),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: backgroundColor ?? AppColors.glassCard,
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: ReduceMotion.scale(context,
normal: 20, reduced: 10),
spreadRadius: -5,
offset: const Offset(0, 10),
),
],
),
child: GlassmorphicIndicator(
child: child,
),
),
),
),
),
),
);
}
}
// 애니메이션이 적용된 글래스모피즘 카드
class AnimatedGlassmorphismCard extends StatefulWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? width;
final double? height;
final double borderRadius;
final double blur;
final double opacity;
final Duration animationDuration;
final VoidCallback? onTap;
const AnimatedGlassmorphismCard({
super.key,
required this.child,
this.padding,
this.margin,
this.width,
this.height,
this.borderRadius = 16.0,
this.blur = 10.0,
this.opacity = 0.1,
this.animationDuration = const Duration(milliseconds: 200),
this.onTap,
});
@override
State<AnimatedGlassmorphismCard> createState() =>
_AnimatedGlassmorphismCardState();
}
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _blurAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.98,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
_blurAnimation = Tween<double>(
begin: widget.blur,
end: widget.blur * 1.5,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
_controller.forward();
}
void _handleTapUp(TapUpDetails details) {
_controller.reverse();
}
void _handleTapCancel() {
_controller.reverse();
}
@override
Widget build(BuildContext context) {
// onTap이 없으면 제스처 처리를 하지 않음
if (widget.onTap == null) {
return GlassmorphismCard(
padding: widget.padding,
margin: widget.margin,
width: widget.width,
height: widget.height,
borderRadius: widget.borderRadius,
blur: widget.blur,
opacity: widget.opacity,
onTap: null,
child: widget.child,
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown,
onTapUp: (details) {
_handleTapUp(details);
// onTap 콜백 실행
if (widget.onTap != null) {
Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행');
widget.onTap!();
}
},
onTapCancel: _handleTapCancel,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final scaleValue = ReduceMotion.scale(context,
normal: _scaleAnimation.value, reduced: 1.0);
return Transform.scale(
scale: scaleValue,
child: GlassmorphismCard(
padding: widget.padding,
margin: widget.margin,
width: widget.width,
height: widget.height,
borderRadius: widget.borderRadius,
blur: ReduceMotion.scale(context,
normal: _blurAnimation.value, reduced: widget.blur),
opacity: widget.opacity,
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
child: widget.child,
),
);
},
),
);
}
}

View File

@@ -7,7 +7,7 @@ import '../widgets/native_ad_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/subscription_list_widget.dart';
import '../widgets/empty_state_widget.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart';
class HomeContent extends StatelessWidget {
@@ -35,9 +35,11 @@ class HomeContent extends StatelessWidget {
final provider = context.watch<SubscriptionProvider>();
if (provider.isLoading) {
return const Center(
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
);
}
@@ -65,7 +67,7 @@ class HomeContent extends StatelessWidget {
onRefresh: () async {
await provider.refreshSubscriptions();
},
color: const Color(0xFF3B82F6),
color: Theme.of(context).colorScheme.primary,
child: CustomScrollView(
controller: scrollController,
physics: const BouncingScrollPhysics(),
@@ -109,7 +111,7 @@ class HomeContent extends StatelessWidget {
child: Text(
AppLocalizations.of(context).mySubscriptions,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
@@ -124,17 +126,17 @@ class HomeContent extends StatelessWidget {
Text(
AppLocalizations.of(context)
.subscriptionCount(provider.subscriptions.length),
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryColor,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 4),
const Icon(
Icon(
Icons.arrow_forward_ios,
size: 14,
color: AppColors.primaryColor,
color: Theme.of(context).colorScheme.primary,
),
],
),

View File

@@ -4,9 +4,8 @@ import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../services/currency_util.dart';
import '../theme/app_colors.dart';
import 'animated_wave_background.dart';
import 'glassmorphism_card.dart';
// Glass 제거: Material 3 Card 사용
import '../l10n/app_localizations.dart';
/// 메인 화면 상단에 표시되는 요약 카드 위젯
@@ -43,20 +42,16 @@ class MainScreenSummaryCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 23, 16, 12),
child: RepaintBoundary(
child: GlassmorphismCard(
borderRadius: 16,
blur: 15,
backgroundColor: AppColors.glassCard,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: AppColors.mainGradient
.map((color) => color.withValues(alpha: 0.2))
.toList(),
),
border: Border.all(
color: AppColors.glassBorder,
width: 1,
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Container(
width: double.infinity,
@@ -66,7 +61,6 @@ class MainScreenSummaryCard extends StatelessWidget {
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Colors.transparent,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
@@ -91,9 +85,9 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
AppLocalizations.of(context)
.monthlyTotalSubscriptionCost,
style: const TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
style: TextStyle(
color:
Theme.of(context).colorScheme.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
),
@@ -113,11 +107,17 @@ class MainScreenSummaryCard extends StatelessWidget {
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFE5F2FF),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
borderRadius:
BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFFBFDBFE),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
width: 1,
),
),
@@ -125,10 +125,12 @@ class MainScreenSummaryCard extends StatelessWidget {
AppLocalizations.of(context)
.exchangeRateDisplay
.replaceAll('@', snapshot.data!),
style: const TextStyle(
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
color: Theme.of(context)
.colorScheme
.primary,
),
),
);
@@ -160,6 +162,18 @@ class MainScreenSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
// 통화 기호를 숫자 앞에 표시
Text(
currencySymbol,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Text(
NumberFormat.currency(
locale: defaultCurrency == 'KRW'
@@ -172,22 +186,15 @@ class MainScreenSummaryCard extends StatelessWidget {
symbol: '',
decimalDigits: decimals,
).format(monthlyCost),
style: const TextStyle(
color: AppColors.darkNavy,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface,
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: -1,
),
),
const SizedBox(width: 4),
Text(
currencySymbol,
style: const TextStyle(
color: AppColors.darkNavy,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
);
},
@@ -248,15 +255,15 @@ class MainScreenSummaryCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(
vertical: 10, horizontal: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0.2),
Colors.white.withValues(alpha: 0.15),
],
),
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.primaryColor
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1,
),
@@ -267,15 +274,17 @@ class MainScreenSummaryCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color:
Colors.white.withValues(alpha: 0.25),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(
child: Icon(
Icons.local_offer_rounded,
size: 14,
color: AppColors
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
color:
Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 10),
@@ -286,9 +295,10 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
AppLocalizations.of(context)
.eventDiscountActive,
style: const TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface,
fontSize: 11,
fontWeight: FontWeight.w500,
),
@@ -328,16 +338,20 @@ class MainScreenSummaryCard extends StatelessWidget {
symbol: currencySymbol,
decimalDigits: decimals,
).format(eventSavings),
style: const TextStyle(
color: AppColors.primaryColor,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.primary,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
Text(
' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})',
style: const TextStyle(
color: AppColors.navyGray,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w500,
),
@@ -371,7 +385,10 @@ class MainScreenSummaryCard extends StatelessWidget {
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: AppColors.glassBackground,
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -379,8 +396,8 @@ class MainScreenSummaryCard extends StatelessWidget {
children: [
Text(
title,
style: const TextStyle(
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w500,
),
@@ -388,8 +405,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 14,
fontWeight: FontWeight.bold,
),

View File

@@ -2,13 +2,17 @@ import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io' show Platform;
import 'glassmorphism_card.dart';
import 'dart:async';
// Glass 제거: Material 3 Card 사용
import '../main.dart' show enableAdMob;
import '../theme/ui_constants.dart';
/// 구글 네이티브 광고 위젯 (AdMob NativeAd)
/// SRP에 따라 광고 전용 위젯으로 분리
class NativeAdWidget extends StatefulWidget {
const NativeAdWidget({Key? key}) : super(key: key);
final bool useOuterPadding; // true이면 외부에서 페이지 패딩을 제공
const NativeAdWidget({Key? key, this.useOuterPadding = false})
: super(key: key);
@override
State<NativeAdWidget> createState() => _NativeAdWidgetState();
@@ -19,6 +23,7 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
bool _isLoaded = false;
String? _error;
bool _isAdLoading = false; // 광고 로드 중복 방지 플래그
Timer? _refreshTimer; // 주기적 리프레시 타이머
@override
void initState() {
@@ -43,24 +48,66 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
return;
}
_nativeAd = NativeAd(
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함
request: const AdRequest(),
listener: NativeAdListener(
onAdLoaded: (ad) {
setState(() {
_isLoaded = true;
});
},
onAdFailedToLoad: (ad, error) {
ad.dispose();
setState(() {
_error = error.message;
});
},
),
)..load();
try {
// 기존 광고 해제 및 상태 초기화
_refreshTimer?.cancel();
_nativeAd?.dispose();
_error = null;
_isLoaded = false;
_nativeAd = NativeAd(
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
// 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다.
nativeTemplateStyle: NativeTemplateStyle(
templateType: TemplateType.small,
mainBackgroundColor: const Color(0x00000000),
cornerRadius: 12,
),
request: const AdRequest(),
listener: NativeAdListener(
onAdLoaded: (ad) {
setState(() {
_isLoaded = true;
});
_scheduleRefresh();
},
onAdFailedToLoad: (ad, error) {
ad.dispose();
setState(() {
_error = error.message;
});
// 실패 시에도 일정 시간 후 재시도
_scheduleRefresh();
},
),
)..load();
} catch (e) {
// 템플릿 미지원 등 예외 시 광고를 비활성화하고 크래시 방지
setState(() {
_error = e.toString();
});
_scheduleRefresh();
}
}
/// 30초 후 새 광고로 교체
void _scheduleRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer(const Duration(seconds: 30), _refreshAd);
}
void _refreshAd() {
if (!mounted) return;
// 다음 로드를 위해 상태 초기화 후 새 광고 요청
try {
_nativeAd?.dispose();
} catch (_) {}
setState(() {
_nativeAd = null;
_isLoaded = false;
_error = null;
});
_loadAd();
}
/// 광고 단위 ID 반환 함수
@@ -79,19 +126,25 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
@override
void dispose() {
_nativeAd?.dispose();
_refreshTimer?.cancel();
super.dispose();
}
/// 웹용 광고 플레이스홀더 위젯
Widget _buildWebPlaceholder() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: GlassmorphismCard(
borderRadius: 16,
blur: 10,
opacity: 0.1,
padding: EdgeInsets.symmetric(
horizontal:
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
vertical: UIConstants.adVerticalPadding,
),
child: Card(
elevation: 1,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: Container(
height: 80,
height: UIConstants.adCardHeight,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
@@ -99,13 +152,16 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.zero,
),
child: const Center(
child: Center(
child: Icon(
Icons.ad_units,
color: Colors.grey,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 32,
),
),
@@ -120,8 +176,11 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
height: 14,
width: 120,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.zero,
),
),
const SizedBox(height: 8),
@@ -129,8 +188,11 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
height: 10,
width: 180,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.zero,
),
),
],
@@ -140,15 +202,18 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
width: 60,
height: 24,
decoration: BoxDecoration(
color: Colors.blue[100],
borderRadius: BorderRadius.circular(12),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.15),
borderRadius: BorderRadius.zero,
),
child: const Center(
child: Center(
child: Text(
'광고영역',
'ads',
style: TextStyle(
fontSize: 12,
color: Colors.blue,
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
@@ -179,27 +244,29 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
}
if (_error != null) {
// 광고 로드 실패 시 빈 공간 반환
return const SizedBox.shrink();
// 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지
return _buildWebPlaceholder();
}
if (!_isLoaded) {
// 광고 로딩 중 로딩 인디케이터 표시
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator()),
);
// 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지
return _buildWebPlaceholder();
}
// 광고 정상 노출
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: GlassmorphismCard(
borderRadius: 16,
blur: 10,
opacity: 0.1,
padding: EdgeInsets.symmetric(
horizontal:
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
vertical: UIConstants.adVerticalPadding,
),
child: Card(
elevation: 1,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: SizedBox(
height: 80, // 네이티브 광고 높이 조정
height: UIConstants.adCardHeight,
child: AdWidget(ad: _nativeAd!),
),
),

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../../theme/app_colors.dart';
// import '../../theme/app_colors.dart';
import '../../widgets/themed_text.dart';
import '../../widgets/common/buttons/primary_button.dart';
import '../../widgets/native_ad_widget.dart';
@@ -32,7 +32,7 @@ class ScanInitialWidget extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 24.0),
child: ThemedText(
errorMessage!,
color: Colors.red,
color: Theme.of(context).colorScheme.error,
textAlign: TextAlign.center,
),
),
@@ -59,7 +59,7 @@ class ScanInitialWidget extends StatelessWidget {
onPressed: onScanPressed,
width: 200,
height: 56,
backgroundColor: AppColors.primaryColor,
backgroundColor: Theme.of(context).colorScheme.primary,
),
],
),

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../../theme/app_colors.dart';
// import '../../theme/app_colors.dart';
import '../../widgets/themed_text.dart';
import '../../l10n/app_localizations.dart';
@@ -14,8 +14,8 @@ class ScanLoadingWidget extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
ThemedText(

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../../theme/app_colors.dart';
// Material colors only
import '../../widgets/themed_text.dart';
class ScanProgressWidget extends StatelessWidget {
@@ -20,7 +20,10 @@ class ScanProgressWidget extends StatelessWidget {
// 진행 상태 표시
LinearProgressIndicator(
value: (currentIndex + 1) / totalCount,
backgroundColor: AppColors.navyGray.withValues(alpha: 0.2),
backgroundColor: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),

View File

@@ -10,7 +10,6 @@ import '../../widgets/common/form_fields/base_text_field.dart';
import '../../widgets/common/form_fields/category_selector.dart';
import '../../widgets/common/snackbar/app_snackbar.dart';
import '../../widgets/native_ad_widget.dart';
import '../../theme/app_colors.dart';
import '../../services/currency_util.dart';
import '../../utils/sms_scan/date_formatter.dart';
import '../../utils/sms_scan/category_icon_mapper.dart';
@@ -74,57 +73,50 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 구독 정보 카드
ClipRRect(
borderRadius: BorderRadius.circular(16.0),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(16.0),
border: Border.all(
color: AppColors.glassBorder,
width: 1,
),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 20,
spreadRadius: -5,
offset: Offset(0, 10),
),
],
Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.4),
),
child: Column(
children: [
// 클릭 가능한 정보 영역
Material(
color: Colors.transparent,
child: InkWell(
onTap: _handleCardTap,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildInfoSection(categoryProvider),
),
),
child: Column(
children: [
// 클릭 가능한 정보 영역
Material(
color: Colors.transparent,
child: InkWell(
onTap: _handleCardTap,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildInfoSection(categoryProvider),
),
),
),
// 구분선
Container(
height: 1,
color: AppColors.navyGray.withValues(alpha: 0.1),
),
// 구분선
Container(
height: 1,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.1),
),
// 클릭 불가능한 액션 영역
Padding(
padding: const EdgeInsets.all(16.0),
child: _buildActionSection(categoryProvider),
),
],
),
// 클릭 불가능한 액션 영역
Padding(
padding: const EdgeInsets.all(16.0),
child: _buildActionSection(categoryProvider),
),
],
),
),
],
@@ -143,7 +135,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).foundSubscription,
fontSize: 18,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 24),
@@ -152,14 +143,12 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).serviceName,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
widget.subscription.serviceName,
fontSize: 22,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 16),
@@ -174,7 +163,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).monthlyCost,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
// 언어별 통화 표시
@@ -189,7 +177,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
snapshot.data ?? '-',
fontSize: 18,
fontWeight: FontWeight.bold,
forceDark: true,
);
},
),
@@ -204,14 +191,12 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).billingCycle,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
widget.subscription.billingCycle,
fontSize: 16,
fontWeight: FontWeight.w500,
forceDark: true,
),
],
),
@@ -225,7 +210,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).nextBillingDateLabel,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
@@ -236,7 +220,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
),
fontSize: 16,
fontWeight: FontWeight.w500,
forceDark: true,
),
],
);
@@ -252,7 +235,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).category,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 8),
CategorySelector(
@@ -261,7 +243,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
widget.selectedCategoryId ?? widget.subscription.category,
onChanged: widget.onCategoryChanged,
baseColor: _getCategoryColor(categoryProvider),
isGlassmorphism: true,
),
const SizedBox(height: 24),
@@ -270,14 +251,14 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
controller: widget.websiteUrlController,
label: AppLocalizations.of(context).websiteUrlAuto,
hintText: AppLocalizations.of(context).websiteUrlHint,
prefixIcon: const Icon(
prefixIcon: Icon(
Icons.language,
color: AppColors.navyGray,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
style: const TextStyle(
color: AppColors.darkNavy,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
fillColor: AppColors.pureWhite.withValues(alpha: 0.8),
fillColor: Theme.of(context).colorScheme.surface,
),
const SizedBox(height: 32),

View File

@@ -5,10 +5,12 @@ import '../providers/category_provider.dart';
import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart';
import '../utils/billing_date_util.dart';
import 'website_icon.dart';
import 'app_navigator.dart';
import '../theme/app_colors.dart';
import 'glassmorphism_card.dart';
// import '../theme/app_colors.dart';
import '../theme/color_scheme_ext.dart';
// import 'glassmorphism_card.dart';
import '../l10n/app_localizations.dart';
class SubscriptionCard extends StatefulWidget {
@@ -30,6 +32,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
late AnimationController _hoverController;
bool _isHovering = false;
String? _displayName;
static const int _nearBillingThresholdDays = 3;
@override
void initState() {
@@ -107,6 +110,16 @@ class _SubscriptionCardState extends State<SubscriptionCard>
// 과거 날짜인 경우, 다음 결제일 계산
final billingCycle = widget.subscription.billingCycle;
final norm = BillingDateUtil.normalizeCycle(billingCycle);
// 분기/반기 구독 처리
if (norm == 'quarterly' || norm == 'half-yearly') {
final nextDate =
BillingDateUtil.ensureFutureDate(nextBillingDate, billingCycle);
final days = nextDate.difference(dateOnlyNow).inDays;
if (days == 0) return AppLocalizations.of(context).paymentDueToday;
return AppLocalizations.of(context).paymentDueInDays(days);
}
// 월간 구독인 경우
if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'monthly') {
@@ -211,26 +224,32 @@ class _SubscriptionCardState extends State<SubscriptionCard>
return AppLocalizations.of(context).paymentInfoNeeded;
}
// 결제일이 가까운지 확인 (7일 이내)
bool _isNearBilling() {
final text = _getNextBillingText();
if (text == AppLocalizations.of(context).paymentDueToday) return true;
int _daysUntilNextBilling() {
final now = DateTime.now();
final dateOnlyNow = DateTime(now.year, now.month, now.day);
final nbd = widget.subscription.nextBillingDate;
final dateOnlyBilling = DateTime(nbd.year, nbd.month, nbd.day);
final regex = RegExp(r'(\d+)');
final match = regex.firstMatch(text);
if (match != null) {
final days = int.parse(match.group(1) ?? '0');
return days <= 7;
if (dateOnlyBilling.isAfter(dateOnlyNow)) {
return dateOnlyBilling.difference(dateOnlyNow).inDays;
}
return false;
final next =
BillingDateUtil.ensureFutureDate(nbd, widget.subscription.billingCycle);
return next.difference(dateOnlyNow).inDays;
}
// 결제일이 가까운지 확인
bool _isNearBilling() {
final days = _daysUntilNextBilling();
return days <= _nearBillingThresholdDays;
}
// 카테고리별 그라데이션 색상 생성
List<Color> _getCategoryGradientColors(BuildContext context) {
try {
if (widget.subscription.categoryId == null) {
return AppColors.blueGradient;
return [Theme.of(context).colorScheme.primary];
}
final categoryProvider = context.watch<CategoryProvider>();
@@ -238,19 +257,16 @@ class _SubscriptionCardState extends State<SubscriptionCard>
categoryProvider.getCategoryById(widget.subscription.categoryId!);
if (category == null) {
return AppColors.blueGradient;
return [Theme.of(context).colorScheme.primary];
}
final categoryColor =
Color(int.parse(category.color.replaceAll('#', '0xFF')));
return [
categoryColor,
categoryColor.withValues(alpha: 0.8),
];
return [categoryColor];
} catch (e) {
// 색상 파싱 실패 시 기본 파란색 그라데이션 반환
return AppColors.blueGradient;
// 색상 파싱 실패 시 기본 primary 색 반환
return [Theme.of(context).colorScheme.primary];
}
}
@@ -296,328 +312,362 @@ class _SubscriptionCardState extends State<SubscriptionCard>
child: MouseRegion(
onEnter: (_) => _onHover(true),
onExit: (_) => _onHover(false),
child: AnimatedGlassmorphismCard(
padding: EdgeInsets.zero,
borderRadius: 16,
blur: _isHovering ? 15 : 10,
width: double.infinity, // 전체 너비를 차지하도록 설정
onTap: widget.onTap ??
() async {
// ignore: use_build_context_synchronously
await AppNavigator.toDetail(context, widget.subscription);
},
child: Column(
children: [
// 그라데이션 상단 바 효과
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 4,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: widget.subscription.isCurrentlyInEvent
? [
const Color(0xFFFF6B6B),
const Color(0xFFFF8787),
]
: isNearBilling
? AppColors.amberGradient
: _getCategoryGradientColors(context),
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
child: Card(
elevation: _isHovering ? 2 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.4),
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: widget.onTap ??
() async {
// ignore: use_build_context_synchronously
await AppNavigator.toDetail(context, widget.subscription);
},
child: Column(
children: [
// 그라데이션 상단 바 효과
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 4,
// 카테고리 우선: 상단 바는 항상 카테고리 색
color: _getCategoryGradientColors(context).first,
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
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),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
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: [
// 서비스명
Flexible(
child: Text(
_displayName ??
widget.subscription.serviceName,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: AppColors
.darkNavy, // color.md 가이드: 메인 텍스트
// 서비스 정보
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 서비스명
Flexible(
child: Text(
_displayName ??
widget.subscription.serviceName,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: Theme.of(context)
.colorScheme
.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// 배지들
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 이벤트 배지
if (widget
.subscription.isCurrentlyInEvent) ...[
// 배지들
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 이벤트 배지
if (widget
.subscription.isCurrentlyInEvent) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.error,
borderRadius:
BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_offer_rounded,
size: 11,
color: Theme.of(context)
.colorScheme
.onError,
),
const SizedBox(width: 3),
Text(
AppLocalizations.of(context)
.event,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.onError,
),
),
],
),
),
const SizedBox(width: 6),
],
// 결제 주기 배지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFFFF6B6B),
Color(0xFFFF8787),
],
),
color: Theme.of(context)
.colorScheme
.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
child: Text(
AppLocalizations.of(context)
.getBillingCycleName(widget
.subscription.billingCycle),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
],
),
],
),
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: [
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,
prices[0],
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
decoration:
TextDecoration.lineThrough,
),
),
const SizedBox(width: 8),
Text(
prices[1],
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Theme.of(context)
.colorScheme
.error,
),
),
],
),
),
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,
),
);
} else {
return Text(
snapshot.data!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: widget.subscription
.isCurrentlyInEvent
? Theme.of(context)
.colorScheme
.error
: Theme.of(context)
.colorScheme
.primary,
),
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,
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B)
color: (isNearBilling
? Theme.of(context)
.colorScheme
.warning
: Theme.of(context)
.colorScheme
.success)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.savings_rounded,
size: 14,
color: Color(0xFFFF6B6B),
Icon(
isNearBilling
? Icons.access_time_filled_rounded
: Icons.check_circle_rounded,
size: 12,
color: isNearBilling
? Theme.of(context)
.colorScheme
.warning
: Theme.of(context)
.colorScheme
.success,
),
const SizedBox(width: 4),
// 이벤트 절약액 표시 (언어별 통화)
FutureBuilder<String>(
future: CurrencyUtil
.formatEventSavingsWithLocale(
widget.subscription,
localeProvider.locale.languageCode,
Text(
_getNextBillingText(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isNearBilling
? Theme.of(context)
.colorScheme
.warning
: Theme.of(context)
.colorScheme
.success,
),
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 가이드: 서브 텍스트
),
),
],
],
),
// 이벤트 절약액 표시
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: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.savings_rounded,
size: 14,
color: Theme.of(context)
.colorScheme
.error,
),
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: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.error,
),
);
},
),
],
),
),
const SizedBox(width: 8),
// 이벤트 종료일까지 남은 일수
if (widget.subscription.eventEndDate !=
null) ...[
Text(
AppLocalizations.of(context)
.daysRemaining(widget
.subscription.eventEndDate!
.difference(DateTime.now())
.inDays),
style: TextStyle(
fontSize: 11,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
],
),
],
],
],
),
),
),
],
],
),
),
),
],
],
),
),
),
),

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
// Color resolution now relies on Theme ColorScheme.
/// 배경에 따라 자동으로 색상 대비를 조정하는 텍스트 위젯
class ThemedText extends StatelessWidget {
@@ -40,28 +40,12 @@ class ThemedText extends StatelessWidget {
bool forceLight = false,
bool forceDark = false,
}) {
if (forceLight) return AppColors.pureWhite;
if (forceDark) return AppColors.darkNavy;
final scheme = Theme.of(context).colorScheme;
if (forceLight) return scheme.onPrimary; // typically white
if (forceDark) return scheme.onSurface; // dark text in light theme
final brightness = Theme.of(context).brightness;
// 글래스모피즘 환경에서는 배경이 밝으므로 어두운 텍스트 사용
if (_isGlassmorphicContext(context)) {
return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트
}
// 일반 환경
return brightness == Brightness.dark
? AppColors.pureWhite
: AppColors.darkNavy;
}
/// 글래스모피즘 컨텍스트인지 확인
static bool _isGlassmorphicContext(BuildContext context) {
// 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인
final glassmorphic =
context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
return glassmorphic != null;
// 기본: 스킴의 onSurface 사용(라이트/다크 자동 대비)
return scheme.onSurface;
}
@override
@@ -176,40 +160,3 @@ class ThemedText extends StatelessWidget {
);
}
}
/// 글래스모피즘 컨텍스트를 표시하는 마커 위젯
class GlassmorphicIndicator extends InheritedWidget {
const GlassmorphicIndicator({
super.key,
required super.child,
});
static GlassmorphicIndicator? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<GlassmorphicIndicator>();
}
@override
bool updateShouldNotify(GlassmorphicIndicator oldWidget) => false;
}
/// 글래스모피즘 환경에서 텍스트 색상을 자동 조정하는 래퍼
class GlassmorphicTextWrapper extends StatelessWidget {
final Widget child;
const GlassmorphicTextWrapper({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return GlassmorphicIndicator(
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
color: ThemedText.getContrastColor(context),
),
child: child,
),
);
}
}

View File

@@ -5,7 +5,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart';
@@ -349,11 +349,11 @@ class _WebsiteIconState extends State<WebsiteIcon>
Color _getColorFromName() {
final int hash = widget.serviceName.hashCode.abs();
final List<Color> colors = [
AppColors.primaryColor,
AppColors.successColor,
AppColors.infoColor,
AppColors.warningColor,
AppColors.dangerColor,
const Color(0xFF2563EB), // primary
const Color(0xFF22C55E), // success
const Color(0xFF6366F1), // info
const Color(0xFFF59E0B), // warning
const Color(0xFFF472B6), // accent/danger
];
return colors[hash % colors.length];
@@ -595,10 +595,10 @@ class _WebsiteIconState extends State<WebsiteIcon>
return Container(
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(widget.size * 0.2),
border: Border.all(
color: AppColors.borderColor,
color: Theme.of(context).colorScheme.outline,
width: 0.5,
),
),
@@ -607,10 +607,10 @@ class _WebsiteIconState extends State<WebsiteIcon>
return Container(
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(widget.size * 0.2),
border: Border.all(
color: AppColors.borderColor,
color: Theme.of(context).colorScheme.outline,
width: 0.5,
),
),
@@ -621,7 +621,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primaryColor.withValues(alpha: 0.7)),
Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)),
),
),
),
@@ -661,10 +661,13 @@ class _WebsiteIconState extends State<WebsiteIcon>
: const Duration(milliseconds: 300),
placeholder: (context, url) {
if (ReduceMotion.isEnabled(context)) {
return Container(color: AppColors.surfaceColorAlt);
return Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest);
}
return Container(
color: AppColors.surfaceColorAlt,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: SizedBox(
width: widget.size * 0.4,
@@ -672,7 +675,10 @@ class _WebsiteIconState extends State<WebsiteIcon>
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primaryColor.withValues(alpha: 0.7)),
Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7)),
),
),
),
@@ -712,14 +718,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
return Container(
key: ValueKey('fallback_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color,
color.withValues(alpha: 0.8), // 약 0.8 알파값
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
color: color,
borderRadius: BorderRadius.circular(widget.size * 0.2),
),
child: Center(