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

@@ -103,7 +103,7 @@ class BaseTextField extends StatelessWidget {
prefixText: prefixText,
suffixIcon: suffixIcon,
filled: true,
fillColor: fillColor ?? AppColors.glassBackground,
fillColor: fillColor ?? AppColors.surfaceColorAlt,
contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
@@ -119,8 +119,8 @@ class BaseTextField extends StatelessWidget {
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppColors.textSecondary,
width: 1,
color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
),
),
disabledBorder: OutlineInputBorder(

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 결제 주기 선택 위젯
/// 월간, 분기별, 반기별, 연간 중 선택할 수 있습니다.
class BillingCycleSelector extends StatelessWidget {
final String billingCycle;
final ValueChanged<String> onChanged;
final Color? baseColor;
final List<Color>? gradientColors;
final bool isGlassmorphism;
const BillingCycleSelector({
super.key,
required this.billingCycle,
required this.onChanged,
this.baseColor,
this.gradientColors,
this.isGlassmorphism = false,
});
@override
Widget build(BuildContext context) {
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
final cycles = isGlassmorphism
? ['매월', '분기별', '반기별', '매년']
: ['월간', '분기별', '반기별', '연간'];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: cycles.map((cycle) {
final isSelected = billingCycle == cycle;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () => onChanged(cycle),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: _getBackgroundColor(isSelected),
borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected),
),
child: Text(
cycle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _getTextColor(isSelected),
),
),
),
),
);
}).toList(),
),
);
}
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) {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.darkNavy
: Colors.grey[700]!;
}
}

View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 카테고리 선택 위젯
/// 구독 서비스의 카테고리를 선택할 수 있습니다.
class CategorySelector extends StatelessWidget {
final List<dynamic> categories;
final String? selectedCategoryId;
final ValueChanged<String?> onChanged;
final Color? baseColor;
final List<Color>? gradientColors;
final bool isGlassmorphism;
const CategorySelector({
super.key,
required this.categories,
required this.selectedCategoryId,
required this.onChanged,
this.baseColor,
this.gradientColors,
this.isGlassmorphism = false,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.map((category) {
final isSelected = selectedCategoryId == category.id;
return InkWell(
onTap: () => onChanged(category.id),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: _getBackgroundColor(isSelected),
borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getCategoryIcon(category),
size: 18,
color: _getTextColor(isSelected),
),
const SizedBox(width: 6),
Text(
category.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _getTextColor(isSelected),
),
),
],
),
),
);
}).toList(),
);
}
IconData _getCategoryIcon(dynamic category) {
// 카테고리명에 따른 아이콘 반환
switch (category.name) {
case '음악':
return Icons.music_note_rounded;
case 'OTT(동영상)':
return Icons.movie_filter_rounded;
case '저장/클라우드':
return Icons.cloud_outlined;
case '통신 · 인터넷 · TV':
return Icons.wifi_rounded;
case '생활/라이프스타일':
return Icons.home_outlined;
case '쇼핑/이커머스':
return Icons.shopping_cart_outlined;
case '프로그래밍':
return Icons.code_rounded;
case '협업/오피스':
return Icons.business_center_outlined;
case 'AI 서비스':
return Icons.smart_toy_outlined;
case '기타':
default:
return Icons.category_outlined;
}
}
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) {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.darkNavy
: Colors.grey[700]!;
}
}

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) {

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 통화 선택 위젯
/// KRW(원화)와 USD(달러) 중 선택할 수 있습니다.
class CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
final bool isGlassmorphism;
const CurrencySelector({
super.key,
required this.currency,
required this.onChanged,
this.isGlassmorphism = false,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
_CurrencyOption(
label: '',
value: 'KRW',
isSelected: currency == 'KRW',
onTap: () => onChanged('KRW'),
isGlassmorphism: isGlassmorphism,
),
const SizedBox(width: 8),
_CurrencyOption(
label: '\$',
value: 'USD',
isSelected: currency == 'USD',
onTap: () => onChanged('USD'),
isGlassmorphism: isGlassmorphism,
),
],
);
}
}
/// 통화 옵션 버튼
class _CurrencyOption extends StatelessWidget {
final String label;
final String value;
final bool isSelected;
final VoidCallback onTap;
final bool isGlassmorphism;
const _CurrencyOption({
required this.label,
required this.value,
required this.isSelected,
required this.onTap,
required this.isGlassmorphism,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _getBackgroundColor(theme),
borderRadius: BorderRadius.circular(12),
border: _getBorder(),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: _getTextColor(),
),
),
),
),
),
);
}
Color _getBackgroundColor(ThemeData theme) {
if (isSelected) {
return isGlassmorphism
? theme.primaryColor
: const Color(0xFF3B82F6);
}
return isGlassmorphism
? AppColors.surfaceColorAlt
: Colors.grey.withValues(alpha: 0.1);
}
Border? _getBorder() {
if (isSelected || !isGlassmorphism) {
return null;
}
return Border.all(
color: AppColors.borderColor,
width: 1.5,
);
}
Color _getTextColor() {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.navyGray
: Colors.grey[600]!;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../theme/app_colors.dart';
/// 날짜 선택 필드 위젯
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
@@ -47,7 +48,7 @@ class DatePickerField extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
@@ -82,10 +83,11 @@ class DatePickerField extends StatelessWidget {
child: Container(
padding: contentPadding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: backgroundColor ?? Colors.white,
color: backgroundColor ?? AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.transparent,
color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
),
),
child: Row(
@@ -96,8 +98,8 @@ class DatePickerField extends StatelessWidget {
style: TextStyle(
fontSize: 16,
color: enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withValues(alpha: 0.5),
? AppColors.textPrimary
: AppColors.textMuted,
),
),
),
@@ -105,8 +107,8 @@ class DatePickerField extends StatelessWidget {
Icons.calendar_today,
size: 20,
color: enabled
? theme.colorScheme.onSurface.withValues(alpha: 0.6)
: theme.colorScheme.onSurface.withValues(alpha: 0.3),
? AppColors.navyGray
: AppColors.textMuted,
),
],
),
@@ -227,8 +229,12 @@ class _DateRangeItem extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
color: AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -237,7 +243,7 @@ class _DateRangeItem extends StatelessWidget {
label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
@@ -249,8 +255,8 @@ class _DateRangeItem extends StatelessWidget {
fontSize: 16,
fontWeight: FontWeight.w500,
color: date != null
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withValues(alpha: 0.4),
? AppColors.textPrimary
: AppColors.textMuted,
),
),
],