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:
44
android/app/build.gradle.kts
Normal file
44
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.submanager"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.submanager"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
7
android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
47
android/app/src/main/AndroidManifest.xml
Normal file
47
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||
<application
|
||||
android:label="구독 관리"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.example.submanager
|
||||
|
||||
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() {
|
||||
private val CHANNEL = "com.submanager/sms"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
18
android/app/src/main/res/values-night/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal file
18
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
7
android/app/src/profile/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
Reference in New Issue
Block a user