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