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:
JiWoong Sul
2025-07-11 00:21:18 +09:00
parent 4731288622
commit 83c5e3d64e
56 changed files with 9092 additions and 4579 deletions

View 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,
),
),
),
),
],
);
}
}

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

View 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),
),
),
],
),
),
);
}
}