Initial commit: SubManager Flutter App
주요 구현 완료 기능: - 구독 관리 (추가/편집/삭제/카테고리 분류) - 이벤트 할인 시스템 (기본값 자동 설정) - SMS 자동 스캔 및 구독 정보 추출 - 알림 시스템 (타임존 처리 안정화) - 환율 변환 지원 (KRW/USD) - 반응형 UI 및 애니메이션 - 다국어 지원 (한국어/영어) 버그 수정: - NotificationService tz.local 초기화 오류 해결 - MainScreenSummaryCard 레이아웃 오버플로우 수정 - 구독 추가 시 LateInitializationError 완전 해결 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
318
lib/services/sms_scanner.dart
Normal file
318
lib/services/sms_scanner.dart
Normal file
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../temp/test_sms_data.dart';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
|
||||
class SmsScanner {
|
||||
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
||||
try {
|
||||
List<dynamic> smsList;
|
||||
print('SmsScanner: 스캔 시작');
|
||||
|
||||
// 디버그 모드에서는 테스트 데이터 사용
|
||||
if (kDebugMode) {
|
||||
print('SmsScanner: 디버그 모드에서 테스트 데이터 사용');
|
||||
smsList = TestSmsData.getTestData();
|
||||
print('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
|
||||
} else {
|
||||
print('SmsScanner: 실제 SMS 데이터 스캔');
|
||||
// 실제 환경에서는 네이티브 코드 호출
|
||||
const platform = MethodChannel('com.submanager/sms');
|
||||
try {
|
||||
smsList = await platform.invokeMethod('scanSubscriptions');
|
||||
print('SmsScanner: 네이티브 호출 성공, SMS 데이터 개수: ${smsList.length}');
|
||||
} catch (e) {
|
||||
print('SmsScanner: 네이티브 호출 실패: $e');
|
||||
// 오류 발생 시 빈 목록 반환
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
||||
final List<SubscriptionModel> subscriptions = [];
|
||||
final Map<String, List<Map<String, dynamic>>> serviceGroups = {};
|
||||
|
||||
// 서비스명별로 SMS 메시지 그룹화
|
||||
for (final sms in smsList) {
|
||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||
if (!serviceGroups.containsKey(serviceName)) {
|
||||
serviceGroups[serviceName] = [];
|
||||
}
|
||||
serviceGroups[serviceName]!.add(sms);
|
||||
}
|
||||
|
||||
print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
||||
|
||||
// 그룹화된 데이터로 구독 분석
|
||||
for (final entry in serviceGroups.entries) {
|
||||
print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
|
||||
|
||||
// 2회 이상 반복된 서비스만 구독으로 간주
|
||||
if (entry.value.length >= 2) {
|
||||
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
|
||||
final subscription = _parseSms(serviceSms, entry.value.length);
|
||||
if (subscription != null) {
|
||||
print(
|
||||
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
||||
subscriptions.add(subscription);
|
||||
} else {
|
||||
print('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||
}
|
||||
} else {
|
||||
print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
||||
}
|
||||
}
|
||||
|
||||
print('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
||||
return subscriptions;
|
||||
} catch (e) {
|
||||
print('SmsScanner: 예외 발생: $e');
|
||||
throw Exception('SMS 스캔 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||
try {
|
||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
|
||||
final billingCycle = sms['billingCycle'] as String? ?? '월간';
|
||||
final nextBillingDateStr = sms['nextBillingDate'] as String?;
|
||||
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
|
||||
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
|
||||
final isRecurring = (sms['isRecurring'] as bool?) ?? (repeatCount >= 2);
|
||||
final message = sms['message'] as String? ?? '';
|
||||
|
||||
// 통화 단위 감지 - 메시지 내용과 서비스명 모두 검사
|
||||
String currency = _detectCurrency(message);
|
||||
|
||||
// 서비스명에 따라 통화 단위 재확인
|
||||
final dollarServices = [
|
||||
'GitHub',
|
||||
'GitHub Pro',
|
||||
'Netflix US',
|
||||
'Spotify',
|
||||
'Spotify Premium'
|
||||
];
|
||||
if (dollarServices.any((service) => serviceName.contains(service))) {
|
||||
print('서비스명 $serviceName으로 USD 통화 단위 확정');
|
||||
currency = 'USD';
|
||||
}
|
||||
|
||||
DateTime? nextBillingDate;
|
||||
if (nextBillingDateStr != null) {
|
||||
nextBillingDate = DateTime.tryParse(nextBillingDateStr);
|
||||
}
|
||||
|
||||
DateTime? lastPaymentDate;
|
||||
final previousPaymentDateStr = sms['previousPaymentDate'] as String?;
|
||||
if (previousPaymentDateStr != null && previousPaymentDateStr.isNotEmpty) {
|
||||
lastPaymentDate = DateTime.tryParse(previousPaymentDateStr);
|
||||
}
|
||||
|
||||
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
|
||||
DateTime adjustedNextBillingDate = _calculateNextBillingDate(
|
||||
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
|
||||
billingCycle);
|
||||
|
||||
return SubscriptionModel(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
serviceName: serviceName,
|
||||
monthlyCost: monthlyCost,
|
||||
billingCycle: billingCycle,
|
||||
nextBillingDate: adjustedNextBillingDate,
|
||||
isAutoDetected: true,
|
||||
repeatCount: actualRepeatCount,
|
||||
lastPaymentDate: lastPaymentDate,
|
||||
websiteUrl: _extractWebsiteUrl(serviceName),
|
||||
currency: currency, // 통화 단위 설정
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 결제일 계산 (현재 날짜 기준으로 조정)
|
||||
DateTime _calculateNextBillingDate(
|
||||
DateTime billingDate, String billingCycle) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 결제일이 이미 미래인 경우 그대로 반환
|
||||
if (billingDate.isAfter(now)) {
|
||||
return billingDate;
|
||||
}
|
||||
|
||||
// 결제 주기별 다음 결제일 계산
|
||||
if (billingCycle == '월간') {
|
||||
int month = now.month;
|
||||
int year = now.year;
|
||||
|
||||
// 현재 달의 결제일이 이미 지났으면 다음 달로 이동
|
||||
if (now.day >= billingDate.day) {
|
||||
month = month + 1;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year = year + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return DateTime(year, month, billingDate.day);
|
||||
} else if (billingCycle == '연간') {
|
||||
// 올해의 결제일이 지났는지 확인
|
||||
final thisYearBilling =
|
||||
DateTime(now.year, billingDate.month, billingDate.day);
|
||||
if (thisYearBilling.isBefore(now)) {
|
||||
return DateTime(now.year + 1, billingDate.month, billingDate.day);
|
||||
} else {
|
||||
return thisYearBilling;
|
||||
}
|
||||
} else if (billingCycle == '주간') {
|
||||
// 가장 가까운 다음 주 같은 요일 계산
|
||||
final dayDifference = billingDate.weekday - now.weekday;
|
||||
final daysToAdd = dayDifference > 0 ? dayDifference : 7 + dayDifference;
|
||||
return now.add(Duration(days: daysToAdd));
|
||||
}
|
||||
|
||||
// 기본 처리: 30일 후
|
||||
return now.add(const Duration(days: 30));
|
||||
}
|
||||
|
||||
String? _extractWebsiteUrl(String serviceName) {
|
||||
// SubscriptionUrlMatcher 서비스를 사용하여 URL 매칭 시도
|
||||
final suggestedUrl = SubscriptionUrlMatcher.findMatchingUrl(serviceName);
|
||||
|
||||
// 매칭된 URL이 있으면 반환, 없으면 기존 매핑 사용
|
||||
if (suggestedUrl != null && suggestedUrl.isNotEmpty) {
|
||||
return suggestedUrl;
|
||||
}
|
||||
|
||||
// 기존 하드코딩된 매핑 (필요한 경우 폴백으로 사용)
|
||||
final Map<String, String> serviceUrls = {
|
||||
'넷플릭스': 'https://www.netflix.com',
|
||||
'디즈니플러스': 'https://www.disneyplus.com',
|
||||
'유튜브프리미엄': 'https://www.youtube.com',
|
||||
'YouTube Premium': 'https://www.youtube.com',
|
||||
'애플 iCloud': 'https://www.icloud.com',
|
||||
'Microsoft 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'멜론': 'https://www.melon.com',
|
||||
'웨이브': 'https://www.wavve.com',
|
||||
'Apple Music': 'https://www.apple.com/apple-music',
|
||||
'Netflix': 'https://www.netflix.com',
|
||||
'Disney+': 'https://www.disneyplus.com',
|
||||
'Spotify': 'https://www.spotify.com',
|
||||
};
|
||||
|
||||
return serviceUrls[serviceName];
|
||||
}
|
||||
|
||||
bool _containsSubscriptionKeywords(String text) {
|
||||
final keywords = [
|
||||
'구독',
|
||||
'결제',
|
||||
'청구',
|
||||
'정기',
|
||||
'자동',
|
||||
'subscription',
|
||||
'payment',
|
||||
'bill',
|
||||
'invoice'
|
||||
];
|
||||
return keywords
|
||||
.any((keyword) => text.toLowerCase().contains(keyword.toLowerCase()));
|
||||
}
|
||||
|
||||
double? _extractAmount(String text) {
|
||||
final RegExp amountRegex = RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{2})?)');
|
||||
final match = amountRegex.firstMatch(text);
|
||||
if (match != null) {
|
||||
final amountStr = match.group(1)?.replaceAll(',', '');
|
||||
return double.tryParse(amountStr ?? '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _extractServiceName(String text) {
|
||||
final serviceNames = [
|
||||
'Netflix',
|
||||
'Spotify',
|
||||
'Disney+',
|
||||
'Apple Music',
|
||||
'YouTube Premium',
|
||||
'Amazon Prime',
|
||||
'Microsoft 365',
|
||||
'Google One',
|
||||
'iCloud',
|
||||
'Dropbox'
|
||||
];
|
||||
|
||||
for (final name in serviceNames) {
|
||||
if (text.contains(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _extractBillingCycle(String text) {
|
||||
if (text.contains('월') || text.contains('month')) {
|
||||
return 'monthly';
|
||||
} else if (text.contains('년') || text.contains('year')) {
|
||||
return 'yearly';
|
||||
} else if (text.contains('주') || text.contains('week')) {
|
||||
return 'weekly';
|
||||
}
|
||||
return 'monthly'; // 기본값
|
||||
}
|
||||
|
||||
DateTime _extractNextBillingDate(String text) {
|
||||
final RegExp dateRegex = RegExp(r'(\d{4}[-/]\d{2}[-/]\d{2})');
|
||||
final match = dateRegex.firstMatch(text);
|
||||
if (match != null) {
|
||||
final dateStr = match.group(1);
|
||||
if (dateStr != null) {
|
||||
final date = DateTime.tryParse(dateStr);
|
||||
if (date != null) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
}
|
||||
return DateTime.now().add(const Duration(days: 30)); // 기본값: 30일 후
|
||||
}
|
||||
|
||||
// 메시지에서 통화 단위를 감지하는 함수
|
||||
String _detectCurrency(String message) {
|
||||
final dollarKeywords = [
|
||||
'\$', 'USD', 'dollar', '달러', 'dollars', 'US\$',
|
||||
// 해외 서비스 이름
|
||||
'Netflix US', 'Spotify Premium', 'Apple US', 'Amazon US', 'GitHub'
|
||||
];
|
||||
|
||||
// 특정 서비스명으로 통화 단위 결정
|
||||
final Map<String, String> serviceCurrencyMap = {
|
||||
'Netflix US': 'USD',
|
||||
'Spotify Premium': 'USD',
|
||||
'Spotify': 'USD',
|
||||
'GitHub': 'USD',
|
||||
'GitHub Pro': 'USD',
|
||||
};
|
||||
|
||||
// 서비스명 기반 통화 단위 확인
|
||||
for (final service in serviceCurrencyMap.keys) {
|
||||
if (message.contains(service)) {
|
||||
print('_detectCurrency: ${service}는 USD 서비스로 판별됨');
|
||||
return 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
// 메시지에 달러 관련 키워드가 있는지 확인
|
||||
for (final keyword in dollarKeywords) {
|
||||
if (message.toLowerCase().contains(keyword.toLowerCase())) {
|
||||
print('_detectCurrency: USD 키워드 발견: $keyword');
|
||||
return 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
// 기본값은 원화
|
||||
return 'KRW';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user