feat: SMS 스캔 패키지를 flutter_sms_inbox로 변경 및 플랫폼별 최적화
- telephony 패키지를 flutter_sms_inbox로 교체 - 플랫폼별 SMS 스캔 로직 구현: * Web: mock data 사용 * Android: flutter_sms_inbox로 실제 SMS 스캔 * iOS: SMS 기능 비활성화 - iOS에서 SMS 스캔 버튼 숨김 처리 - PlatformHelper 유틸리티 추가로 웹 환경 오류 해결 - Android 네이티브 MethodChannel 코드 제거
This commit is contained in:
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,198 +1,8 @@
|
|||||||
package com.example.submanager
|
package com.example.submanager
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.ContentObserver
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.provider.Telephony
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
private val CHANNEL = "com.submanager/sms"
|
// flutter_sms_inbox 패키지가 SMS 처리를 담당하므로
|
||||||
|
// 기존 MethodChannel 코드는 제거되었습니다
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
}
|
||||||
super.configureFlutterEngine(flutterEngine)
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
|
||||||
when (call.method) {
|
|
||||||
"scanSubscriptions" -> {
|
|
||||||
scanSubscriptions(result)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scanSubscriptions(result: MethodChannel.Result) {
|
|
||||||
try {
|
|
||||||
// 메시지를 임시 저장할 맵
|
|
||||||
val messageGroups = mutableMapOf<String, MutableList<Map<String, Any>>>()
|
|
||||||
|
|
||||||
val cursor = contentResolver.query(
|
|
||||||
Telephony.Sms.CONTENT_URI,
|
|
||||||
arrayOf(
|
|
||||||
Telephony.Sms.ADDRESS,
|
|
||||||
Telephony.Sms.BODY,
|
|
||||||
Telephony.Sms.DATE
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
"${Telephony.Sms.DATE} DESC"
|
|
||||||
)
|
|
||||||
|
|
||||||
cursor?.use {
|
|
||||||
while (it.moveToNext()) {
|
|
||||||
val address = it.getString(0)
|
|
||||||
val body = it.getString(1)
|
|
||||||
val date = it.getLong(2)
|
|
||||||
|
|
||||||
// 구독 관련 키워드로 필터링
|
|
||||||
if (isSubscriptionMessage(body)) {
|
|
||||||
val parsedMessage = parseSubscriptionMessage(body, address, date)
|
|
||||||
if (parsedMessage != null) {
|
|
||||||
val key = "${address}_${parsedMessage["monthlyCost"]}"
|
|
||||||
if (!messageGroups.containsKey(key)) {
|
|
||||||
messageGroups[key] = mutableListOf()
|
|
||||||
}
|
|
||||||
messageGroups[key]?.add(parsedMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최종 구독 목록
|
|
||||||
val subscriptions = mutableListOf<Map<String, Any>>()
|
|
||||||
|
|
||||||
// 동일한 발신자와 유사한 금액으로 2회 이상 메시지가 있는 경우만 구독으로 판단
|
|
||||||
for ((key, messages) in messageGroups) {
|
|
||||||
if (messages.size >= 2) {
|
|
||||||
// 가장 최근 메시지 정보를 사용
|
|
||||||
val latestMessage = messages.first()
|
|
||||||
|
|
||||||
// 주기성 추가
|
|
||||||
val enhancedMessage = latestMessage.toMutableMap()
|
|
||||||
enhancedMessage["repeatCount"] = messages.size
|
|
||||||
enhancedMessage["isRecurring"] = true
|
|
||||||
|
|
||||||
// 이전 결제일도 추가
|
|
||||||
if (messages.size > 1) {
|
|
||||||
enhancedMessage["previousPaymentDate"] = messages[1]["nextBillingDate"] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptions.add(enhancedMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.success(subscriptions)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("SMS_SCAN_ERROR", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isSubscriptionMessage(body: String?): Boolean {
|
|
||||||
if (body == null) return false
|
|
||||||
val keywords = listOf(
|
|
||||||
"구독", "결제", "청구", "요금", "월정액", "정기결제",
|
|
||||||
"subscription", "payment", "bill", "fee", "monthly", "recurring"
|
|
||||||
)
|
|
||||||
return keywords.any { body.contains(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseSubscriptionMessage(
|
|
||||||
body: String,
|
|
||||||
address: String,
|
|
||||||
date: Long
|
|
||||||
): Map<String, Any>? {
|
|
||||||
try {
|
|
||||||
// 서비스명 추출 (예: "넷플릭스", "스포티파이" 등)
|
|
||||||
val serviceName = extractServiceName(address, body)
|
|
||||||
|
|
||||||
// 금액 추출 (예: "9,900원", "₩9,900" 등)
|
|
||||||
val amount = extractAmount(body)
|
|
||||||
|
|
||||||
// 결제 주기 추출 (예: "월간", "연간" 등)
|
|
||||||
val billingCycle = extractBillingCycle(body)
|
|
||||||
|
|
||||||
// 다음 결제일 추출
|
|
||||||
val nextBillingDate = extractNextBillingDate(body, date)
|
|
||||||
|
|
||||||
return mapOf(
|
|
||||||
"serviceName" to serviceName,
|
|
||||||
"monthlyCost" to amount,
|
|
||||||
"billingCycle" to billingCycle,
|
|
||||||
"nextBillingDate" to nextBillingDate,
|
|
||||||
"isRecurring" to false,
|
|
||||||
"repeatCount" to 1,
|
|
||||||
"sender" to address,
|
|
||||||
"messageDate" to formatDate(date)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatDate(timestamp: Long): String {
|
|
||||||
val date = Date(timestamp)
|
|
||||||
val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
|
||||||
return formatter.format(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractServiceName(address: String, body: String): String {
|
|
||||||
// 주소에서 서비스명 추출 시도
|
|
||||||
val addressPattern = Regex("""([가-힣a-zA-Z]+)""")
|
|
||||||
val addressMatch = addressPattern.find(address)
|
|
||||||
if (addressMatch != null) {
|
|
||||||
return addressMatch.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// 본문에서 서비스명 추출 시도
|
|
||||||
val bodyPattern = Regex("""([가-힣a-zA-Z]+)\s*(구독|결제|청구)""")
|
|
||||||
val bodyMatch = bodyPattern.find(body)
|
|
||||||
if (bodyMatch != null) {
|
|
||||||
return bodyMatch.groupValues[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return "알 수 없는 서비스"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractAmount(body: String): Double {
|
|
||||||
val pattern = Regex("""(\d{1,3}(?:,\d{3})*)(?:\s*원|\s*₩)""")
|
|
||||||
val match = pattern.find(body)
|
|
||||||
return if (match != null) {
|
|
||||||
match.groupValues[1].replace(",", "").toDouble()
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractBillingCycle(body: String): String {
|
|
||||||
return when {
|
|
||||||
body.contains("월간") || body.contains("월정액") -> "월간"
|
|
||||||
body.contains("연간") || body.contains("연정액") -> "연간"
|
|
||||||
body.contains("주간") || body.contains("주정액") -> "주간"
|
|
||||||
else -> "월간"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractNextBillingDate(body: String, messageDate: Long): String {
|
|
||||||
val datePattern = Regex("""(\d{4})[년/.-](\d{1,2})[월/.-](\d{1,2})일?""")
|
|
||||||
val match = datePattern.find(body)
|
|
||||||
if (match != null) {
|
|
||||||
val (year, month, day) = match.destructured
|
|
||||||
return "$year-${month.padStart(2, '0')}-${day.padStart(2, '0')}"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜를 찾을 수 없는 경우, 메시지 날짜로부터 1개월 후를 반환
|
|
||||||
val calendar = Calendar.getInstance()
|
|
||||||
calendar.timeInMillis = messageDate
|
|
||||||
calendar.add(Calendar.MONTH, 1)
|
|
||||||
return SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(calendar.time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,7 @@ import '../widgets/floating_navigation_bar.dart';
|
|||||||
import '../widgets/glassmorphic_scaffold.dart';
|
import '../widgets/glassmorphic_scaffold.dart';
|
||||||
import '../widgets/home_content.dart';
|
import '../widgets/home_content.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/platform_helper.dart';
|
||||||
|
|
||||||
class MainScreen extends StatefulWidget {
|
class MainScreen extends StatefulWidget {
|
||||||
const MainScreen({super.key});
|
const MainScreen({super.key});
|
||||||
@@ -69,7 +70,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
onShow: () {},
|
onShow: () {},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 화면 목록 초기화
|
// 화면 목록 초기화 (iOS에서는 SMS 스캔 제외)
|
||||||
_screens = [
|
_screens = [
|
||||||
HomeContent(
|
HomeContent(
|
||||||
fadeController: _fadeController,
|
fadeController: _fadeController,
|
||||||
@@ -82,7 +83,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
),
|
),
|
||||||
const AnalysisScreen(),
|
const AnalysisScreen(),
|
||||||
Container(), // 추가 버튼은 별도 처리
|
Container(), // 추가 버튼은 별도 처리
|
||||||
const SmsScanScreen(),
|
if (!PlatformHelper.isIOS) const SmsScanScreen(),
|
||||||
const SettingsScreen(),
|
const SettingsScreen(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -233,7 +234,9 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
|
|
||||||
return GlassmorphicScaffold(
|
return GlassmorphicScaffold(
|
||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
index: currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex,
|
index: PlatformHelper.isIOS
|
||||||
|
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
|
||||||
|
: (currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex), // Android: 기존 로직
|
||||||
children: _screens,
|
children: _screens,
|
||||||
),
|
),
|
||||||
backgroundGradient: backgroundGradient,
|
backgroundGradient: backgroundGradient,
|
||||||
|
|||||||
@@ -1,32 +1,37 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../temp/test_sms_data.dart';
|
import '../temp/test_sms_data.dart';
|
||||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
|
||||||
import '../services/subscription_url_matcher.dart';
|
import '../services/subscription_url_matcher.dart';
|
||||||
|
import '../utils/platform_helper.dart';
|
||||||
|
|
||||||
class SmsScanner {
|
class SmsScanner {
|
||||||
|
final SmsQuery _query = SmsQuery();
|
||||||
|
|
||||||
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
||||||
try {
|
try {
|
||||||
List<dynamic> smsList;
|
List<dynamic> smsList;
|
||||||
print('SmsScanner: 스캔 시작');
|
print('SmsScanner: 스캔 시작');
|
||||||
|
|
||||||
// 디버그 모드에서는 테스트 데이터 사용
|
// 플랫폼별 분기 처리
|
||||||
if (kDebugMode) {
|
if (kIsWeb) {
|
||||||
print('SmsScanner: 디버그 모드에서 테스트 데이터 사용');
|
// 웹 환경: 테스트 데이터 사용
|
||||||
|
print('SmsScanner: 웹 환경에서 테스트 데이터 사용');
|
||||||
smsList = TestSmsData.getTestData();
|
smsList = TestSmsData.getTestData();
|
||||||
print('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
|
print('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
|
||||||
|
} else if (PlatformHelper.isIOS) {
|
||||||
|
// iOS: SMS 접근 불가, 빈 리스트 반환
|
||||||
|
print('SmsScanner: iOS에서는 SMS 스캔 불가');
|
||||||
|
return [];
|
||||||
|
} else if (PlatformHelper.isAndroid) {
|
||||||
|
// Android: flutter_sms_inbox 사용
|
||||||
|
print('SmsScanner: Android에서 실제 SMS 스캔');
|
||||||
|
smsList = await _scanAndroidSms();
|
||||||
|
print('SmsScanner: 스캔된 SMS 개수: ${smsList.length}');
|
||||||
} else {
|
} else {
|
||||||
print('SmsScanner: 실제 SMS 데이터 스캔');
|
// 기타 플랫폼
|
||||||
// 실제 환경에서는 네이티브 코드 호출
|
print('SmsScanner: 지원하지 않는 플랫폼');
|
||||||
const platform = MethodChannel('com.submanager/sms');
|
return [];
|
||||||
try {
|
|
||||||
smsList = await platform.invokeMethod('scanSubscriptions');
|
|
||||||
print('SmsScanner: 네이티브 호출 성공, SMS 데이터 개수: ${smsList.length}');
|
|
||||||
} catch (e) {
|
|
||||||
print('SmsScanner: 네이티브 호출 실패: $e');
|
|
||||||
// 오류 발생 시 빈 목록 반환
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
||||||
@@ -72,6 +77,166 @@ class SmsScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android에서 flutter_sms_inbox를 사용한 SMS 스캔
|
||||||
|
Future<List<dynamic>> _scanAndroidSms() async {
|
||||||
|
try {
|
||||||
|
final messages = await _query.getAllSms;
|
||||||
|
final smsList = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
// SMS 메시지를 분석하여 구독 서비스 감지
|
||||||
|
for (final message in messages) {
|
||||||
|
final parsedData = _parseRawSms(message);
|
||||||
|
if (parsedData != null) {
|
||||||
|
smsList.add(parsedData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return smsList;
|
||||||
|
} catch (e) {
|
||||||
|
print('SmsScanner: Android SMS 스캔 실패: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 SMS 메시지를 파싱하여 구독 정보 추출
|
||||||
|
Map<String, dynamic>? _parseRawSms(SmsMessage message) {
|
||||||
|
try {
|
||||||
|
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'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 구독 관련 키워드가 있는지 확인
|
||||||
|
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(),
|
||||||
|
'previousPaymentDate': date.toIso8601String(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
print('SmsScanner: SMS 파싱 실패: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서비스명 추출 로직
|
||||||
|
String _extractServiceName(String body, String sender) {
|
||||||
|
// 알려진 서비스 매핑
|
||||||
|
final servicePatterns = {
|
||||||
|
'netflix': '넷플릭스',
|
||||||
|
'youtube': '유튜브 프리미엄',
|
||||||
|
'spotify': 'Spotify',
|
||||||
|
'disney': '디즈니플러스',
|
||||||
|
'apple': 'Apple',
|
||||||
|
'microsoft': 'Microsoft',
|
||||||
|
'github': 'GitHub',
|
||||||
|
'adobe': 'Adobe',
|
||||||
|
'멜론': '멜론',
|
||||||
|
'웨이브': '웨이브',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메시지나 발신자에서 서비스명 찾기
|
||||||
|
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})*)'), // 결제 금액
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final pattern in patterns) {
|
||||||
|
final match = pattern.firstMatch(body);
|
||||||
|
if (match != null) {
|
||||||
|
String amountStr = match.group(1) ?? '';
|
||||||
|
amountStr = amountStr.replaceAll(',', '');
|
||||||
|
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')) {
|
||||||
|
return 'yearly';
|
||||||
|
} else if (body.contains('주') || body.contains('weekly')) {
|
||||||
|
return 'weekly';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값
|
||||||
|
return 'monthly';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 결제일 계산
|
||||||
|
DateTime _calculateNextBillingFromDate(DateTime lastDate, String billingCycle) {
|
||||||
|
switch (billingCycle) {
|
||||||
|
case 'monthly':
|
||||||
|
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
|
||||||
|
case 'yearly':
|
||||||
|
return DateTime(lastDate.year + 1, lastDate.month, lastDate.day);
|
||||||
|
case 'weekly':
|
||||||
|
return lastDate.add(const Duration(days: 7));
|
||||||
|
default:
|
||||||
|
return lastDate.add(const Duration(days: 30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||||
try {
|
try {
|
||||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||||
@@ -242,4 +407,4 @@ class SmsScanner {
|
|||||||
// 기본값은 원화
|
// 기본값은 원화
|
||||||
return 'KRW';
|
return 'KRW';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,35 @@
|
|||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||||
|
import '../utils/platform_helper.dart';
|
||||||
|
|
||||||
class SMSService {
|
class SMSService {
|
||||||
static const platform = MethodChannel('com.submanager/sms');
|
static const platform = MethodChannel('com.submanager/sms');
|
||||||
|
|
||||||
static Future<bool> requestSMSPermission() async {
|
static Future<bool> requestSMSPermission() async {
|
||||||
if (kIsWeb) return false;
|
// 웹이나 iOS에서는 SMS 권한 불필요
|
||||||
final status = await permission.Permission.sms.request();
|
if (kIsWeb || PlatformHelper.isIOS) return true;
|
||||||
return status.isGranted;
|
|
||||||
|
// Android에서만 권한 요청
|
||||||
|
if (PlatformHelper.isAndroid) {
|
||||||
|
final status = await permission.Permission.sms.request();
|
||||||
|
return status.isGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> hasSMSPermission() async {
|
static Future<bool> hasSMSPermission() async {
|
||||||
if (kIsWeb) return false;
|
// 웹이나 iOS에서는 항상 true 반환 (권한 불필요)
|
||||||
final status = await permission.Permission.sms.status;
|
if (kIsWeb || PlatformHelper.isIOS) return true;
|
||||||
return status.isGranted;
|
|
||||||
|
// Android에서만 실제 권한 확인
|
||||||
|
if (PlatformHelper.isAndroid) {
|
||||||
|
final status = await permission.Permission.sms.status;
|
||||||
|
return status.isGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>> scanSubscriptions() async {
|
static Future<List<Map<String, dynamic>>> scanSubscriptions() async {
|
||||||
|
|||||||
24
lib/utils/platform_helper.dart
Normal file
24
lib/utils/platform_helper.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class PlatformHelper {
|
||||||
|
static bool get isWeb => kIsWeb;
|
||||||
|
|
||||||
|
static bool get isIOS {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return defaultTargetPlatform == TargetPlatform.iOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get isAndroid {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get isMobile => isIOS || isAndroid;
|
||||||
|
|
||||||
|
static bool get isDesktop {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return defaultTargetPlatform == TargetPlatform.linux ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.windows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import 'glassmorphism_card.dart';
|
import 'glassmorphism_card.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/platform_helper.dart';
|
||||||
|
|
||||||
class FloatingNavigationBar extends StatefulWidget {
|
class FloatingNavigationBar extends StatefulWidget {
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
@@ -117,17 +118,18 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
|||||||
_AddButton(
|
_AddButton(
|
||||||
onTap: () => _onItemTapped(2),
|
onTap: () => _onItemTapped(2),
|
||||||
),
|
),
|
||||||
_NavigationItem(
|
if (!PlatformHelper.isIOS)
|
||||||
icon: Icons.qr_code_scanner_rounded,
|
_NavigationItem(
|
||||||
label: AppLocalizations.of(context).smsScanLabel,
|
icon: Icons.qr_code_scanner_rounded,
|
||||||
isSelected: widget.selectedIndex == 3,
|
label: AppLocalizations.of(context).smsScanLabel,
|
||||||
onTap: () => _onItemTapped(3),
|
isSelected: widget.selectedIndex == 3,
|
||||||
),
|
onTap: () => _onItemTapped(3),
|
||||||
|
),
|
||||||
_NavigationItem(
|
_NavigationItem(
|
||||||
icon: Icons.settings_rounded,
|
icon: Icons.settings_rounded,
|
||||||
label: AppLocalizations.of(context).settings,
|
label: AppLocalizations.of(context).settings,
|
||||||
isSelected: widget.selectedIndex == 4,
|
isSelected: PlatformHelper.isIOS ? widget.selectedIndex == 3 : widget.selectedIndex == 4,
|
||||||
onTap: () => _onItemTapped(4),
|
onTap: () => _onItemTapped(PlatformHelper.isIOS ? 3 : 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -432,14 +432,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
flutter_sms:
|
flutter_sms_inbox:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_sms
|
name: flutter_sms_inbox
|
||||||
sha256: "2fe5f584f02596343557eeca56348f9b82413fefe83a423fab880cdbdf54d8d8"
|
sha256: "50580e4265ab65b128777ea3db9b0286ef7beff12df644de42643b26e8b76ee5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.3"
|
version: "1.0.4"
|
||||||
flutter_svg:
|
flutter_svg:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1143,14 +1143,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.1"
|
version: "3.3.1"
|
||||||
telephony:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: telephony
|
|
||||||
sha256: "821e074acec3c611e48edadd50a6d9fef864943771a545da7924adcc8509ada9"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.2.0"
|
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -33,9 +33,8 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.13.1
|
||||||
flutter_native_splash: ^2.3.10
|
flutter_native_splash: ^2.3.10
|
||||||
telephony: ^0.2.0
|
flutter_sms_inbox: ^1.0.3
|
||||||
flutter_dotenv: ^5.1.0
|
flutter_dotenv: ^5.1.0
|
||||||
flutter_sms: ^2.3.3
|
|
||||||
flutter_svg: ^2.1.0
|
flutter_svg: ^2.1.0
|
||||||
html: ^0.15.6
|
html: ^0.15.6
|
||||||
octo_image: ^2.0.0
|
octo_image: ^2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user