From 186d1bbf66a217cbb05e33d5c6c6990c1123c46b Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 17 Jul 2025 16:59:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SMS=20=EC=8A=A4=EC=BA=94=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?MVC=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SMS 스캔 화면을 컨트롤러/서비스/위젯으로 분리 - 코드 가독성 및 유지보수성 향상 - 새로운 다국어 지원 키 추가 - Git 커밋 가이드라인 문서화 --- CLAUDE.md | 47 + assets/data/text.json | 12 +- lib/controllers/sms_scan_controller.dart | 224 ++++ lib/l10n/app_localizations.dart | 1 + lib/screens/sms_scan_screen.dart | 959 ++---------------- .../sms_scan/subscription_converter.dart | 79 ++ .../sms_scan/subscription_filter.dart | 60 ++ lib/services/subscription_url_matcher.dart | 565 +---------- .../data/service_data_repository.dart | 30 + .../services/cancellation_url_service.dart | 129 +++ .../services/category_mapper_service.dart | 88 ++ .../services/service_name_resolver.dart | 61 ++ .../services/sms_extractor_service.dart | 55 + .../services/url_matcher_service.dart | 235 +++++ lib/utils/sms_scan/category_icon_mapper.dart | 53 + lib/utils/sms_scan/date_formatter.dart | 165 +++ lib/widgets/sms_scan/scan_initial_widget.dart | 70 ++ lib/widgets/sms_scan/scan_loading_widget.dart | 36 + .../sms_scan/scan_progress_widget.dart | 38 + .../sms_scan/subscription_card_widget.dart | 295 ++++++ 20 files changed, 1794 insertions(+), 1408 deletions(-) create mode 100644 lib/controllers/sms_scan_controller.dart create mode 100644 lib/services/sms_scan/subscription_converter.dart create mode 100644 lib/services/sms_scan/subscription_filter.dart create mode 100644 lib/services/url_matcher/data/service_data_repository.dart create mode 100644 lib/services/url_matcher/services/cancellation_url_service.dart create mode 100644 lib/services/url_matcher/services/category_mapper_service.dart create mode 100644 lib/services/url_matcher/services/service_name_resolver.dart create mode 100644 lib/services/url_matcher/services/sms_extractor_service.dart create mode 100644 lib/services/url_matcher/services/url_matcher_service.dart create mode 100644 lib/utils/sms_scan/category_icon_mapper.dart create mode 100644 lib/utils/sms_scan/date_formatter.dart create mode 100644 lib/widgets/sms_scan/scan_initial_widget.dart create mode 100644 lib/widgets/sms_scan/scan_loading_widget.dart create mode 100644 lib/widgets/sms_scan/scan_progress_widget.dart create mode 100644 lib/widgets/sms_scan/subscription_card_widget.dart diff --git a/CLAUDE.md b/CLAUDE.md index 92bd1e8..775a272 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,53 @@ Before starting any task, you MUST respond in the following format: - Follow **Given–When–Then** structure - Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures +## 📝 Git Commit Guidelines + +### Commit Message Format + +- **Use clear, descriptive commit messages in Korean** +- **Follow conventional commit format**: `type: description` +- **Keep commit messages concise and focused** +- **DO NOT include Claude Code attribution or co-author tags** + +### Commit Message Structure + +``` +type: brief description in Korean + +Optional detailed explanation if needed +``` + +### Commit Types + +- `feat`: 새로운 기능 추가 +- `fix`: 버그 수정 +- `refactor`: 코드 리팩토링 +- `style`: 코드 스타일 변경 (formatting, missing semi-colons, etc) +- `docs`: 문서 변경 +- `test`: 테스트 코드 추가 또는 수정 +- `chore`: 빌드 프로세스 또는 보조 도구 변경 + +### Examples + +✅ **Good Examples:** +- `feat: 월별 차트 다국어 지원 추가` +- `fix: 분석화면 총지출 금액 불일치 문제 해결` +- `refactor: 통화 변환 로직 모듈화` + +❌ **Avoid These:** +- Including "🤖 Generated with [Claude Code](https://claude.ai/code)" +- Including "Co-Authored-By: Claude " +- Vague messages like "update code" or "fix stuff" +- English commit messages (use Korean) + +### Critical Rules + +- **NEVER include AI tool attribution in commit messages** +- **Focus on what was changed and why** +- **Use present tense and imperative mood** +- **Keep the first line under 50 characters when possible** + ## 🧠 Error Analysis & Rule Documentation ### Mandatory Process When Errors Occur diff --git a/assets/data/text.json b/assets/data/text.json index 07ee755..7fd9c24 100644 --- a/assets/data/text.json +++ b/assets/data/text.json @@ -215,7 +215,8 @@ "amountRequired": "Please enter amount", "subscriptionDetail": "Subscription Detail", "enterAmount": "Enter amount", - "invalidAmount": "Please enter a valid amount" + "invalidAmount": "Please enter a valid amount", + "featureComingSoon": "This feature is coming soon" }, "ko": { "appTitle": "디지털 월세 관리자", @@ -433,7 +434,8 @@ "amountRequired": "금액을 입력해주세요", "subscriptionDetail": "구독 상세", "enterAmount": "금액을 입력하세요", - "invalidAmount": "올바른 금액을 입력해주세요" + "invalidAmount": "올바른 금액을 입력해주세요", + "featureComingSoon": "이 기능은 곧 출시됩니다" }, "ja": { "appTitle": "デジタル月額管理者", @@ -651,7 +653,8 @@ "amountRequired": "金額を入力してください", "subscriptionDetail": "サブスクリプション詳細", "enterAmount": "金額を入力してください", - "invalidAmount": "正しい金額を入力してください" + "invalidAmount": "正しい金額を入力してください", + "featureComingSoon": "この機能は近日公開予定です" }, "zh": { "appTitle": "数字月租管理器", @@ -869,6 +872,7 @@ "amountRequired": "请输入金额", "subscriptionDetail": "订阅详情", "enterAmount": "请输入金额", - "invalidAmount": "请输入有效的金额" + "invalidAmount": "请输入有效的金额", + "featureComingSoon": "此功能即将推出" } } \ No newline at end of file diff --git a/lib/controllers/sms_scan_controller.dart b/lib/controllers/sms_scan_controller.dart new file mode 100644 index 0000000..7551cab --- /dev/null +++ b/lib/controllers/sms_scan_controller.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import '../services/sms_scanner.dart'; +import '../models/subscription.dart'; +import '../models/subscription_model.dart'; +import '../services/sms_scan/subscription_converter.dart'; +import '../services/sms_scan/subscription_filter.dart'; +import '../providers/subscription_provider.dart'; +import 'package:provider/provider.dart'; +import '../providers/navigation_provider.dart'; +import '../providers/category_provider.dart'; +import '../l10n/app_localizations.dart'; + +class SmsScanController extends ChangeNotifier { + // 상태 관리 + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + List _scannedSubscriptions = []; + List get scannedSubscriptions => _scannedSubscriptions; + + int _currentIndex = 0; + int get currentIndex => _currentIndex; + + String? _selectedCategoryId; + String? get selectedCategoryId => _selectedCategoryId; + + final TextEditingController websiteUrlController = TextEditingController(); + + // 의존성 + final SmsScanner _smsScanner = SmsScanner(); + final SubscriptionConverter _converter = SubscriptionConverter(); + final SubscriptionFilter _filter = SubscriptionFilter(); + + @override + void dispose() { + websiteUrlController.dispose(); + super.dispose(); + } + + void setSelectedCategoryId(String? categoryId) { + _selectedCategoryId = categoryId; + notifyListeners(); + } + + void resetWebsiteUrl() { + websiteUrlController.text = ''; + } + + Future scanSms(BuildContext context) async { + _isLoading = true; + _errorMessage = null; + _scannedSubscriptions = []; + _currentIndex = 0; + notifyListeners(); + + try { + // SMS 스캔 실행 + print('SMS 스캔 시작'); + final scannedSubscriptionModels = await _smsScanner.scanForSubscriptions(); + print('스캔된 구독: ${scannedSubscriptionModels.length}개'); + + if (scannedSubscriptionModels.isNotEmpty) { + print('첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); + } + + if (!context.mounted) return; + + if (scannedSubscriptionModels.isEmpty) { + print('스캔된 구독이 없음'); + _errorMessage = AppLocalizations.of(context).subscriptionNotFound; + _isLoading = false; + notifyListeners(); + return; + } + + // SubscriptionModel을 Subscription으로 변환 + final scannedSubscriptions = _converter.convertModelsToSubscriptions(scannedSubscriptionModels); + + // 2회 이상 반복 결제된 구독만 필터링 + final repeatSubscriptions = _filter.filterByRepeatCount(scannedSubscriptions, 2); + print('반복 결제된 구독: ${repeatSubscriptions.length}개'); + + if (repeatSubscriptions.isNotEmpty) { + print('첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}'); + } + + if (repeatSubscriptions.isEmpty) { + print('반복 결제된 구독이 없음'); + _errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound; + _isLoading = false; + notifyListeners(); + return; + } + + // 구독 목록 가져오기 + final provider = Provider.of(context, listen: false); + final existingSubscriptions = provider.subscriptions; + print('기존 구독: ${existingSubscriptions.length}개'); + + // 중복 구독 필터링 + final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); + print('중복 제거 후 구독: ${filteredSubscriptions.length}개'); + + if (filteredSubscriptions.isNotEmpty) { + print('첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); + } + + // 중복 제거 후 신규 구독이 없는 경우 + if (filteredSubscriptions.isEmpty) { + print('중복 제거 후 신규 구독이 없음'); + _isLoading = false; + notifyListeners(); + return; + } + + _scannedSubscriptions = filteredSubscriptions; + _isLoading = false; + websiteUrlController.text = ''; // URL 입력 필드 초기화 + notifyListeners(); + } catch (e) { + print('SMS 스캔 중 오류 발생: $e'); + if (context.mounted) { + _errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); + _isLoading = false; + notifyListeners(); + } + } + } + + Future addCurrentSubscription(BuildContext context) async { + if (_currentIndex >= _scannedSubscriptions.length) return; + + final subscription = _scannedSubscriptions[_currentIndex]; + + try { + final provider = Provider.of(context, listen: false); + final categoryProvider = Provider.of(context, listen: false); + + final finalCategoryId = _selectedCategoryId ?? subscription.category ?? getDefaultCategoryId(categoryProvider); + + // websiteUrl 처리 + final websiteUrl = websiteUrlController.text.trim().isNotEmpty + ? websiteUrlController.text.trim() + : subscription.websiteUrl; + + print('구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); + + // addSubscription 호출 + await provider.addSubscription( + serviceName: subscription.serviceName, + monthlyCost: subscription.monthlyCost, + billingCycle: subscription.billingCycle, + nextBillingDate: subscription.nextBillingDate, + websiteUrl: websiteUrl, + isAutoDetected: true, + repeatCount: subscription.repeatCount, + lastPaymentDate: subscription.lastPaymentDate, + categoryId: finalCategoryId, + currency: subscription.currency, + ); + + print('구독 추가 성공: ${subscription.serviceName}'); + + moveToNextSubscription(context); + } catch (e) { + print('구독 추가 중 오류 발생: $e'); + // 오류가 있어도 다음 구독으로 이동 + moveToNextSubscription(context); + } + } + + void skipCurrentSubscription(BuildContext context) { + final subscription = _scannedSubscriptions[_currentIndex]; + print('구독 건너뛰기: ${subscription.serviceName}'); + moveToNextSubscription(context); + } + + void moveToNextSubscription(BuildContext context) { + _currentIndex++; + websiteUrlController.text = ''; // URL 입력 필드 초기화 + _selectedCategoryId = null; // 카테고리 선택 초기화 + + // 모든 구독을 처리했으면 홈 화면으로 이동 + if (_currentIndex >= _scannedSubscriptions.length) { + navigateToHome(context); + } + + notifyListeners(); + } + + void navigateToHome(BuildContext context) { + // NavigationProvider를 사용하여 홈 화면으로 이동 + final navigationProvider = Provider.of(context, listen: false); + navigationProvider.updateCurrentIndex(0); + } + + void resetState() { + _scannedSubscriptions = []; + _currentIndex = 0; + _errorMessage = null; + notifyListeners(); + } + + String getDefaultCategoryId(CategoryProvider categoryProvider) { + final otherCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name == 'other', + orElse: () => categoryProvider.categories.first, + ); + print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); + return otherCategory.id; + } + + void initializeWebsiteUrl() { + if (_currentIndex < _scannedSubscriptions.length) { + final currentSub = _scannedSubscriptions[_currentIndex]; + if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { + websiteUrlController.text = currentSub.websiteUrl!; + } + } + } +} \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 44b5bd8..d22b157 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -409,6 +409,7 @@ class AppLocalizations { String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail'; String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount'; String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; + String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; // 결제 주기를 키값으로 변환하여 번역된 이름 반환 String getBillingCycleName(String billingCycleKey) { diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index a75da6a..39b5152 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -1,25 +1,11 @@ import 'package:flutter/material.dart'; -import '../services/sms_scanner.dart'; -import '../providers/subscription_provider.dart'; -import '../providers/navigation_provider.dart'; -import '../providers/locale_provider.dart'; import 'package:provider/provider.dart'; -import '../models/subscription.dart'; -import '../models/subscription_model.dart'; -import '../services/subscription_url_matcher.dart'; -import '../services/currency_util.dart'; -import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가 -import '../widgets/glassmorphism_card.dart'; -import '../widgets/themed_text.dart'; -import '../theme/app_colors.dart'; +import '../controllers/sms_scan_controller.dart'; +import '../widgets/sms_scan/scan_loading_widget.dart'; +import '../widgets/sms_scan/scan_initial_widget.dart'; +import '../widgets/sms_scan/scan_progress_widget.dart'; +import '../widgets/sms_scan/subscription_card_widget.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'; import '../l10n/app_localizations.dart'; class SmsScanScreen extends StatefulWidget { @@ -30,581 +16,90 @@ class SmsScanScreen extends StatefulWidget { } class _SmsScanScreenState extends State { - bool _isLoading = false; - String? _errorMessage; - final SmsScanner _smsScanner = SmsScanner(); + late SmsScanController _controller; - // 스캔한 구독 목록 - List _scannedSubscriptions = []; - - // 현재 표시 중인 구독 인덱스 - int _currentIndex = 0; - - // 웹사이트 URL 컨트롤러 - final TextEditingController _websiteUrlController = TextEditingController(); - - // 선택된 카테고리 ID 저장 - String? _selectedCategoryId; + @override + void initState() { + super.initState(); + _controller = SmsScanController(); + _controller.addListener(_handleControllerUpdate); + } @override void dispose() { - _websiteUrlController.dispose(); + _controller.removeListener(_handleControllerUpdate); + _controller.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 = AppLocalizations.of(context).subscriptionNotFound; - _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 = AppLocalizations.of(context).repeatSubscriptionNotFound; - _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) { - print( - '첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); - } - - // 중복 제거 후 신규 구독이 없는 경우 - if (filteredSubscriptions.isEmpty) { - print('중복 제거 후 신규 구독이 없음'); - setState(() { - _isLoading = false; - }); - - // 스낵바로 안내 메시지 표시 - if (mounted) { - AppSnackBar.showInfo( - context: context, - message: AppLocalizations.of(context).newSubscriptionNotFound, - icon: Icons.search_off_rounded, - ); - } - - return; - } - - setState(() { - _scannedSubscriptions = filteredSubscriptions; - _isLoading = false; - _websiteUrlController.text = ''; // URL 입력 필드 초기화 - }); - } catch (e) { - print('SMS 스캔 중 오류 발생: $e'); - if (mounted) { - setState(() { - _errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); - _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) { - - // 서비스명과 금액이 동일한 기존 구독 찾기 - 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]; - - 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]; - - 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; - - // 카테고리 설정 로직 - final categoryId = _selectedCategoryId ?? subscription.category ?? _getDefaultCategoryId(); - print('카테고리 설정 - 선택된: $_selectedCategoryId, 자동매칭: ${subscription.category}, 최종: $categoryId'); - - await provider.addSubscription( - serviceName: subscription.serviceName, - monthlyCost: subscription.monthlyCost, - billingCycle: subscription.billingCycle, - nextBillingDate: nextBillingDate, - websiteUrl: websiteUrl, - isAutoDetected: true, - repeatCount: safeRepeatCount, - lastPaymentDate: subscription.lastPaymentDate, - categoryId: categoryId, - currency: subscription.currency, // 통화 단위 정보 추가 - ); - - print('구독 추가 성공'); - - // 성공 메시지 표시 - if (mounted) { - AppSnackBar.showSuccess( - context: context, - message: AppLocalizations.of(context).subscriptionAddedWithName(subscription.serviceName), - ); - } - - // 다음 구독으로 이동 - _moveToNextSubscription(); - } catch (e) { - print('구독 추가 중 오류 발생: $e'); - if (mounted) { - AppSnackBar.showError( - context: context, - message: AppLocalizations.of(context).subscriptionAddErrorWithMessage(e.toString()), - ); - - // 오류가 있어도 다음 구독으로 이동 - _moveToNextSubscription(); - } - } - } - - // 현재 구독 건너뛰기 - void _skipCurrentSubscription() { - final subscription = _scannedSubscriptions[_currentIndex]; - + void _handleControllerUpdate() { if (mounted) { - AppSnackBar.showInfo( - context: context, - message: AppLocalizations.of(context).subscriptionSkipped(subscription.serviceName), - icon: Icons.skip_next_rounded, + setState(() {}); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controller.initializeWebsiteUrl(); + } + + Widget _buildContent() { + if (_controller.isLoading) { + return const ScanLoadingWidget(); + } + + if (_controller.scannedSubscriptions.isEmpty) { + return ScanInitialWidget( + onScanPressed: () => _controller.scanSms(context), + errorMessage: _controller.errorMessage, ); } - - _moveToNextSubscription(); - } - // 다음 구독으로 이동 - void _moveToNextSubscription() { - setState(() { - _currentIndex++; - _websiteUrlController.text = ''; // URL 입력 필드 초기화 - _selectedCategoryId = null; // 카테고리 선택 초기화 - - // 모든 구독을 처리했으면 홈 화면으로 이동 - if (_currentIndex >= _scannedSubscriptions.length) { - _navigateToHome(); - } - }); - } - - // 홈 화면으로 이동 - void _navigateToHome() { - // NavigationProvider를 사용하여 홈 화면으로 이동 - final navigationProvider = Provider.of(context, listen: false); - navigationProvider.updateCurrentIndex(0); - - // 완료 메시지 표시 - AppSnackBar.showSuccess( - context: context, - message: AppLocalizations.of(context).allSubscriptionsProcessed, - ); - } - - // 날짜 상태 텍스트 가져오기 - 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; + // 모든 구독 처리 완료 확인 + if (_controller.currentIndex >= _controller.scannedSubscriptions.length) { + // 중복 스낵바 방지를 위해 바로 초기 화면으로 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _controller.scannedSubscriptions.isNotEmpty) { + AppSnackBar.showSuccess( + context: context, + message: AppLocalizations.of(context).allSubscriptionsProcessed, + ); + // 상태 초기화 + _controller.resetState(); } - - 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 AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).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 AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil); - } else { - return '다음 결제일 확인 필요 (과거 날짜)'; - } - } else { - // 미래 날짜인 경우 - final daysUntil = date.difference(now).inDays; - return AppLocalizations.of(context).nextBillingDateInfo(AppLocalizations.of(context).formatDate(date), daysUntil); + }); + return ScanInitialWidget( + onScanPressed: () => _controller.scanSms(context), + errorMessage: _controller.errorMessage, + ); } - } - // 날짜 포맷 함수 - String _formatDate(DateTime date) { - return '${date.year}년 ${date.month}월 ${date.day}일'; - } + final currentSubscription = _controller.scannedSubscriptions[_controller.currentIndex]; - // 결제 반복 횟수 텍스트 - String _getRepeatCountText(int count) { - return AppLocalizations.of(context).repeatCountDetected(count); - } - - // 카테고리 칩 빌드 - Widget _buildCategoryChip(String? categoryId, CategoryProvider categoryProvider) { - final category = categoryId != null - ? categoryProvider.getCategoryById(categoryId) - : null; - - // 카테고리가 없으면 기타 카테고리 찾기 - final defaultCategory = category ?? categoryProvider.categories.firstWhere( - (cat) => cat.name == 'other', - 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, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ScanProgressWidget( + currentIndex: _controller.currentIndex, + totalCount: _controller.scannedSubscriptions.length, ), - const SizedBox(width: 6), - ThemedText( - categoryProvider.getLocalizedCategoryName(context, defaultCategory.name), - fontSize: 14, - fontWeight: FontWeight.w500, - forceDark: true, - ), - ], - ), + ), + const SizedBox(height: 24), + SubscriptionCardWidget( + subscription: currentSubscription, + websiteUrlController: _controller.websiteUrlController, + selectedCategoryId: _controller.selectedCategoryId, + onCategoryChanged: _controller.setSelectedCategoryId, + onAdd: () => _controller.addCurrentSubscription(context), + onSkip: () => _controller.skipCurrentSubscription(context), + ), + ], ); } - - // 카테고리 아이콘 반환 - IconData _getCategoryIcon(CategoryModel category) { - switch (category.name) { - case 'music': - return Icons.music_note_rounded; - case 'ottVideo': - return Icons.movie_filter_rounded; - case 'storageCloud': - return Icons.cloud_outlined; - case 'telecomInternetTv': - return Icons.wifi_rounded; - case 'lifestyle': - return Icons.home_outlined; - case 'shoppingEcommerce': - return Icons.shopping_cart_outlined; - case 'programming': - return Icons.code_rounded; - case 'collaborationOffice': - return Icons.business_center_outlined; - case 'aiService': - return Icons.smart_toy_outlined; - case 'other': - default: - return Icons.category_outlined; - } - } - - // 기본 카테고리 ID (기타) 반환 - String _getDefaultCategoryId() { - final categoryProvider = Provider.of(context, listen: false); - final otherCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name == 'other', - orElse: () => categoryProvider.categories.first, // 만약 "기타"가 없으면 첫 번째 카테고리 - ); - print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); - return otherCategory.id; - } - @override Widget build(BuildContext context) { return SingleChildScrollView( @@ -615,11 +110,7 @@ class _SmsScanScreenState extends State { SizedBox( height: kToolbarHeight + MediaQuery.of(context).padding.top, ), - _isLoading - ? _buildLoadingState() - : (_scannedSubscriptions.isEmpty - ? _buildInitialState() - : _buildSubscriptionState()), + _buildContent(), // FloatingNavigationBar를 위한 충분한 하단 여백 SizedBox( height: 120 + MediaQuery.of(context).padding.bottom, @@ -628,310 +119,4 @@ class _SmsScanScreenState extends State { ), ); } - - // 로딩 상태 UI - Widget _buildLoadingState() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), - ), - const SizedBox(height: 16), - ThemedText(AppLocalizations.of(context).scanningMessages, forceDark: true), - const SizedBox(height: 8), - ThemedText(AppLocalizations.of(context).findingSubscriptions, 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(horizontal: 16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_errorMessage != null) - Padding( - padding: const EdgeInsets.only(bottom: 24.0), - child: ThemedText( - _errorMessage!, - color: Colors.red, - textAlign: TextAlign.center, - ), - ), - ThemedText( - AppLocalizations.of(context).findRepeatSubscriptions, - fontSize: 20, - fontWeight: FontWeight.bold, - forceDark: true, - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: ThemedText( - AppLocalizations.of(context).scanTextMessages, - textAlign: TextAlign.center, - opacity: 0.7, - forceDark: true, - ), - ), - const SizedBox(height: 32), - PrimaryButton( - text: AppLocalizations.of(context).startScanning, - 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(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), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 진행 상태 표시 - LinearProgressIndicator( - value: (_currentIndex + 1) / _scannedSubscriptions.length, - backgroundColor: AppColors.navyGray.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation( - 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: [ - ThemedText( - AppLocalizations.of(context).foundSubscription, - fontSize: 18, - fontWeight: FontWeight.bold, - forceDark: true, - ), - const SizedBox(height: 24), - // 서비스명 - ThemedText( - AppLocalizations.of(context).serviceName, - 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: [ - ThemedText( - AppLocalizations.of(context).monthlyCost, - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 4), - // 언어별 통화 표시 - FutureBuilder( - future: CurrencyUtil.formatAmountWithLocale( - subscription.monthlyCost, - subscription.currency, - context.read().locale.languageCode, - ), - builder: (context, snapshot) { - return ThemedText( - snapshot.data ?? '-', - fontSize: 18, - fontWeight: FontWeight.bold, - forceDark: true, - ); - }, - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ThemedText( - AppLocalizations.of(context).billingCycle, - 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), - - // 다음 결제일 - ThemedText( - AppLocalizations.of(context).nextBillingDateLabel, - 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), - - // 카테고리 선택 - ThemedText( - AppLocalizations.of(context).category, - 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: AppLocalizations.of(context).websiteUrlAuto, - hintText: AppLocalizations.of(context).websiteUrlHint, - 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: AppLocalizations.of(context).skip, - onPressed: _skipCurrentSubscription, - height: 48, - ), - ), - const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - text: AppLocalizations.of(context).add, - 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!; - } - } - } -} +} \ No newline at end of file diff --git a/lib/services/sms_scan/subscription_converter.dart b/lib/services/sms_scan/subscription_converter.dart new file mode 100644 index 0000000..bd61b73 --- /dev/null +++ b/lib/services/sms_scan/subscription_converter.dart @@ -0,0 +1,79 @@ +import '../../models/subscription.dart'; +import '../../models/subscription_model.dart'; + +class SubscriptionConverter { + // SubscriptionModel 리스트를 Subscription 리스트로 변환 + List convertModelsToSubscriptions(List models) { + final result = []; + + for (var model in models) { + try { + final subscription = _convertSingle(model); + result.add(subscription); + + print('모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); + } catch (e) { + print('모델 변환 중 오류 발생: $e'); + } + } + + return result; + } + + // 단일 모델 변환 + Subscription _convertSingle(SubscriptionModel model) { + return Subscription( + id: model.id, + serviceName: model.serviceName, + monthlyCost: model.monthlyCost, + billingCycle: _denormalizeBillingCycle(model.billingCycle), // 영어 -> 한국어 + nextBillingDate: model.nextBillingDate, + category: model.categoryId, // categoryId를 category로 매핑 + repeatCount: model.repeatCount > 0 ? model.repeatCount : 1, + lastPaymentDate: model.lastPaymentDate, + websiteUrl: model.websiteUrl, + currency: model.currency, + ); + } + + // billingCycle 역정규화 (영어 -> 한국어) + String _denormalizeBillingCycle(String cycle) { + switch (cycle.toLowerCase()) { + case 'monthly': + return '월간'; + case 'yearly': + case 'annually': + return '연간'; + case 'weekly': + return '주간'; + case 'daily': + return '일간'; + case 'quarterly': + return '분기별'; + case 'semi-annually': + return '반기별'; + default: + return cycle; // 알 수 없는 형식은 그대로 반환 + } + } + + // billingCycle 정규화 (한국어 -> 영어) + String normalizeBillingCycle(String cycle) { + switch (cycle) { + case '월간': + return 'monthly'; + case '연간': + return 'yearly'; + case '주간': + return 'weekly'; + case '일간': + return 'daily'; + case '분기별': + return 'quarterly'; + case '반기별': + return 'semi-annually'; + default: + return 'monthly'; // 기본값 + } + } +} \ No newline at end of file diff --git a/lib/services/sms_scan/subscription_filter.dart b/lib/services/sms_scan/subscription_filter.dart new file mode 100644 index 0000000..627f692 --- /dev/null +++ b/lib/services/sms_scan/subscription_filter.dart @@ -0,0 +1,60 @@ +import '../../models/subscription.dart'; +import '../../models/subscription_model.dart'; + +class SubscriptionFilter { + // 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주) + List filterDuplicates( + List scanned, List existing) { + print('_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개'); + + // 중복되지 않은 구독만 필터링 + return scanned.where((scannedSub) { + // 기존 구독 중에 같은 서비스명과 월 비용을 가진 것이 있는지 확인 + final isDuplicate = existing.any((existingSub) { + final isSameName = existingSub.serviceName.toLowerCase() == + scannedSub.serviceName.toLowerCase(); + final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost; + + if (isSameName && isSameCost) { + print('중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)'); + return true; + } + return false; + }); + + return !isDuplicate; + }).toList(); + } + + // 반복 횟수 기반 필터링 + List filterByRepeatCount(List subscriptions, int minCount) { + return subscriptions.where((sub) => sub.repeatCount >= minCount).toList(); + } + + // 날짜 기반 필터링 (선택적) + List filterByDateRange( + List subscriptions, DateTime startDate, DateTime endDate) { + return subscriptions.where((sub) { + return sub.nextBillingDate.isAfter(startDate) && + sub.nextBillingDate.isBefore(endDate); + }).toList(); + } + + // 금액 기반 필터링 (선택적) + List filterByPriceRange( + List subscriptions, double minPrice, double maxPrice) { + return subscriptions + .where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice) + .toList(); + } + + // 카테고리 기반 필터링 (선택적) + List filterByCategories( + List subscriptions, List categoryIds) { + if (categoryIds.isEmpty) return subscriptions; + + return subscriptions.where((sub) { + return sub.category != null && categoryIds.contains(sub.category); + }).toList(); + } +} \ No newline at end of file diff --git a/lib/services/subscription_url_matcher.dart b/lib/services/subscription_url_matcher.dart index 99bdd65..9f5d854 100644 --- a/lib/services/subscription_url_matcher.dart +++ b/lib/services/subscription_url_matcher.dart @@ -1,385 +1,79 @@ -import 'dart:convert'; -import 'package:flutter/services.dart'; -import 'package:flutter/material.dart'; -import 'url_matcher/models/service_info.dart'; -import 'url_matcher/data/legacy_service_data.dart'; - // ServiceInfo를 외부에서 접근 가능하도록 export export 'url_matcher/models/service_info.dart'; -/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스 +import 'url_matcher/models/service_info.dart'; +import 'url_matcher/data/service_data_repository.dart'; +import 'url_matcher/services/url_matcher_service.dart'; +import 'url_matcher/services/category_mapper_service.dart'; +import 'url_matcher/services/cancellation_url_service.dart'; +import 'url_matcher/services/service_name_resolver.dart'; +import 'url_matcher/services/sms_extractor_service.dart'; + +/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스 (Facade 패턴) class SubscriptionUrlMatcher { - static Map? _servicesData; - static bool _isInitialized = false; + static ServiceDataRepository? _dataRepository; + static UrlMatcherService? _urlMatcher; + static CategoryMapperService? _categoryMapper; + static CancellationUrlService? _cancellationService; + static ServiceNameResolver? _nameResolver; + static SmsExtractorService? _smsExtractor; - /// JSON 데이터 초기화 + /// 서비스 초기화 static Future initialize() async { - if (_isInitialized) return; + if (_dataRepository != null && _dataRepository!.isInitialized) return; - try { - final jsonString = await rootBundle.loadString('assets/data/subscription_services.json'); - _servicesData = json.decode(jsonString); - _isInitialized = true; - print('SubscriptionUrlMatcher: JSON 데이터 로드 완료'); - } catch (e) { - print('SubscriptionUrlMatcher: JSON 로드 실패 - $e'); - // 로드 실패시 기존 하드코딩 데이터 사용 - _isInitialized = true; - } + // 1. 데이터 저장소 초기화 + _dataRepository = ServiceDataRepository(); + await _dataRepository!.initialize(); + + // 2. 서비스 초기화 + _categoryMapper = CategoryMapperService(_dataRepository!); + _urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!); + _cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!); + _nameResolver = ServiceNameResolver(_dataRepository!); + _smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!); } /// 도메인 추출 (www와 TLD 제외) static String? extractDomain(String url) { - try { - final uri = Uri.parse(url); - final host = uri.host.toLowerCase(); - - // 도메인 부분 추출 - var parts = host.split('.'); - - // www 제거 - if (parts.isNotEmpty && parts[0] == 'www') { - parts = parts.sublist(1); - } - - // 서브도메인 처리 (예: music.youtube.com) - if (parts.length >= 3) { - // 서브도메인 포함 전체 도메인 반환 - return parts.sublist(0, parts.length - 1).join('.'); - } else if (parts.length >= 2) { - // 메인 도메인만 반환 - return parts[0]; - } - - return null; - } catch (e) { - print('SubscriptionUrlMatcher: 도메인 추출 실패 - $e'); - return null; - } + return _urlMatcher?.extractDomain(url); } /// URL로 서비스 찾기 static Future findServiceByUrl(String url) async { await initialize(); - - final domain = extractDomain(url); - if (domain == null) return null; - - // JSON 데이터가 있으면 JSON에서 찾기 - if (_servicesData != null) { - final categories = _servicesData!['categories'] as Map; - - for (final categoryEntry in categories.entries) { - final categoryId = categoryEntry.key; - final categoryData = categoryEntry.value as Map; - final services = categoryData['services'] as Map; - - for (final serviceEntry in services.entries) { - final serviceId = serviceEntry.key; - final serviceData = serviceEntry.value as Map; - final domains = List.from(serviceData['domains'] ?? []); - - // 도메인이 일치하는지 확인 - for (final serviceDomain in domains) { - if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { - final names = List.from(serviceData['names'] ?? []); - final urls = serviceData['urls'] as Map?; - - return ServiceInfo( - serviceId: serviceId, - serviceName: names.isNotEmpty ? names[0] : serviceId, - serviceUrl: urls?['kr'] ?? urls?['en'], - cancellationUrl: null, - categoryId: _getCategoryIdByKey(categoryId), - categoryNameKr: categoryData['nameKr'] ?? '', - categoryNameEn: categoryData['nameEn'] ?? '', - ); - } - } - } - } - } - - // JSON에서 못 찾았으면 레거시 방식으로 찾기 - for (final entry in LegacyServiceData.allServices.entries) { - final serviceUrl = entry.value; - final serviceDomain = extractDomain(serviceUrl); - - if (serviceDomain != null && - (domain.contains(serviceDomain) || serviceDomain.contains(domain))) { - return ServiceInfo( - serviceId: entry.key, - serviceName: entry.key, - serviceUrl: serviceUrl, - cancellationUrl: null, - categoryId: _getCategoryForLegacyService(entry.key), - categoryNameKr: '', - categoryNameEn: '', - ); - } - } - - return null; + return _urlMatcher?.findServiceByUrl(url); } /// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지) static String? suggestUrl(String serviceName) { - if (serviceName.isEmpty) { - print('SubscriptionUrlMatcher: 빈 serviceName'); - return null; - } - - // 소문자로 변환하여 비교 - final lowerName = serviceName.toLowerCase().trim(); - - try { - // 정확한 매칭을 먼저 시도 - for (final entry in LegacyServiceData.allServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print('SubscriptionUrlMatcher: 정확한 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // OTT 서비스 검사 - for (final entry in LegacyServiceData.ottServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 음악 서비스 검사 - for (final entry in LegacyServiceData.musicServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // AI 서비스 검사 - for (final entry in LegacyServiceData.aiServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: AI 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 개발 서비스 검사 - for (final entry in LegacyServiceData.programmingServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: 개발 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 오피스 툴 검사 - for (final entry in LegacyServiceData.officeTools.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 기타 서비스 검사 - for (final entry in LegacyServiceData.otherServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 유사한 이름 검사 (퍼지 매칭) - 단어 기반으로 검색 - for (final entry in LegacyServiceData.allServices.entries) { - final serviceWords = lowerName.split(' '); - final keyWords = entry.key.toLowerCase().split(' '); - - // 단어 단위로 일치하는지 확인 - for (final word in serviceWords) { - if (word.length > 2 && - keyWords.any((keyWord) => keyWord.contains(word))) { - print( - 'SubscriptionUrlMatcher: 단어 기반 매칭 - $word (in $lowerName) -> ${entry.key}'); - return entry.value; - } - } - } - - // 추출 가능한 도메인이 있는지 확인 - final domainMatch = RegExp(r'(\w+)').firstMatch(lowerName); - if (domainMatch != null && domainMatch.group(1)!.length > 2) { - final domain = domainMatch.group(1)!.trim(); - if (domain.length > 2 && - !['the', 'and', 'for', 'www'].contains(domain)) { - final url = 'https://www.$domain.com'; - print('SubscriptionUrlMatcher: 도메인 추출 - $lowerName -> $url'); - return url; - } - } - - print('SubscriptionUrlMatcher: 매칭 실패 - $lowerName'); - return null; - } catch (e) { - print('SubscriptionUrlMatcher: URL 매칭 중 오류 발생: $e'); - return null; - } + return _urlMatcher?.suggestUrl(serviceName); } - /// 해지 안내 URL 찾기 (개선된 버전) + /// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기 static Future findCancellationUrl({ String? serviceName, String? websiteUrl, String locale = 'kr', }) async { await initialize(); - - // JSON 데이터가 있으면 JSON에서 찾기 - if (_servicesData != null) { - final categories = _servicesData!['categories'] as Map; - - // 1. 서비스명으로 찾기 - if (serviceName != null && serviceName.isNotEmpty) { - final lowerName = serviceName.toLowerCase().trim(); - - for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - - for (final serviceData in services.values) { - final names = List.from((serviceData as Map)['names'] ?? []); - - for (final name in names) { - if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { - final cancellationUrls = serviceData['cancellationUrls'] as Map?; - if (cancellationUrls != null) { - // 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환 - return cancellationUrls[locale] ?? - cancellationUrls[locale == 'kr' ? 'en' : 'kr']; - } - } - } - } - } - } - - // 2. URL로 찾기 - if (websiteUrl != null && websiteUrl.isNotEmpty) { - final domain = extractDomain(websiteUrl); - if (domain != null) { - for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - - for (final serviceData in services.values) { - final domains = List.from((serviceData as Map)['domains'] ?? []); - - for (final serviceDomain in domains) { - if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { - final cancellationUrls = serviceData['cancellationUrls'] as Map?; - if (cancellationUrls != null) { - return cancellationUrls[locale] ?? - cancellationUrls[locale == 'kr' ? 'en' : 'kr']; - } - } - } - } - } - } - } - } - - // JSON에서 못 찾았으면 레거시 방식으로 찾기 - return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? ''); + return _cancellationService?.findCancellationUrl( + serviceName: serviceName, + websiteUrl: websiteUrl, + locale: locale, + ); } - - /// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시) - static String? _findCancellationUrlLegacy(String serviceNameOrUrl) { - if (serviceNameOrUrl.isEmpty) { - return null; - } - - // 소문자로 변환하여 처리 - final String lowerText = serviceNameOrUrl.toLowerCase().trim(); - - // 직접 서비스명으로 찾기 - if (LegacyServiceData.cancellationUrls.containsKey(lowerText)) { - return LegacyServiceData.cancellationUrls[lowerText]; - } - - // 서비스명에 부분 포함으로 찾기 - for (var entry in LegacyServiceData.cancellationUrls.entries) { - final String key = entry.key.toLowerCase(); - if (lowerText.contains(key) || key.contains(lowerText)) { - return entry.value; - } - } - - // URL을 통해 서비스명 추출 후 찾기 - if (lowerText.startsWith('http')) { - // URL 도메인 추출 (https://www.netflix.com 에서 netflix 추출) - final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)'); - final match = domainRegex.firstMatch(lowerText); - - if (match != null && match.groupCount >= 1) { - final domain = match.group(1)?.toLowerCase() ?? ''; - - // 도메인으로 서비스명 찾기 - for (var entry in LegacyServiceData.cancellationUrls.entries) { - if (entry.key.toLowerCase().contains(domain)) { - return entry.value; - } - } - } - } - - // 해지 안내 페이지를 찾지 못함 - return null; - } - + /// 서비스에 공식 해지 안내 페이지가 있는지 확인 static Future hasCancellationPage(String serviceNameOrUrl) async { - // 새로운 JSON 기반 방식으로 확인 - final cancellationUrl = await findCancellationUrl( - serviceName: serviceNameOrUrl, - websiteUrl: serviceNameOrUrl, - ); - return cancellationUrl != null; + await initialize(); + return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false; } /// 서비스명으로 카테고리 찾기 static Future findCategoryByServiceName(String serviceName) async { await initialize(); - if (serviceName.isEmpty) return null; - - final lowerName = serviceName.toLowerCase().trim(); - - // JSON 데이터가 있으면 JSON에서 찾기 - if (_servicesData != null) { - final categories = _servicesData!['categories'] as Map; - - for (final categoryEntry in categories.entries) { - final categoryId = categoryEntry.key; - final categoryData = categoryEntry.value as Map; - final services = categoryData['services'] as Map; - - for (final serviceData in services.values) { - final names = List.from((serviceData as Map)['names'] ?? []); - - for (final name in names) { - if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { - return _getCategoryIdByKey(categoryId); - } - } - } - } - } - - // JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측 - return _getCategoryForLegacyService(serviceName); + return _categoryMapper?.findCategoryByServiceName(serviceName); } /// 현재 로케일에 따라 서비스 표시명 가져오기 @@ -388,189 +82,26 @@ class SubscriptionUrlMatcher { required String locale, }) async { await initialize(); - - if (_servicesData == null) { - return serviceName; - } - - final lowerName = serviceName.toLowerCase().trim(); - final categories = _servicesData!['categories'] as Map; - - // JSON에서 서비스 찾기 - for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - - for (final serviceData in services.values) { - final data = serviceData as Map; - final names = List.from(data['names'] ?? []); - - // names 배열에 있는지 확인 - for (final name in names) { - if (lowerName == name.toLowerCase() || - lowerName.contains(name.toLowerCase()) || - name.toLowerCase().contains(lowerName)) { - // 로케일에 따라 적절한 이름 반환 - if (locale == 'ko' || locale == 'kr') { - return data['nameKr'] ?? serviceName; - } else { - return data['nameEn'] ?? serviceName; - } - } - } - - // nameKr/nameEn에 직접 매칭 확인 - final nameKr = (data['nameKr'] ?? '').toString().toLowerCase(); - final nameEn = (data['nameEn'] ?? '').toString().toLowerCase(); - - if (lowerName == nameKr || lowerName == nameEn) { - if (locale == 'ko' || locale == 'kr') { - return data['nameKr'] ?? serviceName; - } else { - return data['nameEn'] ?? serviceName; - } - } - } - } - - // 찾지 못한 경우 원래 이름 반환 - return serviceName; - } - - /// 카테고리 키를 실제 카테고리 ID로 매핑 - static String _getCategoryIdByKey(String key) { - // 여기에 실제 앱의 카테고리 ID 매핑을 추가 - // 임시로 카테고리명 기반 매핑 - switch (key) { - case 'music': - return 'music_streaming'; - case 'ott': - return 'ott_services'; - case 'storage': - return 'cloud_storage'; - case 'ai': - return 'ai_services'; - case 'programming': - return 'dev_tools'; - case 'office': - return 'office_tools'; - case 'lifestyle': - return 'lifestyle'; - case 'shopping': - return 'shopping'; - case 'gaming': - return 'gaming'; - case 'telecom': - return 'telecom'; - default: - return 'other'; - } - } - - /// 레거시 서비스명으로 카테고리 추측 - static String _getCategoryForLegacyService(String serviceName) { - final lowerName = serviceName.toLowerCase(); - - if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services'; - if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming'; - if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage'; - if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services'; - if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools'; - if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools'; - if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle'; - if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping'; - if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom'; - - return 'other'; + return await _nameResolver?.getServiceDisplayName( + serviceName: serviceName, + locale: locale, + ) ?? serviceName; } /// SMS에서 URL과 서비스 정보 추출 static Future extractServiceFromSms(String smsText) async { await initialize(); - - // URL 패턴 찾기 - final urlPattern = RegExp( - r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)', - caseSensitive: false, - ); - - final matches = urlPattern.allMatches(smsText); - - for (final match in matches) { - final url = match.group(0); - if (url != null) { - final serviceInfo = await findServiceByUrl(url); - if (serviceInfo != null) { - return serviceInfo; - } - } - } - - // URL로 못 찾았으면 서비스명으로 시도 - final lowerSms = smsText.toLowerCase(); - - // 모든 서비스명 검사 - for (final entry in LegacyServiceData.allServices.entries) { - if (lowerSms.contains(entry.key.toLowerCase())) { - final categoryId = await findCategoryByServiceName(entry.key) ?? 'other'; - - return ServiceInfo( - serviceId: entry.key, - serviceName: entry.key, - serviceUrl: entry.value, - cancellationUrl: null, - categoryId: categoryId, - categoryNameKr: '', - categoryNameEn: '', - ); - } - } - - return null; + return _smsExtractor?.extractServiceFromSms(smsText); } /// URL이 알려진 서비스 URL인지 확인 static Future isKnownServiceUrl(String url) async { - final serviceInfo = await findServiceByUrl(url); - return serviceInfo != null; + await initialize(); + return await _urlMatcher?.isKnownServiceUrl(url) ?? false; } /// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성) static String? findMatchingUrl(String text, {bool usePartialMatch = true}) { - // 입력 텍스트가 비어있거나 null인 경우 - if (text.isEmpty) { - return null; - } - - // 소문자로 변환하여 처리 - final String lowerText = text.toLowerCase().trim(); - - // 정확히 일치하는 경우 - if (LegacyServiceData.allServices.containsKey(lowerText)) { - return LegacyServiceData.allServices[lowerText]; - } - - // 부분 일치 검색이 활성화된 경우 - if (usePartialMatch) { - // 가장 긴 부분 매칭 찾기 - String? bestMatch; - int maxLength = 0; - - for (var entry in LegacyServiceData.allServices.entries) { - final String key = entry.key; - - // 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우 - if (lowerText.contains(key) || key.contains(lowerText)) { - // 더 긴 매칭을 우선시 - if (key.length > maxLength) { - maxLength = key.length; - bestMatch = entry.value; - } - } - } - - return bestMatch; - } - - return null; + return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch); } } \ No newline at end of file diff --git a/lib/services/url_matcher/data/service_data_repository.dart b/lib/services/url_matcher/data/service_data_repository.dart new file mode 100644 index 0000000..018f66c --- /dev/null +++ b/lib/services/url_matcher/data/service_data_repository.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; + +/// 서비스 데이터를 관리하는 저장소 클래스 +class ServiceDataRepository { + Map? _servicesData; + bool _isInitialized = false; + + /// JSON 데이터 초기화 + Future initialize() async { + if (_isInitialized) return; + + try { + final jsonString = await rootBundle.loadString('assets/data/subscription_services.json'); + _servicesData = json.decode(jsonString); + _isInitialized = true; + print('ServiceDataRepository: JSON 데이터 로드 완료'); + } catch (e) { + print('ServiceDataRepository: JSON 로드 실패 - $e'); + // 로드 실패시 기존 하드코딩 데이터 사용 + _isInitialized = true; + } + } + + /// 서비스 데이터 가져오기 + Map? getServicesData() => _servicesData; + + /// 초기화 여부 확인 + bool get isInitialized => _isInitialized; +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/cancellation_url_service.dart b/lib/services/url_matcher/services/cancellation_url_service.dart new file mode 100644 index 0000000..74542ac --- /dev/null +++ b/lib/services/url_matcher/services/cancellation_url_service.dart @@ -0,0 +1,129 @@ +import '../data/service_data_repository.dart'; +import '../data/legacy_service_data.dart'; +import 'url_matcher_service.dart'; + +/// 해지 URL 관련 기능을 제공하는 서비스 클래스 +class CancellationUrlService { + final ServiceDataRepository _dataRepository; + final UrlMatcherService _urlMatcher; + + CancellationUrlService(this._dataRepository, this._urlMatcher); + + /// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기 + Future findCancellationUrl({ + String? serviceName, + String? websiteUrl, + String locale = 'kr', + }) async { + // JSON 데이터가 있으면 JSON에서 찾기 + final servicesData = _dataRepository.getServicesData(); + if (servicesData != null) { + final categories = servicesData['categories'] as Map; + + // 1. 서비스명으로 찾기 + if (serviceName != null && serviceName.isNotEmpty) { + final lowerName = serviceName.toLowerCase().trim(); + + for (final categoryData in categories.values) { + final services = (categoryData as Map)['services'] as Map; + + for (final serviceData in services.values) { + final names = List.from((serviceData as Map)['names'] ?? []); + + for (final name in names) { + if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { + final cancellationUrls = serviceData['cancellationUrls'] as Map?; + if (cancellationUrls != null) { + // 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환 + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; + } + } + } + } + } + } + + // 2. URL로 찾기 + if (websiteUrl != null && websiteUrl.isNotEmpty) { + final domain = _urlMatcher.extractDomain(websiteUrl); + if (domain != null) { + for (final categoryData in categories.values) { + final services = (categoryData as Map)['services'] as Map; + + for (final serviceData in services.values) { + final domains = List.from((serviceData as Map)['domains'] ?? []); + + for (final serviceDomain in domains) { + if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { + final cancellationUrls = serviceData['cancellationUrls'] as Map?; + if (cancellationUrls != null) { + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; + } + } + } + } + } + } + } + } + + // JSON에서 못 찾았으면 레거시 방식으로 찾기 + return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? ''); + } + + /// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시) + String? _findCancellationUrlLegacy(String serviceNameOrUrl) { + if (serviceNameOrUrl.isEmpty) { + return null; + } + + // 소문자로 변환하여 처리 + final String lowerText = serviceNameOrUrl.toLowerCase().trim(); + + // 직접 서비스명으로 찾기 + if (LegacyServiceData.cancellationUrls.containsKey(lowerText)) { + return LegacyServiceData.cancellationUrls[lowerText]; + } + + // 서비스명에 부분 포함으로 찾기 + for (var entry in LegacyServiceData.cancellationUrls.entries) { + final String key = entry.key.toLowerCase(); + if (lowerText.contains(key) || key.contains(lowerText)) { + return entry.value; + } + } + + // URL을 통해 서비스명 추출 후 찾기 + if (lowerText.startsWith('http')) { + // URL 도메인 추출 (https://www.netflix.com 에서 netflix 추출) + final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)'); + final match = domainRegex.firstMatch(lowerText); + + if (match != null && match.groupCount >= 1) { + final domain = match.group(1)?.toLowerCase() ?? ''; + + // 도메인으로 서비스명 찾기 + for (var entry in LegacyServiceData.cancellationUrls.entries) { + if (entry.key.toLowerCase().contains(domain)) { + return entry.value; + } + } + } + } + + // 해지 안내 페이지를 찾지 못함 + return null; + } + + /// 서비스에 공식 해지 안내 페이지가 있는지 확인 + Future hasCancellationPage(String serviceNameOrUrl) async { + // 새로운 JSON 기반 방식으로 확인 + final cancellationUrl = await findCancellationUrl( + serviceName: serviceNameOrUrl, + websiteUrl: serviceNameOrUrl, + ); + return cancellationUrl != null; + } +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/category_mapper_service.dart b/lib/services/url_matcher/services/category_mapper_service.dart new file mode 100644 index 0000000..872a093 --- /dev/null +++ b/lib/services/url_matcher/services/category_mapper_service.dart @@ -0,0 +1,88 @@ +import '../data/service_data_repository.dart'; +import '../data/legacy_service_data.dart'; + +/// 카테고리 매핑 관련 기능을 제공하는 서비스 클래스 +class CategoryMapperService { + final ServiceDataRepository _dataRepository; + + CategoryMapperService(this._dataRepository); + + /// 서비스명으로 카테고리 찾기 + Future findCategoryByServiceName(String serviceName) async { + if (serviceName.isEmpty) return null; + + final lowerName = serviceName.toLowerCase().trim(); + + // JSON 데이터가 있으면 JSON에서 찾기 + final servicesData = _dataRepository.getServicesData(); + if (servicesData != null) { + final categories = servicesData['categories'] as Map; + + for (final categoryEntry in categories.entries) { + final categoryId = categoryEntry.key; + final categoryData = categoryEntry.value as Map; + final services = categoryData['services'] as Map; + + for (final serviceData in services.values) { + final names = List.from((serviceData as Map)['names'] ?? []); + + for (final name in names) { + if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { + return getCategoryIdByKey(categoryId); + } + } + } + } + } + + // JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측 + return getCategoryForLegacyService(serviceName); + } + + /// 카테고리 키를 실제 카테고리 ID로 매핑 + String getCategoryIdByKey(String key) { + // 여기에 실제 앱의 카테고리 ID 매핑을 추가 + // 임시로 카테고리명 기반 매핑 + switch (key) { + case 'music': + return 'music_streaming'; + case 'ott': + return 'ott_services'; + case 'storage': + return 'cloud_storage'; + case 'ai': + return 'ai_services'; + case 'programming': + return 'dev_tools'; + case 'office': + return 'office_tools'; + case 'lifestyle': + return 'lifestyle'; + case 'shopping': + return 'shopping'; + case 'gaming': + return 'gaming'; + case 'telecom': + return 'telecom'; + default: + return 'other'; + } + } + + /// 레거시 서비스명으로 카테고리 추측 + String getCategoryForLegacyService(String serviceName) { + final lowerName = serviceName.toLowerCase(); + + if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services'; + if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming'; + if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage'; + if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services'; + if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools'; + if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools'; + if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle'; + if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping'; + if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom'; + + return 'other'; + } +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/service_name_resolver.dart b/lib/services/url_matcher/services/service_name_resolver.dart new file mode 100644 index 0000000..76eaa17 --- /dev/null +++ b/lib/services/url_matcher/services/service_name_resolver.dart @@ -0,0 +1,61 @@ +import '../data/service_data_repository.dart'; + +/// 서비스명 관련 기능을 제공하는 서비스 클래스 +class ServiceNameResolver { + final ServiceDataRepository _dataRepository; + + ServiceNameResolver(this._dataRepository); + + /// 현재 로케일에 따라 서비스 표시명 가져오기 + Future getServiceDisplayName({ + required String serviceName, + required String locale, + }) async { + final servicesData = _dataRepository.getServicesData(); + if (servicesData == null) { + return serviceName; + } + + final lowerName = serviceName.toLowerCase().trim(); + final categories = servicesData['categories'] as Map; + + // JSON에서 서비스 찾기 + for (final categoryData in categories.values) { + final services = (categoryData as Map)['services'] as Map; + + for (final serviceData in services.values) { + final data = serviceData as Map; + final names = List.from(data['names'] ?? []); + + // names 배열에 있는지 확인 + for (final name in names) { + if (lowerName == name.toLowerCase() || + lowerName.contains(name.toLowerCase()) || + name.toLowerCase().contains(lowerName)) { + // 로케일에 따라 적절한 이름 반환 + if (locale == 'ko' || locale == 'kr') { + return data['nameKr'] ?? serviceName; + } else { + return data['nameEn'] ?? serviceName; + } + } + } + + // nameKr/nameEn에 직접 매칭 확인 + final nameKr = (data['nameKr'] ?? '').toString().toLowerCase(); + final nameEn = (data['nameEn'] ?? '').toString().toLowerCase(); + + if (lowerName == nameKr || lowerName == nameEn) { + if (locale == 'ko' || locale == 'kr') { + return data['nameKr'] ?? serviceName; + } else { + return data['nameEn'] ?? serviceName; + } + } + } + } + + // 찾지 못한 경우 원래 이름 반환 + return serviceName; + } +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/sms_extractor_service.dart b/lib/services/url_matcher/services/sms_extractor_service.dart new file mode 100644 index 0000000..4e1f7b5 --- /dev/null +++ b/lib/services/url_matcher/services/sms_extractor_service.dart @@ -0,0 +1,55 @@ +import '../models/service_info.dart'; +import '../data/legacy_service_data.dart'; +import 'url_matcher_service.dart'; +import 'category_mapper_service.dart'; + +/// SMS에서 서비스 정보를 추출하는 서비스 클래스 +class SmsExtractorService { + final UrlMatcherService _urlMatcher; + final CategoryMapperService _categoryMapper; + + SmsExtractorService(this._urlMatcher, this._categoryMapper); + + /// SMS에서 URL과 서비스 정보 추출 + Future extractServiceFromSms(String smsText) async { + // URL 패턴 찾기 + final urlPattern = RegExp( + r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)', + caseSensitive: false, + ); + + final matches = urlPattern.allMatches(smsText); + + for (final match in matches) { + final url = match.group(0); + if (url != null) { + final serviceInfo = await _urlMatcher.findServiceByUrl(url); + if (serviceInfo != null) { + return serviceInfo; + } + } + } + + // URL로 못 찾았으면 서비스명으로 시도 + final lowerSms = smsText.toLowerCase(); + + // 모든 서비스명 검사 + for (final entry in LegacyServiceData.allServices.entries) { + if (lowerSms.contains(entry.key.toLowerCase())) { + final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other'; + + return ServiceInfo( + serviceId: entry.key, + serviceName: entry.key, + serviceUrl: entry.value, + cancellationUrl: null, + categoryId: categoryId, + categoryNameKr: '', + categoryNameEn: '', + ); + } + } + + return null; + } +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/url_matcher_service.dart b/lib/services/url_matcher/services/url_matcher_service.dart new file mode 100644 index 0000000..090a83d --- /dev/null +++ b/lib/services/url_matcher/services/url_matcher_service.dart @@ -0,0 +1,235 @@ +import '../models/service_info.dart'; +import '../data/service_data_repository.dart'; +import '../data/legacy_service_data.dart'; +import 'category_mapper_service.dart'; + +/// URL 매칭 관련 기능을 제공하는 서비스 클래스 +class UrlMatcherService { + final ServiceDataRepository _dataRepository; + final CategoryMapperService _categoryMapper; + + UrlMatcherService(this._dataRepository, this._categoryMapper); + + /// 도메인 추출 (www와 TLD 제외) + String? extractDomain(String url) { + try { + final uri = Uri.parse(url); + final host = uri.host.toLowerCase(); + + // 도메인 부분 추출 + var parts = host.split('.'); + + // www 제거 + if (parts.isNotEmpty && parts[0] == 'www') { + parts = parts.sublist(1); + } + + // 서브도메인 처리 (예: music.youtube.com) + if (parts.length >= 3) { + // 서브도메인 포함 전체 도메인 반환 + return parts.sublist(0, parts.length - 1).join('.'); + } else if (parts.length >= 2) { + // 메인 도메인만 반환 + return parts[0]; + } + + return null; + } catch (e) { + print('UrlMatcherService: 도메인 추출 실패 - $e'); + return null; + } + } + + /// URL로 서비스 찾기 + Future findServiceByUrl(String url) async { + final domain = extractDomain(url); + if (domain == null) return null; + + // JSON 데이터가 있으면 JSON에서 찾기 + final servicesData = _dataRepository.getServicesData(); + if (servicesData != null) { + final categories = servicesData['categories'] as Map; + + for (final categoryEntry in categories.entries) { + final categoryId = categoryEntry.key; + final categoryData = categoryEntry.value as Map; + final services = categoryData['services'] as Map; + + for (final serviceEntry in services.entries) { + final serviceId = serviceEntry.key; + final serviceData = serviceEntry.value as Map; + final domains = List.from(serviceData['domains'] ?? []); + + // 도메인이 일치하는지 확인 + for (final serviceDomain in domains) { + if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { + final names = List.from(serviceData['names'] ?? []); + final urls = serviceData['urls'] as Map?; + + return ServiceInfo( + serviceId: serviceId, + serviceName: names.isNotEmpty ? names[0] : serviceId, + serviceUrl: urls?['kr'] ?? urls?['en'], + cancellationUrl: null, + categoryId: _categoryMapper.getCategoryIdByKey(categoryId), + categoryNameKr: categoryData['nameKr'] ?? '', + categoryNameEn: categoryData['nameEn'] ?? '', + ); + } + } + } + } + } + + // JSON에서 못 찾았으면 레거시 방식으로 찾기 + for (final entry in LegacyServiceData.allServices.entries) { + final serviceUrl = entry.value; + final serviceDomain = extractDomain(serviceUrl); + + if (serviceDomain != null && + (domain.contains(serviceDomain) || serviceDomain.contains(domain))) { + return ServiceInfo( + serviceId: entry.key, + serviceName: entry.key, + serviceUrl: serviceUrl, + cancellationUrl: null, + categoryId: _categoryMapper.getCategoryForLegacyService(entry.key), + categoryNameKr: '', + categoryNameEn: '', + ); + } + } + + return null; + } + + /// 서비스명으로 URL 찾기 + String? suggestUrl(String serviceName) { + if (serviceName.isEmpty) { + print('UrlMatcherService: 빈 serviceName'); + return null; + } + + // 소문자로 변환하여 비교 + final lowerName = serviceName.toLowerCase().trim(); + + try { + // 정확한 매칭을 먼저 시도 + for (final entry in LegacyServiceData.allServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // OTT 서비스 검사 + for (final entry in LegacyServiceData.ottServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // 음악 서비스 검사 + for (final entry in LegacyServiceData.musicServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // AI 서비스 검사 + for (final entry in LegacyServiceData.aiServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // 프로그래밍 서비스 검사 + for (final entry in LegacyServiceData.programmingServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // 오피스 툴 검사 + for (final entry in LegacyServiceData.officeTools.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // 기타 서비스 검사 + for (final entry in LegacyServiceData.otherServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // 전체 서비스에서 부분 매칭 재시도 + for (final entry in LegacyServiceData.allServices.entries) { + final key = entry.key.toLowerCase(); + if (key.contains(lowerName) || lowerName.contains(key)) { + print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + print('UrlMatcherService: 매칭 실패 - $lowerName'); + return null; + } catch (e) { + print('UrlMatcherService: suggestUrl 에러 - $e'); + return null; + } + } + + /// URL이 알려진 서비스 URL인지 확인 + Future isKnownServiceUrl(String url) async { + final serviceInfo = await findServiceByUrl(url); + return serviceInfo != null; + } + + /// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 + String? findMatchingUrl(String text, {bool usePartialMatch = true}) { + // 입력 텍스트가 비어있거나 null인 경우 + if (text.isEmpty) { + return null; + } + + // 소문자로 변환하여 처리 + final String lowerText = text.toLowerCase().trim(); + + // 정확히 일치하는 경우 + if (LegacyServiceData.allServices.containsKey(lowerText)) { + return LegacyServiceData.allServices[lowerText]; + } + + // 부분 일치 검색이 활성화된 경우 + if (usePartialMatch) { + // 가장 긴 부분 매칭 찾기 + String? bestMatch; + int maxLength = 0; + + for (var entry in LegacyServiceData.allServices.entries) { + final String key = entry.key; + + // 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우 + if (lowerText.contains(key) || key.contains(lowerText)) { + // 더 긴 매칭을 우선시 + if (key.length > maxLength) { + maxLength = key.length; + bestMatch = entry.value; + } + } + } + + return bestMatch; + } + + return null; + } +} \ No newline at end of file diff --git a/lib/utils/sms_scan/category_icon_mapper.dart b/lib/utils/sms_scan/category_icon_mapper.dart new file mode 100644 index 0000000..3d30f93 --- /dev/null +++ b/lib/utils/sms_scan/category_icon_mapper.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import '../../models/category_model.dart'; + +class CategoryIconMapper { + // 카테고리 아이콘 반환 + static IconData getCategoryIcon(CategoryModel category) { + switch (category.name) { + case 'music': + return Icons.music_note_rounded; + case 'ottVideo': + return Icons.movie_filter_rounded; + case 'storageCloud': + return Icons.cloud_outlined; + case 'telecomInternetTv': + return Icons.wifi_rounded; + case 'lifestyle': + return Icons.home_outlined; + case 'shoppingEcommerce': + return Icons.shopping_cart_outlined; + case 'programming': + return Icons.code_rounded; + case 'collaborationOffice': + return Icons.business_center_outlined; + case 'aiService': + return Icons.smart_toy_outlined; + case 'other': + default: + return Icons.category_outlined; + } + } + + // 카테고리별 배경색 반환 + static Color getCategoryColor(CategoryModel category) { + final colorString = category.color; + try { + return Color(int.parse(colorString.replaceFirst('#', '0xFF'))); + } catch (e) { + // 파싱 실패 시 기본 색상 반환 + return const Color(0xFF6B7280); // 기본 회색 + } + } + + // 카테고리별 아이콘 크기 반환 + static double getCategoryIconSize(CategoryModel category) { + switch (category.name) { + case 'music': + case 'ottVideo': + return 18.0; + default: + return 16.0; + } + } +} \ No newline at end of file diff --git a/lib/utils/sms_scan/date_formatter.dart b/lib/utils/sms_scan/date_formatter.dart new file mode 100644 index 0000000..2d8353e --- /dev/null +++ b/lib/utils/sms_scan/date_formatter.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import '../../l10n/app_localizations.dart'; + +class SmsDateFormatter { + // 날짜 상태 텍스트 가져오기 + static String getNextBillingText( + BuildContext context, + DateTime date, + String billingCycle, + ) { + final now = DateTime.now(); + + if (date.isBefore(now)) { + return _getPastDateText(context, date, billingCycle, now); + } else { + return _getFutureDateText(context, date, now); + } + } + + // 과거 날짜 처리 + static String _getPastDateText( + BuildContext context, + DateTime date, + String billingCycle, + DateTime now, + ) { + // 주기에 따라 다음 결제일 예측 + DateTime? predictedDate = _predictNextBillingDate(date, billingCycle, now); + + if (predictedDate != null) { + final daysUntil = predictedDate.difference(now).inDays; + return AppLocalizations.of(context).nextBillingDateEstimated( + AppLocalizations.of(context).formatDate(predictedDate), + daysUntil, + ); + } + + return '다음 결제일 확인 필요 (과거 날짜)'; + } + + // 미래 날짜 처리 + static String _getFutureDateText( + BuildContext context, + DateTime date, + DateTime now, + ) { + final daysUntil = date.difference(now).inDays; + return AppLocalizations.of(context).nextBillingDateInfo( + AppLocalizations.of(context).formatDate(date), + daysUntil, + ); + } + + // 다음 결제일 예측 + static DateTime? _predictNextBillingDate( + DateTime lastDate, + String billingCycle, + DateTime now, + ) { + switch (billingCycle) { + case '월간': + return _getNextMonthlyDate(lastDate, now); + case '연간': + return _getNextYearlyDate(lastDate, now); + case '주간': + return _getNextWeeklyDate(lastDate, now); + case '일간': + return _getNextDailyDate(lastDate, now); + case '분기별': + return _getNextQuarterlyDate(lastDate, now); + case '반기별': + return _getNextSemiAnnuallyDate(lastDate, now); + default: + return null; + } + } + + // 다음 월간 결제일 계산 + static DateTime _getNextMonthlyDate(DateTime lastDate, DateTime now) { + int day = lastDate.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); + } + + return adjusted; + } + + // 다음 연간 결제일 계산 + static DateTime _getNextYearlyDate(DateTime lastDate, DateTime now) { + int day = lastDate.day; + + // 해당 월의 마지막 날을 초과하는 경우 조정 + final lastDay = DateTime(now.year, lastDate.month + 1, 0).day; + if (day > lastDay) { + day = lastDay; + } + + DateTime adjusted = DateTime(now.year, lastDate.month, day); + if (adjusted.isBefore(now)) { + // 다음 해 해당 월의 마지막 날을 초과하는 경우 조정 + final nextYearLastDay = DateTime(now.year + 1, lastDate.month + 1, 0).day; + if (day > nextYearLastDay) { + day = nextYearLastDay; + } + adjusted = DateTime(now.year + 1, lastDate.month, day); + } + + return adjusted; + } + + // 다음 주간 결제일 계산 + static DateTime _getNextWeeklyDate(DateTime lastDate, DateTime now) { + DateTime next = lastDate; + while (next.isBefore(now)) { + next = next.add(const Duration(days: 7)); + } + return next; + } + + // 다음 일간 결제일 계산 + static DateTime _getNextDailyDate(DateTime lastDate, DateTime now) { + return now.add(const Duration(days: 1)); + } + + // 다음 분기별 결제일 계산 + static DateTime _getNextQuarterlyDate(DateTime lastDate, DateTime now) { + DateTime next = lastDate; + while (next.isBefore(now)) { + next = DateTime(next.year, next.month + 3, next.day); + } + return next; + } + + // 다음 반기별 결제일 계산 + static DateTime _getNextSemiAnnuallyDate(DateTime lastDate, DateTime now) { + DateTime next = lastDate; + while (next.isBefore(now)) { + next = DateTime(next.year, next.month + 6, next.day); + } + return next; + } + + // 날짜 포맷 함수 + static String formatDate(DateTime date) { + return '${date.year}년 ${date.month}월 ${date.day}일'; + } + + // 결제 반복 횟수 텍스트 + static String getRepeatCountText(BuildContext context, int count) { + return AppLocalizations.of(context).repeatCountDetected(count); + } +} \ No newline at end of file diff --git a/lib/widgets/sms_scan/scan_initial_widget.dart b/lib/widgets/sms_scan/scan_initial_widget.dart new file mode 100644 index 0000000..bf2059b --- /dev/null +++ b/lib/widgets/sms_scan/scan_initial_widget.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../widgets/themed_text.dart'; +import '../../widgets/common/buttons/primary_button.dart'; +import '../../widgets/native_ad_widget.dart'; +import '../../l10n/app_localizations.dart'; + +class ScanInitialWidget extends StatelessWidget { + final VoidCallback onScanPressed; + final String? errorMessage; + + const ScanInitialWidget({ + super.key, + required this.onScanPressed, + this.errorMessage, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // 광고 위젯 추가 + const NativeAdWidget(key: ValueKey('sms_scan_start_ad')), + const SizedBox(height: 48), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: ThemedText( + errorMessage!, + color: Colors.red, + textAlign: TextAlign.center, + ), + ), + ThemedText( + AppLocalizations.of(context).findRepeatSubscriptions, + fontSize: 20, + fontWeight: FontWeight.bold, + forceDark: true, + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ThemedText( + AppLocalizations.of(context).scanTextMessages, + textAlign: TextAlign.center, + opacity: 0.7, + forceDark: true, + ), + ), + const SizedBox(height: 32), + PrimaryButton( + text: AppLocalizations.of(context).startScanning, + icon: Icons.search_rounded, + onPressed: onScanPressed, + width: 200, + height: 56, + backgroundColor: AppColors.primaryColor, + ), + ], + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/sms_scan/scan_loading_widget.dart b/lib/widgets/sms_scan/scan_loading_widget.dart new file mode 100644 index 0000000..ad28115 --- /dev/null +++ b/lib/widgets/sms_scan/scan_loading_widget.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../widgets/themed_text.dart'; +import '../../l10n/app_localizations.dart'; + +class ScanLoadingWidget extends StatelessWidget { + const ScanLoadingWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), + ), + const SizedBox(height: 16), + ThemedText( + AppLocalizations.of(context).scanningMessages, + forceDark: true, + ), + const SizedBox(height: 8), + ThemedText( + AppLocalizations.of(context).findingSubscriptions, + opacity: 0.7, + forceDark: true, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/sms_scan/scan_progress_widget.dart b/lib/widgets/sms_scan/scan_progress_widget.dart new file mode 100644 index 0000000..7a830fc --- /dev/null +++ b/lib/widgets/sms_scan/scan_progress_widget.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../widgets/themed_text.dart'; + +class ScanProgressWidget extends StatelessWidget { + final int currentIndex; + final int totalCount; + + const ScanProgressWidget({ + super.key, + required this.currentIndex, + required this.totalCount, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 진행 상태 표시 + LinearProgressIndicator( + value: (currentIndex + 1) / totalCount, + backgroundColor: AppColors.navyGray.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 8), + ThemedText( + '${currentIndex + 1}/$totalCount', + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/sms_scan/subscription_card_widget.dart b/lib/widgets/sms_scan/subscription_card_widget.dart new file mode 100644 index 0000000..83de2a6 --- /dev/null +++ b/lib/widgets/sms_scan/subscription_card_widget.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/subscription.dart'; +import '../../providers/category_provider.dart'; +import '../../providers/locale_provider.dart'; +import '../../widgets/glassmorphism_card.dart'; +import '../../widgets/themed_text.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 '../../widgets/common/form_fields/category_selector.dart'; +import '../../widgets/common/snackbar/app_snackbar.dart'; +import '../../widgets/native_ad_widget.dart'; +import '../../theme/app_colors.dart'; +import '../../services/currency_util.dart'; +import '../../utils/sms_scan/date_formatter.dart'; +import '../../utils/sms_scan/category_icon_mapper.dart'; +import '../../l10n/app_localizations.dart'; + +class SubscriptionCardWidget extends StatefulWidget { + final Subscription subscription; + final TextEditingController websiteUrlController; + final String? selectedCategoryId; + final Function(String?) onCategoryChanged; + final VoidCallback onAdd; + final VoidCallback onSkip; + + const SubscriptionCardWidget({ + super.key, + required this.subscription, + required this.websiteUrlController, + this.selectedCategoryId, + required this.onCategoryChanged, + required this.onAdd, + required this.onSkip, + }); + + @override + State createState() => _SubscriptionCardWidgetState(); +} + +class _SubscriptionCardWidgetState extends State { + @override + void initState() { + super.initState(); + // URL 필드 자동 설정 + if (widget.websiteUrlController.text.isEmpty && widget.subscription.websiteUrl != null) { + widget.websiteUrlController.text = widget.subscription.websiteUrl!; + } + } + + void _handleCardTap() { + // 디버그 로그 추가 + print('[SubscriptionCard] Card tapped! Service: ${widget.subscription.serviceName}'); + + // 구독 카드 클릭 시 처리 + AppSnackBar.showInfo( + context: context, + message: '이 기능은 곧 출시됩니다', // 임시로 하드코딩 + icon: Icons.info_outline, + ); + } + + @override + Widget build(BuildContext context) { + final categoryProvider = Provider.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 광고 위젯 추가 + const NativeAdWidget(key: ValueKey('sms_scan_result_ad')), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 구독 정보 카드 + GlassmorphismCard( + width: double.infinity, + padding: EdgeInsets.zero, + child: Column( + children: [ + // 클릭 가능한 정보 영역 + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleCardTap, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: _buildInfoSection(categoryProvider), + ), + ), + + // 구분선 + Container( + height: 1, + color: AppColors.navyGray.withValues(alpha: 0.1), + ), + + // 클릭 불가능한 액션 영역 + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildActionSection(categoryProvider), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + // 정보 섹션 (클릭 가능) + Widget _buildInfoSection(CategoryProvider categoryProvider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText( + AppLocalizations.of(context).foundSubscription, + fontSize: 18, + fontWeight: FontWeight.bold, + forceDark: true, + ), + const SizedBox(height: 24), + + // 서비스명 + ThemedText( + AppLocalizations.of(context).serviceName, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 4), + ThemedText( + widget.subscription.serviceName, + fontSize: 22, + fontWeight: FontWeight.bold, + forceDark: true, + ), + const SizedBox(height: 16), + + // 금액 및 결제 주기 + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText( + AppLocalizations.of(context).monthlyCost, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 4), + // 언어별 통화 표시 + FutureBuilder( + future: CurrencyUtil.formatAmountWithLocale( + widget.subscription.monthlyCost, + widget.subscription.currency, + context.read().locale.languageCode, + ), + builder: (context, snapshot) { + return ThemedText( + snapshot.data ?? '-', + fontSize: 18, + fontWeight: FontWeight.bold, + forceDark: true, + ); + }, + ), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText( + AppLocalizations.of(context).billingCycle, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 4), + ThemedText( + widget.subscription.billingCycle, + fontSize: 16, + fontWeight: FontWeight.w500, + forceDark: true, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // 다음 결제일 + ThemedText( + AppLocalizations.of(context).nextBillingDateLabel, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 4), + ThemedText( + SmsDateFormatter.getNextBillingText( + context, + widget.subscription.nextBillingDate, + widget.subscription.billingCycle, + ), + fontSize: 16, + fontWeight: FontWeight.w500, + forceDark: true, + ), + ], + ); + } + + // 액션 섹션 (클릭 불가능) + Widget _buildActionSection(CategoryProvider categoryProvider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 카테고리 선택 + ThemedText( + AppLocalizations.of(context).category, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 8), + CategorySelector( + categories: categoryProvider.categories, + selectedCategoryId: widget.selectedCategoryId ?? widget.subscription.category, + onChanged: widget.onCategoryChanged, + baseColor: _getCategoryColor(categoryProvider), + isGlassmorphism: true, + ), + const SizedBox(height: 24), + + // 웹사이트 URL 입력 필드 + BaseTextField( + controller: widget.websiteUrlController, + label: AppLocalizations.of(context).websiteUrlAuto, + hintText: AppLocalizations.of(context).websiteUrlHint, + 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: AppLocalizations.of(context).skip, + onPressed: widget.onSkip, + height: 48, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + text: AppLocalizations.of(context).add, + onPressed: widget.onAdd, + height: 48, + ), + ), + ], + ), + ], + ); + } + + Color? _getCategoryColor(CategoryProvider categoryProvider) { + final categoryId = widget.selectedCategoryId ?? widget.subscription.category; + if (categoryId == null) return null; + + final category = categoryProvider.getCategoryById(categoryId); + if (category == null) return null; + + return CategoryIconMapper.getCategoryColor(category); + } +} \ No newline at end of file