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

@@ -14,6 +14,10 @@ import '../widgets/common/snackbar/app_snackbar.dart';
import '../widgets/common/buttons/primary_button.dart';
import '../widgets/common/buttons/secondary_button.dart';
import '../widgets/common/form_fields/base_text_field.dart';
import '../providers/category_provider.dart';
import '../models/category_model.dart';
import '../widgets/common/form_fields/category_selector.dart';
import '../widgets/native_ad_widget.dart';
class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key});
@@ -35,6 +39,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 웹사이트 URL 컨트롤러
final TextEditingController _websiteUrlController = TextEditingController();
// 선택된 카테고리 ID 저장
String? _selectedCategoryId;
@override
void dispose() {
@@ -113,6 +120,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
// 중복 제거 후 신규 구독이 없는 경우
if (filteredSubscriptions.isEmpty) {
print('중복 제거 후 신규 구독이 없음');
setState(() {
_isLoading = false;
});
// 스낵바로 안내 메시지 표시
if (mounted) {
AppSnackBar.showInfo(
context: context,
message: '신규 구독 관련 SMS를 찾을 수 없습니다',
icon: Icons.search_off_rounded,
);
}
return;
}
setState(() {
_scannedSubscriptions = filteredSubscriptions;
_isLoading = false;
@@ -349,7 +375,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
isAutoDetected: true,
repeatCount: safeRepeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: subscription.category,
categoryId: _selectedCategoryId ?? subscription.category,
currency: subscription.currency, // 통화 단위 정보 추가
);
@@ -399,6 +425,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
setState(() {
_currentIndex++;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
_selectedCategoryId = null; // 카테고리 선택 초기화
// 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) {
@@ -493,15 +520,90 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
return '$count회 결제 감지됨';
}
// 카테고리 칩 빌드
Widget _buildCategoryChip(String? categoryId, CategoryProvider categoryProvider) {
final category = categoryId != null
? categoryProvider.getCategoryById(categoryId)
: null;
// 카테고리가 없으면 기타 카테고리 찾기
final defaultCategory = category ?? categoryProvider.categories.firstWhere(
(cat) => cat.name == '기타',
orElse: () => categoryProvider.categories.first,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.navyGray.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 카테고리 아이콘 표시
Icon(
_getCategoryIcon(defaultCategory),
size: 16,
color: AppColors.darkNavy,
),
const SizedBox(width: 6),
ThemedText(
defaultCategory.name,
fontSize: 14,
fontWeight: FontWeight.w500,
forceDark: true,
),
],
),
);
}
// 카테고리 아이콘 반환
IconData _getCategoryIcon(CategoryModel 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;
}
}
@override
Widget build(BuildContext context) {
return Padding(
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState()),
child: Column(
children: [
_isLoading
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState()),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
],
),
);
}
@@ -525,10 +627,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 초기 상태 UI
Widget _buildInitialState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
return Column(
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
const SizedBox(height: 48),
Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(16.0),
@@ -564,8 +672,10 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
height: 56,
backgroundColor: AppColors.primaryColor,
),
],
),
],
),
),
],
);
}
@@ -579,6 +689,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
}
final subscription = _scannedSubscriptions[_currentIndex];
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
@@ -588,6 +699,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16),
// 진행 상태 표시
LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length,
@@ -634,7 +748,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
),
const SizedBox(height: 16),
// 금액 및 반복 횟수
// 금액 및 결제 주기
Row(
children: [
Expanded(
@@ -667,33 +781,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ThemedText(
'반복 횟수',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
_getRepeatCountText(subscription.repeatCount),
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.secondary,
),
],
),
),
],
),
const SizedBox(height: 16),
// 결제 주기
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -714,28 +801,51 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ThemedText(
'결제일',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
_getNextBillingText(subscription.nextBillingDate),
fontSize: 14,
fontWeight: FontWeight.w500,
forceDark: true,
),
],
),
),
],
),
const SizedBox(height: 16),
// 다음 결제일
const ThemedText(
'다음 결제일',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
_getNextBillingText(subscription.nextBillingDate),
fontSize: 16,
fontWeight: FontWeight.w500,
forceDark: true,
),
const SizedBox(height: 16),
// 카테고리 선택
const ThemedText(
'카테고리',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 8),
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: _selectedCategoryId ?? subscription.category,
onChanged: (categoryId) {
setState(() {
_selectedCategoryId = categoryId;
});
},
baseColor: (() {
final categoryId = _selectedCategoryId ?? subscription.category;
if (categoryId == null) return null;
final category = categoryProvider.getCategoryById(categoryId);
if (category == null) return null;
return Color(int.parse(category.color.replaceFirst('#', '0xFF')));
})(),
isGlassmorphism: true,
),
const SizedBox(height: 24),
// 웹사이트 URL 입력 필드 추가/수정