Refactor screens to MVC architecture with modular widgets
- Extract business logic from screens into dedicated controllers - Split large screen files into smaller, reusable widget components - Add controllers for AddSubscriptionScreen and DetailScreen - Create modular widgets for subscription and detail features - Improve code organization and maintainability - Remove duplicated code and improve reusability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
145
lib/widgets/common/form_fields/base_text_field.dart
Normal file
145
lib/widgets/common/form_fields/base_text_field.dart
Normal file
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 공통 텍스트 필드 위젯
|
||||
/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다.
|
||||
class BaseTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final String? label;
|
||||
final String? hintText;
|
||||
final TextInputAction? textInputAction;
|
||||
final TextInputType? keyboardType;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final Function()? onTap;
|
||||
final Function()? onEditingComplete;
|
||||
final Function(String)? onChanged;
|
||||
final String? Function(String?)? validator;
|
||||
final bool enabled;
|
||||
final Widget? prefixIcon;
|
||||
final String? prefixText;
|
||||
final Widget? suffixIcon;
|
||||
final bool obscureText;
|
||||
final int? maxLines;
|
||||
final int? minLines;
|
||||
final bool readOnly;
|
||||
final TextStyle? style;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final Color? fillColor;
|
||||
final Color? cursorColor;
|
||||
|
||||
const BaseTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.label,
|
||||
this.hintText,
|
||||
this.textInputAction = TextInputAction.next,
|
||||
this.keyboardType,
|
||||
this.inputFormatters,
|
||||
this.onTap,
|
||||
this.onEditingComplete,
|
||||
this.onChanged,
|
||||
this.validator,
|
||||
this.enabled = true,
|
||||
this.prefixIcon,
|
||||
this.prefixText,
|
||||
this.suffixIcon,
|
||||
this.obscureText = false,
|
||||
this.maxLines = 1,
|
||||
this.minLines,
|
||||
this.readOnly = false,
|
||||
this.style,
|
||||
this.contentPadding,
|
||||
this.fillColor,
|
||||
this.cursorColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (label != null) ...[
|
||||
Text(
|
||||
label!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
textInputAction: textInputAction,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
onTap: onTap,
|
||||
onEditingComplete: onEditingComplete,
|
||||
onChanged: onChanged,
|
||||
validator: validator,
|
||||
enabled: enabled,
|
||||
obscureText: obscureText,
|
||||
maxLines: maxLines,
|
||||
minLines: minLines,
|
||||
readOnly: readOnly,
|
||||
cursorColor: cursorColor ?? theme.primaryColor,
|
||||
style: style ?? TextStyle(
|
||||
fontSize: 16,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
prefixIcon: prefixIcon,
|
||||
prefixText: prefixText,
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: fillColor ?? Colors.white,
|
||||
contentPadding: contentPadding ?? const EdgeInsets.all(16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(
|
||||
color: theme.primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
138
lib/widgets/common/form_fields/currency_input_field.dart
Normal file
138
lib/widgets/common/form_fields/currency_input_field.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'base_text_field.dart';
|
||||
|
||||
/// 통화 입력 필드 위젯
|
||||
/// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다.
|
||||
class CurrencyInputField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String currency; // 'KRW' or 'USD'
|
||||
final String? label;
|
||||
final String? hintText;
|
||||
final Function(double?)? onChanged;
|
||||
final String? Function(String?)? validator;
|
||||
final FocusNode? focusNode;
|
||||
final TextInputAction? textInputAction;
|
||||
final Function()? onEditingComplete;
|
||||
final bool enabled;
|
||||
|
||||
const CurrencyInputField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.currency,
|
||||
this.label,
|
||||
this.hintText,
|
||||
this.onChanged,
|
||||
this.validator,
|
||||
this.focusNode,
|
||||
this.textInputAction,
|
||||
this.onEditingComplete,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CurrencyInputField> createState() => _CurrencyInputFieldState();
|
||||
}
|
||||
|
||||
class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||
late TextEditingController _formattedController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_formattedController = TextEditingController();
|
||||
_updateFormattedValue();
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
_formattedController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
_updateFormattedValue();
|
||||
}
|
||||
|
||||
void _updateFormattedValue() {
|
||||
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
|
||||
if (value != null) {
|
||||
_formattedController.text = _formatCurrency(value);
|
||||
} else {
|
||||
_formattedController.text = '';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatCurrency(double value) {
|
||||
if (widget.currency == 'KRW') {
|
||||
return NumberFormat.decimalPattern().format(value.toInt());
|
||||
} else {
|
||||
return NumberFormat('#,##0.00').format(value);
|
||||
}
|
||||
}
|
||||
|
||||
double? _parseValue(String text) {
|
||||
final cleanText = text.replaceAll(',', '').replaceAll('₩', '').replaceAll('\$', '').trim();
|
||||
return double.tryParse(cleanText);
|
||||
}
|
||||
|
||||
String get _prefixText {
|
||||
return widget.currency == 'KRW' ? '₩ ' : '\$ ';
|
||||
}
|
||||
|
||||
String get _defaultHintText {
|
||||
return widget.currency == 'KRW' ? '금액을 입력하세요' : 'Enter amount';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseTextField(
|
||||
controller: _formattedController,
|
||||
focusNode: widget.focusNode,
|
||||
label: widget.label,
|
||||
hintText: widget.hintText ?? _defaultHintText,
|
||||
textInputAction: widget.textInputAction,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')),
|
||||
],
|
||||
prefixText: _prefixText,
|
||||
onEditingComplete: widget.onEditingComplete,
|
||||
enabled: widget.enabled,
|
||||
onChanged: (value) {
|
||||
final parsedValue = _parseValue(value);
|
||||
if (parsedValue != null) {
|
||||
widget.controller.text = parsedValue.toString();
|
||||
widget.onChanged?.call(parsedValue);
|
||||
} else {
|
||||
widget.controller.text = '';
|
||||
widget.onChanged?.call(null);
|
||||
}
|
||||
|
||||
// 포맷팅 업데이트
|
||||
if (parsedValue != null) {
|
||||
final formattedText = _formatCurrency(parsedValue);
|
||||
if (formattedText != value) {
|
||||
_formattedController.value = TextEditingValue(
|
||||
text: formattedText,
|
||||
selection: TextSelection.collapsed(offset: formattedText.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
validator: widget.validator ?? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '금액을 입력해주세요';
|
||||
}
|
||||
final parsedValue = _parseValue(value);
|
||||
if (parsedValue == null || parsedValue <= 0) {
|
||||
return '올바른 금액을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
263
lib/widgets/common/form_fields/date_picker_field.dart
Normal file
263
lib/widgets/common/form_fields/date_picker_field.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 날짜 선택 필드 위젯
|
||||
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
|
||||
class DatePickerField extends StatelessWidget {
|
||||
final DateTime selectedDate;
|
||||
final Function(DateTime) onDateSelected;
|
||||
final String label;
|
||||
final String? hintText;
|
||||
final DateTime? firstDate;
|
||||
final DateTime? lastDate;
|
||||
final bool enabled;
|
||||
final FocusNode? focusNode;
|
||||
final Color? backgroundColor;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final String? dateFormat;
|
||||
final Color? primaryColor;
|
||||
|
||||
const DatePickerField({
|
||||
super.key,
|
||||
required this.selectedDate,
|
||||
required this.onDateSelected,
|
||||
required this.label,
|
||||
this.hintText,
|
||||
this.firstDate,
|
||||
this.lastDate,
|
||||
this.enabled = true,
|
||||
this.focusNode,
|
||||
this.backgroundColor,
|
||||
this.contentPadding,
|
||||
this.dateFormat,
|
||||
this.primaryColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
|
||||
final effectiveDateFormat = dateFormat ?? 'yyyy년 MM월 dd일';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
focusNode: focusNode,
|
||||
onTap: enabled ? () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: selectedDate,
|
||||
firstDate: firstDate ?? DateTime.now().subtract(const Duration(days: 365 * 10)),
|
||||
lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return Theme(
|
||||
data: ThemeData.light().copyWith(
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: effectivePrimaryColor,
|
||||
onPrimary: Colors.white,
|
||||
surface: Colors.white,
|
||||
onSurface: Colors.black,
|
||||
),
|
||||
dialogBackgroundColor: Colors.white,
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null && picked != selectedDate) {
|
||||
onDateSelected(picked);
|
||||
}
|
||||
} : null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: contentPadding ?? const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
DateFormat(effectiveDateFormat).format(selectedDate),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: enabled
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: enabled
|
||||
? theme.colorScheme.onSurface.withOpacity(0.6)
|
||||
: theme.colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 날짜 범위 선택 필드 위젯
|
||||
/// 시작일과 종료일을 선택할 수 있는 필드입니다.
|
||||
class DateRangePickerField extends StatelessWidget {
|
||||
final DateTime? startDate;
|
||||
final DateTime? endDate;
|
||||
final Function(DateTime?) onStartDateSelected;
|
||||
final Function(DateTime?) onEndDateSelected;
|
||||
final String startLabel;
|
||||
final String endLabel;
|
||||
final bool enabled;
|
||||
final Color? primaryColor;
|
||||
|
||||
const DateRangePickerField({
|
||||
super.key,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.onStartDateSelected,
|
||||
required this.onEndDateSelected,
|
||||
this.startLabel = '시작일',
|
||||
this.endLabel = '종료일',
|
||||
this.enabled = true,
|
||||
this.primaryColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _DateRangeItem(
|
||||
date: startDate,
|
||||
label: startLabel,
|
||||
enabled: enabled,
|
||||
primaryColor: primaryColor,
|
||||
onDateSelected: onStartDateSelected,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||
lastDate: endDate ?? DateTime.now().add(const Duration(days: 365 * 2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _DateRangeItem(
|
||||
date: endDate,
|
||||
label: endLabel,
|
||||
enabled: enabled && startDate != null,
|
||||
primaryColor: primaryColor,
|
||||
onDateSelected: onEndDateSelected,
|
||||
firstDate: startDate ?? DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateRangeItem extends StatelessWidget {
|
||||
final DateTime? date;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final Color? primaryColor;
|
||||
final Function(DateTime?) onDateSelected;
|
||||
final DateTime firstDate;
|
||||
final DateTime lastDate;
|
||||
|
||||
const _DateRangeItem({
|
||||
required this.date,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.primaryColor,
|
||||
required this.onDateSelected,
|
||||
required this.firstDate,
|
||||
required this.lastDate,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
|
||||
|
||||
return InkWell(
|
||||
onTap: enabled ? () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: date ?? DateTime.now(),
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return Theme(
|
||||
data: ThemeData.light().copyWith(
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: effectivePrimaryColor,
|
||||
onPrimary: Colors.white,
|
||||
surface: Colors.white,
|
||||
onSurface: Colors.black,
|
||||
),
|
||||
dialogBackgroundColor: Colors.white,
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
onDateSelected(picked);
|
||||
}
|
||||
} : null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
date != null
|
||||
? DateFormat('MM/dd').format(date!)
|
||||
: '선택',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: date != null
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user