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