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:
JiWoong Sul
2025-07-09 14:29:53 +09:00
commit 8619e96739
177 changed files with 23085 additions and 0 deletions

View 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 = "../.."
}

View 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>

View 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>

View File

@@ -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)
}
}

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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>

View 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>

View 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>