Files
submanager/lib/screens/sms_scan_screen.dart
JiWoong Sul 111c519883 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>
2025-07-14 15:47:46 +09:00

906 lines
31 KiB
Dart

import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../providers/subscription_provider.dart';
import '../providers/navigation_provider.dart';
import 'package:provider/provider.dart';
import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/subscription_url_matcher.dart';
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
import '../widgets/glassmorphism_card.dart';
import '../widgets/themed_text.dart';
import '../theme/app_colors.dart';
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});
@override
State<SmsScanScreen> createState() => _SmsScanScreenState();
}
class _SmsScanScreenState extends State<SmsScanScreen> {
bool _isLoading = false;
String? _errorMessage;
final SmsScanner _smsScanner = SmsScanner();
// 스캔한 구독 목록
List<Subscription> _scannedSubscriptions = [];
// 현재 표시 중인 구독 인덱스
int _currentIndex = 0;
// 웹사이트 URL 컨트롤러
final TextEditingController _websiteUrlController = TextEditingController();
// 선택된 카테고리 ID 저장
String? _selectedCategoryId;
@override
void dispose() {
_websiteUrlController.dispose();
super.dispose();
}
// SMS 스캔 실행
Future<void> _scanSms() async {
setState(() {
_isLoading = true;
_errorMessage = null;
_scannedSubscriptions = [];
_currentIndex = 0;
});
try {
// SMS 스캔 실행
print('SMS 스캔 시작');
final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions();
print('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) {
print(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
}
if (!mounted) return;
if (scannedSubscriptionModels.isEmpty) {
print('스캔된 구독이 없음');
setState(() {
_errorMessage = '구독 정보를 찾을 수 없습니다.';
_isLoading = false;
});
return;
}
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
scannedSubscriptions.where((sub) => sub.repeatCount >= 2).toList();
print('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) {
print(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
}
if (repeatSubscriptions.isEmpty) {
print('반복 결제된 구독이 없음');
setState(() {
_errorMessage = '반복 결제된 구독 정보를 찾을 수 없습니다.';
_isLoading = false;
});
return;
}
// 구독 목록 가져오기
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions;
print('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링
final filteredSubscriptions =
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) {
print(
'첫 번째 필터링된 구독: ${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;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
});
} catch (e) {
print('SMS 스캔 중 오류 발생: $e');
if (mounted) {
setState(() {
_errorMessage = 'SMS 스캔 중 오류가 발생했습니다: $e';
_isLoading = false;
});
}
}
}
// SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> _convertModelsToSubscriptions(
List<SubscriptionModel> models) {
final result = <Subscription>[];
for (var model in models) {
try {
// 모델의 필드가 null인 경우 기본값 사용
result.add(Subscription(
id: model.id,
serviceName: model.serviceName,
monthlyCost: model.monthlyCost,
billingCycle: model.billingCycle,
nextBillingDate: model.nextBillingDate,
category: model.categoryId, // categoryId를 category로 올바르게 매핑
repeatCount: model.repeatCount > 0
? model.repeatCount
: 1, // 반복 횟수가 0 이하인 경우 기본값 1 사용
lastPaymentDate: model.lastPaymentDate,
websiteUrl: model.websiteUrl,
currency: model.currency, // 통화 단위 정보 추가
));
print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
} catch (e) {
print('모델 변환 중 오류 발생: $e');
}
}
return result;
}
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
List<Subscription> _filterDuplicates(
List<Subscription> scanned, List<SubscriptionModel> existing) {
print(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링
final nonDuplicates = scanned.where((scannedSub) {
// 서비스명과 금액이 동일한 기존 구독 찾기
final hasDuplicate = existing.any((existingSub) =>
existingSub.serviceName.toLowerCase() ==
scannedSub.serviceName.toLowerCase() &&
existingSub.monthlyCost == scannedSub.monthlyCost);
if (hasDuplicate) {
print('_filterDuplicates: 중복 발견 - ${scannedSub.serviceName}');
}
// 중복이 없으면 true 반환
return !hasDuplicate;
}).toList();
print('_filterDuplicates: 중복 제거 후 ${nonDuplicates.length}');
// 각 구독에 웹사이트 URL 자동 매칭 시도
final result = <Subscription>[];
for (int i = 0; i < nonDuplicates.length; i++) {
final subscription = nonDuplicates[i];
String? websiteUrl = subscription.websiteUrl;
if (websiteUrl == null || websiteUrl.isEmpty) {
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
print(
'_filterDuplicates: URL 자동 매칭 시도 - ${subscription.serviceName}, 결과: ${websiteUrl ?? "매칭 실패"}');
}
try {
// 유효성 검사
if (subscription.serviceName.isEmpty) {
print('_filterDuplicates: 서비스명이 비어 있습니다. 건너뜁니다.');
continue;
}
if (subscription.monthlyCost <= 0) {
print('_filterDuplicates: 월 비용이 0 이하입니다. 건너뜁니다.');
continue;
}
// Subscription 객체에 URL 설정 (새 객체 생성)
result.add(Subscription(
id: subscription.id,
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate,
category: subscription.category,
notes: subscription.notes,
repeatCount:
subscription.repeatCount > 0 ? subscription.repeatCount : 1,
lastPaymentDate: subscription.lastPaymentDate,
websiteUrl: websiteUrl,
currency: subscription.currency, // 통화 단위 정보 추가
));
print(
'_filterDuplicates: URL 설정 - ${subscription.serviceName}, URL: ${websiteUrl ?? "없음"}, 카테고리: ${subscription.category ?? "없음"}, 통화: ${subscription.currency}');
} catch (e) {
print('_filterDuplicates: 구독 객체 생성 중 오류 발생: $e');
}
}
print('_filterDuplicates: URL 설정 완료, 최종 ${result.length}개 구독');
return result;
}
// 현재 구독 추가
Future<void> _addCurrentSubscription() async {
if (_scannedSubscriptions.isEmpty ||
_currentIndex >= _scannedSubscriptions.length) {
print(
'오류: 인덱스가 범위를 벗어났습니다. (index: $_currentIndex, size: ${_scannedSubscriptions.length})');
return;
}
final subscription = _scannedSubscriptions[_currentIndex];
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
// 날짜가 과거면 다음 결제일을 조정
final now = DateTime.now();
DateTime nextBillingDate = subscription.nextBillingDate;
if (nextBillingDate.isBefore(now)) {
// 주기에 따라 다음 결제일 조정
if (subscription.billingCycle == '월간') {
// 현재 달의 결제일
int day = nextBillingDate.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjustedDate = DateTime(now.year, now.month, day);
// 현재 날짜보다 이전이라면 다음 달로 설정
if (adjustedDate.isBefore(now)) {
// 다음 달의 마지막 날을 초과하는 경우 조정
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
if (day > nextMonthLastDay) {
day = nextMonthLastDay;
}
adjustedDate = DateTime(now.year, now.month + 1, day);
}
nextBillingDate = adjustedDate;
} else if (subscription.billingCycle == '연간') {
// 현재 년도의 결제일
int day = nextBillingDate.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, nextBillingDate.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjustedDate = DateTime(now.year, nextBillingDate.month, day);
// 현재 날짜보다 이전이라면 다음 해로 설정
if (adjustedDate.isBefore(now)) {
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
final nextYearLastDay =
DateTime(now.year + 1, nextBillingDate.month + 1, 0).day;
if (day > nextYearLastDay) {
day = nextYearLastDay;
}
adjustedDate = DateTime(now.year + 1, nextBillingDate.month, day);
}
nextBillingDate = adjustedDate;
} else if (subscription.billingCycle == '주간') {
// 현재 날짜에서 가장 가까운 다음 주 같은 요일
final daysUntilNext = 7 - (now.weekday - nextBillingDate.weekday) % 7;
nextBillingDate =
now.add(Duration(days: daysUntilNext == 0 ? 7 : daysUntilNext));
}
}
// 웹사이트 URL이 비어있으면 자동 매칭 시도
String? websiteUrl = _websiteUrlController.text.trim();
if (websiteUrl.isEmpty && subscription.websiteUrl != null) {
websiteUrl = subscription.websiteUrl;
print('구독 추가: 기존 URL 사용 - ${websiteUrl ?? "없음"}');
} else if (websiteUrl.isEmpty) {
try {
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
print(
'구독 추가: URL 자동 매칭 - ${subscription.serviceName} -> ${websiteUrl ?? "매칭 실패"}');
} catch (e) {
print('구독 추가: URL 자동 매칭 실패 - $e');
websiteUrl = null;
}
} else {
print('구독 추가: 사용자 입력 URL 사용 - $websiteUrl');
}
try {
print(
'구독 추가 시도 - 서비스명: ${subscription.serviceName}, 비용: ${subscription.monthlyCost}, 반복 횟수: ${subscription.repeatCount}');
// 반복 횟수가 0 이하인 경우 기본값 1 사용
final int safeRepeatCount =
subscription.repeatCount > 0 ? subscription.repeatCount : 1;
await provider.addSubscription(
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl,
isAutoDetected: true,
repeatCount: safeRepeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: _selectedCategoryId ?? subscription.category,
currency: subscription.currency, // 통화 단위 정보 추가
);
print('구독 추가 성공');
// 성공 메시지 표시
if (mounted) {
AppSnackBar.showSuccess(
context: context,
message: '${subscription.serviceName} 구독이 추가되었습니다.',
);
}
// 다음 구독으로 이동
_moveToNextSubscription();
} catch (e) {
print('구독 추가 중 오류 발생: $e');
if (mounted) {
AppSnackBar.showError(
context: context,
message: '구독 추가 중 오류가 발생했습니다: $e',
);
// 오류가 있어도 다음 구독으로 이동
_moveToNextSubscription();
}
}
}
// 현재 구독 건너뛰기
void _skipCurrentSubscription() {
final subscription = _scannedSubscriptions[_currentIndex];
if (mounted) {
AppSnackBar.showInfo(
context: context,
message: '${subscription.serviceName} 구독을 건너뛰었습니다.',
icon: Icons.skip_next_rounded,
);
}
_moveToNextSubscription();
}
// 다음 구독으로 이동
void _moveToNextSubscription() {
setState(() {
_currentIndex++;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
_selectedCategoryId = null; // 카테고리 선택 초기화
// 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) {
_navigateToHome();
}
});
}
// 홈 화면으로 이동
void _navigateToHome() {
// NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0);
// 완료 메시지 표시
AppSnackBar.showSuccess(
context: context,
message: '모든 구독이 처리되었습니다.',
);
}
// 날짜 상태 텍스트 가져오기
String _getNextBillingText(DateTime date) {
final now = DateTime.now();
if (date.isBefore(now)) {
// 주기에 따라 다음 결제일 예측
if (_currentIndex >= _scannedSubscriptions.length) {
return '다음 결제일 확인 필요';
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription.billingCycle == '월간') {
// 이번 달 또는 다음 달 같은 날짜
int day = date.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjusted = DateTime(now.year, now.month, day);
if (adjusted.isBefore(now)) {
// 다음 달의 마지막 날을 초과하는 경우 조정
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
if (day > nextMonthLastDay) {
day = nextMonthLastDay;
}
adjusted = DateTime(now.year, now.month + 1, day);
}
final daysUntil = adjusted.difference(now).inDays;
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil일 후)';
} else if (subscription.billingCycle == '연간') {
// 올해 또는 내년 같은 날짜
int day = date.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, date.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjusted = DateTime(now.year, date.month, day);
if (adjusted.isBefore(now)) {
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
final nextYearLastDay = DateTime(now.year + 1, date.month + 1, 0).day;
if (day > nextYearLastDay) {
day = nextYearLastDay;
}
adjusted = DateTime(now.year + 1, date.month, day);
}
final daysUntil = adjusted.difference(now).inDays;
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil일 후)';
} else {
return '다음 결제일 확인 필요 (과거 날짜)';
}
} else {
// 미래 날짜인 경우
final daysUntil = date.difference(now).inDays;
return '다음 결제일: ${_formatDate(date)} ($daysUntil일 후)';
}
}
// 날짜 포맷 함수
String _formatDate(DateTime date) {
return '${date.year}${date.month}${date.day}';
}
// 결제 반복 횟수 텍스트
String _getRepeatCountText(int count) {
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 SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
_isLoading
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState()),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
],
),
);
}
// 로딩 상태 UI
Widget _buildLoadingState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
),
const SizedBox(height: 16),
const ThemedText('SMS 메시지를 스캔 중입니다...', forceDark: true),
const SizedBox(height: 8),
const ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7, forceDark: true),
],
),
);
}
// 초기 상태 UI
Widget _buildInitialState() {
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),
child: ThemedText(
_errorMessage!,
color: Colors.red,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
const ThemedText(
'2회 이상 결제된 구독 서비스 찾기',
fontSize: 20,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0),
child: ThemedText(
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
textAlign: TextAlign.center,
opacity: 0.7,
forceDark: true,
),
),
const SizedBox(height: 32),
PrimaryButton(
text: '스캔 시작하기',
icon: Icons.search_rounded,
onPressed: _scanSms,
width: 200,
height: 56,
backgroundColor: AppColors.primaryColor,
),
],
),
),
],
);
}
// 구독 표시 상태 UI
Widget _buildSubscriptionState() {
if (_currentIndex >= _scannedSubscriptions.length) {
// 처리 완료 후 초기 상태로 복귀
_scannedSubscriptions = [];
_currentIndex = 0;
return _buildInitialState(); // 스캔 버튼이 있는 초기 화면으로 돌아감
}
final subscription = _scannedSubscriptions[_currentIndex];
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
_websiteUrlController.text = subscription.websiteUrl!;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16),
// 진행 상태 표시
LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length,
backgroundColor: AppColors.navyGray.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 8),
ThemedText(
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 24),
// 구독 정보 카드
GlassmorphismCard(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ThemedText(
'다음 구독을 찾았습니다',
fontSize: 18,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 24),
// 서비스명
const ThemedText(
'서비스명',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
subscription.serviceName,
fontSize: 22,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 16),
// 금액 및 결제 주기
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ThemedText(
'월 비용',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
subscription.currency == 'USD'
? NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(subscription.monthlyCost)
: NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(subscription.monthlyCost),
fontSize: 18,
fontWeight: FontWeight.bold,
forceDark: true,
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ThemedText(
'결제 주기',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
subscription.billingCycle,
fontSize: 16,
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 입력 필드 추가/수정
BaseTextField(
controller: _websiteUrlController,
label: '웹사이트 URL (자동 추출됨)',
hintText: '웹사이트 URL을 수정하거나 비워두세요',
prefixIcon: Icon(
Icons.language,
color: AppColors.navyGray,
),
style: TextStyle(
color: AppColors.darkNavy,
),
fillColor: AppColors.pureWhite.withValues(alpha: 0.8),
),
const SizedBox(height: 32),
// 작업 버튼
Row(
children: [
Expanded(
child: SecondaryButton(
text: '건너뛰기',
onPressed: _skipCurrentSubscription,
height: 48,
),
),
const SizedBox(width: 16),
Expanded(
child: PrimaryButton(
text: '추가하기',
onPressed: _addCurrentSubscription,
height: 48,
),
),
],
),
],
),
),
],
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_scannedSubscriptions.isNotEmpty &&
_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
_websiteUrlController.text = currentSub.websiteUrl!;
}
}
}
}