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:
134
lib/services/currency_util.dart
Normal file
134
lib/services/currency_util.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import 'exchange_rate_service.dart';
|
||||
|
||||
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
||||
class CurrencyUtil {
|
||||
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||
|
||||
/// 구독 목록의 총 월 비용을 계산 (원화로 환산, 이벤트 가격 반영)
|
||||
static Future<double> calculateTotalMonthlyExpense(
|
||||
List<SubscriptionModel> subscriptions) async {
|
||||
double total = 0.0;
|
||||
|
||||
for (var subscription in subscriptions) {
|
||||
// 이벤트 가격이 있으면 currentPrice 사용
|
||||
final price = subscription.currentPrice;
|
||||
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD인 경우 KRW로 변환
|
||||
final krwAmount = await _exchangeRateService
|
||||
.convertUsdToKrw(price);
|
||||
if (krwAmount != null) {
|
||||
total += krwAmount;
|
||||
}
|
||||
} else {
|
||||
// KRW인 경우 그대로 합산
|
||||
total += price;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영)
|
||||
static Future<String> formatSubscriptionAmount(
|
||||
SubscriptionModel subscription) async {
|
||||
// 이벤트 가격이 있으면 currentPrice 사용
|
||||
final price = subscription.currentPrice;
|
||||
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD 표시 + 원화 환산 금액
|
||||
final usdFormatted = NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(price);
|
||||
|
||||
// 원화 환산 금액
|
||||
final krwAmount = await _exchangeRateService
|
||||
.getFormattedKrwAmount(price);
|
||||
|
||||
return '$usdFormatted $krwAmount';
|
||||
} else {
|
||||
// 원화 표시
|
||||
return NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(price);
|
||||
}
|
||||
}
|
||||
|
||||
/// 총액을 원화로 표시
|
||||
static String formatTotalAmount(double amount) {
|
||||
return NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(amount);
|
||||
}
|
||||
|
||||
/// 환율 정보 텍스트 가져오기
|
||||
static Future<String> getExchangeRateInfo() {
|
||||
return _exchangeRateService.getFormattedExchangeRateInfo();
|
||||
}
|
||||
|
||||
/// 이벤트로 인한 총 절약액 계산 (원화로 환산)
|
||||
static Future<double> calculateTotalEventSavings(
|
||||
List<SubscriptionModel> subscriptions) async {
|
||||
double total = 0.0;
|
||||
|
||||
for (var subscription in subscriptions) {
|
||||
if (subscription.isCurrentlyInEvent) {
|
||||
final savings = subscription.eventSavings;
|
||||
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD인 경우 KRW로 변환
|
||||
final krwAmount = await _exchangeRateService
|
||||
.convertUsdToKrw(savings);
|
||||
if (krwAmount != null) {
|
||||
total += krwAmount;
|
||||
}
|
||||
} else {
|
||||
// KRW인 경우 그대로 합산
|
||||
total += savings;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// 이벤트 절약액을 표시 형식에 맞게 변환
|
||||
static Future<String> formatEventSavings(
|
||||
SubscriptionModel subscription) async {
|
||||
if (!subscription.isCurrentlyInEvent) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final savings = subscription.eventSavings;
|
||||
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD 표시 + 원화 환산 금액
|
||||
final usdFormatted = NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(savings);
|
||||
|
||||
// 원화 환산 금액
|
||||
final krwAmount = await _exchangeRateService
|
||||
.getFormattedKrwAmount(savings);
|
||||
|
||||
return '$usdFormatted $krwAmount';
|
||||
} else {
|
||||
// 원화 표시
|
||||
return NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(savings);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
lib/services/exchange_rate_service.dart
Normal file
92
lib/services/exchange_rate_service.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 환율 정보 서비스 클래스
|
||||
class ExchangeRateService {
|
||||
// 싱글톤 인스턴스
|
||||
static final ExchangeRateService _instance = ExchangeRateService._internal();
|
||||
|
||||
// 팩토리 생성자
|
||||
factory ExchangeRateService() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
// 내부 생성자
|
||||
ExchangeRateService._internal();
|
||||
|
||||
// 캐싱된 환율 정보
|
||||
double? _usdToKrwRate;
|
||||
DateTime? _lastUpdated;
|
||||
|
||||
// API 요청 URL (ExchangeRate-API 사용)
|
||||
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD';
|
||||
|
||||
/// 현재 USD to KRW 환율 정보를 가져옵니다.
|
||||
/// 최근 6시간 이내 조회했던 정보가 있다면 캐싱된 정보를 반환합니다.
|
||||
Future<double?> getUsdToKrwRate() async {
|
||||
// 캐싱된 데이터 있고 6시간 이내면 캐싱된 데이터 반환
|
||||
if (_usdToKrwRate != null && _lastUpdated != null) {
|
||||
final difference = DateTime.now().difference(_lastUpdated!);
|
||||
if (difference.inHours < 6) {
|
||||
return _usdToKrwRate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// API 요청
|
||||
final response = await http.get(Uri.parse(_apiUrl));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
_usdToKrwRate = data['rates']['KRW'].toDouble();
|
||||
_lastUpdated = DateTime.now();
|
||||
return _usdToKrwRate;
|
||||
} else {
|
||||
// 실패 시 캐싱된 값이라도 반환
|
||||
return _usdToKrwRate;
|
||||
}
|
||||
} catch (e) {
|
||||
// 오류 발생 시 캐싱된 값이라도 반환
|
||||
return _usdToKrwRate;
|
||||
}
|
||||
}
|
||||
|
||||
/// USD 금액을 KRW로 변환합니다.
|
||||
Future<double?> convertUsdToKrw(double usdAmount) async {
|
||||
final rate = await getUsdToKrwRate();
|
||||
if (rate != null) {
|
||||
return usdAmount * rate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 현재 환율 정보를 포맷팅하여 텍스트로 반환합니다.
|
||||
Future<String> getFormattedExchangeRateInfo() async {
|
||||
final rate = await getUsdToKrwRate();
|
||||
if (rate != null) {
|
||||
final formattedRate = NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(rate);
|
||||
return '오늘 기준 환율 : $formattedRate';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||
Future<String> getFormattedKrwAmount(double usdAmount) async {
|
||||
final krwAmount = await convertUsdToKrw(usdAmount);
|
||||
if (krwAmount != null) {
|
||||
final formattedAmount = NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(krwAmount);
|
||||
return '($formattedAmount)';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
548
lib/services/notification_service.dart
Normal file
548
lib/services/notification_service.dart
Normal file
@@ -0,0 +1,548 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
static final _secureStorage = const FlutterSecureStorage();
|
||||
|
||||
static const _notificationEnabledKey = 'notification_enabled';
|
||||
static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
|
||||
static const _reminderDaysKey = 'reminder_days';
|
||||
static const _reminderHourKey = 'reminder_hour';
|
||||
static const _reminderMinuteKey = 'reminder_minute';
|
||||
static const _dailyReminderKey = 'daily_reminder_enabled';
|
||||
|
||||
// 초기화 상태를 추적하기 위한 플래그
|
||||
static bool _initialized = false;
|
||||
|
||||
// 웹 플랫폼 여부 확인 (웹에서는 flutter_local_notifications가 지원되지 않음)
|
||||
static bool get _isWeb => kIsWeb;
|
||||
|
||||
static const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
'subscription_channel',
|
||||
'Subscription Notifications',
|
||||
description: 'Channel for subscription reminders',
|
||||
importance: Importance.high,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
// 알림 초기화
|
||||
static Future<void> init() async {
|
||||
try {
|
||||
// 웹 플랫폼인 경우 초기화 건너뛰기
|
||||
if (_isWeb) {
|
||||
debugPrint('웹 플랫폼에서는 로컬 알림이 지원되지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
tz.initializeTimeZones();
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
} catch (e) {
|
||||
// 타임존 찾기 실패시 UTC 사용
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
debugPrint('타임존 설정 실패, UTC 사용: $e');
|
||||
}
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings();
|
||||
const initSettings =
|
||||
InitializationSettings(android: androidSettings, iOS: iosSettings);
|
||||
|
||||
await _notifications.initialize(initSettings);
|
||||
_initialized = true;
|
||||
debugPrint('알림 서비스 초기화 완료');
|
||||
} catch (e) {
|
||||
_initialized = false;
|
||||
debugPrint('알림 서비스 초기화 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> isNotificationEnabled() async {
|
||||
final value = await _secureStorage.read(key: _notificationEnabledKey);
|
||||
return value == 'true';
|
||||
}
|
||||
|
||||
static Future<bool> isPaymentNotificationEnabled() async {
|
||||
final value =
|
||||
await _secureStorage.read(key: _paymentNotificationEnabledKey);
|
||||
return value == 'true';
|
||||
}
|
||||
|
||||
static Future<void> setNotificationEnabled(bool value) async {
|
||||
await _secureStorage.write(
|
||||
key: _notificationEnabledKey,
|
||||
value: value.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> setPaymentNotificationEnabled(bool value) async {
|
||||
await _secureStorage.write(
|
||||
key: _paymentNotificationEnabledKey,
|
||||
value: value.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// 알림 시점 설정 (1일전, 2일전, 3일전) 가져오기
|
||||
static Future<int> getReminderDays() async {
|
||||
final value = await _secureStorage.read(key: _reminderDaysKey);
|
||||
return value != null ? int.tryParse(value) ?? 3 : 3;
|
||||
}
|
||||
|
||||
// 알림 시간 가져오기
|
||||
static Future<int> getReminderHour() async {
|
||||
final value = await _secureStorage.read(key: _reminderHourKey);
|
||||
return value != null ? int.tryParse(value) ?? 10 : 10;
|
||||
}
|
||||
|
||||
static Future<int> getReminderMinute() async {
|
||||
final value = await _secureStorage.read(key: _reminderMinuteKey);
|
||||
return value != null ? int.tryParse(value) ?? 0 : 0;
|
||||
}
|
||||
|
||||
// 반복 알림 설정 가져오기
|
||||
static Future<bool> isDailyReminderEnabled() async {
|
||||
final value = await _secureStorage.read(key: _dailyReminderKey);
|
||||
return value == 'true';
|
||||
}
|
||||
|
||||
// 모든 구독의 알림 일정 다시 예약
|
||||
static Future<void> reschedulAllNotifications(
|
||||
List<SubscriptionModel> subscriptions) async {
|
||||
try {
|
||||
// 웹 플랫폼이거나 알림 초기화 실패한 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 알림 모두 취소
|
||||
await cancelAllNotifications();
|
||||
|
||||
// 알림 설정 가져오기
|
||||
final isPaymentEnabled = await isPaymentNotificationEnabled();
|
||||
if (!isPaymentEnabled) return;
|
||||
|
||||
final reminderDays = await getReminderDays();
|
||||
final reminderHour = await getReminderHour();
|
||||
final reminderMinute = await getReminderMinute();
|
||||
final isDailyReminder = await isDailyReminderEnabled();
|
||||
|
||||
// 각 구독에 대해 알림 재설정
|
||||
for (final subscription in subscriptions) {
|
||||
await schedulePaymentReminder(
|
||||
id: subscription.id.hashCode,
|
||||
serviceName: subscription.serviceName,
|
||||
amount: subscription.monthlyCost,
|
||||
billingDate: subscription.nextBillingDate,
|
||||
reminderDays: reminderDays,
|
||||
reminderHour: reminderHour,
|
||||
reminderMinute: reminderMinute,
|
||||
isDailyReminder: isDailyReminder,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('알림 일정 재설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 서비스가 초기화되었는지 확인하는 메서드
|
||||
static bool _isInitialized() {
|
||||
// 웹 플랫폼인 경우 항상 false 반환
|
||||
if (_isWeb) return false;
|
||||
// 초기화 플래그 확인
|
||||
return _initialized;
|
||||
}
|
||||
|
||||
static Future<bool> requestPermission() async {
|
||||
final result = await _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermission();
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
// 알림 스케줄 설정
|
||||
static Future<void> scheduleNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledDate,
|
||||
}) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'구독 알림',
|
||||
channelDescription: '구독 관련 알림을 보여줍니다.',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
final iosDetails = const DarwinNotificationDetails();
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tz.TZDateTime.from(scheduledDate, location),
|
||||
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 취소
|
||||
static Future<void> cancelNotification(int id) async {
|
||||
await _notifications.cancel(id);
|
||||
}
|
||||
|
||||
// 모든 알림 취소
|
||||
static Future<void> cancelAllNotifications() async {
|
||||
try {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 취소할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
await _notifications.cancelAll();
|
||||
debugPrint('모든 알림이 취소되었습니다.');
|
||||
} catch (e) {
|
||||
debugPrint('알림 취소 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> scheduleSubscriptionNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final notificationId = subscription.id.hashCode;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'구독 알림',
|
||||
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
final iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
notificationId,
|
||||
'구독 만료 알림',
|
||||
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
||||
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
||||
notificationDetails,
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> updateSubscriptionNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
await cancelSubscriptionNotification(subscription);
|
||||
await scheduleSubscriptionNotification(subscription);
|
||||
}
|
||||
|
||||
static Future<void> cancelSubscriptionNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
final notificationId = subscription.id.hashCode;
|
||||
await _notifications.cancel(notificationId);
|
||||
}
|
||||
|
||||
static Future<void> schedulePaymentNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final paymentDate = subscription.nextBillingDate;
|
||||
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
subscription.id.hashCode,
|
||||
'구독 결제 예정 알림',
|
||||
'${subscription.serviceName} 결제가 3일 후 예정되어 있습니다.',
|
||||
tz.TZDateTime.from(reminderDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'payment_channel',
|
||||
'Payment Notifications',
|
||||
channelDescription: 'Channel for subscription payment reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> scheduleExpirationNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final expirationDate = subscription.nextBillingDate;
|
||||
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
(subscription.id + '_expiration').hashCode,
|
||||
'구독 만료 예정 알림',
|
||||
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
|
||||
tz.TZDateTime.from(reminderDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'expiration_channel',
|
||||
'Expiration Notifications',
|
||||
channelDescription: 'Channel for subscription expiration reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> schedulePaymentReminder({
|
||||
required int id,
|
||||
required String serviceName,
|
||||
required double amount,
|
||||
required DateTime billingDate,
|
||||
int reminderDays = 3,
|
||||
int reminderHour = 10,
|
||||
int reminderMinute = 0,
|
||||
bool isDailyReminder = false,
|
||||
}) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 알림 예약 (지정된 일수 전)
|
||||
final scheduledDate =
|
||||
billingDate.subtract(Duration(days: reminderDays)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String daysText = '$reminderDays일 후';
|
||||
if (reminderDays == 1) {
|
||||
daysText = '내일';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id,
|
||||
'구독 결제 예정 알림',
|
||||
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.',
|
||||
tz.TZDateTime.from(scheduledDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'Subscription Notifications',
|
||||
channelDescription: 'Channel for subscription reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
|
||||
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
|
||||
if (isDailyReminder && reminderDays >= 2) {
|
||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||
final dailyDate = billingDate.subtract(Duration(days: i)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String remainingDaysText = '$i일 후';
|
||||
if (i == 1) {
|
||||
remainingDaysText = '내일';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
||||
'구독 결제 예정 알림',
|
||||
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.',
|
||||
tz.TZDateTime.from(dailyDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'Subscription Notifications',
|
||||
channelDescription: 'Channel for subscription reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static String getNotificationBody(String serviceName, double amount) {
|
||||
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
|
||||
}
|
||||
}
|
||||
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';
|
||||
}
|
||||
}
|
||||
35
lib/services/sms_service.dart
Normal file
35
lib/services/sms_service.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
|
||||
class SMSService {
|
||||
static const platform = MethodChannel('com.submanager/sms');
|
||||
|
||||
static Future<bool> requestSMSPermission() async {
|
||||
if (kIsWeb) return false;
|
||||
final status = await permission.Permission.sms.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> hasSMSPermission() async {
|
||||
if (kIsWeb) return false;
|
||||
final status = await permission.Permission.sms.status;
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> scanSubscriptions() async {
|
||||
if (kIsWeb) return [];
|
||||
|
||||
try {
|
||||
if (!await hasSMSPermission()) {
|
||||
throw Exception('SMS 권한이 없습니다.');
|
||||
}
|
||||
|
||||
final List<dynamic> result =
|
||||
await platform.invokeMethod('scanSubscriptions');
|
||||
return result.map((item) => item as Map<String, dynamic>).toList();
|
||||
} on PlatformException catch (e) {
|
||||
throw Exception('SMS 스캔 중 오류 발생: ${e.message}');
|
||||
}
|
||||
}
|
||||
}
|
||||
476
lib/services/subscription_url_matcher.dart
Normal file
476
lib/services/subscription_url_matcher.dart
Normal file
@@ -0,0 +1,476 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스
|
||||
class SubscriptionUrlMatcher {
|
||||
// OTT 서비스
|
||||
static final Map<String, String> ottServices = {
|
||||
'netflix': 'https://www.netflix.com',
|
||||
'넷플릭스': 'https://www.netflix.com',
|
||||
'disney+': 'https://www.disneyplus.com',
|
||||
'디즈니플러스': 'https://www.disneyplus.com',
|
||||
'youtube premium': 'https://www.youtube.com/premium',
|
||||
'유튜브 프리미엄': 'https://www.youtube.com/premium',
|
||||
'watcha': 'https://watcha.com',
|
||||
'왓챠': 'https://watcha.com',
|
||||
'wavve': 'https://www.wavve.com',
|
||||
'웨이브': 'https://www.wavve.com',
|
||||
'apple tv+': 'https://tv.apple.com',
|
||||
'애플 티비플러스': 'https://tv.apple.com',
|
||||
'tving': 'https://www.tving.com',
|
||||
'티빙': 'https://www.tving.com',
|
||||
'prime video': 'https://www.primevideo.com',
|
||||
'프라임 비디오': 'https://www.primevideo.com',
|
||||
'amazon prime': 'https://www.amazon.com/prime',
|
||||
'아마존 프라임': 'https://www.amazon.com/prime',
|
||||
'coupang play': 'https://play.coupangplay.com',
|
||||
'쿠팡 플레이': 'https://play.coupangplay.com',
|
||||
'hulu': 'https://www.hulu.com',
|
||||
'훌루': 'https://www.hulu.com',
|
||||
};
|
||||
|
||||
// 음악 서비스
|
||||
static final Map<String, String> musicServices = {
|
||||
'spotify': 'https://www.spotify.com',
|
||||
'스포티파이': 'https://www.spotify.com',
|
||||
'apple music': 'https://music.apple.com',
|
||||
'애플 뮤직': 'https://music.apple.com',
|
||||
'melon': 'https://www.melon.com',
|
||||
'멜론': 'https://www.melon.com',
|
||||
'genie': 'https://www.genie.co.kr',
|
||||
'지니': 'https://www.genie.co.kr',
|
||||
'youtube music': 'https://music.youtube.com',
|
||||
'유튜브 뮤직': 'https://music.youtube.com',
|
||||
'bugs': 'https://music.bugs.co.kr',
|
||||
'벅스': 'https://music.bugs.co.kr',
|
||||
'flo': 'https://www.music-flo.com',
|
||||
'플로': 'https://www.music-flo.com',
|
||||
'vibe': 'https://vibe.naver.com',
|
||||
'바이브': 'https://vibe.naver.com',
|
||||
'tidal': 'https://www.tidal.com',
|
||||
'타이달': 'https://www.tidal.com',
|
||||
};
|
||||
|
||||
// AI 서비스
|
||||
static final Map<String, String> aiServices = {
|
||||
'chatgpt': 'https://chat.openai.com',
|
||||
'챗GPT': 'https://chat.openai.com',
|
||||
'openai': 'https://openai.com',
|
||||
'오픈AI': 'https://openai.com',
|
||||
'claude': 'https://claude.ai',
|
||||
'클로드': 'https://claude.ai',
|
||||
'anthropic': 'https://www.anthropic.com',
|
||||
'앤트로픽': 'https://www.anthropic.com',
|
||||
'midjourney': 'https://www.midjourney.com',
|
||||
'미드저니': 'https://www.midjourney.com',
|
||||
'perplexity': 'https://www.perplexity.ai',
|
||||
'퍼플렉시티': 'https://www.perplexity.ai',
|
||||
'copilot': 'https://copilot.microsoft.com',
|
||||
'코파일럿': 'https://copilot.microsoft.com',
|
||||
'gemini': 'https://gemini.google.com',
|
||||
'제미니': 'https://gemini.google.com',
|
||||
'google ai': 'https://ai.google',
|
||||
'구글 AI': 'https://ai.google',
|
||||
'bard': 'https://bard.google.com',
|
||||
'바드': 'https://bard.google.com',
|
||||
'dall-e': 'https://openai.com/dall-e',
|
||||
'달리': 'https://openai.com/dall-e',
|
||||
'stable diffusion': 'https://stability.ai',
|
||||
'스테이블 디퓨전': 'https://stability.ai',
|
||||
};
|
||||
|
||||
// 프로그래밍 / 개발 서비스
|
||||
static final Map<String, String> programmingServices = {
|
||||
'github': 'https://github.com',
|
||||
'깃허브': 'https://github.com',
|
||||
'cursor': 'https://cursor.com',
|
||||
'커서': 'https://cursor.com',
|
||||
'jetbrains': 'https://www.jetbrains.com',
|
||||
'제트브레인스': 'https://www.jetbrains.com',
|
||||
'intellij': 'https://www.jetbrains.com/idea',
|
||||
'인텔리제이': 'https://www.jetbrains.com/idea',
|
||||
'visual studio': 'https://visualstudio.microsoft.com',
|
||||
'비주얼 스튜디오': 'https://visualstudio.microsoft.com',
|
||||
'aws': 'https://aws.amazon.com',
|
||||
'아마존 웹서비스': 'https://aws.amazon.com',
|
||||
'azure': 'https://azure.microsoft.com',
|
||||
'애저': 'https://azure.microsoft.com',
|
||||
'google cloud': 'https://cloud.google.com',
|
||||
'구글 클라우드': 'https://cloud.google.com',
|
||||
'digitalocean': 'https://www.digitalocean.com',
|
||||
'디지털오션': 'https://www.digitalocean.com',
|
||||
'heroku': 'https://www.heroku.com',
|
||||
'헤로쿠': 'https://www.heroku.com',
|
||||
'codecademy': 'https://www.codecademy.com',
|
||||
'코드아카데미': 'https://www.codecademy.com',
|
||||
'udemy': 'https://www.udemy.com',
|
||||
'유데미': 'https://www.udemy.com',
|
||||
'coursera': 'https://www.coursera.org',
|
||||
'코세라': 'https://www.coursera.org',
|
||||
};
|
||||
|
||||
// 오피스 및 협업 툴
|
||||
static final Map<String, String> officeTools = {
|
||||
'microsoft 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'마이크로소프트 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'office 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'오피스 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'google workspace': 'https://workspace.google.com',
|
||||
'구글 워크스페이스': 'https://workspace.google.com',
|
||||
'slack': 'https://slack.com',
|
||||
'슬랙': 'https://slack.com',
|
||||
'notion': 'https://www.notion.so',
|
||||
'노션': 'https://www.notion.so',
|
||||
'trello': 'https://trello.com',
|
||||
'트렐로': 'https://trello.com',
|
||||
'asana': 'https://asana.com',
|
||||
'아사나': 'https://asana.com',
|
||||
'dropbox': 'https://www.dropbox.com',
|
||||
'드롭박스': 'https://www.dropbox.com',
|
||||
'figma': 'https://www.figma.com',
|
||||
'피그마': 'https://www.figma.com',
|
||||
'adobe creative cloud': 'https://www.adobe.com/creativecloud.html',
|
||||
'어도비 크리에이티브 클라우드': 'https://www.adobe.com/creativecloud.html',
|
||||
};
|
||||
|
||||
// 기타 유명 서비스
|
||||
static final Map<String, String> otherServices = {
|
||||
'google one': 'https://one.google.com',
|
||||
'구글 원': 'https://one.google.com',
|
||||
'icloud': 'https://www.icloud.com',
|
||||
'아이클라우드': 'https://www.icloud.com',
|
||||
'nintendo switch online': 'https://www.nintendo.com/switch/online-service',
|
||||
'닌텐도 스위치 온라인': 'https://www.nintendo.com/switch/online-service',
|
||||
'playstation plus': 'https://www.playstation.com/ps-plus',
|
||||
'플레이스테이션 플러스': 'https://www.playstation.com/ps-plus',
|
||||
'xbox game pass': 'https://www.xbox.com/xbox-game-pass',
|
||||
'엑스박스 게임 패스': 'https://www.xbox.com/xbox-game-pass',
|
||||
'ea play': 'https://www.ea.com/ea-play',
|
||||
'EA 플레이': 'https://www.ea.com/ea-play',
|
||||
'ubisoft+': 'https://ubisoft.com/plus',
|
||||
'유비소프트+': 'https://ubisoft.com/plus',
|
||||
'epic games': 'https://www.epicgames.com',
|
||||
'에픽 게임즈': 'https://www.epicgames.com',
|
||||
'steam': 'https://store.steampowered.com',
|
||||
'스팀': 'https://store.steampowered.com',
|
||||
};
|
||||
|
||||
// 해지 안내 페이지 URL 목록 (공식 해지 안내 페이지가 있는 서비스들)
|
||||
static final Map<String, String> cancellationUrls = {
|
||||
// OTT 서비스 해지 안내 페이지
|
||||
'netflix': 'https://help.netflix.com/ko/node/407',
|
||||
'넷플릭스': 'https://help.netflix.com/ko/node/407',
|
||||
'disney+':
|
||||
'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979',
|
||||
'디즈니플러스':
|
||||
'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979',
|
||||
'youtube premium': 'https://support.google.com/youtube/answer/6308278',
|
||||
'유튜브 프리미엄': 'https://support.google.com/youtube/answer/6308278',
|
||||
'watcha': 'https://watcha.com/settings/payment',
|
||||
'왓챠': 'https://watcha.com/settings/payment',
|
||||
'wavve': 'https://www.wavve.com/my',
|
||||
'웨이브': 'https://www.wavve.com/my',
|
||||
'apple tv+': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'애플 티비플러스': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'tving': 'https://www.tving.com/my/cancelMembership',
|
||||
'티빙': 'https://www.tving.com/my/cancelMembership',
|
||||
'amazon prime': 'https://www.amazon.com/gp/primecentral/managemembership',
|
||||
'아마존 프라임': 'https://www.amazon.com/gp/primecentral/managemembership',
|
||||
|
||||
// 음악 서비스 해지 안내 페이지
|
||||
'spotify': 'https://support.spotify.com/us/article/cancel-premium/',
|
||||
'스포티파이': 'https://support.spotify.com/us/article/cancel-premium/',
|
||||
'apple music': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'애플 뮤직': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'melon':
|
||||
'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021',
|
||||
'멜론':
|
||||
'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021',
|
||||
'youtube music': 'https://support.google.com/youtubemusic/answer/6308278',
|
||||
'유튜브 뮤직': 'https://support.google.com/youtubemusic/answer/6308278',
|
||||
|
||||
// AI 서비스 해지 안내 페이지
|
||||
'chatgpt':
|
||||
'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription',
|
||||
'챗GPT':
|
||||
'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription',
|
||||
'claude':
|
||||
'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription',
|
||||
'클로드':
|
||||
'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription',
|
||||
'midjourney': 'https://docs.midjourney.com/docs/manage-subscription',
|
||||
'미드저니': 'https://docs.midjourney.com/docs/manage-subscription',
|
||||
|
||||
// 프로그래밍 / 개발 서비스 해지 안내 페이지
|
||||
'github':
|
||||
'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription',
|
||||
'깃허브':
|
||||
'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription',
|
||||
'jetbrains':
|
||||
'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-',
|
||||
'제트브레인스':
|
||||
'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-',
|
||||
|
||||
// 오피스 및 협업 툴 해지 안내 페이지
|
||||
'microsoft 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'마이크로소프트 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'office 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'오피스 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'slack':
|
||||
'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription',
|
||||
'슬랙':
|
||||
'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription',
|
||||
'notion':
|
||||
'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription',
|
||||
'노션':
|
||||
'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription',
|
||||
'dropbox': 'https://help.dropbox.com/accounts-billing/cancellation',
|
||||
'드롭박스': 'https://help.dropbox.com/accounts-billing/cancellation',
|
||||
'adobe creative cloud':
|
||||
'https://helpx.adobe.com/manage-account/using/cancel-subscription.html',
|
||||
'어도비 크리에이티브 클라우드':
|
||||
'https://helpx.adobe.com/manage-account/using/cancel-subscription.html',
|
||||
|
||||
// 기타 유명 서비스 해지 안내 페이지
|
||||
'google one': 'https://support.google.com/googleone/answer/9140429',
|
||||
'구글 원': 'https://support.google.com/googleone/answer/9140429',
|
||||
'icloud': 'https://support.apple.com/ko-kr/HT207594',
|
||||
'아이클라우드': 'https://support.apple.com/ko-kr/HT207594',
|
||||
'nintendo switch online':
|
||||
'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership',
|
||||
'닌텐도 스위치 온라인':
|
||||
'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership',
|
||||
'playstation plus':
|
||||
'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/',
|
||||
'플레이스테이션 플러스':
|
||||
'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/',
|
||||
'xbox game pass':
|
||||
'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel',
|
||||
'엑스박스 게임 패스':
|
||||
'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel',
|
||||
};
|
||||
|
||||
// 모든 서비스 매핑을 합친 맵
|
||||
static final Map<String, String> allServices = {
|
||||
...ottServices,
|
||||
...musicServices,
|
||||
...aiServices,
|
||||
...programmingServices,
|
||||
...officeTools,
|
||||
...otherServices,
|
||||
};
|
||||
|
||||
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환
|
||||
///
|
||||
/// [text] 검색할 텍스트 (서비스명)
|
||||
/// [usePartialMatch] 부분 일치도 허용할지 여부 (기본값: true)
|
||||
///
|
||||
/// 반환값: 매칭된 URL 또는 null (매칭 실패시)
|
||||
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
||||
// 입력 텍스트가 비어있거나 null인 경우
|
||||
if (text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = text.toLowerCase().trim();
|
||||
|
||||
// 정확히 일치하는 경우
|
||||
if (allServices.containsKey(lowerText)) {
|
||||
return allServices[lowerText];
|
||||
}
|
||||
|
||||
// 부분 일치 검색이 활성화된 경우
|
||||
if (usePartialMatch) {
|
||||
// 가장 긴 부분 매칭 찾기
|
||||
String? bestMatch;
|
||||
int maxLength = 0;
|
||||
|
||||
for (var entry in 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;
|
||||
}
|
||||
|
||||
/// 서비스 이름을 기반으로 URL 제안
|
||||
static String? suggestUrl(String serviceName) {
|
||||
if (serviceName.isEmpty) {
|
||||
print('SubscriptionUrlMatcher: 빈 serviceName');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 비교
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
// 정확한 매칭을 먼저 시도
|
||||
for (final entry in allServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('SubscriptionUrlMatcher: 정확한 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// OTT 서비스 검사
|
||||
for (final entry in ottServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 음악 서비스 검사
|
||||
for (final entry in musicServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// AI 서비스 검사
|
||||
for (final entry in aiServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: AI 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 서비스 검사
|
||||
for (final entry in programmingServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 개발 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 오피스 툴 검사
|
||||
for (final entry in officeTools.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 서비스 검사
|
||||
for (final entry in otherServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 유사한 이름 검사 (퍼지 매칭) - 단어 기반으로 검색
|
||||
for (final entry in 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을 기반으로 해지 안내 페이지 URL 찾기
|
||||
///
|
||||
/// [serviceNameOrUrl] 서비스명 또는 웹사이트 URL
|
||||
///
|
||||
/// 반환값: 해지 안내 페이지 URL 또는 null (해지 안내 페이지가 없는 경우)
|
||||
static String? findCancellationUrl(String serviceNameOrUrl) {
|
||||
if (serviceNameOrUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = serviceNameOrUrl.toLowerCase().trim();
|
||||
|
||||
// 직접 서비스명으로 찾기
|
||||
if (cancellationUrls.containsKey(lowerText)) {
|
||||
return cancellationUrls[lowerText];
|
||||
}
|
||||
|
||||
// 서비스명에 부분 포함으로 찾기
|
||||
for (var entry in 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 cancellationUrls.entries) {
|
||||
if (entry.key.toLowerCase().contains(domain)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 해지 안내 페이지를 찾지 못함
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
||||
///
|
||||
/// [serviceNameOrUrl] 서비스명 또는 웹사이트 URL
|
||||
///
|
||||
/// 반환값: 해지 안내 페이지 제공 여부
|
||||
static bool hasCancellationPage(String serviceNameOrUrl) {
|
||||
return findCancellationUrl(serviceNameOrUrl) != null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user