style: apply dart format across project
This commit is contained in:
@@ -66,7 +66,7 @@ class CurrencyUtil {
|
||||
final locale = _getLocaleForCurrency(currency);
|
||||
final symbol = getCurrencySymbol(currency);
|
||||
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
|
||||
|
||||
|
||||
return NumberFormat.currency(
|
||||
locale: locale,
|
||||
symbol: symbol,
|
||||
@@ -81,27 +81,29 @@ class CurrencyUtil {
|
||||
String locale,
|
||||
) async {
|
||||
final defaultCurrency = getDefaultCurrency(locale);
|
||||
|
||||
|
||||
// 입력 통화가 기본 통화인 경우
|
||||
if (currency == defaultCurrency) {
|
||||
return _formatSingleCurrency(amount, currency);
|
||||
}
|
||||
|
||||
|
||||
// USD 입력인 경우 - 기본 통화로 변환하여 표시
|
||||
if (currency == 'USD' && defaultCurrency != 'USD') {
|
||||
final convertedAmount = await _exchangeRateService.convertUsdToTarget(amount, defaultCurrency);
|
||||
final convertedAmount = await _exchangeRateService.convertUsdToTarget(
|
||||
amount, defaultCurrency);
|
||||
if (convertedAmount != null) {
|
||||
final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency);
|
||||
final primaryFormatted =
|
||||
_formatSingleCurrency(convertedAmount, defaultCurrency);
|
||||
final usdFormatted = _formatSingleCurrency(amount, 'USD');
|
||||
return '$primaryFormatted ($usdFormatted)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 영어 사용자가 KRW 선택한 경우
|
||||
if (locale == 'en' && currency == 'KRW') {
|
||||
return _formatSingleCurrency(amount, currency);
|
||||
}
|
||||
|
||||
|
||||
// 기타 통화 입력인 경우
|
||||
return _formatSingleCurrency(amount, currency);
|
||||
}
|
||||
@@ -116,13 +118,13 @@ class CurrencyUtil {
|
||||
|
||||
for (var subscription in subscriptions) {
|
||||
final price = subscription.currentPrice;
|
||||
|
||||
|
||||
final converted = await _exchangeRateService.convertBetweenCurrencies(
|
||||
price,
|
||||
subscription.currency,
|
||||
defaultCurrency,
|
||||
);
|
||||
|
||||
|
||||
total += converted ?? price;
|
||||
}
|
||||
|
||||
@@ -178,13 +180,13 @@ class CurrencyUtil {
|
||||
for (var subscription in subscriptions) {
|
||||
if (subscription.isCurrentlyInEvent) {
|
||||
final savings = subscription.eventSavings;
|
||||
|
||||
|
||||
final converted = await _exchangeRateService.convertBetweenCurrencies(
|
||||
savings,
|
||||
subscription.currency,
|
||||
defaultCurrency,
|
||||
);
|
||||
|
||||
|
||||
total += converted ?? savings;
|
||||
}
|
||||
}
|
||||
@@ -204,7 +206,7 @@ class CurrencyUtil {
|
||||
if (!subscription.isCurrentlyInEvent) {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
final savings = subscription.eventSavings;
|
||||
return formatAmountWithLocale(savings, subscription.currency, locale);
|
||||
}
|
||||
@@ -225,4 +227,4 @@ class CurrencyUtil {
|
||||
static Future<String> formatAmount(double amount, String currency) async {
|
||||
return formatAmountWithCurrencyAndLocale(amount, currency, 'ko');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +75,10 @@ class ExchangeRateService {
|
||||
}
|
||||
|
||||
/// USD 금액을 지정된 통화로 변환합니다.
|
||||
Future<double?> convertUsdToTarget(double usdAmount, String targetCurrency) async {
|
||||
Future<double?> convertUsdToTarget(
|
||||
double usdAmount, String targetCurrency) async {
|
||||
await _fetchAllRatesIfNeeded();
|
||||
|
||||
|
||||
switch (targetCurrency) {
|
||||
case 'KRW':
|
||||
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
||||
@@ -96,9 +97,10 @@ class ExchangeRateService {
|
||||
}
|
||||
|
||||
/// 지정된 통화를 USD로 변환합니다.
|
||||
Future<double?> convertTargetToUsd(double amount, String sourceCurrency) async {
|
||||
Future<double?> convertTargetToUsd(
|
||||
double amount, String sourceCurrency) async {
|
||||
await _fetchAllRatesIfNeeded();
|
||||
|
||||
|
||||
switch (sourceCurrency) {
|
||||
case 'KRW':
|
||||
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
||||
@@ -118,25 +120,22 @@ class ExchangeRateService {
|
||||
|
||||
/// 두 통화 간 변환을 수행합니다. (USD를 거쳐서 변환)
|
||||
Future<double?> convertBetweenCurrencies(
|
||||
double amount,
|
||||
String fromCurrency,
|
||||
String toCurrency
|
||||
) async {
|
||||
double amount, String fromCurrency, String toCurrency) async {
|
||||
if (fromCurrency == toCurrency) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
|
||||
// fromCurrency → USD → toCurrency
|
||||
double? usdAmount;
|
||||
|
||||
|
||||
if (fromCurrency == 'USD') {
|
||||
usdAmount = amount;
|
||||
} else {
|
||||
usdAmount = await convertTargetToUsd(amount, fromCurrency);
|
||||
}
|
||||
|
||||
|
||||
if (usdAmount == null) return null;
|
||||
|
||||
|
||||
if (toCurrency == 'USD') {
|
||||
return usdAmount;
|
||||
} else {
|
||||
@@ -161,7 +160,7 @@ class ExchangeRateService {
|
||||
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
|
||||
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
|
||||
await _fetchAllRatesIfNeeded();
|
||||
|
||||
|
||||
switch (locale) {
|
||||
case 'ko':
|
||||
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
||||
@@ -204,12 +203,13 @@ class ExchangeRateService {
|
||||
}
|
||||
|
||||
/// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||
Future<String> getFormattedAmountForLocale(double usdAmount, String locale) async {
|
||||
Future<String> getFormattedAmountForLocale(
|
||||
double usdAmount, String locale) async {
|
||||
String targetCurrency;
|
||||
String localeCode;
|
||||
String symbol;
|
||||
int decimalDigits;
|
||||
|
||||
|
||||
switch (locale) {
|
||||
case 'ko':
|
||||
targetCurrency = 'KRW';
|
||||
@@ -232,7 +232,7 @@ class ExchangeRateService {
|
||||
default:
|
||||
return '\$$usdAmount';
|
||||
}
|
||||
|
||||
|
||||
final convertedAmount = await convertUsdToTarget(usdAmount, targetCurrency);
|
||||
if (convertedAmount != null) {
|
||||
final formattedAmount = NumberFormat.currency(
|
||||
|
||||
@@ -150,17 +150,16 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static Future<bool> requestPermission() async {
|
||||
// 웹 플랫폼인 경우 false 반환
|
||||
if (_isWeb) return false;
|
||||
|
||||
|
||||
// iOS 처리
|
||||
if (Platform.isIOS) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
final iosImplementation =
|
||||
_notifications.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final granted = await iosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
@@ -170,20 +169,20 @@ class NotificationService {
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Android 처리
|
||||
if (Platform.isAndroid) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
final androidImplementation =
|
||||
_notifications.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
|
||||
if (androidImplementation != null) {
|
||||
final granted = await androidImplementation
|
||||
.requestNotificationsPermission();
|
||||
final granted =
|
||||
await androidImplementation.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -191,32 +190,32 @@ class NotificationService {
|
||||
static Future<bool> checkPermission() async {
|
||||
// 웹 플랫폼인 경우 false 반환
|
||||
if (_isWeb) return false;
|
||||
|
||||
|
||||
// Android 처리
|
||||
if (Platform.isAndroid) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
final androidImplementation =
|
||||
_notifications.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 이상에서만 권한 확인 필요
|
||||
final isEnabled = await androidImplementation.areNotificationsEnabled();
|
||||
return isEnabled ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// iOS 처리
|
||||
if (Platform.isIOS) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
final iosImplementation =
|
||||
_notifications.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final settings = await iosImplementation.checkPermissions();
|
||||
return settings?.isEnabled ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return true; // 기본값
|
||||
}
|
||||
|
||||
@@ -232,7 +231,7 @@ class NotificationService {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
@@ -243,7 +242,7 @@ class NotificationService {
|
||||
);
|
||||
|
||||
final iosDetails = const DarwinNotificationDetails();
|
||||
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
@@ -305,7 +304,7 @@ class NotificationService {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
final notificationId = subscription.id.hashCode;
|
||||
|
||||
@@ -327,7 +326,7 @@ class NotificationService {
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
@@ -380,11 +379,11 @@ class NotificationService {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
final paymentDate = subscription.nextBillingDate;
|
||||
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
||||
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
@@ -433,11 +432,11 @@ class NotificationService {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
final expirationDate = subscription.nextBillingDate;
|
||||
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
||||
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
@@ -510,16 +509,17 @@ class NotificationService {
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 기본 알림 예약 (지정된 일수 전)
|
||||
final scheduledDate =
|
||||
subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
final scheduledDate = subscription.nextBillingDate
|
||||
.subtract(Duration(days: reminderDays))
|
||||
.copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String daysText = '$reminderDays일 후';
|
||||
@@ -529,19 +529,21 @@ class NotificationService {
|
||||
|
||||
// 이벤트 종료로 인한 가격 변동 확인
|
||||
String notificationBody;
|
||||
if (subscription.isEventActive &&
|
||||
if (subscription.isEventActive &&
|
||||
subscription.eventEndDate != null &&
|
||||
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||
// 이벤트가 결제일 전에 종료되는 경우
|
||||
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
|
||||
final normalPrice = subscription.monthlyCost;
|
||||
notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
|
||||
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
||||
notificationBody =
|
||||
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
|
||||
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
||||
} else {
|
||||
// 일반 알림
|
||||
final currentPrice = subscription.currentPrice;
|
||||
notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
|
||||
notificationBody =
|
||||
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
@@ -568,13 +570,14 @@ class NotificationService {
|
||||
if (isDailyReminder && reminderDays >= 2) {
|
||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||
final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
final dailyDate =
|
||||
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String remainingDaysText = '$i일 후';
|
||||
@@ -584,17 +587,21 @@ class NotificationService {
|
||||
|
||||
// 각 날짜에 대한 이벤트 종료 확인
|
||||
String dailyNotificationBody;
|
||||
if (subscription.isEventActive &&
|
||||
if (subscription.isEventActive &&
|
||||
subscription.eventEndDate != null &&
|
||||
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
||||
subscription.eventEndDate!
|
||||
.isBefore(subscription.nextBillingDate) &&
|
||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
|
||||
final eventPrice =
|
||||
subscription.eventPrice ?? subscription.monthlyCost;
|
||||
final normalPrice = subscription.monthlyCost;
|
||||
dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
|
||||
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
||||
dailyNotificationBody =
|
||||
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
|
||||
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
||||
} else {
|
||||
final currentPrice = subscription.currentPrice;
|
||||
dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
|
||||
dailyNotificationBody =
|
||||
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
|
||||
@@ -3,15 +3,17 @@ import '../../models/subscription_model.dart';
|
||||
|
||||
class SubscriptionConverter {
|
||||
// SubscriptionModel 리스트를 Subscription 리스트로 변환
|
||||
List<Subscription> convertModelsToSubscriptions(List<SubscriptionModel> models) {
|
||||
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}');
|
||||
|
||||
print(
|
||||
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
|
||||
} catch (e) {
|
||||
print('모델 변환 중 오류 발생: $e');
|
||||
}
|
||||
@@ -76,4 +78,4 @@ class SubscriptionConverter {
|
||||
return 'monthly'; // 기본값
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ class SubscriptionFilter {
|
||||
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
|
||||
List<Subscription> filterDuplicates(
|
||||
List<Subscription> scanned, List<SubscriptionModel> existing) {
|
||||
print('_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개');
|
||||
print(
|
||||
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개');
|
||||
|
||||
// 중복되지 않은 구독만 필터링
|
||||
return scanned.where((scannedSub) {
|
||||
@@ -16,7 +17,8 @@ class SubscriptionFilter {
|
||||
final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost;
|
||||
|
||||
if (isSameName && isSameCost) {
|
||||
print('중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
|
||||
print(
|
||||
'중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -27,7 +29,8 @@ class SubscriptionFilter {
|
||||
}
|
||||
|
||||
// 반복 횟수 기반 필터링
|
||||
List<Subscription> filterByRepeatCount(List<Subscription> subscriptions, int minCount) {
|
||||
List<Subscription> filterByRepeatCount(
|
||||
List<Subscription> subscriptions, int minCount) {
|
||||
return subscriptions.where((sub) => sub.repeatCount >= minCount).toList();
|
||||
}
|
||||
|
||||
@@ -44,7 +47,8 @@ class SubscriptionFilter {
|
||||
List<Subscription> filterByPriceRange(
|
||||
List<Subscription> subscriptions, double minPrice, double maxPrice) {
|
||||
return subscriptions
|
||||
.where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
|
||||
.where(
|
||||
(sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -52,9 +56,9 @@ class SubscriptionFilter {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class SmsScanner {
|
||||
try {
|
||||
final messages = await _query.getAllSms;
|
||||
final smsList = <Map<String, dynamic>>[];
|
||||
|
||||
|
||||
// SMS 메시지를 분석하여 구독 서비스 감지
|
||||
for (final message in messages) {
|
||||
final parsedData = _parseRawSms(message);
|
||||
@@ -90,7 +90,7 @@ class SmsScanner {
|
||||
smsList.add(parsedData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return smsList;
|
||||
} catch (e) {
|
||||
print('SmsScanner: Android SMS 스캔 실패: $e');
|
||||
@@ -104,41 +104,59 @@ class SmsScanner {
|
||||
final body = message.body ?? '';
|
||||
final sender = message.address ?? '';
|
||||
final date = message.date ?? DateTime.now();
|
||||
|
||||
|
||||
// 구독 서비스 키워드 매칭
|
||||
final subscriptionKeywords = [
|
||||
'구독', '결제', '정기결제', '자동결제', '월정액',
|
||||
'subscription', 'payment', 'billing', 'charge',
|
||||
'넷플릭스', 'Netflix', '유튜브', 'YouTube', 'Spotify',
|
||||
'멜론', '웨이브', 'Disney+', '디즈니플러스', 'Apple',
|
||||
'Microsoft', 'GitHub', 'Adobe', 'Amazon'
|
||||
'구독',
|
||||
'결제',
|
||||
'정기결제',
|
||||
'자동결제',
|
||||
'월정액',
|
||||
'subscription',
|
||||
'payment',
|
||||
'billing',
|
||||
'charge',
|
||||
'넷플릭스',
|
||||
'Netflix',
|
||||
'유튜브',
|
||||
'YouTube',
|
||||
'Spotify',
|
||||
'멜론',
|
||||
'웨이브',
|
||||
'Disney+',
|
||||
'디즈니플러스',
|
||||
'Apple',
|
||||
'Microsoft',
|
||||
'GitHub',
|
||||
'Adobe',
|
||||
'Amazon'
|
||||
];
|
||||
|
||||
|
||||
// 구독 관련 키워드가 있는지 확인
|
||||
bool isSubscription = subscriptionKeywords.any((keyword) =>
|
||||
body.toLowerCase().contains(keyword.toLowerCase()) ||
|
||||
sender.toLowerCase().contains(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
bool isSubscription = subscriptionKeywords.any((keyword) =>
|
||||
body.toLowerCase().contains(keyword.toLowerCase()) ||
|
||||
sender.toLowerCase().contains(keyword.toLowerCase()));
|
||||
|
||||
if (!isSubscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 서비스명 추출
|
||||
String serviceName = _extractServiceName(body, sender);
|
||||
|
||||
|
||||
// 금액 추출
|
||||
double? amount = _extractAmount(body);
|
||||
|
||||
|
||||
// 결제 주기 추출
|
||||
String billingCycle = _extractBillingCycle(body);
|
||||
|
||||
|
||||
return {
|
||||
'serviceName': serviceName,
|
||||
'monthlyCost': amount ?? 0.0,
|
||||
'billingCycle': billingCycle,
|
||||
'message': body,
|
||||
'nextBillingDate': _calculateNextBillingFromDate(date, billingCycle).toIso8601String(),
|
||||
'nextBillingDate':
|
||||
_calculateNextBillingFromDate(date, billingCycle).toIso8601String(),
|
||||
'previousPaymentDate': date.toIso8601String(),
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -146,7 +164,7 @@ class SmsScanner {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 서비스명 추출 로직
|
||||
String _extractServiceName(String body, String sender) {
|
||||
// 알려진 서비스 매핑
|
||||
@@ -162,41 +180,41 @@ class SmsScanner {
|
||||
'멜론': '멜론',
|
||||
'웨이브': '웨이브',
|
||||
};
|
||||
|
||||
|
||||
// 메시지나 발신자에서 서비스명 찾기
|
||||
final combinedText = '$body $sender'.toLowerCase();
|
||||
|
||||
|
||||
for (final entry in servicePatterns.entries) {
|
||||
if (combinedText.contains(entry.key)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 찾지 못한 경우
|
||||
return _extractServiceNameFromSender(sender);
|
||||
}
|
||||
|
||||
|
||||
// 발신자 정보에서 서비스명 추출
|
||||
String _extractServiceNameFromSender(String sender) {
|
||||
// 숫자만 있으면 제거
|
||||
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
||||
return '알 수 없는 서비스';
|
||||
}
|
||||
|
||||
|
||||
// 특수문자 제거하고 서비스명으로 사용
|
||||
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
|
||||
}
|
||||
|
||||
|
||||
// 금액 추출 로직
|
||||
double? _extractAmount(String body) {
|
||||
// 다양한 금액 패턴 매칭
|
||||
final patterns = [
|
||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
||||
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
||||
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
||||
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
||||
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
||||
];
|
||||
|
||||
|
||||
for (final pattern in patterns) {
|
||||
final match = pattern.firstMatch(body);
|
||||
if (match != null) {
|
||||
@@ -205,26 +223,29 @@ class SmsScanner {
|
||||
return double.tryParse(amountStr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 결제 주기 추출 로직
|
||||
String _extractBillingCycle(String body) {
|
||||
if (body.contains('월') || body.contains('monthly') || body.contains('매월')) {
|
||||
return 'monthly';
|
||||
} else if (body.contains('년') || body.contains('yearly') || body.contains('annual')) {
|
||||
} else if (body.contains('년') ||
|
||||
body.contains('yearly') ||
|
||||
body.contains('annual')) {
|
||||
return 'yearly';
|
||||
} else if (body.contains('주') || body.contains('weekly')) {
|
||||
return 'weekly';
|
||||
}
|
||||
|
||||
|
||||
// 기본값
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
|
||||
// 다음 결제일 계산
|
||||
DateTime _calculateNextBillingFromDate(DateTime lastDate, String billingCycle) {
|
||||
DateTime _calculateNextBillingFromDate(
|
||||
DateTime lastDate, String billingCycle) {
|
||||
switch (billingCycle) {
|
||||
case 'monthly':
|
||||
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
|
||||
@@ -241,7 +262,8 @@ class SmsScanner {
|
||||
try {
|
||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
|
||||
final billingCycle = SubscriptionModel.normalizeBillingCycle(sms['billingCycle'] as String? ?? 'monthly');
|
||||
final billingCycle = SubscriptionModel.normalizeBillingCycle(
|
||||
sms['billingCycle'] as String? ?? 'monthly');
|
||||
final nextBillingDateStr = sms['nextBillingDate'] as String?;
|
||||
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
|
||||
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
|
||||
@@ -369,8 +391,6 @@ class SmsScanner {
|
||||
return serviceUrls[serviceName];
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 메시지에서 통화 단위를 감지하는 함수
|
||||
String _detectCurrency(String message) {
|
||||
final dollarKeywords = [
|
||||
@@ -407,4 +427,4 @@ class SmsScanner {
|
||||
// 기본값은 원화
|
||||
return 'KRW';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,26 +9,26 @@ class SMSService {
|
||||
static Future<bool> requestSMSPermission() async {
|
||||
// 웹이나 iOS에서는 SMS 권한 불필요
|
||||
if (kIsWeb || PlatformHelper.isIOS) return true;
|
||||
|
||||
|
||||
// Android에서만 권한 요청
|
||||
if (PlatformHelper.isAndroid) {
|
||||
final status = await permission.Permission.sms.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> hasSMSPermission() async {
|
||||
// 웹이나 iOS에서는 항상 true 반환 (권한 불필요)
|
||||
if (kIsWeb || PlatformHelper.isIOS) return true;
|
||||
|
||||
|
||||
// Android에서만 실제 권한 확인
|
||||
if (PlatformHelper.isAndroid) {
|
||||
final status = await permission.Permission.sms.status;
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,39 +17,40 @@ class SubscriptionUrlMatcher {
|
||||
static CancellationUrlService? _cancellationService;
|
||||
static ServiceNameResolver? _nameResolver;
|
||||
static SmsExtractorService? _smsExtractor;
|
||||
|
||||
|
||||
/// 서비스 초기화
|
||||
static Future<void> initialize() async {
|
||||
if (_dataRepository != null && _dataRepository!.isInitialized) return;
|
||||
|
||||
|
||||
// 1. 데이터 저장소 초기화
|
||||
_dataRepository = ServiceDataRepository();
|
||||
await _dataRepository!.initialize();
|
||||
|
||||
|
||||
// 2. 서비스 초기화
|
||||
_categoryMapper = CategoryMapperService(_dataRepository!);
|
||||
_urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!);
|
||||
_cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!);
|
||||
_cancellationService =
|
||||
CancellationUrlService(_dataRepository!, _urlMatcher!);
|
||||
_nameResolver = ServiceNameResolver(_dataRepository!);
|
||||
_smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!);
|
||||
}
|
||||
|
||||
|
||||
/// 도메인 추출 (www와 TLD 제외)
|
||||
static String? extractDomain(String url) {
|
||||
return _urlMatcher?.extractDomain(url);
|
||||
}
|
||||
|
||||
|
||||
/// URL로 서비스 찾기
|
||||
static Future<ServiceInfo?> findServiceByUrl(String url) async {
|
||||
await initialize();
|
||||
return _urlMatcher?.findServiceByUrl(url);
|
||||
}
|
||||
|
||||
|
||||
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
|
||||
static String? suggestUrl(String serviceName) {
|
||||
return _urlMatcher?.suggestUrl(serviceName);
|
||||
}
|
||||
|
||||
|
||||
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
|
||||
static Future<String?> findCancellationUrl({
|
||||
String? serviceName,
|
||||
@@ -63,19 +64,20 @@ class SubscriptionUrlMatcher {
|
||||
locale: locale,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
||||
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
|
||||
await initialize();
|
||||
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false;
|
||||
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ??
|
||||
false;
|
||||
}
|
||||
|
||||
|
||||
/// 서비스명으로 카테고리 찾기
|
||||
static Future<String?> findCategoryByServiceName(String serviceName) async {
|
||||
await initialize();
|
||||
return _categoryMapper?.findCategoryByServiceName(serviceName);
|
||||
}
|
||||
|
||||
|
||||
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
||||
static Future<String> getServiceDisplayName({
|
||||
required String serviceName,
|
||||
@@ -83,17 +85,18 @@ class SubscriptionUrlMatcher {
|
||||
}) async {
|
||||
await initialize();
|
||||
return await _nameResolver?.getServiceDisplayName(
|
||||
serviceName: serviceName,
|
||||
locale: locale,
|
||||
) ?? serviceName;
|
||||
serviceName: serviceName,
|
||||
locale: locale,
|
||||
) ??
|
||||
serviceName;
|
||||
}
|
||||
|
||||
|
||||
/// SMS에서 URL과 서비스 정보 추출
|
||||
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
||||
await initialize();
|
||||
return _smsExtractor?.extractServiceFromSms(smsText);
|
||||
}
|
||||
|
||||
|
||||
/// URL이 알려진 서비스 URL인지 확인
|
||||
static Future<bool> isKnownServiceUrl(String url) async {
|
||||
await initialize();
|
||||
@@ -104,4 +107,4 @@ class SubscriptionUrlMatcher {
|
||||
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
||||
return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,22 +336,22 @@ class LegacyServiceData {
|
||||
|
||||
// 모든 서비스 매핑을 합친 맵
|
||||
static Map<String, String> get allServices => {
|
||||
...ottServices,
|
||||
...musicServices,
|
||||
...storageServices,
|
||||
...aiServices,
|
||||
...programmingServices,
|
||||
...officeTools,
|
||||
...lifestyleServices,
|
||||
...shoppingServices,
|
||||
...telecomServices,
|
||||
...otherServices,
|
||||
};
|
||||
...ottServices,
|
||||
...musicServices,
|
||||
...storageServices,
|
||||
...aiServices,
|
||||
...programmingServices,
|
||||
...officeTools,
|
||||
...lifestyleServices,
|
||||
...shoppingServices,
|
||||
...telecomServices,
|
||||
...otherServices,
|
||||
};
|
||||
|
||||
/// 서비스 카테고리 찾기
|
||||
static String? getCategoryForService(String serviceName) {
|
||||
final lowerName = serviceName.toLowerCase();
|
||||
|
||||
|
||||
if (ottServices.containsKey(lowerName)) return 'ott';
|
||||
if (musicServices.containsKey(lowerName)) return 'music';
|
||||
if (storageServices.containsKey(lowerName)) return 'storage';
|
||||
@@ -362,7 +362,7 @@ class LegacyServiceData {
|
||||
if (shoppingServices.containsKey(lowerName)) return 'shopping';
|
||||
if (telecomServices.containsKey(lowerName)) return 'telecom';
|
||||
if (otherServices.containsKey(lowerName)) return 'other';
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ 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');
|
||||
final jsonString =
|
||||
await rootBundle.loadString('assets/data/subscription_services.json');
|
||||
_servicesData = json.decode(jsonString);
|
||||
_isInitialized = true;
|
||||
print('ServiceDataRepository: JSON 데이터 로드 완료');
|
||||
@@ -21,10 +22,10 @@ class ServiceDataRepository {
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 서비스 데이터 가져오기
|
||||
Map<String, dynamic>? getServicesData() => _servicesData;
|
||||
|
||||
|
||||
/// 초기화 여부 확인
|
||||
bool get isInitialized => _isInitialized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ class ServiceInfo {
|
||||
final String categoryId;
|
||||
final String categoryNameKr;
|
||||
final String categoryNameEn;
|
||||
|
||||
|
||||
ServiceInfo({
|
||||
required this.serviceId,
|
||||
required this.serviceName,
|
||||
@@ -17,4 +17,4 @@ class ServiceInfo {
|
||||
required this.categoryNameKr,
|
||||
required this.categoryNameEn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import 'url_matcher_service.dart';
|
||||
class CancellationUrlService {
|
||||
final ServiceDataRepository _dataRepository;
|
||||
final UrlMatcherService _urlMatcher;
|
||||
|
||||
|
||||
CancellationUrlService(this._dataRepository, this._urlMatcher);
|
||||
|
||||
|
||||
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
|
||||
Future<String?> findCancellationUrl({
|
||||
String? serviceName,
|
||||
@@ -19,47 +19,55 @@ class CancellationUrlService {
|
||||
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>;
|
||||
|
||||
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'] ?? []);
|
||||
|
||||
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 (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'];
|
||||
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>;
|
||||
|
||||
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'] ?? []);
|
||||
|
||||
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 (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'];
|
||||
return cancellationUrls[locale] ??
|
||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,7 +76,7 @@ class CancellationUrlService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
|
||||
}
|
||||
@@ -126,4 +134,4 @@ class CancellationUrlService {
|
||||
);
|
||||
return cancellationUrl != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,41 +4,43 @@ 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'] ?? []);
|
||||
|
||||
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)) {
|
||||
if (lowerName.contains(name.toLowerCase()) ||
|
||||
name.toLowerCase().contains(lowerName)) {
|
||||
return getCategoryIdByKey(categoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
|
||||
return getCategoryForLegacyService(serviceName);
|
||||
}
|
||||
|
||||
|
||||
/// 카테고리 키를 실제 카테고리 ID로 매핑
|
||||
String getCategoryIdByKey(String key) {
|
||||
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
|
||||
@@ -68,21 +70,30 @@ class CategoryMapperService {
|
||||
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';
|
||||
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import '../data/service_data_repository.dart';
|
||||
/// 서비스명 관련 기능을 제공하는 서비스 클래스
|
||||
class ServiceNameResolver {
|
||||
final ServiceDataRepository _dataRepository;
|
||||
|
||||
|
||||
ServiceNameResolver(this._dataRepository);
|
||||
|
||||
|
||||
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
||||
Future<String> getServiceDisplayName({
|
||||
required String serviceName,
|
||||
@@ -15,22 +15,23 @@ class ServiceNameResolver {
|
||||
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>;
|
||||
|
||||
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()) ||
|
||||
if (lowerName == name.toLowerCase() ||
|
||||
lowerName.contains(name.toLowerCase()) ||
|
||||
name.toLowerCase().contains(lowerName)) {
|
||||
// 로케일에 따라 적절한 이름 반환
|
||||
if (locale == 'ko' || locale == 'kr') {
|
||||
@@ -40,11 +41,11 @@ class ServiceNameResolver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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;
|
||||
@@ -54,8 +55,8 @@ class ServiceNameResolver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 찾지 못한 경우 원래 이름 반환
|
||||
return serviceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import 'category_mapper_service.dart';
|
||||
class SmsExtractorService {
|
||||
final UrlMatcherService _urlMatcher;
|
||||
final CategoryMapperService _categoryMapper;
|
||||
|
||||
|
||||
SmsExtractorService(this._urlMatcher, this._categoryMapper);
|
||||
|
||||
|
||||
/// SMS에서 URL과 서비스 정보 추출
|
||||
Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
||||
// URL 패턴 찾기
|
||||
@@ -17,9 +17,9 @@ class SmsExtractorService {
|
||||
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) {
|
||||
@@ -29,15 +29,17 @@ class SmsExtractorService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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';
|
||||
|
||||
final categoryId =
|
||||
await _categoryMapper.findCategoryByServiceName(entry.key) ??
|
||||
'other';
|
||||
|
||||
return ServiceInfo(
|
||||
serviceId: entry.key,
|
||||
serviceName: entry.key,
|
||||
@@ -49,7 +51,7 @@ class SmsExtractorService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,23 +7,23 @@ import 'category_mapper_service.dart';
|
||||
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) {
|
||||
// 서브도메인 포함 전체 도메인 반환
|
||||
@@ -32,40 +32,41 @@ class UrlMatcherService {
|
||||
// 메인 도메인만 반환
|
||||
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)) {
|
||||
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,
|
||||
@@ -80,13 +81,13 @@ class UrlMatcherService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
final serviceUrl = entry.value;
|
||||
final serviceDomain = extractDomain(serviceUrl);
|
||||
|
||||
if (serviceDomain != null &&
|
||||
|
||||
if (serviceDomain != null &&
|
||||
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
|
||||
return ServiceInfo(
|
||||
serviceId: entry.key,
|
||||
@@ -99,10 +100,10 @@ class UrlMatcherService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/// 서비스명으로 URL 찾기
|
||||
String? suggestUrl(String serviceName) {
|
||||
if (serviceName.isEmpty) {
|
||||
@@ -186,7 +187,7 @@ class UrlMatcherService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// URL이 알려진 서비스 URL인지 확인
|
||||
Future<bool> isKnownServiceUrl(String url) async {
|
||||
final serviceInfo = await findServiceByUrl(url);
|
||||
@@ -232,4 +233,4 @@ class UrlMatcherService {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/// URL Matcher 패키지의 export 파일
|
||||
export 'models/service_info.dart';
|
||||
export 'models/service_info.dart';
|
||||
|
||||
Reference in New Issue
Block a user