feat: 폼 필드 컴포넌트 분리 및 구독 카드 인터랙션 개선

- billing_cycle_selector, category_selector, currency_selector 컴포넌트 분리
- 구독 카드 클릭 이슈 해결을 위한 리팩토링
- SMS 스캔 화면 UI/UX 개선 및 기능 강화
- 상세 화면 컨트롤러 로직 개선
- 알림 서비스 및 구독 URL 매칭 기능 추가
- CLAUDE.md 프로젝트 가이드라인 대폭 확장
- 전반적인 코드 구조 개선 및 타입 안정성 강화

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-14 15:47:46 +09:00
parent 2f60ef585a
commit 111c519883
39 changed files with 2376 additions and 1231 deletions

View File

@@ -36,33 +36,62 @@ class CurrencyInputField extends StatefulWidget {
}
class _CurrencyInputFieldState extends State<CurrencyInputField> {
late TextEditingController _formattedController;
late FocusNode _focusNode;
bool _isFormatted = false;
@override
void initState() {
super.initState();
_formattedController = TextEditingController();
_updateFormattedValue();
widget.controller.addListener(_onControllerChanged);
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onFocusChanged);
// 초기값이 있으면 포맷팅 적용
if (widget.controller.text.isNotEmpty) {
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
if (value != null) {
widget.controller.text = _formatCurrency(value);
_isFormatted = true;
}
}
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
_formattedController.dispose();
if (widget.focusNode == null) {
_focusNode.dispose();
} else {
_focusNode.removeListener(_onFocusChanged);
}
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 = '';
void _onFocusChanged() {
if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) {
// 포커스를 잃었을 때 포맷팅 적용
final value = _parseValue(widget.controller.text);
if (value != null) {
setState(() {
widget.controller.text = _formatCurrency(value);
_isFormatted = true;
});
}
} else if (_focusNode.hasFocus && _isFormatted) {
// 포커스를 받았을 때 포맷팅 제거
final value = _parseValue(widget.controller.text);
if (value != null) {
setState(() {
if (widget.currency == 'KRW') {
widget.controller.text = value.toInt().toString();
} else {
widget.controller.text = value.toString();
}
_isFormatted = false;
});
// 커서를 끝으로 이동
widget.controller.selection = TextSelection.fromPosition(
TextPosition(offset: widget.controller.text.length),
);
}
}
}
@@ -90,38 +119,42 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
@override
Widget build(BuildContext context) {
return BaseTextField(
controller: _formattedController,
focusNode: widget.focusNode,
controller: widget.controller,
focusNode: _focusNode,
label: widget.label,
hintText: widget.hintText ?? _defaultHintText,
textInputAction: widget.textInputAction,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')),
FilteringTextInputFormatter.allow(
widget.currency == 'KRW'
? RegExp(r'[0-9]')
: RegExp(r'[0-9.]')
),
if (widget.currency == 'USD')
// USD의 경우 소수점 이하 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;
}
if (parts.length == 2 && parts[1].length > 2) {
// 소수점 이하가 2자리를 초과하는 경우 거부
return oldValue;
}
return newValue;
}),
],
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),
);
}
}
widget.onChanged?.call(parsedValue);
},
validator: widget.validator ?? (value) {
if (value == null || value.isEmpty) {