feat: SMS 스캔 화면 리팩토링 및 MVC 패턴 적용
- SMS 스캔 화면을 컨트롤러/서비스/위젯으로 분리 - 코드 가독성 및 유지보수성 향상 - 새로운 다국어 지원 키 추가 - Git 커밋 가이드라인 문서화
This commit is contained in:
47
CLAUDE.md
47
CLAUDE.md
@@ -159,6 +159,53 @@ Before starting any task, you MUST respond in the following format:
|
|||||||
- Follow **Given–When–Then** structure
|
- Follow **Given–When–Then** structure
|
||||||
- Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures
|
- 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 <noreply@anthropic.com>"
|
||||||
|
- 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
|
## 🧠 Error Analysis & Rule Documentation
|
||||||
|
|
||||||
### Mandatory Process When Errors Occur
|
### Mandatory Process When Errors Occur
|
||||||
|
|||||||
@@ -215,7 +215,8 @@
|
|||||||
"amountRequired": "Please enter amount",
|
"amountRequired": "Please enter amount",
|
||||||
"subscriptionDetail": "Subscription Detail",
|
"subscriptionDetail": "Subscription Detail",
|
||||||
"enterAmount": "Enter amount",
|
"enterAmount": "Enter amount",
|
||||||
"invalidAmount": "Please enter a valid amount"
|
"invalidAmount": "Please enter a valid amount",
|
||||||
|
"featureComingSoon": "This feature is coming soon"
|
||||||
},
|
},
|
||||||
"ko": {
|
"ko": {
|
||||||
"appTitle": "디지털 월세 관리자",
|
"appTitle": "디지털 월세 관리자",
|
||||||
@@ -433,7 +434,8 @@
|
|||||||
"amountRequired": "금액을 입력해주세요",
|
"amountRequired": "금액을 입력해주세요",
|
||||||
"subscriptionDetail": "구독 상세",
|
"subscriptionDetail": "구독 상세",
|
||||||
"enterAmount": "금액을 입력하세요",
|
"enterAmount": "금액을 입력하세요",
|
||||||
"invalidAmount": "올바른 금액을 입력해주세요"
|
"invalidAmount": "올바른 금액을 입력해주세요",
|
||||||
|
"featureComingSoon": "이 기능은 곧 출시됩니다"
|
||||||
},
|
},
|
||||||
"ja": {
|
"ja": {
|
||||||
"appTitle": "デジタル月額管理者",
|
"appTitle": "デジタル月額管理者",
|
||||||
@@ -651,7 +653,8 @@
|
|||||||
"amountRequired": "金額を入力してください",
|
"amountRequired": "金額を入力してください",
|
||||||
"subscriptionDetail": "サブスクリプション詳細",
|
"subscriptionDetail": "サブスクリプション詳細",
|
||||||
"enterAmount": "金額を入力してください",
|
"enterAmount": "金額を入力してください",
|
||||||
"invalidAmount": "正しい金額を入力してください"
|
"invalidAmount": "正しい金額を入力してください",
|
||||||
|
"featureComingSoon": "この機能は近日公開予定です"
|
||||||
},
|
},
|
||||||
"zh": {
|
"zh": {
|
||||||
"appTitle": "数字月租管理器",
|
"appTitle": "数字月租管理器",
|
||||||
@@ -869,6 +872,7 @@
|
|||||||
"amountRequired": "请输入金额",
|
"amountRequired": "请输入金额",
|
||||||
"subscriptionDetail": "订阅详情",
|
"subscriptionDetail": "订阅详情",
|
||||||
"enterAmount": "请输入金额",
|
"enterAmount": "请输入金额",
|
||||||
"invalidAmount": "请输入有效的金额"
|
"invalidAmount": "请输入有效的金额",
|
||||||
|
"featureComingSoon": "此功能即将推出"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
224
lib/controllers/sms_scan_controller.dart
Normal file
224
lib/controllers/sms_scan_controller.dart
Normal file
@@ -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<Subscription> _scannedSubscriptions = [];
|
||||||
|
List<Subscription> 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<void> 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<SubscriptionProvider>(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<void> addCurrentSubscription(BuildContext context) async {
|
||||||
|
if (_currentIndex >= _scannedSubscriptions.length) return;
|
||||||
|
|
||||||
|
final subscription = _scannedSubscriptions[_currentIndex];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||||
|
final categoryProvider = Provider.of<CategoryProvider>(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<NavigationProvider>(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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -409,6 +409,7 @@ class AppLocalizations {
|
|||||||
String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
|
String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
|
||||||
String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
|
String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
|
||||||
String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid 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) {
|
String getBillingCycleName(String billingCycleKey) {
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 'package:provider/provider.dart';
|
||||||
import '../models/subscription.dart';
|
import '../controllers/sms_scan_controller.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../widgets/sms_scan/scan_loading_widget.dart';
|
||||||
import '../services/subscription_url_matcher.dart';
|
import '../widgets/sms_scan/scan_initial_widget.dart';
|
||||||
import '../services/currency_util.dart';
|
import '../widgets/sms_scan/scan_progress_widget.dart';
|
||||||
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
|
import '../widgets/sms_scan/subscription_card_widget.dart';
|
||||||
import '../widgets/glassmorphism_card.dart';
|
|
||||||
import '../widgets/themed_text.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
import '../widgets/common/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';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class SmsScanScreen extends StatefulWidget {
|
class SmsScanScreen extends StatefulWidget {
|
||||||
@@ -30,581 +16,90 @@ class SmsScanScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SmsScanScreenState extends State<SmsScanScreen> {
|
class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||||
bool _isLoading = false;
|
late SmsScanController _controller;
|
||||||
String? _errorMessage;
|
|
||||||
final SmsScanner _smsScanner = SmsScanner();
|
|
||||||
|
|
||||||
// 스캔한 구독 목록
|
@override
|
||||||
List<Subscription> _scannedSubscriptions = [];
|
void initState() {
|
||||||
|
super.initState();
|
||||||
// 현재 표시 중인 구독 인덱스
|
_controller = SmsScanController();
|
||||||
int _currentIndex = 0;
|
_controller.addListener(_handleControllerUpdate);
|
||||||
|
}
|
||||||
// 웹사이트 URL 컨트롤러
|
|
||||||
final TextEditingController _websiteUrlController = TextEditingController();
|
|
||||||
|
|
||||||
// 선택된 카테고리 ID 저장
|
|
||||||
String? _selectedCategoryId;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_websiteUrlController.dispose();
|
_controller.removeListener(_handleControllerUpdate);
|
||||||
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMS 스캔 실행
|
void _handleControllerUpdate() {
|
||||||
Future<void> _scanSms() async {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
_errorMessage = null;
|
|
||||||
_scannedSubscriptions = [];
|
|
||||||
_currentIndex = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// SMS 스캔 실행
|
|
||||||
print('SMS 스캔 시작');
|
|
||||||
final scannedSubscriptionModels =
|
|
||||||
await _smsScanner.scanForSubscriptions();
|
|
||||||
print('스캔된 구독: ${scannedSubscriptionModels.length}개');
|
|
||||||
|
|
||||||
if (scannedSubscriptionModels.isNotEmpty) {
|
|
||||||
print(
|
|
||||||
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (scannedSubscriptionModels.isEmpty) {
|
|
||||||
print('스캔된 구독이 없음');
|
|
||||||
setState(() {
|
|
||||||
_errorMessage = 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<SubscriptionProvider>(context, listen: false);
|
|
||||||
final existingSubscriptions = provider.subscriptions;
|
|
||||||
print('기존 구독: ${existingSubscriptions.length}개');
|
|
||||||
|
|
||||||
// 중복 구독 필터링
|
|
||||||
final filteredSubscriptions =
|
|
||||||
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
|
|
||||||
print('중복 제거 후 구독: ${filteredSubscriptions.length}개');
|
|
||||||
|
|
||||||
if (filteredSubscriptions.isNotEmpty) {
|
|
||||||
print(
|
|
||||||
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 중복 제거 후 신규 구독이 없는 경우
|
|
||||||
if (filteredSubscriptions.isEmpty) {
|
|
||||||
print('중복 제거 후 신규 구독이 없음');
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 스낵바로 안내 메시지 표시
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
AppSnackBar.showInfo(
|
setState(() {});
|
||||||
context: context,
|
}
|
||||||
message: AppLocalizations.of(context).newSubscriptionNotFound,
|
}
|
||||||
icon: Icons.search_off_rounded,
|
|
||||||
|
@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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
// 모든 구독 처리 완료 확인
|
||||||
}
|
if (_controller.currentIndex >= _controller.scannedSubscriptions.length) {
|
||||||
|
// 중복 스낵바 방지를 위해 바로 초기 화면으로
|
||||||
setState(() {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scannedSubscriptions = filteredSubscriptions;
|
if (mounted && _controller.scannedSubscriptions.isNotEmpty) {
|
||||||
_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<Subscription> _convertModelsToSubscriptions(
|
|
||||||
List<SubscriptionModel> models) {
|
|
||||||
final result = <Subscription>[];
|
|
||||||
|
|
||||||
for (var model in models) {
|
|
||||||
try {
|
|
||||||
// 모델의 필드가 null인 경우 기본값 사용
|
|
||||||
result.add(Subscription(
|
|
||||||
id: model.id,
|
|
||||||
serviceName: model.serviceName,
|
|
||||||
monthlyCost: model.monthlyCost,
|
|
||||||
billingCycle: model.billingCycle,
|
|
||||||
nextBillingDate: model.nextBillingDate,
|
|
||||||
category: model.categoryId, // categoryId를 category로 올바르게 매핑
|
|
||||||
repeatCount: model.repeatCount > 0
|
|
||||||
? model.repeatCount
|
|
||||||
: 1, // 반복 횟수가 0 이하인 경우 기본값 1 사용
|
|
||||||
lastPaymentDate: model.lastPaymentDate,
|
|
||||||
websiteUrl: model.websiteUrl,
|
|
||||||
currency: model.currency, // 통화 단위 정보 추가
|
|
||||||
));
|
|
||||||
|
|
||||||
print(
|
|
||||||
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
|
|
||||||
} catch (e) {
|
|
||||||
print('모델 변환 중 오류 발생: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
|
|
||||||
List<Subscription> _filterDuplicates(
|
|
||||||
List<Subscription> scanned, List<SubscriptionModel> existing) {
|
|
||||||
print(
|
|
||||||
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개');
|
|
||||||
|
|
||||||
// 중복되지 않은 구독만 필터링
|
|
||||||
final nonDuplicates = scanned.where((scannedSub) {
|
|
||||||
|
|
||||||
// 서비스명과 금액이 동일한 기존 구독 찾기
|
|
||||||
final hasDuplicate = existing.any((existingSub) =>
|
|
||||||
existingSub.serviceName.toLowerCase() ==
|
|
||||||
scannedSub.serviceName.toLowerCase() &&
|
|
||||||
existingSub.monthlyCost == scannedSub.monthlyCost);
|
|
||||||
|
|
||||||
if (hasDuplicate) {
|
|
||||||
print('_filterDuplicates: 중복 발견 - ${scannedSub.serviceName}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 중복이 없으면 true 반환
|
|
||||||
return !hasDuplicate;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
print('_filterDuplicates: 중복 제거 후 ${nonDuplicates.length}개');
|
|
||||||
|
|
||||||
// 각 구독에 웹사이트 URL 자동 매칭 시도
|
|
||||||
final result = <Subscription>[];
|
|
||||||
|
|
||||||
for (int i = 0; i < nonDuplicates.length; i++) {
|
|
||||||
final subscription = nonDuplicates[i];
|
|
||||||
|
|
||||||
String? websiteUrl = subscription.websiteUrl;
|
|
||||||
|
|
||||||
if (websiteUrl == null || websiteUrl.isEmpty) {
|
|
||||||
websiteUrl =
|
|
||||||
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
|
|
||||||
print(
|
|
||||||
'_filterDuplicates: URL 자동 매칭 시도 - ${subscription.serviceName}, 결과: ${websiteUrl ?? "매칭 실패"}');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 유효성 검사
|
|
||||||
if (subscription.serviceName.isEmpty) {
|
|
||||||
print('_filterDuplicates: 서비스명이 비어 있습니다. 건너뜁니다.');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subscription.monthlyCost <= 0) {
|
|
||||||
print('_filterDuplicates: 월 비용이 0 이하입니다. 건너뜁니다.');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscription 객체에 URL 설정 (새 객체 생성)
|
|
||||||
result.add(Subscription(
|
|
||||||
id: subscription.id,
|
|
||||||
serviceName: subscription.serviceName,
|
|
||||||
monthlyCost: subscription.monthlyCost,
|
|
||||||
billingCycle: subscription.billingCycle,
|
|
||||||
nextBillingDate: subscription.nextBillingDate,
|
|
||||||
category: subscription.category,
|
|
||||||
notes: subscription.notes,
|
|
||||||
repeatCount:
|
|
||||||
subscription.repeatCount > 0 ? subscription.repeatCount : 1,
|
|
||||||
lastPaymentDate: subscription.lastPaymentDate,
|
|
||||||
websiteUrl: websiteUrl,
|
|
||||||
currency: subscription.currency, // 통화 단위 정보 추가
|
|
||||||
));
|
|
||||||
|
|
||||||
print(
|
|
||||||
'_filterDuplicates: URL 설정 - ${subscription.serviceName}, URL: ${websiteUrl ?? "없음"}, 카테고리: ${subscription.category ?? "없음"}, 통화: ${subscription.currency}');
|
|
||||||
} catch (e) {
|
|
||||||
print('_filterDuplicates: 구독 객체 생성 중 오류 발생: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print('_filterDuplicates: URL 설정 완료, 최종 ${result.length}개 구독');
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 현재 구독 추가
|
|
||||||
Future<void> _addCurrentSubscription() async {
|
|
||||||
if (_scannedSubscriptions.isEmpty ||
|
|
||||||
_currentIndex >= _scannedSubscriptions.length) {
|
|
||||||
print(
|
|
||||||
'오류: 인덱스가 범위를 벗어났습니다. (index: $_currentIndex, size: ${_scannedSubscriptions.length})');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final subscription = _scannedSubscriptions[_currentIndex];
|
|
||||||
|
|
||||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
|
||||||
|
|
||||||
// 날짜가 과거면 다음 결제일을 조정
|
|
||||||
final now = DateTime.now();
|
|
||||||
DateTime nextBillingDate = subscription.nextBillingDate;
|
|
||||||
|
|
||||||
if (nextBillingDate.isBefore(now)) {
|
|
||||||
// 주기에 따라 다음 결제일 조정
|
|
||||||
if (subscription.billingCycle == '월간') {
|
|
||||||
// 현재 달의 결제일
|
|
||||||
int day = nextBillingDate.day;
|
|
||||||
// 현재 월의 마지막 날을 초과하는 경우 조정
|
|
||||||
final lastDay = DateTime(now.year, now.month + 1, 0).day;
|
|
||||||
if (day > lastDay) {
|
|
||||||
day = lastDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime adjustedDate = DateTime(now.year, now.month, day);
|
|
||||||
|
|
||||||
// 현재 날짜보다 이전이라면 다음 달로 설정
|
|
||||||
if (adjustedDate.isBefore(now)) {
|
|
||||||
// 다음 달의 마지막 날을 초과하는 경우 조정
|
|
||||||
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
|
|
||||||
if (day > nextMonthLastDay) {
|
|
||||||
day = nextMonthLastDay;
|
|
||||||
}
|
|
||||||
adjustedDate = DateTime(now.year, now.month + 1, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextBillingDate = adjustedDate;
|
|
||||||
} else if (subscription.billingCycle == '연간') {
|
|
||||||
// 현재 년도의 결제일
|
|
||||||
int day = nextBillingDate.day;
|
|
||||||
// 해당 월의 마지막 날을 초과하는 경우 조정
|
|
||||||
final lastDay = DateTime(now.year, nextBillingDate.month + 1, 0).day;
|
|
||||||
if (day > lastDay) {
|
|
||||||
day = lastDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime adjustedDate = DateTime(now.year, nextBillingDate.month, day);
|
|
||||||
|
|
||||||
// 현재 날짜보다 이전이라면 다음 해로 설정
|
|
||||||
if (adjustedDate.isBefore(now)) {
|
|
||||||
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
|
|
||||||
final nextYearLastDay =
|
|
||||||
DateTime(now.year + 1, nextBillingDate.month + 1, 0).day;
|
|
||||||
if (day > nextYearLastDay) {
|
|
||||||
day = nextYearLastDay;
|
|
||||||
}
|
|
||||||
adjustedDate = DateTime(now.year + 1, nextBillingDate.month, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextBillingDate = adjustedDate;
|
|
||||||
} else if (subscription.billingCycle == '주간') {
|
|
||||||
// 현재 날짜에서 가장 가까운 다음 주 같은 요일
|
|
||||||
final daysUntilNext = 7 - (now.weekday - nextBillingDate.weekday) % 7;
|
|
||||||
nextBillingDate =
|
|
||||||
now.add(Duration(days: daysUntilNext == 0 ? 7 : daysUntilNext));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 웹사이트 URL이 비어있으면 자동 매칭 시도
|
|
||||||
String? websiteUrl = _websiteUrlController.text.trim();
|
|
||||||
if (websiteUrl.isEmpty && subscription.websiteUrl != null) {
|
|
||||||
websiteUrl = subscription.websiteUrl;
|
|
||||||
print('구독 추가: 기존 URL 사용 - ${websiteUrl ?? "없음"}');
|
|
||||||
} else if (websiteUrl.isEmpty) {
|
|
||||||
try {
|
|
||||||
websiteUrl =
|
|
||||||
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
|
|
||||||
print(
|
|
||||||
'구독 추가: URL 자동 매칭 - ${subscription.serviceName} -> ${websiteUrl ?? "매칭 실패"}');
|
|
||||||
} catch (e) {
|
|
||||||
print('구독 추가: URL 자동 매칭 실패 - $e');
|
|
||||||
websiteUrl = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print('구독 추가: 사용자 입력 URL 사용 - $websiteUrl');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
print(
|
|
||||||
'구독 추가 시도 - 서비스명: ${subscription.serviceName}, 비용: ${subscription.monthlyCost}, 반복 횟수: ${subscription.repeatCount}');
|
|
||||||
|
|
||||||
// 반복 횟수가 0 이하인 경우 기본값 1 사용
|
|
||||||
final int safeRepeatCount =
|
|
||||||
subscription.repeatCount > 0 ? subscription.repeatCount : 1;
|
|
||||||
|
|
||||||
// 카테고리 설정 로직
|
|
||||||
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];
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
AppSnackBar.showInfo(
|
|
||||||
context: context,
|
|
||||||
message: AppLocalizations.of(context).subscriptionSkipped(subscription.serviceName),
|
|
||||||
icon: Icons.skip_next_rounded,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_moveToNextSubscription();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 다음 구독으로 이동
|
|
||||||
void _moveToNextSubscription() {
|
|
||||||
setState(() {
|
|
||||||
_currentIndex++;
|
|
||||||
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
|
||||||
_selectedCategoryId = null; // 카테고리 선택 초기화
|
|
||||||
|
|
||||||
// 모든 구독을 처리했으면 홈 화면으로 이동
|
|
||||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
|
||||||
_navigateToHome();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 홈 화면으로 이동
|
|
||||||
void _navigateToHome() {
|
|
||||||
// NavigationProvider를 사용하여 홈 화면으로 이동
|
|
||||||
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
|
||||||
navigationProvider.updateCurrentIndex(0);
|
|
||||||
|
|
||||||
// 완료 메시지 표시
|
|
||||||
AppSnackBar.showSuccess(
|
AppSnackBar.showSuccess(
|
||||||
context: context,
|
context: context,
|
||||||
message: AppLocalizations.of(context).allSubscriptionsProcessed,
|
message: AppLocalizations.of(context).allSubscriptionsProcessed,
|
||||||
);
|
);
|
||||||
|
// 상태 초기화
|
||||||
|
_controller.resetState();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// 날짜 상태 텍스트 가져오기
|
return ScanInitialWidget(
|
||||||
String _getNextBillingText(DateTime date) {
|
onScanPressed: () => _controller.scanSms(context),
|
||||||
final now = DateTime.now();
|
errorMessage: _controller.errorMessage,
|
||||||
|
|
||||||
if (date.isBefore(now)) {
|
|
||||||
// 주기에 따라 다음 결제일 예측
|
|
||||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
|
||||||
return '다음 결제일 확인 필요';
|
|
||||||
}
|
|
||||||
|
|
||||||
final subscription = _scannedSubscriptions[_currentIndex];
|
|
||||||
if (subscription.billingCycle == '월간') {
|
|
||||||
// 이번 달 또는 다음 달 같은 날짜
|
|
||||||
int day = date.day;
|
|
||||||
// 현재 월의 마지막 날을 초과하는 경우 조정
|
|
||||||
final lastDay = DateTime(now.year, now.month + 1, 0).day;
|
|
||||||
if (day > lastDay) {
|
|
||||||
day = lastDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime adjusted = DateTime(now.year, now.month, day);
|
|
||||||
if (adjusted.isBefore(now)) {
|
|
||||||
// 다음 달의 마지막 날을 초과하는 경우 조정
|
|
||||||
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
|
|
||||||
if (day > nextMonthLastDay) {
|
|
||||||
day = nextMonthLastDay;
|
|
||||||
}
|
|
||||||
adjusted = DateTime(now.year, now.month + 1, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
final daysUntil = adjusted.difference(now).inDays;
|
|
||||||
return 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 포맷 함수
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
return '${date.year}년 ${date.month}월 ${date.day}일';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 결제 반복 횟수 텍스트
|
|
||||||
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(
|
final currentSubscription = _controller.scannedSubscriptions[_controller.currentIndex];
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
return Column(
|
||||||
color: AppColors.navyGray.withValues(alpha: 0.1),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
// 카테고리 아이콘 표시
|
Padding(
|
||||||
Icon(
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
_getCategoryIcon(defaultCategory),
|
child: ScanProgressWidget(
|
||||||
size: 16,
|
currentIndex: _controller.currentIndex,
|
||||||
color: AppColors.darkNavy,
|
totalCount: _controller.scannedSubscriptions.length,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
),
|
||||||
ThemedText(
|
const SizedBox(height: 24),
|
||||||
categoryProvider.getLocalizedCategoryName(context, defaultCategory.name),
|
SubscriptionCardWidget(
|
||||||
fontSize: 14,
|
subscription: currentSubscription,
|
||||||
fontWeight: FontWeight.w500,
|
websiteUrlController: _controller.websiteUrlController,
|
||||||
forceDark: true,
|
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<CategoryProvider>(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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
@@ -615,11 +110,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||||
),
|
),
|
||||||
_isLoading
|
_buildContent(),
|
||||||
? _buildLoadingState()
|
|
||||||
: (_scannedSubscriptions.isEmpty
|
|
||||||
? _buildInitialState()
|
|
||||||
: _buildSubscriptionState()),
|
|
||||||
// FloatingNavigationBar를 위한 충분한 하단 여백
|
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 120 + MediaQuery.of(context).padding.bottom,
|
height: 120 + MediaQuery.of(context).padding.bottom,
|
||||||
@@ -628,310 +119,4 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로딩 상태 UI
|
|
||||||
Widget _buildLoadingState() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(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<CategoryProvider>(context, listen: false);
|
|
||||||
|
|
||||||
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
|
|
||||||
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
|
|
||||||
_websiteUrlController.text = subscription.websiteUrl!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// 광고 위젯 추가
|
|
||||||
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
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<Color>(
|
|
||||||
Theme.of(context).colorScheme.primary),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ThemedText(
|
|
||||||
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
opacity: 0.7,
|
|
||||||
forceDark: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 구독 정보 카드
|
|
||||||
GlassmorphismCard(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
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<String>(
|
|
||||||
future: CurrencyUtil.formatAmountWithLocale(
|
|
||||||
subscription.monthlyCost,
|
|
||||||
subscription.currency,
|
|
||||||
context.read<LocaleProvider>().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!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
79
lib/services/sms_scan/subscription_converter.dart
Normal file
79
lib/services/sms_scan/subscription_converter.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import '../../models/subscription.dart';
|
||||||
|
import '../../models/subscription_model.dart';
|
||||||
|
|
||||||
|
class SubscriptionConverter {
|
||||||
|
// SubscriptionModel 리스트를 Subscription 리스트로 변환
|
||||||
|
List<Subscription> convertModelsToSubscriptions(List<SubscriptionModel> models) {
|
||||||
|
final result = <Subscription>[];
|
||||||
|
|
||||||
|
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'; // 기본값
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
lib/services/sms_scan/subscription_filter.dart
Normal file
60
lib/services/sms_scan/subscription_filter.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import '../../models/subscription.dart';
|
||||||
|
import '../../models/subscription_model.dart';
|
||||||
|
|
||||||
|
class SubscriptionFilter {
|
||||||
|
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
|
||||||
|
List<Subscription> filterDuplicates(
|
||||||
|
List<Subscription> scanned, List<SubscriptionModel> 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<Subscription> filterByRepeatCount(List<Subscription> subscriptions, int minCount) {
|
||||||
|
return subscriptions.where((sub) => sub.repeatCount >= minCount).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 기반 필터링 (선택적)
|
||||||
|
List<Subscription> filterByDateRange(
|
||||||
|
List<Subscription> subscriptions, DateTime startDate, DateTime endDate) {
|
||||||
|
return subscriptions.where((sub) {
|
||||||
|
return sub.nextBillingDate.isAfter(startDate) &&
|
||||||
|
sub.nextBillingDate.isBefore(endDate);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 금액 기반 필터링 (선택적)
|
||||||
|
List<Subscription> filterByPriceRange(
|
||||||
|
List<Subscription> subscriptions, double minPrice, double maxPrice) {
|
||||||
|
return subscriptions
|
||||||
|
.where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 기반 필터링 (선택적)
|
||||||
|
List<Subscription> filterByCategories(
|
||||||
|
List<Subscription> subscriptions, List<String> categoryIds) {
|
||||||
|
if (categoryIds.isEmpty) return subscriptions;
|
||||||
|
|
||||||
|
return subscriptions.where((sub) {
|
||||||
|
return sub.category != null && categoryIds.contains(sub.category);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// ServiceInfo를 외부에서 접근 가능하도록 export
|
||||||
export 'url_matcher/models/service_info.dart';
|
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 {
|
class SubscriptionUrlMatcher {
|
||||||
static Map<String, dynamic>? _servicesData;
|
static ServiceDataRepository? _dataRepository;
|
||||||
static bool _isInitialized = false;
|
static UrlMatcherService? _urlMatcher;
|
||||||
|
static CategoryMapperService? _categoryMapper;
|
||||||
|
static CancellationUrlService? _cancellationService;
|
||||||
|
static ServiceNameResolver? _nameResolver;
|
||||||
|
static SmsExtractorService? _smsExtractor;
|
||||||
|
|
||||||
/// JSON 데이터 초기화
|
/// 서비스 초기화
|
||||||
static Future<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_dataRepository != null && _dataRepository!.isInitialized) return;
|
||||||
|
|
||||||
try {
|
// 1. 데이터 저장소 초기화
|
||||||
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
|
_dataRepository = ServiceDataRepository();
|
||||||
_servicesData = json.decode(jsonString);
|
await _dataRepository!.initialize();
|
||||||
_isInitialized = true;
|
|
||||||
print('SubscriptionUrlMatcher: JSON 데이터 로드 완료');
|
// 2. 서비스 초기화
|
||||||
} catch (e) {
|
_categoryMapper = CategoryMapperService(_dataRepository!);
|
||||||
print('SubscriptionUrlMatcher: JSON 로드 실패 - $e');
|
_urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!);
|
||||||
// 로드 실패시 기존 하드코딩 데이터 사용
|
_cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!);
|
||||||
_isInitialized = true;
|
_nameResolver = ServiceNameResolver(_dataRepository!);
|
||||||
}
|
_smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 도메인 추출 (www와 TLD 제외)
|
/// 도메인 추출 (www와 TLD 제외)
|
||||||
static String? extractDomain(String url) {
|
static String? extractDomain(String url) {
|
||||||
try {
|
return _urlMatcher?.extractDomain(url);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL로 서비스 찾기
|
/// URL로 서비스 찾기
|
||||||
static Future<ServiceInfo?> findServiceByUrl(String url) async {
|
static Future<ServiceInfo?> findServiceByUrl(String url) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
|
return _urlMatcher?.findServiceByUrl(url);
|
||||||
final domain = extractDomain(url);
|
|
||||||
if (domain == null) return null;
|
|
||||||
|
|
||||||
// JSON 데이터가 있으면 JSON에서 찾기
|
|
||||||
if (_servicesData != null) {
|
|
||||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final categoryEntry in categories.entries) {
|
|
||||||
final categoryId = categoryEntry.key;
|
|
||||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
|
||||||
final services = categoryData['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceEntry in services.entries) {
|
|
||||||
final serviceId = serviceEntry.key;
|
|
||||||
final serviceData = serviceEntry.value as Map<String, dynamic>;
|
|
||||||
final domains = List<String>.from(serviceData['domains'] ?? []);
|
|
||||||
|
|
||||||
// 도메인이 일치하는지 확인
|
|
||||||
for (final serviceDomain in domains) {
|
|
||||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
|
||||||
final names = List<String>.from(serviceData['names'] ?? []);
|
|
||||||
final urls = serviceData['urls'] as Map<String, dynamic>?;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
|
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
|
||||||
static String? suggestUrl(String serviceName) {
|
static String? suggestUrl(String serviceName) {
|
||||||
if (serviceName.isEmpty) {
|
return _urlMatcher?.suggestUrl(serviceName);
|
||||||
print('SubscriptionUrlMatcher: 빈 serviceName');
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 소문자로 변환하여 비교
|
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 해지 안내 URL 찾기 (개선된 버전)
|
|
||||||
static Future<String?> findCancellationUrl({
|
static Future<String?> findCancellationUrl({
|
||||||
String? serviceName,
|
String? serviceName,
|
||||||
String? websiteUrl,
|
String? websiteUrl,
|
||||||
String locale = 'kr',
|
String locale = 'kr',
|
||||||
}) async {
|
}) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
|
return _cancellationService?.findCancellationUrl(
|
||||||
// JSON 데이터가 있으면 JSON에서 찾기
|
serviceName: serviceName,
|
||||||
if (_servicesData != null) {
|
websiteUrl: websiteUrl,
|
||||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
locale: locale,
|
||||||
|
);
|
||||||
// 1. 서비스명으로 찾기
|
|
||||||
if (serviceName != null && serviceName.isNotEmpty) {
|
|
||||||
final lowerName = serviceName.toLowerCase().trim();
|
|
||||||
|
|
||||||
for (final categoryData in categories.values) {
|
|
||||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceData in services.values) {
|
|
||||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
|
||||||
|
|
||||||
for (final name in names) {
|
|
||||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
|
||||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
|
||||||
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<String, dynamic>)['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceData in services.values) {
|
|
||||||
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
|
|
||||||
|
|
||||||
for (final serviceDomain in domains) {
|
|
||||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
|
||||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
|
||||||
if (cancellationUrls != null) {
|
|
||||||
return cancellationUrls[locale] ??
|
|
||||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
|
||||||
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 서비스명 또는 웹사이트 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<bool> hasCancellationPage(String serviceNameOrUrl) async {
|
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
|
||||||
// 새로운 JSON 기반 방식으로 확인
|
await initialize();
|
||||||
final cancellationUrl = await findCancellationUrl(
|
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false;
|
||||||
serviceName: serviceNameOrUrl,
|
|
||||||
websiteUrl: serviceNameOrUrl,
|
|
||||||
);
|
|
||||||
return cancellationUrl != null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 서비스명으로 카테고리 찾기
|
/// 서비스명으로 카테고리 찾기
|
||||||
static Future<String?> findCategoryByServiceName(String serviceName) async {
|
static Future<String?> findCategoryByServiceName(String serviceName) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
if (serviceName.isEmpty) return null;
|
return _categoryMapper?.findCategoryByServiceName(serviceName);
|
||||||
|
|
||||||
final lowerName = serviceName.toLowerCase().trim();
|
|
||||||
|
|
||||||
// JSON 데이터가 있으면 JSON에서 찾기
|
|
||||||
if (_servicesData != null) {
|
|
||||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final categoryEntry in categories.entries) {
|
|
||||||
final categoryId = categoryEntry.key;
|
|
||||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
|
||||||
final services = categoryData['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceData in services.values) {
|
|
||||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
|
||||||
|
|
||||||
for (final name in names) {
|
|
||||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
|
||||||
return _getCategoryIdByKey(categoryId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
|
|
||||||
return _getCategoryForLegacyService(serviceName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
||||||
@@ -388,189 +82,26 @@ class SubscriptionUrlMatcher {
|
|||||||
required String locale,
|
required String locale,
|
||||||
}) async {
|
}) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
|
return await _nameResolver?.getServiceDisplayName(
|
||||||
if (_servicesData == null) {
|
serviceName: serviceName,
|
||||||
return serviceName;
|
locale: locale,
|
||||||
}
|
) ?? serviceName;
|
||||||
|
|
||||||
final lowerName = serviceName.toLowerCase().trim();
|
|
||||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
// JSON에서 서비스 찾기
|
|
||||||
for (final categoryData in categories.values) {
|
|
||||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceData in services.values) {
|
|
||||||
final data = serviceData as Map<String, dynamic>;
|
|
||||||
final names = List<String>.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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SMS에서 URL과 서비스 정보 추출
|
/// SMS에서 URL과 서비스 정보 추출
|
||||||
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
|
return _smsExtractor?.extractServiceFromSms(smsText);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL이 알려진 서비스 URL인지 확인
|
/// URL이 알려진 서비스 URL인지 확인
|
||||||
static Future<bool> isKnownServiceUrl(String url) async {
|
static Future<bool> isKnownServiceUrl(String url) async {
|
||||||
final serviceInfo = await findServiceByUrl(url);
|
await initialize();
|
||||||
return serviceInfo != null;
|
return await _urlMatcher?.isKnownServiceUrl(url) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성)
|
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성)
|
||||||
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
||||||
// 입력 텍스트가 비어있거나 null인 경우
|
return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
30
lib/services/url_matcher/data/service_data_repository.dart
Normal file
30
lib/services/url_matcher/data/service_data_repository.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// 서비스 데이터를 관리하는 저장소 클래스
|
||||||
|
class ServiceDataRepository {
|
||||||
|
Map<String, dynamic>? _servicesData;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// JSON 데이터 초기화
|
||||||
|
Future<void> 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<String, dynamic>? getServicesData() => _servicesData;
|
||||||
|
|
||||||
|
/// 초기화 여부 확인
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
}
|
||||||
129
lib/services/url_matcher/services/cancellation_url_service.dart
Normal file
129
lib/services/url_matcher/services/cancellation_url_service.dart
Normal file
@@ -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<String?> findCancellationUrl({
|
||||||
|
String? serviceName,
|
||||||
|
String? websiteUrl,
|
||||||
|
String locale = 'kr',
|
||||||
|
}) async {
|
||||||
|
// JSON 데이터가 있으면 JSON에서 찾기
|
||||||
|
final servicesData = _dataRepository.getServicesData();
|
||||||
|
if (servicesData != null) {
|
||||||
|
final categories = servicesData['categories'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// 1. 서비스명으로 찾기
|
||||||
|
if (serviceName != null && serviceName.isNotEmpty) {
|
||||||
|
final lowerName = serviceName.toLowerCase().trim();
|
||||||
|
|
||||||
|
for (final categoryData in categories.values) {
|
||||||
|
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
for (final serviceData in services.values) {
|
||||||
|
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
||||||
|
|
||||||
|
for (final name in names) {
|
||||||
|
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
||||||
|
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||||
|
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<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
for (final serviceData in services.values) {
|
||||||
|
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
|
||||||
|
|
||||||
|
for (final serviceDomain in domains) {
|
||||||
|
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
||||||
|
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||||
|
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<bool> hasCancellationPage(String serviceNameOrUrl) async {
|
||||||
|
// 새로운 JSON 기반 방식으로 확인
|
||||||
|
final cancellationUrl = await findCancellationUrl(
|
||||||
|
serviceName: serviceNameOrUrl,
|
||||||
|
websiteUrl: serviceNameOrUrl,
|
||||||
|
);
|
||||||
|
return cancellationUrl != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String?> 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<String, dynamic>;
|
||||||
|
|
||||||
|
for (final categoryEntry in categories.entries) {
|
||||||
|
final categoryId = categoryEntry.key;
|
||||||
|
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||||
|
final services = categoryData['services'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
for (final serviceData in services.values) {
|
||||||
|
final names = List<String>.from((serviceData as Map<String, dynamic>)['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';
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/services/url_matcher/services/service_name_resolver.dart
Normal file
61
lib/services/url_matcher/services/service_name_resolver.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import '../data/service_data_repository.dart';
|
||||||
|
|
||||||
|
/// 서비스명 관련 기능을 제공하는 서비스 클래스
|
||||||
|
class ServiceNameResolver {
|
||||||
|
final ServiceDataRepository _dataRepository;
|
||||||
|
|
||||||
|
ServiceNameResolver(this._dataRepository);
|
||||||
|
|
||||||
|
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
||||||
|
Future<String> 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<String, dynamic>;
|
||||||
|
|
||||||
|
// JSON에서 서비스 찾기
|
||||||
|
for (final categoryData in categories.values) {
|
||||||
|
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
for (final serviceData in services.values) {
|
||||||
|
final data = serviceData as Map<String, dynamic>;
|
||||||
|
final names = List<String>.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
lib/services/url_matcher/services/sms_extractor_service.dart
Normal file
55
lib/services/url_matcher/services/sms_extractor_service.dart
Normal file
@@ -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<ServiceInfo?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
235
lib/services/url_matcher/services/url_matcher_service.dart
Normal file
235
lib/services/url_matcher/services/url_matcher_service.dart
Normal file
@@ -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<ServiceInfo?> 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<String, dynamic>;
|
||||||
|
|
||||||
|
for (final categoryEntry in categories.entries) {
|
||||||
|
final categoryId = categoryEntry.key;
|
||||||
|
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||||
|
final services = categoryData['services'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
for (final serviceEntry in services.entries) {
|
||||||
|
final serviceId = serviceEntry.key;
|
||||||
|
final serviceData = serviceEntry.value as Map<String, dynamic>;
|
||||||
|
final domains = List<String>.from(serviceData['domains'] ?? []);
|
||||||
|
|
||||||
|
// 도메인이 일치하는지 확인
|
||||||
|
for (final serviceDomain in domains) {
|
||||||
|
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
||||||
|
final names = List<String>.from(serviceData['names'] ?? []);
|
||||||
|
final urls = serviceData['urls'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
lib/utils/sms_scan/category_icon_mapper.dart
Normal file
53
lib/utils/sms_scan/category_icon_mapper.dart
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
lib/utils/sms_scan/date_formatter.dart
Normal file
165
lib/utils/sms_scan/date_formatter.dart
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
lib/widgets/sms_scan/scan_initial_widget.dart
Normal file
70
lib/widgets/sms_scan/scan_initial_widget.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
lib/widgets/sms_scan/scan_loading_widget.dart
Normal file
36
lib/widgets/sms_scan/scan_loading_widget.dart
Normal file
@@ -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<Color>(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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/widgets/sms_scan/scan_progress_widget.dart
Normal file
38
lib/widgets/sms_scan/scan_progress_widget.dart
Normal file
@@ -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<Color>(
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ThemedText(
|
||||||
|
'${currentIndex + 1}/$totalCount',
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
opacity: 0.7,
|
||||||
|
forceDark: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
295
lib/widgets/sms_scan/subscription_card_widget.dart
Normal file
295
lib/widgets/sms_scan/subscription_card_widget.dart
Normal file
@@ -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<SubscriptionCardWidget> createState() => _SubscriptionCardWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
|
||||||
|
@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<CategoryProvider>(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<String>(
|
||||||
|
future: CurrencyUtil.formatAmountWithLocale(
|
||||||
|
widget.subscription.monthlyCost,
|
||||||
|
widget.subscription.currency,
|
||||||
|
context.read<LocaleProvider>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user