import 'package:flutter/material.dart'; import '../services/sms_scanner.dart'; import '../providers/subscription_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 추가 class SmsScanScreen extends StatefulWidget { const SmsScanScreen({super.key}); @override State createState() => _SmsScanScreenState(); } class _SmsScanScreenState extends State { bool _isLoading = false; String? _errorMessage; final SmsScanner _smsScanner = SmsScanner(); // 스캔한 구독 목록 List _scannedSubscriptions = []; // 현재 표시 중인 구독 인덱스 int _currentIndex = 0; // 웹사이트 URL 컨트롤러 final TextEditingController _websiteUrlController = TextEditingController(); @override void dispose() { _websiteUrlController.dispose(); super.dispose(); } // SMS 스캔 실행 Future _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(context, listen: false); final existingSubscriptions = provider.subscriptions; print('기존 구독: ${existingSubscriptions.length}개'); // 중복 구독 필터링 final filteredSubscriptions = _filterDuplicates(repeatSubscriptions, existingSubscriptions); print('중복 제거 후 구독: ${filteredSubscriptions.length}개'); if (filteredSubscriptions.isNotEmpty && filteredSubscriptions[0] != null) { print( '첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); } setState(() { _scannedSubscriptions = filteredSubscriptions; _isLoading = false; _websiteUrlController.text = ''; // URL 입력 필드 초기화 }); } catch (e) { print('SMS 스캔 중 오류 발생: $e'); if (mounted) { setState(() { _errorMessage = 'SMS 스캔 중 오류가 발생했습니다: $e'; _isLoading = false; }); } } } // SubscriptionModel 리스트를 Subscription 리스트로 변환 List _convertModelsToSubscriptions( List models) { final result = []; 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 _filterDuplicates( List scanned, List existing) { print( '_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개'); // 중복되지 않은 구독만 필터링 final nonDuplicates = scanned.where((scannedSub) { if (scannedSub == null) { print('_filterDuplicates: null 구독 객체 발견'); return false; } // 서비스명과 금액이 동일한 기존 구독 찾기 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 = []; for (int i = 0; i < nonDuplicates.length; i++) { final subscription = nonDuplicates[i]; if (subscription == null) { print('_filterDuplicates: null 구독 객체 무시'); continue; } 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 _addCurrentSubscription() async { if (_scannedSubscriptions.isEmpty || _currentIndex >= _scannedSubscriptions.length) { print( '오류: 인덱스가 범위를 벗어났습니다. (index: $_currentIndex, size: ${_scannedSubscriptions.length})'); return; } final subscription = _scannedSubscriptions[_currentIndex]; if (subscription == null) { print('오류: 현재 인덱스의 구독이 null입니다. (index: $_currentIndex)'); _moveToNextSubscription(); return; } final provider = Provider.of(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: subscription.category, currency: subscription.currency, // 통화 단위 정보 추가 ); print('구독 추가 성공'); // 성공 메시지 표시 if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${subscription.serviceName} 구독이 추가되었습니다.'), backgroundColor: Colors.green, duration: const Duration(seconds: 2), ), ); } // 다음 구독으로 이동 _moveToNextSubscription(); } catch (e) { print('구독 추가 중 오류 발생: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('구독 추가 중 오류가 발생했습니다: $e'), backgroundColor: Colors.red, duration: const Duration(seconds: 2), ), ); // 오류가 있어도 다음 구독으로 이동 _moveToNextSubscription(); } } } // 현재 구독 건너뛰기 void _skipCurrentSubscription() { _moveToNextSubscription(); } // 다음 구독으로 이동 void _moveToNextSubscription() { setState(() { _currentIndex++; _websiteUrlController.text = ''; // URL 입력 필드 초기화 // 모든 구독을 처리했으면 화면 종료 if (_currentIndex >= _scannedSubscriptions.length) { Navigator.of(context).pop(true); } }); } // 날짜 상태 텍스트 가져오기 String _getNextBillingText(DateTime date) { final now = DateTime.now(); if (date.isBefore(now)) { // 주기에 따라 다음 결제일 예측 if (_currentIndex >= _scannedSubscriptions.length || _scannedSubscriptions[_currentIndex] == null) { 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회 결제 감지됨'; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('SMS 스캔'), ), body: Padding( padding: const EdgeInsets.all(16.0), child: _isLoading ? _buildLoadingState() : (_scannedSubscriptions.isEmpty ? _buildInitialState() : _buildSubscriptionState())), ); } // 로딩 상태 UI Widget _buildLoadingState() { return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('SMS 메시지를 스캔 중입니다...'), SizedBox(height: 8), Text('구독 서비스를 찾고 있습니다', style: TextStyle(color: Colors.grey)), ], ), ); } // 초기 상태 UI Widget _buildInitialState() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ if (_errorMessage != null) Padding( padding: const EdgeInsets.all(16.0), child: Text( _errorMessage!, style: const TextStyle(color: Colors.red), textAlign: TextAlign.center, ), ), const SizedBox(height: 24), const Text( '2회 이상 결제된 구독 서비스 찾기', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), const Padding( padding: EdgeInsets.symmetric(horizontal: 32.0), child: Text( '문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey), ), ), const SizedBox(height: 32), ElevatedButton.icon( onPressed: _scanSms, icon: const Icon(Icons.search), label: const Text('스캔 시작하기'), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), ), ), ], ), ); } // 구독 표시 상태 UI Widget _buildSubscriptionState() { if (_currentIndex >= _scannedSubscriptions.length) { return const Center( child: Text('모든 구독 처리 완료'), ); } final subscription = _scannedSubscriptions[_currentIndex]; if (subscription == null) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('오류: 구독 정보를 불러올 수 없습니다.'), SizedBox(height: 16), ElevatedButton( onPressed: _moveToNextSubscription, child: Text('건너뛰기'), ), ], ), ); } // 구독 리스트 카드를 표시할 때 URL 필드 자동 설정 if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) { _websiteUrlController.text = subscription.websiteUrl!; } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 진행 상태 표시 LinearProgressIndicator( value: (_currentIndex + 1) / _scannedSubscriptions.length, backgroundColor: Colors.grey.withOpacity(0.2), valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary), ), const SizedBox(height: 8), Text( '${_currentIndex + 1}/${_scannedSubscriptions.length}', style: TextStyle( color: Colors.grey.shade600, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 24), // 구독 정보 카드 Card( elevation: 4, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '다음 구독을 찾았습니다', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 24), // 서비스명 const Text( '서비스명', style: TextStyle( color: Colors.grey, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Text( subscription.serviceName, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 16), // 금액 및 반복 횟수 Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '월 비용', style: TextStyle( color: Colors.grey, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Text( 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), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), ], ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '반복 횟수', style: TextStyle( color: Colors.grey, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Text( _getRepeatCountText(subscription.repeatCount), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.secondary, ), ), ], ), ), ], ), const SizedBox(height: 16), // 결제 주기 Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '결제 주기', style: TextStyle( color: Colors.grey, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Text( subscription.billingCycle, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w500, ), ), ], ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '결제일', style: TextStyle( color: Colors.grey, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Text( _getNextBillingText(subscription.nextBillingDate), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, ), ), ], ), ), ], ), const SizedBox(height: 24), // 웹사이트 URL 입력 필드 추가/수정 Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: _websiteUrlController, decoration: const InputDecoration( labelText: '웹사이트 URL (자동 추출됨)', hintText: '웹사이트 URL을 수정하거나 비워두세요', prefixIcon: Icon(Icons.language), border: OutlineInputBorder(), ), ), ), const SizedBox(height: 32), // 작업 버튼 Row( children: [ Expanded( child: OutlinedButton( onPressed: _skipCurrentSubscription, style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), child: const Text('건너뛰기'), ), ), const SizedBox(width: 16), Expanded( child: ElevatedButton( onPressed: _addCurrentSubscription, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), child: const Text('추가하기'), ), ), ], ), ], ), ), ), ], ); } @override void didChangeDependencies() { super.didChangeDependencies(); if (_scannedSubscriptions.isNotEmpty && _currentIndex < _scannedSubscriptions.length && _scannedSubscriptions[_currentIndex] != null) { final currentSub = _scannedSubscriptions[_currentIndex]; if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { _websiteUrlController.text = currentSub.websiteUrl!; } } } }