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

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# API Keys (Required to enable respective provider)
ANTHROPIC_API_KEY="your_anthropic_api_key_here" # Required: Format: sk-ant-api03-...
PERPLEXITY_API_KEY="your_perplexity_api_key_here" # Optional: Format: pplx-...
OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI/OpenRouter models. Format: sk-proj-...
GOOGLE_API_KEY="your_google_api_key_here" # Optional, for Google Gemini models.
MISTRAL_API_KEY="your_mistral_key_here" # Optional, for Mistral AI models.
XAI_API_KEY="YOUR_XAI_KEY_HERE" # Optional, for xAI AI models.
AZURE_OPENAI_API_KEY="your_azure_key_here" # Optional, for Azure OpenAI models (requires endpoint in .taskmasterconfig).
OLLAMA_API_KEY="your_ollama_api_key_here" # Optional: For remote Ollama servers that require authentication.

78
.gitignore vendored Normal file
View File

@@ -0,0 +1,78 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Added by Claude Task Master
# Logs
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dev-debug.log
# Dependency directories
node_modules/
# Environment variables
.env
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS specific
# Task files
tasks.json
tasks/
# Development environment files
.cluade/
.cursor/
.roo/
.roomodes
.taskmasterconfig
.windsurfrules
cursor_*

45
.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "c23637390482d4cf9598c3ce3f2be31aa7332daf"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: android
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: ios
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: linux
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: macos
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: web
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: windows
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# submanager
A new Flutter project.

1
analysis_options.yaml Normal file
View File

@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml

14
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

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>

21
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,21 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip

View File

@@ -0,0 +1,25 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
}
include(":app")

2023
doc/SubManager_PRD.md Normal file

File diff suppressed because it is too large Load Diff

411
doc/project_analysis.md Normal file
View File

@@ -0,0 +1,411 @@
# SubManager 프로젝트 분석
## 1. 개요
### 프로젝트 소개
SubManager는 포괄적인 구독 서비스 관리를 위해 설계된 정교한 Flutter 기반 모바일 애플리케이션입니다. 이 앱은 사용자가 반복 구독을 추적하고, 지출 패턴을 분석하며, 결제 알림을 받을 수 있도록 하면서, 서버리스 아키텍처를 통해 완전한 프라이버시를 유지합니다.
### 주요 발견사항
- **아키텍처**: Provider 상태 관리를 사용한 잘 구조화된 MVVM 패턴
- **기술 스택**: 로컬 저장소로 Hive를 사용하는 현대적인 Flutter 구현
- **핵심 혁신**: SMS 기반 자동 구독 감지
- **프라이버시 중심**: 서버 종속성 없이 모든 데이터를 로컬에 저장
- **플랫폼 지원**: iOS, Android 및 부분적 웹 지원
- **현지화**: 한국어 및 영어 지원
### 기술적 하이라이트
- **클린 아키텍처**: 데이터, 비즈니스 로직, 프레젠테이션 계층 간의 명확한 분리
- **고급 기능**: 생체 인증, 실시간 환율, 자동화된 알림
- **성능**: Hive를 사용한 효율적인 로컬 저장소, 최적화된 애니메이션
- **디자인 시스템**: 커스텀 테마와 그라데이션이 적용된 Material 3 구현
## 2. 아키텍처 개요
### 상위 수준 아키텍처 다이어그램
```
┌─────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Screens │ │ Widgets │ │ Theme │ │
│ └──────┬──────┘ └──────┬───────┘ └──────┬───────┘ │
└─────────┼─────────────────┼─────────────────┼──────────┘
│ │ │
┌─────────┼─────────────────┼─────────────────┼──────────┐
│ │ State Management Layer │ │
│ ┌──────▼──────────────────▼────────────────▼──────┐ │
│ │ Provider Pattern │ │
│ │ (Subscription, Category, Notification, etc.) │ │
│ └──────────────────────┬──────────────────────────┘ │
└─────────────────────────┼──────────────────────────────┘
┌─────────────────────────┼──────────────────────────────┐
│ Business Logic Layer │
│ ┌──────────────┐ ┌───▼────────┐ ┌──────────────┐ │
│ │ Services │ │ Utils │ │ Models │ │
│ │ (SMS, Notif) │ │ (Format) │ │(Subscription)│ │
│ └──────┬───────┘ └──────┬──────┘ └──────┬───────┘ │
└─────────┼──────────────────┼────────────────┼──────────┘
│ │ │
┌─────────┼──────────────────┼────────────────┼──────────┐
│ │ Data Layer │ │
│ ┌──────▼───────┐ ┌──────▼──────┐ ┌─────▼───────┐ │
│ │ Local Storage│ │External APIs│ │Platform Chls│ │
│ │ (Hive) │ │(Exchange RT)│ │(SMS, Notif) │ │
│ └──────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 계층 설명
#### 프레젠테이션 계층
- **화면**: 앱의 다양한 측면을 관리하는 9개의 개별 화면
- **위젯**: 일관된 스타일링을 가진 10개 이상의 재사용 가능한 컴포넌트
- **테마**: 색상, 타이포그래피, 간격이 포함된 중앙화된 디자인 시스템
#### 상태 관리 계층
- **Provider 패턴**: 다양한 도메인을 관리하는 5개의 전문화된 Provider
- **반응형 업데이트**: UI 동기화를 위한 ChangeNotifier 패턴
- **의존성 주입**: 클린 아키텍처를 위한 MultiProvider 설정
#### 비즈니스 로직 계층
- **서비스**: SMS 스캔, 알림 등을 위한 6개의 전문화된 서비스
- **유틸**: 포맷팅, 애니메이션, 계산을 위한 헬퍼 함수
- **모델**: Hive 영속성 지원이 포함된 데이터 구조
#### 데이터 계층
- **로컬 저장소**: 오프라인 우선 아키텍처를 위한 Hive NoSQL 데이터베이스
- **외부 API**: 환율 API 통합
- **플랫폼 채널**: 네이티브 SMS 및 알림 접근
### 데이터 흐름 패턴
1. **사용자 인터랙션 흐름**:
```
사용자 액션 → 화면 → Provider → 서비스 → 데이터 계층
↑ ↓
└──────── 상태 업데이트 ←───────┘
```
2. **SMS 감지 흐름**:
```
SMS 권한 → 플랫폼 채널 → SMS 스캐너 → 패턴 분석
UI 업데이트 ← Provider 업데이트 ← 구독 생성 ←─┘
```
3. **알림 흐름**:
```
구독 데이터 → 알림 서비스 → 플랫폼 채널
알림 스케줄링 → 시스템 전달
```
## 3. 기능 분석
### 핵심 기능 분해
#### 1. 구독 관리
- **구현**: Hive 영속성을 사용한 CRUD 작업
- **주요 컴포넌트**:
- 코드 생성이 포함된 `SubscriptionModel`
- 상태 관리를 위한 `SubscriptionProvider`
- 표시를 위한 `SubscriptionCard` 위젯
- **기능**:
- 폼 검증을 통한 수동 추가
- 편집/삭제 기능
- 카테고리 구성
- 다중 통화 지원 (KRW/USD)
#### 2. SMS 자동 감지
- **구현**: 패턴 인식과 함께 플랫폼 채널 통합
- **주요 컴포넌트**:
- 정규식 패턴을 사용하는 `SmsScanner` 서비스
- 서비스 식별을 위한 `SubscriptionUrlMatcher`
- MethodChannel을 통한 네이티브 Android 구현
- **알고리즘**:
- 서비스명별로 SMS 그룹화
- 반복 패턴 식별 (2회 이상 결제)
- 금액, 날짜, 서비스 정보 추출
- 웹사이트 URL 자동 제안
#### 3. 재무 분석
- **구현**: 차트 시각화와 함께 실시간 계산
- **주요 컴포넌트**:
- 데이터 시각화를 위한 `fl_chart`
- 다중 통화 계산을 위한 `CurrencyUtil`
- 실시간 환율을 위한 `ExchangeRateService`
- **기능**:
- 월별 지출 추세 (6개월 기록)
- 카테고리별 분포
- 총 비용 추적
- 통화 변환 표시
#### 4. 알림 시스템
- **구현**: 시간대 지원이 포함된 로컬 알림
- **주요 컴포넌트**:
- `flutter_local_notifications` 통합
- 스케줄링 로직이 포함된 `NotificationService`
- 플랫폼별 구성
- **기능**:
- 구성 가능한 알림 시간 (1-3일 전)
- 매일 알림 옵션
- 사용자 정의 알림 시간
- 자동 재스케줄링
#### 5. 보안 기능
- **구현**: 생체 인증 (현재 비활성화)
- **주요 컴포넌트**:
- `local_auth` 패키지 통합
- 민감한 데이터를 위한 `flutter_secure_storage`
- 상태 관리를 위한 `AppLockProvider`
- **상태**: 인프라는 구축되었지만 기능은 비활성화
### 통합 지점
1. **플랫폼 통합**:
- MethodChannel을 통한 Android SMS API
- iOS/Android 알림 시스템
- 생체 인증 API
- 파비콘 캐싱을 위한 파일 시스템
2. **외부 서비스**:
- USD/KRW 환율을 위한 ExchangeRate-API
- 파비콘 서비스 (여러 제공자)
- Google Mobile Ads (Android/iOS만)
3. **데이터 동기화**:
- Provider 패턴이 UI 일관성 보장
- 화면 간 반응형 업데이트
- Hive를 통한 영구 저장소
## 4. 기술 세부사항
### 의존성 분석
#### 핵심 의존성
- **상태 관리**: `provider: ^6.1.1`
- **로컬 저장소**: `hive: ^2.2.3`, `hive_flutter: ^1.1.0`
- **UI/UX**: `fl_chart: ^0.66.2`, `font_awesome_flutter: ^10.7.0`
- **플랫폼 기능**: `local_auth: ^2.1.6`, `telephony: ^0.2.0`
- **유틸리티**: `intl: ^0.19.0`, `uuid: ^4.2.1`, `timezone: ^0.9.2`
#### 개발 의존성
- **코드 생성**: `build_runner: ^2.4.6`, `hive_generator: ^2.0.1`
- **린팅**: `flutter_lints: ^2.0.0`
### 플랫폼 고려사항
#### Android
- 최소 SDK: Flutter 기본값에 의해 결정
- 자동 감지를 위한 SMS 권한 필요
- 네이티브 광고 통합
- SMS 접근을 위한 MethodChannel
#### iOS
- iOS 11.0+ (의존성 기반)
- 알림 권한 필요
- 생체 인증 지원
- 네이티브 광고 통합
#### Web (부분 지원)
- SMS 기능 비활성화
- 알림 기능 비활성화
- 대체 파비콘 로딩 전략
- 네이티브 광고 대신 플레이스홀더 광고
### 성능 고려사항
1. **메모리 관리**:
- 파비콘을 위한 효율적인 이미지 캐싱
- 구독 목록의 지연 로딩
- 애니메이션 컨트롤러의 적절한 해제
2. **저장소 최적화**:
- 빠른 로컬 쿼리를 위한 Hive 박스
- 선택적 데이터 로딩
- 생성된 어댑터를 사용한 효율적인 데이터 모델
3. **UI 성능**:
- 적절한 커브를 사용한 부드러운 애니메이션
- 스켈레톤 로딩 상태
- Provider를 통한 최적화된 재빌드
## 5. 코드 구성
### 디렉터리 구조 분석
```
lib/
├── l10n/ # 현지화 파일
├── main.dart # 애플리케이션 진입점
├── models/ # Hive 어댑터가 포함된 데이터 모델
├── navigator_key.dart # 전역 네비게이션
├── providers/ # 상태 관리 프로바이더
├── screens/ # UI 화면 (9개 화면)
├── services/ # 비즈니스 로직 서비스
├── temp/ # 개발용 테스트 데이터
├── theme/ # 디자인 시스템 구현
├── utils/ # 헬퍼 함수
└── widgets/ # 재사용 가능한 UI 컴포넌트
```
### 명명 규칙
- **파일**: snake_case (예: `subscription_card.dart`)
- **클래스**: PascalCase (예: `SubscriptionProvider`)
- **변수**: camelCase (예: `monthlyCost`)
- **상수**: UPPER_SNAKE_CASE 또는 camelCase
- **비공개 멤버**: 선행 언더스코어 (예: `_isLoading`)
### 사용된 디자인 패턴
1. **MVVM 아키텍처**:
- Models: 데이터 구조
- View-Models: Provider
- Views: 화면 및 위젯
2. **Provider 패턴**:
- 상태 관리
- 의존성 주입
- 반응형 프로그래밍
3. **싱글톤 패턴**:
- `ExchangeRateService`
- 전역 네비게이션 키
4. **팩토리 패턴**:
- 맵에서 모델 생성
- 위젯 빌더
5. **옵저버 패턴**:
- ChangeNotifier 구현
- 스트림 구독
## 6. 권장사항
### 잠재적 개선사항
1. **코드 품질**:
- 포괄적인 단위 테스트 추가
- 통합 테스트 구현
- 코드 문서화 추가
- 개발자 README 작성
2. **아키텍처**:
- 데이터 접근을 위한 Repository 패턴 고려
- 적절한 오류 처리 전략 구현
- 로깅/분석 프레임워크 추가
- 추상 서비스 인터페이스 생성
3. **기능**:
- 앱 잠금 기능 활성화 및 테스트
- 데이터 내보내기/가져오기 기능 추가
- 구독 공유 기능 구현
- 예산 알림 추가
4. **성능**:
- 큰 목록을 위한 지연 로딩 구현
- SMS 스캔을 위한 페이지네이션 추가
- 이미지 로딩 최적화
- 상태 영속성 고려
### 보안 고려사항
1. **데이터 보호**:
- 앱 잠금 기능 활성화
- 민감한 정보에 대한 데이터 암호화 구현
- 안전한 백업 기능 추가
- 생체 인증을 위한 PIN 폴백 추가 고려
2. **프라이버시**:
- SMS 권한 사용 검토
- 개인정보 처리방침 화면 추가
- 데이터 삭제 옵션 구현
- GDPR 준수 기능 추가
3. **API 보안**:
- API 키 로테이션 구현
- 요청 서명 추가
- 인증서 고정 사용
- 속도 제한 구현
### 확장성 제안
1. **데이터 관리**:
- 오래된 구독에 대한 데이터 아카이빙 구현
- 검색 기능 추가
- 데이터 마이그레이션 전략 수립
- 클라우드 동기화 옵션 고려
2. **기능 확장**:
- 가족 공유 추가
- 구독 추천 기능 구현
- 지출 목표 생성
- 영수증 스캔 추가
3. **플랫폼 확장**:
- 웹 지원 완성
- 데스크톱 애플리케이션 고려
- 태블릿 최적화 레이아웃 추가
- 워치 앱 컴패니언 구현
## 7. 부록
### 파일 목록 요약
- **총 Dart 파일**: 50개 이상
- **화면**: 9개의 주요 화면
- **위젯**: 10개 이상의 재사용 가능한 컴포넌트
- **서비스**: 6개의 비즈니스 로직 서비스
- **프로바이더**: 5개의 상태 관리 프로바이더
- **모델**: Hive 지원이 포함된 3개의 데이터 모델
### 주요 코드 패턴
#### Provider 설정 예시
```dart
final provider = Provider.of<SubscriptionProvider>(context);
await provider.loadSubscriptions();
```
#### 서비스 패턴 예시
```dart
class ExchangeRateService {
static final instance = ExchangeRateService._();
Future<double> getExchangeRate() async {...}
}
```
#### 모델 패턴 예시
```dart
@HiveType(typeId: 0)
class SubscriptionModel extends HiveObject {
@HiveField(0)
final String id;
// ... 다른 필드
}
```
### API/서비스 매핑
1. **외부 API**:
- 환율: `https://api.exchangerate-api.com/v4/latest/USD`
- 파비콘 서비스: 중복성을 위한 여러 제공자
2. **플랫폼 채널**:
- SMS 채널: `com.submanager/sms`
- 메서드: `getSmsMessages`, `checkSmsPermission`, `requestSmsPermission`
3. **로컬 저장소 키**:
- 구독: `subscriptions` 박스
- 카테고리: `categories` 박스
- 앱 잠금: `app_lock` 박스
- 설정: 보안 저장소의 다양한 키
### 개발 노트
1. **테스트 데이터**: `/lib/temp/test_sms_data.dart`에서 사용 가능
2. **디버그 모드**: 실제 메시지 대신 테스트 SMS 데이터 사용
3. **플랫폼 감지**: 웹용 자동 기능 비활성화
4. **현지화**: 한국어와 영어를 지원하며 쉽게 확장 가능
---
이 분석은 SubManager 프로젝트의 포괄적인 개요를 제공하며, 잘 설계된 아키텍처, 강력한 기능 세트 및 미래 개선 가능성을 강조합니다. 코드베이스는 사용자 경험과 유지보수성에 초점을 맞춘 전문적인 Flutter 개발 관행을 보여줍니다.

34
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
ios/Podfile Normal file
View File

@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View File

@@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

51
ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Submanager</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>submanager</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSMessageUsageDescription</key>
<string>구독 결제 정보를 자동으로 추가하기 위해 SMS 접근이 필요합니다.</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
class AppLocalizations {
final Locale locale;
AppLocalizations(this.locale);
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const _localizedValues = <String, Map<String, String>>{
'en': {
'appTitle': 'SubManager',
'subscriptionManagement': 'Subscription Management',
'addSubscription': 'Add Subscription',
'subscriptionName': 'Service Name',
'monthlyCost': 'Monthly Cost',
'billingCycle': 'Billing Cycle',
'nextBillingDate': 'Next Billing Date',
'save': 'Save',
'cancel': 'Cancel',
'delete': 'Delete',
'edit': 'Edit',
'totalSubscriptions': 'Total Subscriptions',
'totalMonthlyExpense': 'Total Monthly Expense',
'noSubscriptions': 'No subscriptions registered',
'addSubscriptionNow': 'Add Subscription Now',
'paymentReminder': 'Payment Reminder',
'expirationReminder': 'Expiration Reminder',
'daysLeft': 'days left',
'categoryManagement': 'Category Management',
'categoryName': 'Category Name',
'selectColor': 'Select Color',
'selectIcon': 'Select Icon',
'addCategory': 'Add Category',
'settings': 'Settings',
'darkMode': 'Dark Mode',
'language': 'Language',
'notifications': 'Notifications',
'appLock': 'App Lock',
},
'ko': {
'appTitle': '구독 관리',
'subscriptionManagement': '구독 관리',
'addSubscription': '구독 추가',
'subscriptionName': '서비스명',
'monthlyCost': '월 비용',
'billingCycle': '결제 주기',
'nextBillingDate': '다음 결제일',
'save': '저장',
'cancel': '취소',
'delete': '삭제',
'edit': '수정',
'totalSubscriptions': '총 구독',
'totalMonthlyExpense': '이번 달 총 지출',
'noSubscriptions': '등록된 구독 서비스가 없습니다',
'addSubscriptionNow': '구독 추가하기',
'paymentReminder': '결제 예정 알림',
'expirationReminder': '만료 예정 알림',
'daysLeft': '일 남음',
'categoryManagement': '카테고리 관리',
'categoryName': '카테고리 이름',
'selectColor': '색상 선택',
'selectIcon': '아이콘 선택',
'addCategory': '카테고리 추가',
'settings': '설정',
'darkMode': '다크 모드',
'language': '언어',
'notifications': '알림',
'appLock': '앱 잠금',
},
};
String get appTitle => _localizedValues[locale.languageCode]!['appTitle']!;
String get subscriptionManagement =>
_localizedValues[locale.languageCode]!['subscriptionManagement']!;
String get addSubscription =>
_localizedValues[locale.languageCode]!['addSubscription']!;
String get subscriptionName =>
_localizedValues[locale.languageCode]!['subscriptionName']!;
String get monthlyCost =>
_localizedValues[locale.languageCode]!['monthlyCost']!;
String get billingCycle =>
_localizedValues[locale.languageCode]!['billingCycle']!;
String get nextBillingDate =>
_localizedValues[locale.languageCode]!['nextBillingDate']!;
String get save => _localizedValues[locale.languageCode]!['save']!;
String get cancel => _localizedValues[locale.languageCode]!['cancel']!;
String get delete => _localizedValues[locale.languageCode]!['delete']!;
String get edit => _localizedValues[locale.languageCode]!['edit']!;
String get totalSubscriptions =>
_localizedValues[locale.languageCode]!['totalSubscriptions']!;
String get totalMonthlyExpense =>
_localizedValues[locale.languageCode]!['totalMonthlyExpense']!;
String get noSubscriptions =>
_localizedValues[locale.languageCode]!['noSubscriptions']!;
String get addSubscriptionNow =>
_localizedValues[locale.languageCode]!['addSubscriptionNow']!;
String get paymentReminder =>
_localizedValues[locale.languageCode]!['paymentReminder']!;
String get expirationReminder =>
_localizedValues[locale.languageCode]!['expirationReminder']!;
String get daysLeft => _localizedValues[locale.languageCode]!['daysLeft']!;
String get categoryManagement =>
_localizedValues[locale.languageCode]!['categoryManagement']!;
String get categoryName =>
_localizedValues[locale.languageCode]!['categoryName']!;
String get selectColor =>
_localizedValues[locale.languageCode]!['selectColor']!;
String get selectIcon =>
_localizedValues[locale.languageCode]!['selectIcon']!;
String get addCategory =>
_localizedValues[locale.languageCode]!['addCategory']!;
String get settings => _localizedValues[locale.languageCode]!['settings']!;
String get darkMode => _localizedValues[locale.languageCode]!['darkMode']!;
String get language => _localizedValues[locale.languageCode]!['language']!;
String get notifications =>
_localizedValues[locale.languageCode]!['notifications']!;
String get appLock => _localizedValues[locale.languageCode]!['appLock']!;
}
class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const AppLocalizationsDelegate();
@override
bool isSupported(Locale locale) => ['en', 'ko'].contains(locale.languageCode);
@override
Future<AppLocalizations> load(Locale locale) async {
return AppLocalizations(locale);
}
@override
bool shouldReload(AppLocalizationsDelegate old) => false;
}

126
lib/main.dart Normal file
View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_localizations/flutter_localizations.dart';
import 'models/subscription_model.dart';
import 'models/category_model.dart';
import 'providers/subscription_provider.dart';
import 'providers/app_lock_provider.dart';
import 'providers/notification_provider.dart';
import 'screens/main_screen.dart';
import 'screens/app_lock_screen.dart';
import 'services/notification_service.dart';
import 'providers/category_provider.dart';
import 'providers/locale_provider.dart';
import 'l10n/app_localizations.dart';
import 'theme/app_theme.dart';
import 'screens/splash_screen.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform;
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만)
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
await MobileAds.instance.initialize();
}
// 앱 시작 시 이미지 캐시 관리
try {
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
final cache = PaintingBinding.instance.imageCache;
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
await DefaultCacheManager().emptyCache();
print('이미지 캐시 관리 초기화 완료');
} catch (e) {
print('캐시 초기화 오류: $e');
}
// Hive 초기화
await Hive.initFlutter();
Hive.registerAdapter(SubscriptionModelAdapter());
Hive.registerAdapter(CategoryModelAdapter());
await Hive.openBox<SubscriptionModel>('subscriptions');
await Hive.openBox<CategoryModel>('categories');
final appLockBox = await Hive.openBox<bool>('app_lock');
// 알림 서비스를 가장 먼저 초기화
await NotificationService.init();
final subscriptionProvider = SubscriptionProvider();
final categoryProvider = CategoryProvider();
final localeProvider = LocaleProvider();
final notificationProvider = NotificationProvider();
await subscriptionProvider.init();
await categoryProvider.init();
await localeProvider.init();
await notificationProvider.init();
// NotificationProvider에 SubscriptionProvider 연결 (알림 재예약용)
// SRP 원칙에 따라 다른 Provider 객체를 명시적으로 주입
notificationProvider.setSubscriptionProvider(subscriptionProvider);
// 별도의 비동기 함수로 알림 관련 초기화 오류 처리
Future.delayed(Duration.zero, () {
try {
if (notificationProvider.isPaymentEnabled) {
// 백그라운드에서 비동기적으로 알림 설정 업데이트
NotificationService.reschedulAllNotifications(
subscriptionProvider.subscriptions);
}
} catch (e) {
debugPrint('알림 초기 설정 중 오류 발생: $e');
}
});
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => subscriptionProvider),
ChangeNotifierProvider(create: (_) => categoryProvider),
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
ChangeNotifierProvider(create: (_) => notificationProvider),
ChangeNotifierProvider(create: (_) => localeProvider),
],
child: const SubManagerApp(),
),
);
}
class SubManagerApp extends StatelessWidget {
const SubManagerApp({super.key});
@override
Widget build(BuildContext context) {
return Consumer<LocaleProvider>(
builder: (context, localeProvider, child) {
return MaterialApp(
title: 'SubManager',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
locale: localeProvider.locale,
localizationsDelegates: const [
AppLocalizationsDelegate(),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('ko'),
],
navigatorKey: navigatorKey,
home: const SplashScreen(),
);
},
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:hive/hive.dart';
part 'category_model.g.dart';
@HiveType(typeId: 1)
class CategoryModel extends HiveObject {
@HiveField(0)
String id;
@HiveField(1)
String name;
@HiveField(2)
String color;
@HiveField(3)
String icon;
CategoryModel({
required this.id,
required this.name,
required this.color,
required this.icon,
});
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'category_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CategoryModelAdapter extends TypeAdapter<CategoryModel> {
@override
final int typeId = 1;
@override
CategoryModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CategoryModel(
id: fields[0] as String,
name: fields[1] as String,
color: fields[2] as String,
icon: fields[3] as String,
);
}
@override
void write(BinaryWriter writer, CategoryModel obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.name)
..writeByte(2)
..write(obj.color)
..writeByte(3)
..write(obj.icon);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CategoryModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,64 @@
class Subscription {
final String id;
final String serviceName;
final double monthlyCost;
final String billingCycle;
final DateTime nextBillingDate;
final String? category;
final String? notes;
final int repeatCount;
final DateTime? lastPaymentDate;
final String? websiteUrl;
final String currency;
Subscription({
required this.id,
required this.serviceName,
required this.monthlyCost,
required this.billingCycle,
required this.nextBillingDate,
this.category,
this.notes,
this.repeatCount = 1,
this.lastPaymentDate,
this.websiteUrl,
this.currency = 'KRW',
});
Map<String, dynamic> toMap() {
return {
'id': id,
'serviceName': serviceName,
'monthlyCost': monthlyCost,
'billingCycle': billingCycle,
'nextBillingDate': nextBillingDate.toIso8601String(),
'category': category,
'notes': notes,
'repeatCount': repeatCount,
'lastPaymentDate': lastPaymentDate?.toIso8601String(),
'websiteUrl': websiteUrl,
'currency': currency,
};
}
factory Subscription.fromMap(Map<String, dynamic> map) {
return Subscription(
id: map['id'] as String,
serviceName: map['serviceName'] as String,
monthlyCost: map['monthlyCost'] as double,
billingCycle: map['billingCycle'] as String,
nextBillingDate: DateTime.parse(map['nextBillingDate'] as String),
category: map['category'] as String?,
notes: map['notes'] as String?,
repeatCount: (map['repeatCount'] as num?)?.toInt() ?? 1,
lastPaymentDate: map['lastPaymentDate'] != null
? DateTime.parse(map['lastPaymentDate'] as String)
: null,
websiteUrl: map['websiteUrl'] as String?,
currency: map['currency'] as String? ?? 'KRW',
);
}
// 주기적 결제 여부 확인
bool get isRecurring => repeatCount > 1;
}

View File

@@ -0,0 +1,101 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'subscription_model.g.dart';
@HiveType(typeId: 0)
class SubscriptionModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
String serviceName;
@HiveField(2)
double monthlyCost;
@HiveField(3)
String billingCycle; // '월간', '연간', '주간' 등
@HiveField(4)
DateTime nextBillingDate;
@HiveField(5)
bool isAutoDetected; // SMS로 추가된 경우 true
@HiveField(6)
String? categoryId;
@HiveField(7)
String? websiteUrl; // 홈페이지 URL
@HiveField(8)
int repeatCount; // 반복 결제 횟수
@HiveField(9)
DateTime? lastPaymentDate; // 마지막 결제일
@HiveField(10)
String currency; // 통화 단위: 'KRW' 또는 'USD'
@HiveField(11)
bool isEventActive; // 이벤트 활성화 여부
@HiveField(12)
DateTime? eventStartDate; // 이벤트 시작일
@HiveField(13)
DateTime? eventEndDate; // 이벤트 종료일
@HiveField(14)
double? eventPrice; // 이벤트 기간 중 가격
SubscriptionModel({
required this.id,
required this.serviceName,
required this.monthlyCost,
required this.billingCycle,
required this.nextBillingDate,
this.isAutoDetected = false,
this.categoryId,
this.websiteUrl,
this.repeatCount = 1,
this.lastPaymentDate,
this.currency = 'KRW', // 기본값은 KRW
this.isEventActive = false, // 기본값은 false
this.eventStartDate,
this.eventEndDate,
this.eventPrice,
});
// 주기적 결제 여부 확인
bool get isRecurring => repeatCount > 1;
// 현재 이벤트 기간인지 확인
bool get isCurrentlyInEvent {
if (!isEventActive || eventStartDate == null || eventEndDate == null) {
return false;
}
final now = DateTime.now();
return now.isAfter(eventStartDate!) && now.isBefore(eventEndDate!);
}
// 현재 적용되는 가격 (이벤트 또는 정상 가격)
double get currentPrice {
if (isCurrentlyInEvent && eventPrice != null) {
return eventPrice!;
}
return monthlyCost;
}
// 이벤트로 인한 절약액
double get eventSavings {
if (isCurrentlyInEvent && eventPrice != null) {
return monthlyCost - eventPrice!;
}
return 0;
}
}
// Hive TypeAdapter 생성을 위한 명령어
// flutter pub run build_runner build

View File

@@ -0,0 +1,83 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'subscription_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
@override
final int typeId = 0;
@override
SubscriptionModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return SubscriptionModel(
id: fields[0] as String,
serviceName: fields[1] as String,
monthlyCost: fields[2] as double,
billingCycle: fields[3] as String,
nextBillingDate: fields[4] as DateTime,
isAutoDetected: fields[5] as bool,
categoryId: fields[6] as String?,
websiteUrl: fields[7] as String?,
repeatCount: fields[8] as int,
lastPaymentDate: fields[9] as DateTime?,
currency: fields[10] as String,
isEventActive: fields[11] as bool,
eventStartDate: fields[12] as DateTime?,
eventEndDate: fields[13] as DateTime?,
eventPrice: fields[14] as double?,
);
}
@override
void write(BinaryWriter writer, SubscriptionModel obj) {
writer
..writeByte(15)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.serviceName)
..writeByte(2)
..write(obj.monthlyCost)
..writeByte(3)
..write(obj.billingCycle)
..writeByte(4)
..write(obj.nextBillingDate)
..writeByte(5)
..write(obj.isAutoDetected)
..writeByte(6)
..write(obj.categoryId)
..writeByte(7)
..write(obj.websiteUrl)
..writeByte(8)
..write(obj.repeatCount)
..writeByte(9)
..write(obj.lastPaymentDate)
..writeByte(10)
..write(obj.currency)
..writeByte(11)
..write(obj.isEventActive)
..writeByte(12)
..write(obj.eventStartDate)
..writeByte(13)
..write(obj.eventEndDate)
..writeByte(14)
..write(obj.eventPrice);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SubscriptionModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

3
lib/navigator_key.dart Normal file
View File

@@ -0,0 +1,3 @@
import 'package:flutter/material.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:local_auth/local_auth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../services/notification_service.dart';
import '../providers/subscription_provider.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
class AppLockProvider extends ChangeNotifier {
final Box<bool> _appLockBox;
final LocalAuthentication _localAuth = LocalAuthentication();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
static const String _isEnabledKey = 'app_lock_enabled';
bool _isEnabled = false;
bool _isLocked = false;
bool _isBiometricEnabled = false;
bool _isBiometricAvailable = false;
AppLockProvider(this._appLockBox) {
_init();
}
bool get isEnabled => _isEnabled;
bool get isLocked => _isLocked;
bool get isBiometricEnabled => _isBiometricEnabled;
bool get isBiometricAvailable => _isBiometricAvailable;
Future<void> _init() async {
if (!kIsWeb) {
_isBiometricAvailable = await _localAuth.canCheckBiometrics;
} else {
_isBiometricAvailable = false;
}
_isLocked = _appLockBox.get('isLocked', defaultValue: false) ?? false;
await _loadState();
notifyListeners();
}
Future<void> _loadState() async {
final value = await _secureStorage.read(key: _isEnabledKey);
_isEnabled = value == 'true';
_isLocked = _appLockBox.get('isLocked', defaultValue: false) ?? false;
await _loadSettings();
notifyListeners();
}
Future<void> _loadSettings() async {
try {
final biometricEnabled =
await _secureStorage.read(key: 'biometric_enabled');
_isBiometricEnabled = biometricEnabled == 'true';
notifyListeners();
} catch (e) {
debugPrint('설정 로드 중 오류 발생: $e');
}
}
Future<bool> authenticate() async {
if (kIsWeb) {
_isLocked = false;
notifyListeners();
return true;
}
try {
final canCheck = await _checkBiometrics();
if (!canCheck) {
_isLocked = false;
notifyListeners();
return true;
}
final authenticated = await _localAuth.authenticate(
localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: true,
),
);
if (authenticated) {
_isLocked = false;
await _appLockBox.put('isLocked', false);
notifyListeners();
}
return authenticated;
} catch (e) {
_isLocked = false;
notifyListeners();
return true;
}
}
Future<bool> _checkBiometrics() async {
if (kIsWeb) return false;
try {
return await _localAuth.canCheckBiometrics;
} catch (e) {
return false;
}
}
Future<void> toggleBiometricAuth() async {
if (kIsWeb) {
_isBiometricEnabled = false;
await _appLockBox.put('biometric_enabled', false);
notifyListeners();
return;
}
try {
final canCheck = await _checkBiometrics();
if (!canCheck) {
_isBiometricEnabled = false;
await _appLockBox.put('biometric_enabled', false);
notifyListeners();
return;
}
_isBiometricEnabled = !_isBiometricEnabled;
await _secureStorage.write(
key: 'biometric_enabled',
value: _isBiometricEnabled.toString(),
);
await _appLockBox.put('biometric_enabled', _isBiometricEnabled);
notifyListeners();
} catch (e) {
_isBiometricEnabled = false;
await _appLockBox.put('biometric_enabled', false);
notifyListeners();
}
}
void lock() {
if (_isBiometricEnabled) {
_isLocked = true;
notifyListeners();
}
}
Future<void> unlock() async {
_isLocked = false;
await _appLockBox.put('isLocked', false);
notifyListeners();
}
Future<void> enable() async {
_isEnabled = true;
await _secureStorage.write(key: _isEnabledKey, value: 'true');
notifyListeners();
}
Future<void> disable() async {
_isEnabled = false;
await _secureStorage.write(key: _isEnabledKey, value: 'false');
notifyListeners();
}
Future<void> refreshNotifications(BuildContext context) async {
final subscriptionProvider = Provider.of<SubscriptionProvider>(
context,
listen: false,
);
for (var subscription in subscriptionProvider.subscriptions) {
await NotificationService.scheduleSubscriptionNotification(subscription);
}
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../models/category_model.dart';
import 'package:uuid/uuid.dart';
class CategoryProvider extends ChangeNotifier {
List<CategoryModel> _categories = [];
late Box<CategoryModel> _categoryBox;
List<CategoryModel> get categories => _categories;
Future<void> init() async {
_categoryBox = await Hive.openBox<CategoryModel>('categories');
_categories = _categoryBox.values.toList();
// 카테고리가 비어있으면 기본 카테고리 추가
if (_categories.isEmpty) {
await _initDefaultCategories();
}
notifyListeners();
}
// 기본 카테고리 초기화
Future<void> _initDefaultCategories() async {
final defaultCategories = [
{'name': 'OTT 서비스', 'color': '#3B82F6', 'icon': 'live_tv'},
{'name': '음악 서비스', 'color': '#EC4899', 'icon': 'music_note'},
{'name': 'AI 서비스', 'color': '#8B5CF6', 'icon': 'psychology'},
{'name': '프로그래밍/개발', 'color': '#10B981', 'icon': 'code'},
{'name': '오피스/협업 툴', 'color': '#F59E0B', 'icon': 'business_center'},
{'name': '기타 서비스', 'color': '#6B7280', 'icon': 'more_horiz'},
];
for (final category in defaultCategories) {
final newCategory = CategoryModel(
id: const Uuid().v4(),
name: category['name']!,
color: category['color']!,
icon: category['icon']!,
);
await _categoryBox.put(newCategory.id, newCategory);
_categories.add(newCategory);
}
}
Future<void> addCategory({
required String name,
required String color,
required String icon,
}) async {
final newCategory = CategoryModel(
id: const Uuid().v4(),
name: name,
color: color,
icon: icon,
);
await _categoryBox.put(newCategory.id, newCategory);
_categories.add(newCategory);
notifyListeners();
}
Future<void> updateCategory(CategoryModel updated) async {
await _categoryBox.put(updated.id, updated);
int index = _categories.indexWhere((cat) => cat.id == updated.id);
if (index != -1) {
_categories[index] = updated;
notifyListeners();
}
}
Future<void> deleteCategory(String id) async {
await _categoryBox.delete(id);
_categories.removeWhere((cat) => cat.id == id);
notifyListeners();
}
CategoryModel? getCategoryById(String id) {
try {
return _categories.firstWhere((cat) => cat.id == id);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
class LocaleProvider extends ChangeNotifier {
late Box<String> _localeBox;
Locale _locale = const Locale('ko');
Locale get locale => _locale;
Future<void> init() async {
_localeBox = await Hive.openBox<String>('locale');
final savedLocale = _localeBox.get('locale', defaultValue: 'ko');
_locale = Locale(savedLocale ?? 'ko');
notifyListeners();
}
Future<void> setLocale(String languageCode) async {
_locale = Locale(languageCode);
await _localeBox.put('locale', languageCode);
notifyListeners();
}
}

View File

@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter/foundation.dart';
import '../services/notification_service.dart';
import '../providers/subscription_provider.dart';
class NotificationProvider extends ChangeNotifier {
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
static const String _paymentNotificationKey = 'payment_notification_enabled';
static const String _unusedServiceNotificationKey =
'unused_service_notification_enabled';
static const String _reminderDaysKey = 'reminder_days';
static const String _reminderHourKey = 'reminder_hour';
static const String _reminderMinuteKey = 'reminder_minute';
static const String _dailyReminderKey = 'daily_reminder_enabled';
bool _isEnabled = false;
bool _isPaymentEnabled = false;
bool _isUnusedServiceNotificationEnabled = false;
int _reminderDays = 3; // 기본값: 3일 전
int _reminderHour = 10; // 기본값: 오전 10시
int _reminderMinute = 0; // 기본값: 0분
bool _isDailyReminderEnabled = false; // 기본값: 반복 알림 비활성화
// 웹 플랫폼 여부 확인 (웹에서는 알림이 지원되지 않음)
bool get _isWeb => kIsWeb;
// SubscriptionProvider 인스턴스 (알림 재예약 시 사용)
SubscriptionProvider? _subscriptionProvider;
bool get isEnabled => _isEnabled;
bool get isPaymentEnabled => _isPaymentEnabled;
bool get isUnusedServiceNotificationEnabled =>
_isUnusedServiceNotificationEnabled;
int get reminderDays => _reminderDays;
int get reminderHour => _reminderHour;
int get reminderMinute => _reminderMinute;
bool get isDailyReminderEnabled => _isDailyReminderEnabled;
NotificationProvider() {
_loadSettings();
}
// SubscriptionProvider 설정 (알림 재예약 시 사용)
void setSubscriptionProvider(SubscriptionProvider provider) {
_subscriptionProvider = provider;
}
Future<void> _loadSettings() async {
final paymentValue =
await _secureStorage.read(key: _paymentNotificationKey);
final unusedValue =
await _secureStorage.read(key: _unusedServiceNotificationKey);
final reminderDaysValue = await _secureStorage.read(key: _reminderDaysKey);
final reminderHourValue = await _secureStorage.read(key: _reminderHourKey);
final reminderMinuteValue =
await _secureStorage.read(key: _reminderMinuteKey);
final dailyReminderValue =
await _secureStorage.read(key: _dailyReminderKey);
_isPaymentEnabled = paymentValue == 'true';
_isUnusedServiceNotificationEnabled = unusedValue == 'true';
_reminderDays =
reminderDaysValue != null ? int.tryParse(reminderDaysValue) ?? 3 : 3;
_reminderHour =
reminderHourValue != null ? int.tryParse(reminderHourValue) ?? 10 : 10;
_reminderMinute = reminderMinuteValue != null
? int.tryParse(reminderMinuteValue) ?? 0
: 0;
_isDailyReminderEnabled = dailyReminderValue == 'true';
notifyListeners();
}
Future<void> init() async {
try {
_isEnabled = await NotificationService.isNotificationEnabled();
_isPaymentEnabled =
await NotificationService.isPaymentNotificationEnabled();
notifyListeners();
} catch (e) {
debugPrint('알림 설정 초기화 중 오류 발생: $e');
}
}
Future<void> setEnabled(bool value) async {
try {
_isEnabled = value;
await NotificationService.setNotificationEnabled(value);
notifyListeners();
} catch (e) {
debugPrint('알림 활성화 설정 중 오류 발생: $e');
}
}
Future<void> setPaymentEnabled(bool value) async {
try {
// 설정값이 변경된 경우에만 처리
if (_isPaymentEnabled != value) {
_isPaymentEnabled = value;
await NotificationService.setPaymentNotificationEnabled(value);
// 웹에서는 알림 기능 비활성화
if (_isWeb) {
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
notifyListeners();
return;
}
// 알림이 활성화된 경우에만 알림 재예약 (비활성화 시에는 필요 없음)
if (value) {
// 알림 설정 변경 시 모든 구독의 알림 재예약
// 지연 실행으로 UI 응답성 향상
Future.microtask(() => _rescheduleNotificationsIfNeeded());
} else {
// 알림이 비활성화되면 모든 알림 취소
try {
await NotificationService.cancelAllNotifications();
} catch (e) {
debugPrint('알림 취소 중 오류 발생: $e');
}
}
notifyListeners();
}
} catch (e) {
debugPrint('결제 알림 설정 중 오류 발생: $e');
}
}
Future<void> setUnusedServiceNotificationEnabled(bool value) async {
try {
_isUnusedServiceNotificationEnabled = value;
await _secureStorage.write(
key: _unusedServiceNotificationKey,
value: value.toString(),
);
notifyListeners();
} catch (e) {
debugPrint('미사용 서비스 알림 설정 중 오류 발생: $e');
}
}
// 알림 시점 설정 (1일전, 2일전, 3일전)
Future<void> setReminderDays(int days) async {
try {
// 값이 변경된 경우에만 처리
if (_reminderDays != days) {
_reminderDays = days;
await _secureStorage.write(
key: _reminderDaysKey,
value: days.toString(),
);
// 웹에서는 알림 기능 비활성화
if (_isWeb) {
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
notifyListeners();
return;
}
// 1일 전으로 설정되면 반복 알림 자동 비활성화
if (days == 1 && _isDailyReminderEnabled) {
await setDailyReminderEnabled(false);
} else if (_isPaymentEnabled) {
// 알림이 활성화된 경우에만 처리
// 알림 설정 변경 시 모든 구독의 알림 재예약 (백그라운드에서 처리)
Future.microtask(() => _rescheduleNotificationsIfNeeded());
}
notifyListeners();
}
} catch (e) {
debugPrint('알림 시점 설정 중 오류 발생: $e');
}
}
// 알림 시간 설정
Future<void> setReminderTime(int hour, int minute) async {
try {
// 값이 변경된 경우에만 처리
if (_reminderHour != hour || _reminderMinute != minute) {
_reminderHour = hour;
_reminderMinute = minute;
await _secureStorage.write(
key: _reminderHourKey,
value: hour.toString(),
);
await _secureStorage.write(
key: _reminderMinuteKey,
value: minute.toString(),
);
// 웹에서는 알림 기능 비활성화
if (_isWeb) {
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
notifyListeners();
return;
}
// 알림이 활성화된 경우에만 처리
if (_isPaymentEnabled) {
// 알림 설정 변경 시 모든 구독의 알림 재예약 (백그라운드에서 처리)
Future.microtask(() => _rescheduleNotificationsIfNeeded());
}
notifyListeners();
}
} catch (e) {
debugPrint('알림 시간 설정 중 오류 발생: $e');
}
}
// 반복 알림 설정
Future<void> setDailyReminderEnabled(bool value) async {
try {
// 값이 변경된 경우에만 처리
if (_isDailyReminderEnabled != value) {
_isDailyReminderEnabled = value;
await _secureStorage.write(
key: _dailyReminderKey,
value: value.toString(),
);
// 웹에서는 알림 기능 비활성화
if (_isWeb) {
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
notifyListeners();
return;
}
// 알림이 활성화된 경우에만 처리
if (_isPaymentEnabled) {
// 알림 설정 변경 시 모든 구독의 알림 재예약 (백그라운드에서 처리)
Future.microtask(() => _rescheduleNotificationsIfNeeded());
}
notifyListeners();
}
} catch (e) {
debugPrint('반복 알림 설정 중 오류 발생: $e');
}
}
// 알림 설정 변경 시 모든 구독의 알림 일정 재예약
Future<void> _rescheduleNotificationsIfNeeded() async {
try {
// 웹 플랫폼에서는 지원하지 않음
if (_isWeb) {
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
return;
}
// 구독 목록을 가져올 수 있고, 알림이 활성화된 경우에만 재예약
if (_subscriptionProvider != null && _isPaymentEnabled) {
final subscriptions = _subscriptionProvider!.subscriptions;
await NotificationService.reschedulAllNotifications(subscriptions);
}
} catch (e) {
debugPrint('알림 재예약 중 오류 발생: $e');
// 오류가 발생해도 앱 동작에 영향을 주지 않도록 처리
}
}
}

View File

@@ -0,0 +1,246 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:uuid/uuid.dart';
import '../models/subscription_model.dart';
import '../services/notification_service.dart';
import 'package:provider/provider.dart';
import 'notification_provider.dart';
import '../navigator_key.dart';
class SubscriptionProvider extends ChangeNotifier {
late Box<SubscriptionModel> _subscriptionBox;
List<SubscriptionModel> _subscriptions = [];
bool _isLoading = true;
List<SubscriptionModel> get subscriptions => _subscriptions;
bool get isLoading => _isLoading;
double get totalMonthlyExpense {
return _subscriptions.fold(
0.0,
(sum, subscription) => sum + subscription.currentPrice, // 이벤트 가격 반영
);
}
/// 월간 총 비용을 반환합니다.
double getTotalMonthlyCost() {
return totalMonthlyExpense;
}
/// 이벤트로 인한 총 절약액을 반환합니다.
double get totalEventSavings {
return _subscriptions.fold(
0.0,
(sum, subscription) => sum + subscription.eventSavings,
);
}
/// 현재 이벤트 중인 구독 목록을 반환합니다.
List<SubscriptionModel> get activeEventSubscriptions {
return _subscriptions.where((sub) => sub.isCurrentlyInEvent).toList();
}
Future<void> init() async {
try {
_isLoading = true;
notifyListeners();
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
await refreshSubscriptions();
// 앱 시작 시 이벤트 상태 확인
await checkAndUpdateEventStatus();
_isLoading = false;
notifyListeners();
} catch (e) {
debugPrint('구독 초기화 중 오류 발생: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
Future<void> refreshSubscriptions() async {
try {
_subscriptions = _subscriptionBox.values.toList()
..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
notifyListeners();
} catch (e) {
debugPrint('구독 목록 새로고침 중 오류 발생: $e');
rethrow;
}
}
Future<void> addSubscription({
required String serviceName,
required double monthlyCost,
required String billingCycle,
required DateTime nextBillingDate,
String? websiteUrl,
String? categoryId,
bool isAutoDetected = false,
int repeatCount = 1,
DateTime? lastPaymentDate,
String currency = 'KRW',
bool isEventActive = false,
DateTime? eventStartDate,
DateTime? eventEndDate,
double? eventPrice,
}) async {
try {
final subscription = SubscriptionModel(
id: const Uuid().v4(),
serviceName: serviceName,
monthlyCost: monthlyCost,
billingCycle: billingCycle,
nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl,
categoryId: categoryId,
isAutoDetected: isAutoDetected,
repeatCount: repeatCount,
lastPaymentDate: lastPaymentDate,
currency: currency,
isEventActive: isEventActive,
eventStartDate: eventStartDate,
eventEndDate: eventEndDate,
eventPrice: eventPrice,
);
await _subscriptionBox.put(subscription.id, subscription);
await refreshSubscriptions();
// 이벤트가 활성화된 경우 알림 스케줄 재설정
if (isEventActive && eventEndDate != null) {
await _scheduleEventEndNotification(subscription);
}
} catch (e) {
debugPrint('구독 추가 중 오류 발생: $e');
rethrow;
}
}
Future<void> updateSubscription(SubscriptionModel subscription) async {
try {
notifyListeners();
await _subscriptionBox.put(subscription.id, subscription);
// 이벤트 관련 알림 업데이트
if (subscription.isEventActive && subscription.eventEndDate != null) {
await _scheduleEventEndNotification(subscription);
} else {
// 이벤트가 비활성화된 경우 이벤트 종료 알림 취소
await NotificationService.cancelNotification(
'${subscription.id}_event_end'.hashCode,
);
}
await refreshSubscriptions();
notifyListeners();
} catch (e) {
debugPrint('구독 업데이트 중 오류 발생: $e');
rethrow;
}
}
Future<void> deleteSubscription(String id) async {
try {
await _subscriptionBox.delete(id);
await refreshSubscriptions();
} catch (e) {
debugPrint('구독 삭제 중 오류 발생: $e');
rethrow;
}
}
Future<void> _scheduleNotifications() async {
final BuildContext? context = navigatorKey.currentContext;
if (context == null) return;
final notificationProvider = Provider.of<NotificationProvider>(
context,
listen: false,
);
if (!notificationProvider.isEnabled ||
!notificationProvider.isPaymentEnabled) {
return;
}
for (final subscription in _subscriptions) {
final notificationDate = subscription.nextBillingDate.subtract(
const Duration(days: 3),
);
if (notificationDate.isAfter(DateTime.now())) {
await NotificationService.scheduleNotification(
id: subscription.id.hashCode,
title: '구독 결제 예정 알림',
body: '${subscription.serviceName}의 결제가 3일 후 예정되어 있습니다.',
scheduledDate: notificationDate,
);
}
}
}
Future<void> clearAllSubscriptions() async {
_isLoading = true;
notifyListeners();
try {
// 모든 알림 취소
for (var subscription in _subscriptions) {
await NotificationService.cancelSubscriptionNotification(subscription);
}
// 모든 데이터 삭제
await _subscriptionBox.clear();
_subscriptions = [];
notifyListeners();
} catch (e) {
debugPrint('모든 구독 정보 삭제 중 오류 발생: $e');
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 이벤트 종료 알림을 스케줄링합니다.
Future<void> _scheduleEventEndNotification(SubscriptionModel subscription) async {
if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
await NotificationService.scheduleNotification(
id: '${subscription.id}_event_end'.hashCode,
title: '이벤트 종료 알림',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
scheduledDate: subscription.eventEndDate!,
);
}
}
/// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다.
Future<void> checkAndUpdateEventStatus() async {
bool hasChanges = false;
for (var subscription in _subscriptions) {
// 이벤트가 종료되었지만 아직 활성화되어 있는 경우
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(DateTime.now())) {
subscription.isEventActive = false;
await _subscriptionBox.put(subscription.id, subscription);
hasChanges = true;
}
}
if (hasChanges) {
await refreshSubscriptions();
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
class AppLockScreen extends StatelessWidget {
const AppLockScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.lock_outline,
size: 80,
color: Colors.grey,
),
const SizedBox(height: 24),
const Text(
'앱이 잠겨 있습니다',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
const Text(
'생체 인증으로 잠금을 해제하세요',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () async {
final appLock = context.read<AppLockProvider>();
final success = await appLock.authenticate();
if (!success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('인증에 실패했습니다. 다시 시도해주세요.'),
),
);
}
},
icon: const Icon(Icons.fingerprint),
label: const Text('생체 인증으로 잠금 해제'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/category_provider.dart';
import '../models/category_model.dart';
class CategoryManagementScreen extends StatefulWidget {
const CategoryManagementScreen({super.key});
@override
State<CategoryManagementScreen> createState() =>
_CategoryManagementScreenState();
}
class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
String _selectedColor = '#1976D2';
String _selectedIcon = 'subscriptions';
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _addCategory() async {
if (_formKey.currentState!.validate()) {
await Provider.of<CategoryProvider>(context, listen: false).addCategory(
name: _nameController.text,
color: _selectedColor,
icon: _selectedIcon,
);
_nameController.clear();
setState(() {
_selectedColor = '#1976D2';
_selectedIcon = 'subscriptions';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('카테고리 관리'),
backgroundColor: const Color(0xFF1976D2),
),
body: Consumer<CategoryProvider>(
builder: (context, provider, child) {
return Column(
children: [
// 카테고리 추가 폼
Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '카테고리 이름',
),
validator: (value) {
if (value == null || value.isEmpty) {
return '카테고리 이름을 입력하세요';
}
return null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedColor,
decoration: const InputDecoration(
labelText: '색상 선택',
),
items: const [
DropdownMenuItem(
value: '#1976D2', child: Text('파란색')),
DropdownMenuItem(
value: '#4CAF50', child: Text('초록색')),
DropdownMenuItem(
value: '#FF9800', child: Text('주황색')),
DropdownMenuItem(
value: '#F44336', child: Text('빨간색')),
DropdownMenuItem(
value: '#9C27B0', child: Text('보라색')),
],
onChanged: (value) {
setState(() {
_selectedColor = value!;
});
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedIcon,
decoration: const InputDecoration(
labelText: '아이콘 선택',
),
items: const [
DropdownMenuItem(
value: 'subscriptions', child: Text('구독')),
DropdownMenuItem(value: 'movie', child: Text('영화')),
DropdownMenuItem(
value: 'music_note', child: Text('음악')),
DropdownMenuItem(
value: 'fitness_center', child: Text('운동')),
DropdownMenuItem(
value: 'shopping_cart', child: Text('쇼핑')),
],
onChanged: (value) {
setState(() {
_selectedIcon = value!;
});
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _addCategory,
child: const Text('카테고리 추가'),
),
],
),
),
),
),
// 카테고리 목록
Expanded(
child: ListView.builder(
itemCount: provider.categories.length,
itemBuilder: (context, index) {
final category = provider.categories[index];
return ListTile(
leading: Icon(
IconData(
_getIconCode(category.icon),
fontFamily: 'MaterialIcons',
),
color: Color(
int.parse(category.color.replaceAll('#', '0xFF'))),
),
title: Text(category.name),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
await provider.deleteCategory(category.id);
},
),
);
},
),
),
],
);
},
),
);
}
int _getIconCode(String iconName) {
switch (iconName) {
case 'subscriptions':
return 0xe8c1;
case 'movie':
return 0xe8c2;
case 'music_note':
return 0xe405;
case 'fitness_center':
return 0xeb43;
case 'shopping_cart':
return 0xe8cc;
default:
return 0xe8c1;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,442 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'dart:math' as math;
import 'package:intl/intl.dart';
import '../providers/subscription_provider.dart';
import '../providers/app_lock_provider.dart';
import '../theme/app_colors.dart';
import '../services/subscription_url_matcher.dart';
import '../models/subscription_model.dart';
import 'add_subscription_screen.dart';
import 'analysis_screen.dart';
import 'app_lock_screen.dart';
import 'settings_screen.dart';
import '../widgets/subscription_card.dart';
import '../widgets/skeleton_loading.dart';
import 'sms_scan_screen.dart';
import '../providers/category_provider.dart';
import '../utils/subscription_category_helper.dart';
import '../utils/animation_controller_helper.dart';
import '../widgets/subscription_list_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/empty_state_widget.dart';
import '../widgets/native_ad_widget.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen>
with WidgetsBindingObserver, TickerProviderStateMixin {
late AnimationController _fadeController;
late AnimationController _scaleController;
late AnimationController _rotateController;
late AnimationController _slideController;
late AnimationController _pulseController;
late AnimationController _waveController;
late ScrollController _scrollController;
double _scrollOffset = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_checkAppLock();
// 애니메이션 컨트롤러 초기화
_fadeController = AnimationController(vsync: this);
_scaleController = AnimationController(vsync: this);
_rotateController = AnimationController(vsync: this);
_slideController = AnimationController(vsync: this);
_pulseController = AnimationController(vsync: this);
_waveController = AnimationController(vsync: this);
// 헬퍼 클래스를 사용해 애니메이션 컨트롤러 초기화
AnimationControllerHelper.initControllers(
vsync: this,
fadeController: _fadeController,
scaleController: _scaleController,
rotateController: _rotateController,
slideController: _slideController,
pulseController: _pulseController,
waveController: _waveController,
);
_scrollController = ScrollController()
..addListener(() {
setState(() {
_scrollOffset = _scrollController.offset;
});
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
// 헬퍼 클래스를 사용해 애니메이션 컨트롤러 해제
AnimationControllerHelper.disposeControllers(
fadeController: _fadeController,
scaleController: _scaleController,
rotateController: _rotateController,
slideController: _slideController,
pulseController: _pulseController,
waveController: _waveController,
);
_scrollController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// 앱이 백그라운드로 갈 때
final appLockProvider = context.read<AppLockProvider>();
if (appLockProvider.isBiometricEnabled) {
appLockProvider.lock();
}
} else if (state == AppLifecycleState.resumed) {
// 앱이 포그라운드로 돌아올 때
_checkAppLock();
_resetAnimations();
}
}
void _resetAnimations() {
AnimationControllerHelper.resetAnimations(
fadeController: _fadeController,
scaleController: _scaleController,
slideController: _slideController,
pulseController: _pulseController,
waveController: _waveController,
);
}
Future<void> _checkAppLock() async {
final appLockProvider = context.read<AppLockProvider>();
if (appLockProvider.isLocked) {
await Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const AppLockScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
);
}
}
void _navigateToSmsScan(BuildContext context) async {
final added = await Navigator.push<bool>(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const SmsScanScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
),
);
if (added == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')),
);
}
_resetAnimations();
}
void _navigateToAnalysis(BuildContext context) {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const AnalysisScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
),
);
}
void _navigateToAddSubscription(BuildContext context) {
HapticFeedback.mediumImpact();
Navigator.of(context)
.push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const AddSubscriptionScreen(),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.8, end: 1.0).animate(animation),
child: child,
),
);
},
),
)
.then((_) => _resetAnimations());
}
void _navigateToSettings(BuildContext context) {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const SettingsScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
),
);
}
@override
Widget build(BuildContext context) {
final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100));
return Scaffold(
backgroundColor: AppColors.backgroundColor,
extendBodyBehindAppBar: true,
appBar: _buildAppBar(appBarOpacity),
body: _buildBody(context, context.watch<SubscriptionProvider>()),
floatingActionButton: _buildFloatingActionButton(context),
);
}
PreferredSize _buildAppBar(double appBarOpacity) {
return PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Container(
decoration: BoxDecoration(
color: AppColors.surfaceColor.withOpacity(appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
color: Colors.black.withOpacity(0.06 * appBarOpacity),
spreadRadius: 0,
blurRadius: 12,
offset: const Offset(0, 4),
)
]
: null,
),
child: SafeArea(
child: AppBar(
title: FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _fadeController, curve: Curves.easeInOut)),
child: const Text(
'SubManager',
style: TextStyle(
fontFamily: 'Montserrat',
fontSize: 26,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: Color(0xFF1E293B),
),
),
),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
IconButton(
icon: const FaIcon(FontAwesomeIcons.chartPie,
size: 20, color: Color(0xFF64748B)),
tooltip: '분석',
onPressed: () => _navigateToAnalysis(context),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.sms,
size: 20, color: Color(0xFF64748B)),
tooltip: 'SMS 스캔',
onPressed: () => _navigateToSmsScan(context),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.gear,
size: 20, color: Color(0xFF64748B)),
tooltip: '설정',
onPressed: () => _navigateToSettings(context),
),
],
),
),
),
);
}
Widget _buildFloatingActionButton(BuildContext context) {
return AnimatedBuilder(
animation: _scaleController,
builder: (context, child) {
return Transform.scale(
scale: Tween<double>(begin: 0.95, end: 1.0)
.animate(CurvedAnimation(
parent: _scaleController, curve: Curves.easeOutBack))
.value,
child: FloatingActionButton.extended(
onPressed: () => _navigateToAddSubscription(context),
icon: const Icon(Icons.add_rounded),
label: const Text(
'구독 추가',
style: TextStyle(
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
elevation: 4,
),
);
},
);
}
Widget _buildBody(BuildContext context, SubscriptionProvider provider) {
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
),
);
}
if (provider.subscriptions.isEmpty) {
return EmptyStateWidget(
fadeController: _fadeController,
rotateController: _rotateController,
slideController: _slideController,
onAddPressed: () => _navigateToAddSubscription(context),
);
}
// 카테고리별 구독 구분
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categorizedSubscriptions =
SubscriptionCategoryHelper.categorizeSubscriptions(
provider.subscriptions,
categoryProvider,
);
return RefreshIndicator(
onRefresh: () async {
await provider.refreshSubscriptions();
_resetAnimations();
},
color: const Color(0xFF3B82F6),
child: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.top + 60),
),
SliverToBoxAdapter(
child: NativeAdWidget(key: UniqueKey()),
),
SliverToBoxAdapter(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController, curve: Curves.easeOutCubic)),
child: MainScreenSummaryCard(
provider: provider,
fadeController: _fadeController,
pulseController: _pulseController,
waveController: _waveController,
slideController: _slideController,
onTap: () => _navigateToAnalysis(context),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SlideTransition(
position: Tween<Offset>(
begin: const Offset(-0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController, curve: Curves.easeOutCubic)),
child: Text(
'나의 구독 서비스',
style: Theme.of(context).textTheme.titleLarge,
),
),
SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController, curve: Curves.easeOutCubic)),
child: Row(
children: [
Text(
'${provider.subscriptions.length}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryColor,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: AppColors.primaryColor,
),
],
),
),
],
),
),
),
SubscriptionListWidget(
categorizedSubscriptions: categorizedSubscriptions,
fadeController: _fadeController,
),
SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
),
);
}
}

View File

@@ -0,0 +1,561 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
import '../providers/notification_provider.dart';
import '../providers/subscription_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'package:path/path.dart' as path;
import '../services/notification_service.dart';
import '../screens/sms_scan_screen.dart';
import 'package:url_launcher/url_launcher.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
// 알림 시점 라디오 버튼 생성 헬퍼 메서드
Widget _buildReminderDayRadio(BuildContext context,
NotificationProvider provider, int value, String label) {
final isSelected = provider.reminderDays == value;
return Expanded(
child: InkWell(
onTap: () => provider.setReminderDays(value),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withOpacity(0.2)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline.withOpacity(0.5),
width: isSelected ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
size: 24,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
),
);
}
Future<void> _backupData(BuildContext context) async {
try {
final provider = context.read<SubscriptionProvider>();
final subscriptions = provider.subscriptions;
// 임시 디렉토리에 백업 파일 생성
final tempDir = await getTemporaryDirectory();
final backupFile =
File(path.join(tempDir.path, 'submanager_backup.json'));
// 구독 데이터를 JSON 형식으로 저장
final jsonData = subscriptions
.map((sub) => {
'id': sub.id,
'serviceName': sub.serviceName,
'monthlyCost': sub.monthlyCost,
'billingCycle': sub.billingCycle,
'nextBillingDate': sub.nextBillingDate.toIso8601String(),
'isAutoDetected': sub.isAutoDetected,
'repeatCount': sub.repeatCount,
'lastPaymentDate': sub.lastPaymentDate?.toIso8601String(),
})
.toList();
await backupFile.writeAsString(jsonData.toString());
// 파일 공유
await Share.shareXFiles(
[XFile(backupFile.path)],
text: 'SubManager 백업 파일',
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('백업 파일이 생성되었습니다')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('백업 중 오류가 발생했습니다: $e')),
);
}
}
}
// SMS 스캔 화면으로 이동
void _navigateToSmsScan(BuildContext context) async {
final added = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (context) => const SmsScanScreen()),
);
if (added == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('설정'),
),
body: ListView(
children: [
// 앱 잠금 설정 UI 숨김
// Card(
// margin: const EdgeInsets.all(16),
// child: Consumer<AppLockProvider>(
// builder: (context, provider, child) {
// return SwitchListTile(
// title: const Text('앱 잠금'),
// subtitle: const Text('생체 인증으로 앱 잠금'),
// value: provider.isEnabled,
// onChanged: (value) async {
// if (value) {
// final isAuthenticated = await provider.authenticate();
// if (isAuthenticated) {
// provider.enable();
// }
// } else {
// provider.disable();
// }
// },
// );
// },
// ),
// ),
// 알림 설정
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Consumer<NotificationProvider>(
builder: (context, provider, child) {
return Column(
children: [
ListTile(
title: const Text('알림 권한'),
subtitle: const Text('알림을 받으려면 권한이 필요합니다'),
trailing: ElevatedButton(
onPressed: () async {
final granted =
await NotificationService.requestPermission();
if (granted) {
provider.setEnabled(true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('알림 권한이 거부되었습니다'),
),
);
}
},
child: const Text('권한 요청'),
),
),
const Divider(),
// 결제 예정 알림 기본 스위치
SwitchListTile(
title: const Text('결제 예정 알림'),
subtitle: const Text('결제 예정일 알림 받기'),
value: provider.isPaymentEnabled,
onChanged: (value) {
provider.setPaymentEnabled(value);
},
),
// 알림 세부 설정 (알림 활성화된 경우에만 표시)
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: provider.isPaymentEnabled
? Padding(
padding: const EdgeInsets.only(
left: 16.0, right: 16.0, bottom: 8.0),
child: Card(
elevation: 0,
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// 알림 시점 선택 (1일전, 2일전, 3일전)
const Text('알림 시점',
style: TextStyle(
fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
_buildReminderDayRadio(
context, provider, 1, '1일 전'),
_buildReminderDayRadio(
context, provider, 2, '2일 전'),
_buildReminderDayRadio(
context, provider, 3, '3일 전'),
],
),
),
const SizedBox(height: 16),
// 알림 시간 선택
const Text('알림 시간',
style: TextStyle(
fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
InkWell(
onTap: () async {
final TimeOfDay? picked =
await showTimePicker(
context: context,
initialTime: TimeOfDay(
hour: provider.reminderHour,
minute:
provider.reminderMinute),
);
if (picked != null) {
provider.setReminderTime(
picked.hour, picked.minute);
}
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 16),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withOpacity(0.5),
),
borderRadius:
BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Row(
children: [
Icon(
Icons.access_time,
color: Theme.of(context)
.colorScheme
.primary,
size: 22,
),
const SizedBox(width: 12),
Text(
'${provider.reminderHour.toString().padLeft(2, '0')}:${provider.reminderMinute.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 16,
fontWeight:
FontWeight.bold,
color: Theme.of(context)
.colorScheme
.onSurface,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(context)
.colorScheme
.outline,
),
],
),
),
),
// 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화)
if (provider.reminderDays >= 2)
Padding(
padding:
const EdgeInsets.only(top: 16.0),
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 4, horizontal: 4),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.3),
borderRadius:
BorderRadius.circular(8),
),
child: SwitchListTile(
contentPadding:
const EdgeInsets.symmetric(
horizontal: 12),
title: const Text('1일마다 반복 알림'),
subtitle: const Text(
'결제일까지 매일 알림을 받습니다'),
value: provider
.isDailyReminderEnabled,
activeColor: Theme.of(context)
.colorScheme
.primary,
onChanged: (value) {
provider
.setDailyReminderEnabled(
value);
},
),
),
),
],
),
),
),
)
: const SizedBox.shrink(),
),
// 미사용 서비스 알림 기능 비활성화
// const Divider(),
// SwitchListTile(
// title: const Text('미사용 서비스 알림'),
// subtitle: const Text('2개월 이상 미사용 시 알림'),
// value: provider.isUnusedServiceNotificationEnabled,
// onChanged: (value) {
// provider.setUnusedServiceNotificationEnabled(value);
// },
// ),
],
);
},
),
),
// 데이터 관리
Card(
margin: const EdgeInsets.all(16),
child: Column(
children: [
// 데이터 백업 기능 비활성화
// ListTile(
// title: const Text('데이터 백업'),
// subtitle: const Text('구독 데이터를 백업합니다'),
// leading: const Icon(Icons.backup),
// onTap: () => _backupData(context),
// ),
// const Divider(),
// SMS 스캔 - 시각적으로 강조된 UI
InkWell(
onTap: () => _navigateToSmsScan(context),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor.withOpacity(0.1),
Theme.of(context).primaryColor.withOpacity(0.2),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16.0, horizontal: 8.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(left: 8, right: 16),
decoration: BoxDecoration(
color: Theme.of(context)
.primaryColor
.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.sms_rounded,
color: Theme.of(context).primaryColor,
size: 28,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'SMS 스캔으로 구독 자동 찾기',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(context).primaryColor,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'추천',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 6),
const Text(
'2회 이상 반복 결제된 구독 서비스를 자동으로 찾아 추가합니다',
style: TextStyle(
color: Colors.black54,
fontSize: 13,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 16),
],
),
),
),
),
],
),
),
// 앱 정보
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: ListTile(
title: const Text('앱 정보'),
subtitle: const Text('버전 1.0.0'),
leading: const Icon(Icons.info),
onTap: () async {
// 웹 환경에서는 기본 다이얼로그 표시
if (kIsWeb) {
showAboutDialog(
context: context,
applicationName: 'SubManager',
applicationVersion: '1.0.0',
applicationIcon: const FlutterLogo(size: 50),
children: [
const Text('구독 관리 앱'),
const SizedBox(height: 8),
const Text('개발자: SubManager Team'),
],
);
return;
}
// 앱 스토어 링크
String storeUrl = '';
// 플랫폼에 따라 스토어 링크 설정
if (Platform.isAndroid) {
// Android - Google Play 스토어 링크
storeUrl =
'https://play.google.com/store/apps/details?id=com.submanager.app';
} else if (Platform.isIOS) {
// iOS - App Store 링크
storeUrl =
'https://apps.apple.com/app/submanager/id123456789';
}
if (storeUrl.isNotEmpty) {
try {
final Uri url = Uri.parse(storeUrl);
await launchUrl(url, mode: LaunchMode.externalApplication);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('스토어를 열 수 없습니다')),
);
}
}
} else {
// 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시
showAboutDialog(
context: context,
applicationName: 'SubManager',
applicationVersion: '1.0.0',
applicationIcon: const FlutterLogo(size: 50),
children: [
const Text('구독 관리 앱'),
const SizedBox(height: 8),
const Text('개발자: SubManager Team'),
],
);
}
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,820 @@
import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart';
import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/subscription_url_matcher.dart';
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key});
@override
State<SmsScanScreen> createState() => _SmsScanScreenState();
}
class _SmsScanScreenState extends State<SmsScanScreen> {
bool _isLoading = false;
String? _errorMessage;
final SmsScanner _smsScanner = SmsScanner();
// 스캔한 구독 목록
List<Subscription> _scannedSubscriptions = [];
// 현재 표시 중인 구독 인덱스
int _currentIndex = 0;
// 웹사이트 URL 컨트롤러
final TextEditingController _websiteUrlController = TextEditingController();
@override
void dispose() {
_websiteUrlController.dispose();
super.dispose();
}
// SMS 스캔 실행
Future<void> _scanSms() async {
setState(() {
_isLoading = true;
_errorMessage = null;
_scannedSubscriptions = [];
_currentIndex = 0;
});
try {
// SMS 스캔 실행
print('SMS 스캔 시작');
final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions();
print('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) {
print(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
}
if (!mounted) return;
if (scannedSubscriptionModels.isEmpty) {
print('스캔된 구독이 없음');
setState(() {
_errorMessage = '구독 정보를 찾을 수 없습니다.';
_isLoading = false;
});
return;
}
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
scannedSubscriptions.where((sub) => sub.repeatCount >= 2).toList();
print('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) {
print(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
}
if (repeatSubscriptions.isEmpty) {
print('반복 결제된 구독이 없음');
setState(() {
_errorMessage = '반복 결제된 구독 정보를 찾을 수 없습니다.';
_isLoading = false;
});
return;
}
// 구독 목록 가져오기
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions;
print('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링
final filteredSubscriptions =
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty &&
filteredSubscriptions[0] != null) {
print(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
setState(() {
_scannedSubscriptions = filteredSubscriptions;
_isLoading = false;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
});
} catch (e) {
print('SMS 스캔 중 오류 발생: $e');
if (mounted) {
setState(() {
_errorMessage = 'SMS 스캔 중 오류가 발생했습니다: $e';
_isLoading = false;
});
}
}
}
// SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> _convertModelsToSubscriptions(
List<SubscriptionModel> models) {
final result = <Subscription>[];
for (var model in models) {
try {
// 모델의 필드가 null인 경우 기본값 사용
result.add(Subscription(
id: model.id,
serviceName: model.serviceName,
monthlyCost: model.monthlyCost,
billingCycle: model.billingCycle,
nextBillingDate: model.nextBillingDate,
category: model.categoryId, // categoryId를 category로 올바르게 매핑
repeatCount: model.repeatCount > 0
? model.repeatCount
: 1, // 반복 횟수가 0 이하인 경우 기본값 1 사용
lastPaymentDate: model.lastPaymentDate,
websiteUrl: model.websiteUrl,
currency: model.currency, // 통화 단위 정보 추가
));
print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
} catch (e) {
print('모델 변환 중 오류 발생: $e');
}
}
return result;
}
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
List<Subscription> _filterDuplicates(
List<Subscription> scanned, List<SubscriptionModel> existing) {
print(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링
final nonDuplicates = scanned.where((scannedSub) {
if (scannedSub == null) {
print('_filterDuplicates: null 구독 객체 발견');
return false;
}
// 서비스명과 금액이 동일한 기존 구독 찾기
final hasDuplicate = existing.any((existingSub) =>
existingSub.serviceName.toLowerCase() ==
scannedSub.serviceName.toLowerCase() &&
existingSub.monthlyCost == scannedSub.monthlyCost);
if (hasDuplicate) {
print('_filterDuplicates: 중복 발견 - ${scannedSub.serviceName}');
}
// 중복이 없으면 true 반환
return !hasDuplicate;
}).toList();
print('_filterDuplicates: 중복 제거 후 ${nonDuplicates.length}');
// 각 구독에 웹사이트 URL 자동 매칭 시도
final result = <Subscription>[];
for (int i = 0; i < nonDuplicates.length; i++) {
final subscription = nonDuplicates[i];
if (subscription == null) {
print('_filterDuplicates: null 구독 객체 무시');
continue;
}
String? websiteUrl = subscription.websiteUrl;
if (websiteUrl == null || websiteUrl.isEmpty) {
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
print(
'_filterDuplicates: URL 자동 매칭 시도 - ${subscription.serviceName}, 결과: ${websiteUrl ?? "매칭 실패"}');
}
try {
// 유효성 검사
if (subscription.serviceName.isEmpty) {
print('_filterDuplicates: 서비스명이 비어 있습니다. 건너뜁니다.');
continue;
}
if (subscription.monthlyCost <= 0) {
print('_filterDuplicates: 월 비용이 0 이하입니다. 건너뜁니다.');
continue;
}
// Subscription 객체에 URL 설정 (새 객체 생성)
result.add(Subscription(
id: subscription.id,
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate,
category: subscription.category,
notes: subscription.notes,
repeatCount:
subscription.repeatCount > 0 ? subscription.repeatCount : 1,
lastPaymentDate: subscription.lastPaymentDate,
websiteUrl: websiteUrl,
currency: subscription.currency, // 통화 단위 정보 추가
));
print(
'_filterDuplicates: URL 설정 - ${subscription.serviceName}, URL: ${websiteUrl ?? "없음"}, 카테고리: ${subscription.category ?? "없음"}, 통화: ${subscription.currency}');
} catch (e) {
print('_filterDuplicates: 구독 객체 생성 중 오류 발생: $e');
}
}
print('_filterDuplicates: URL 설정 완료, 최종 ${result.length}개 구독');
return result;
}
// 현재 구독 추가
Future<void> _addCurrentSubscription() async {
if (_scannedSubscriptions.isEmpty ||
_currentIndex >= _scannedSubscriptions.length) {
print(
'오류: 인덱스가 범위를 벗어났습니다. (index: $_currentIndex, size: ${_scannedSubscriptions.length})');
return;
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription == null) {
print('오류: 현재 인덱스의 구독이 null입니다. (index: $_currentIndex)');
_moveToNextSubscription();
return;
}
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
// 날짜가 과거면 다음 결제일을 조정
final now = DateTime.now();
DateTime nextBillingDate = subscription.nextBillingDate;
if (nextBillingDate.isBefore(now)) {
// 주기에 따라 다음 결제일 조정
if (subscription.billingCycle == '월간') {
// 현재 달의 결제일
int day = nextBillingDate.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjustedDate = DateTime(now.year, now.month, day);
// 현재 날짜보다 이전이라면 다음 달로 설정
if (adjustedDate.isBefore(now)) {
// 다음 달의 마지막 날을 초과하는 경우 조정
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
if (day > nextMonthLastDay) {
day = nextMonthLastDay;
}
adjustedDate = DateTime(now.year, now.month + 1, day);
}
nextBillingDate = adjustedDate;
} else if (subscription.billingCycle == '연간') {
// 현재 년도의 결제일
int day = nextBillingDate.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, nextBillingDate.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjustedDate = DateTime(now.year, nextBillingDate.month, day);
// 현재 날짜보다 이전이라면 다음 해로 설정
if (adjustedDate.isBefore(now)) {
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
final nextYearLastDay =
DateTime(now.year + 1, nextBillingDate.month + 1, 0).day;
if (day > nextYearLastDay) {
day = nextYearLastDay;
}
adjustedDate = DateTime(now.year + 1, nextBillingDate.month, day);
}
nextBillingDate = adjustedDate;
} else if (subscription.billingCycle == '주간') {
// 현재 날짜에서 가장 가까운 다음 주 같은 요일
final daysUntilNext = 7 - (now.weekday - nextBillingDate.weekday) % 7;
nextBillingDate =
now.add(Duration(days: daysUntilNext == 0 ? 7 : daysUntilNext));
}
}
// 웹사이트 URL이 비어있으면 자동 매칭 시도
String? websiteUrl = _websiteUrlController.text.trim();
if (websiteUrl.isEmpty && subscription.websiteUrl != null) {
websiteUrl = subscription.websiteUrl;
print('구독 추가: 기존 URL 사용 - ${websiteUrl ?? "없음"}');
} else if (websiteUrl.isEmpty) {
try {
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
print(
'구독 추가: URL 자동 매칭 - ${subscription.serviceName} -> ${websiteUrl ?? "매칭 실패"}');
} catch (e) {
print('구독 추가: URL 자동 매칭 실패 - $e');
websiteUrl = null;
}
} else {
print('구독 추가: 사용자 입력 URL 사용 - $websiteUrl');
}
try {
print(
'구독 추가 시도 - 서비스명: ${subscription.serviceName}, 비용: ${subscription.monthlyCost}, 반복 횟수: ${subscription.repeatCount}');
// 반복 횟수가 0 이하인 경우 기본값 1 사용
final int safeRepeatCount =
subscription.repeatCount > 0 ? subscription.repeatCount : 1;
await provider.addSubscription(
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl,
isAutoDetected: true,
repeatCount: safeRepeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: subscription.category,
currency: subscription.currency, // 통화 단위 정보 추가
);
print('구독 추가 성공');
// 성공 메시지 표시
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${subscription.serviceName} 구독이 추가되었습니다.'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
// 다음 구독으로 이동
_moveToNextSubscription();
} catch (e) {
print('구독 추가 중 오류 발생: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('구독 추가 중 오류가 발생했습니다: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 2),
),
);
// 오류가 있어도 다음 구독으로 이동
_moveToNextSubscription();
}
}
}
// 현재 구독 건너뛰기
void _skipCurrentSubscription() {
_moveToNextSubscription();
}
// 다음 구독으로 이동
void _moveToNextSubscription() {
setState(() {
_currentIndex++;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
// 모든 구독을 처리했으면 화면 종료
if (_currentIndex >= _scannedSubscriptions.length) {
Navigator.of(context).pop(true);
}
});
}
// 날짜 상태 텍스트 가져오기
String _getNextBillingText(DateTime date) {
final now = DateTime.now();
if (date.isBefore(now)) {
// 주기에 따라 다음 결제일 예측
if (_currentIndex >= _scannedSubscriptions.length ||
_scannedSubscriptions[_currentIndex] == null) {
return '다음 결제일 확인 필요';
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription.billingCycle == '월간') {
// 이번 달 또는 다음 달 같은 날짜
int day = date.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjusted = DateTime(now.year, now.month, day);
if (adjusted.isBefore(now)) {
// 다음 달의 마지막 날을 초과하는 경우 조정
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
if (day > nextMonthLastDay) {
day = nextMonthLastDay;
}
adjusted = DateTime(now.year, now.month + 1, day);
}
final daysUntil = adjusted.difference(now).inDays;
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil일 후)';
} else if (subscription.billingCycle == '연간') {
// 올해 또는 내년 같은 날짜
int day = date.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, date.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjusted = DateTime(now.year, date.month, day);
if (adjusted.isBefore(now)) {
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
final nextYearLastDay = DateTime(now.year + 1, date.month + 1, 0).day;
if (day > nextYearLastDay) {
day = nextYearLastDay;
}
adjusted = DateTime(now.year + 1, date.month, day);
}
final daysUntil = adjusted.difference(now).inDays;
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil일 후)';
} else {
return '다음 결제일 확인 필요 (과거 날짜)';
}
} else {
// 미래 날짜인 경우
final daysUntil = date.difference(now).inDays;
return '다음 결제일: ${_formatDate(date)} ($daysUntil일 후)';
}
}
// 날짜 포맷 함수
String _formatDate(DateTime date) {
return '${date.year}${date.month}${date.day}';
}
// 결제 반복 횟수 텍스트
String _getRepeatCountText(int count) {
return '$count회 결제 감지됨';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('SMS 스캔'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState())),
);
}
// 로딩 상태 UI
Widget _buildLoadingState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('SMS 메시지를 스캔 중입니다...'),
SizedBox(height: 8),
Text('구독 서비스를 찾고 있습니다', style: TextStyle(color: Colors.grey)),
],
),
);
}
// 초기 상태 UI
Widget _buildInitialState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
const Text(
'2회 이상 결제된 구독 서비스 찾기',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: _scanSms,
icon: const Icon(Icons.search),
label: const Text('스캔 시작하기'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
),
],
),
);
}
// 구독 표시 상태 UI
Widget _buildSubscriptionState() {
if (_currentIndex >= _scannedSubscriptions.length) {
return const Center(
child: Text('모든 구독 처리 완료'),
);
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('오류: 구독 정보를 불러올 수 없습니다.'),
SizedBox(height: 16),
ElevatedButton(
onPressed: _moveToNextSubscription,
child: Text('건너뛰기'),
),
],
),
);
}
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
_websiteUrlController.text = subscription.websiteUrl!;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 진행 상태 표시
LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length,
backgroundColor: Colors.grey.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 8),
Text(
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
style: TextStyle(
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 24),
// 구독 정보 카드
Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'다음 구독을 찾았습니다',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// 서비스명
const Text(
'서비스명',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
subscription.serviceName,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 금액 및 반복 횟수
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'월 비용',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
subscription.currency == 'USD'
? NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(subscription.monthlyCost)
: NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(subscription.monthlyCost),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'반복 횟수',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
_getRepeatCountText(subscription.repeatCount),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.secondary,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// 결제 주기
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'결제 주기',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
subscription.billingCycle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'결제일',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
_getNextBillingText(subscription.nextBillingDate),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
const SizedBox(height: 24),
// 웹사이트 URL 입력 필드 추가/수정
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _websiteUrlController,
decoration: const InputDecoration(
labelText: '웹사이트 URL (자동 추출됨)',
hintText: '웹사이트 URL을 수정하거나 비워두세요',
prefixIcon: Icon(Icons.language),
border: OutlineInputBorder(),
),
),
),
const SizedBox(height: 32),
// 작업 버튼
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _skipCurrentSubscription,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('건너뛰기'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _addCurrentSubscription,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('추가하기'),
),
),
],
),
],
),
),
),
],
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_scannedSubscriptions.isNotEmpty &&
_currentIndex < _scannedSubscriptions.length &&
_scannedSubscriptions[_currentIndex] != null) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
_websiteUrlController.text = currentSub.websiteUrl!;
}
}
}
}

View File

@@ -0,0 +1,371 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
import '../theme/app_colors.dart';
import 'app_lock_screen.dart';
import 'main_screen.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
late Animation<double> _slideAnimation;
late Animation<double> _rotateAnimation;
// 파티클 애니메이션을 위한 변수
final List<Map<String, dynamic>> _particles = [];
@override
void initState() {
super.initState();
// 애니메이션 컨트롤러 초기화
_animationController = AnimationController(
duration: const Duration(milliseconds: 2500),
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.7, curve: Curves.easeInOut),
);
_scaleAnimation = Tween<double>(
begin: 0.6,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.elasticOut,
));
_slideAnimation = Tween<double>(
begin: 50.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.3, 0.8, curve: Curves.easeOutCubic),
));
_rotateAnimation = Tween<double>(
begin: 0.0,
end: 0.1,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.5, curve: Curves.easeOutBack),
));
// 랜덤 파티클 생성
_generateParticles();
_animationController.forward();
// 애니메이션 완료 후 메인화면으로 이동
Timer(const Duration(seconds: 2), () {
navigateToNextScreen();
});
}
void _generateParticles() {
final random = DateTime.now().millisecondsSinceEpoch;
for (int i = 0; i < 20; i++) {
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
final x = (random % 100) / 100 * 300; // 랜덤 X 위치
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
final duration = (random % 10) / 10 * 3000 + 2000; // 2-5초 사이의 지속시간
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
int colorIndex = (random + i) % AppColors.blueGradient.length;
_particles.add({
'size': size,
'x': x,
'y': y,
'opacity': opacity,
'duration': duration,
'delay': delay,
'color': AppColors.blueGradient[colorIndex],
});
}
}
void navigateToNextScreen() {
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const MainScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
transitionDuration: const Duration(milliseconds: 500),
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: AppColors.blueGradient,
),
),
child: Stack(
children: [
// 배경 파티클
..._particles.map((particle) {
return AnimatedPositioned(
duration: Duration(milliseconds: particle['duration'].toInt()),
curve: Curves.easeInOut,
left: particle['x'] - 50 + (size.width * 0.1),
top: particle['y'] - 50 + (size.height * 0.1),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: particle['opacity']),
duration:
Duration(milliseconds: particle['duration'].toInt()),
builder: (context, value, child) {
return Opacity(
opacity: value,
child: child,
);
},
child: Container(
width: particle['size'],
height: particle['size'],
decoration: BoxDecoration(
color: particle['color'],
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: particle['color'].withOpacity(0.3),
blurRadius: 10,
spreadRadius: 1,
),
],
),
),
),
);
}).toList(),
// 상단 원형 그라데이션
Positioned(
top: -size.height * 0.2,
right: -size.width * 0.2,
child: Container(
width: size.width * 0.8,
height: size.width * 0.8,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.white.withOpacity(0.1),
Colors.white.withOpacity(0.0),
],
stops: const [0.2, 1.0],
),
),
),
),
// 하단 원형 그라데이션
Positioned(
bottom: -size.height * 0.1,
left: -size.width * 0.3,
child: Container(
width: size.width * 0.9,
height: size.width * 0.9,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.white.withOpacity(0.07),
Colors.white.withOpacity(0.0),
],
stops: const [0.4, 1.0],
),
),
),
),
// 메인 콘텐츠
Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 로고 애니메이션
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotateAnimation.value,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
spreadRadius: 0,
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Center(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, _) {
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) =>
LinearGradient(
colors: AppColors.blueGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(bounds),
child: Icon(
Icons.subscriptions_outlined,
size: 64,
color: Theme.of(context)
.primaryColor,
),
);
}),
),
),
),
);
},
),
const SizedBox(height: 40),
// 앱 이름 텍스트
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value),
child: child,
),
);
},
child: const Text(
'SubManager',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 16),
// 부제목 텍스트
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value * 1.2),
child: child,
),
);
},
child: const Text(
'구독 서비스 관리를 더 쉽게',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: 60),
// 로딩 인디케이터
FadeTransition(
opacity: _fadeAnimation,
child: Container(
width: 60,
height: 60,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(50),
),
child: const CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
strokeWidth: 3,
),
),
),
],
),
),
),
// 카피라이트 텍스트
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: FadeTransition(
opacity: _fadeAnimation,
child: const Text(
'© 2023 CClabs. All rights reserved.',
style: TextStyle(
fontSize: 12,
color: Colors.white60,
letterSpacing: 0.5,
),
),
),
),
],
),
],
),
),
);
}
}

View 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);
}
}
}

View 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 '';
}
}

View 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)}원이 결제되었습니다.';
}
}

View 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';
}
}

View 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}');
}
}
}

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

342
lib/temp/test_sms_data.dart Normal file
View File

@@ -0,0 +1,342 @@
// 테스트용 SMS 데이터
// 실제 SMS를 스캔하는 대신 이 테스트 데이터를 사용합니다.
class TestSmsData {
static List<Map<String, dynamic>> getTestData() {
// 현재 날짜 기준으로 미래 날짜 계산
final now = DateTime.now();
final nextMonth = DateTime(now.year, now.month + 1, now.day);
final prevMonth = DateTime(now.year, now.month - 1, now.day);
final String formattedNextMonth =
'${nextMonth.year}-${nextMonth.month.toString().padLeft(2, '0')}-${nextMonth.day.toString().padLeft(2, '0')}';
final String formattedPrevMonth =
'${prevMonth.year}-${prevMonth.month.toString().padLeft(2, '0')}-${prevMonth.day.toString().padLeft(2, '0')}';
final String formattedNow =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
// 1년 후 날짜 계산 (연간 구독용)
final nextYear = DateTime(now.year + 1, now.month, now.day);
final prevYear = DateTime(now.year - 1, now.month, now.day);
final String formattedNextYear =
'${nextYear.year}-${nextYear.month.toString().padLeft(2, '0')}-${nextYear.day.toString().padLeft(2, '0')}';
final String formattedPrevYear =
'${prevYear.year}-${prevYear.month.toString().padLeft(2, '0')}-${prevYear.day.toString().padLeft(2, '0')}';
// 기본 테스트 데이터 정의
final baseTestData = [
{
'serviceName': '넷플릭스',
'monthlyCost': 9900.0,
'billingCycle': '월간',
'nextBillingDate': formattedNextMonth,
'isRecurring': true,
'repeatCount': 5,
'sender': '15885000',
'messageDate': formattedNow,
'previousPaymentDate': formattedPrevMonth,
'message': '[넷플릭스] 정기 결제 완료: 9,900원이 결제되었습니다. 다음 결제일: ${nextMonth.day}'
},
{
'serviceName': '유튜브프리미엄',
'monthlyCost': 14900.0,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 20).year}-${DateTime(now.year, now.month + 1, 20).month.toString().padLeft(2, '0')}-20',
'isRecurring': true,
'repeatCount': 7,
'sender': '15882018',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 20).year}-${DateTime(now.year, now.month - 1, 20).month.toString().padLeft(2, '0')}-20',
'message': '[Google] 유튜브프리미엄 구독료 14,900원이 자동 결제되었습니다.'
},
{
'serviceName': '디즈니플러스',
'monthlyCost': 8900.0,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 10).year}-${DateTime(now.year, now.month + 1, 10).month.toString().padLeft(2, '0')}-10',
'isRecurring': true,
'repeatCount': 3,
'sender': '15771055',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 10).year}-${DateTime(now.year, now.month - 1, 10).month.toString().padLeft(2, '0')}-10',
'message': '[디즈니플러스] 8,900원 정기결제가 완료되었습니다.'
},
{
'serviceName': '애플 iCloud',
'monthlyCost': 2900.0,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 5).year}-${DateTime(now.year, now.month + 1, 5).month.toString().padLeft(2, '0')}-05',
'isRecurring': true,
'repeatCount': 12,
'sender': '0802011900',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 5).year}-${DateTime(now.year, now.month - 1, 5).month.toString().padLeft(2, '0')}-05',
'message': 'Apple: iCloud+ 50GB 월 구독(₩2,900)이 자동으로 갱신되었습니다.'
},
{
'serviceName': '멜론',
'monthlyCost': 10900.0,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 22).year}-${DateTime(now.year, now.month + 1, 22).month.toString().padLeft(2, '0')}-22',
'isRecurring': true,
'repeatCount': 4,
'sender': '16001950',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 22).year}-${DateTime(now.year, now.month - 1, 22).month.toString().padLeft(2, '0')}-22',
'message':
'[멜론] 스트리밍클럽 정기결제 10,900원 완료. 다음 결제일: ${DateTime(now.year, now.month + 1, 22).day}'
},
{
'serviceName': 'Microsoft 365',
'monthlyCost': 12800.0,
'billingCycle': '연간',
'nextBillingDate': formattedNextYear,
'isRecurring': true,
'repeatCount': 2,
'sender': '0801136532',
'messageDate': formattedNow,
'previousPaymentDate': formattedPrevYear,
'message': '[Microsoft] Microsoft 365 연간 구독료 12,800원이 결제되었습니다.'
},
{
'serviceName': '웨이브',
'monthlyCost': 7900.0,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 15).year}-${DateTime(now.year, now.month + 1, 15).month.toString().padLeft(2, '0')}-15',
'isRecurring': true,
'repeatCount': 2,
'sender': '1800-1234',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 15).year}-${DateTime(now.year, now.month - 1, 15).month.toString().padLeft(2, '0')}-15',
'message': '[웨이브] 구독료 7,900원이 정기결제 되었습니다. 감사합니다.'
},
// 달러 결제 서비스 추가
{
'serviceName': 'Netflix US',
'monthlyCost': 9.99,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 7).year}-${DateTime(now.year, now.month + 1, 7).month.toString().padLeft(2, '0')}-07',
'isRecurring': true,
'repeatCount': 6,
'sender': '334455',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 7).year}-${DateTime(now.year, now.month - 1, 7).month.toString().padLeft(2, '0')}-07',
'message':
'[Netflix US] Your subscription has been renewed. \$9.99 has been charged to your account. Next billing date: ${DateTime(now.year, now.month + 1, 7).day}'
},
{
'serviceName': 'Spotify Premium',
'monthlyCost': 10.99,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 12).year}-${DateTime(now.year, now.month + 1, 12).month.toString().padLeft(2, '0')}-12',
'isRecurring': true,
'repeatCount': 4,
'sender': '223344',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 12).year}-${DateTime(now.year, now.month - 1, 12).month.toString().padLeft(2, '0')}-12',
'message':
'[Spotify] Your premium subscription was automatically renewed. USD 10.99 charged to your card. Your next payment will be on ${DateTime(now.year, now.month + 1, 12).day}'
},
{
'serviceName': 'GitHub Pro',
'monthlyCost': 4.00,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 3).year}-${DateTime(now.year, now.month + 1, 3).month.toString().padLeft(2, '0')}-03',
'isRecurring': true,
'repeatCount': 8,
'sender': '112233',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 3).year}-${DateTime(now.year, now.month - 1, 3).month.toString().padLeft(2, '0')}-03',
'message':
'[GitHub] Your Pro plan has been renewed for \$4.00 USD. View your receipt at github.com/receipt. Next bill on ${DateTime(now.year, now.month + 1, 3).day}'
},
];
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)
final List<Map<String, dynamic>> resultData = [];
// 각 기본 데이터를 복제하여 여러 개의 메시지 생성
for (final service in baseTestData) {
// 원본 메시지 추가
resultData.add(Map<String, dynamic>.from(service));
// 각 서비스에 대해 과거 메시지 추가 (2개 이상의 메시지가 있어야 반복 구독으로 인식)
for (int i = 2; i <= (service['repeatCount'] as int); i++) {
// 과거 결제일 계산
final pastMonth = DateTime(
now.year,
now.month - i + 1 > 0 ? now.month - i + 1 : now.month - i + 1 + 12,
service['billingCycle'] == '월간' ? now.day : now.day);
final formattedPastMonth =
'${pastMonth.year}-${pastMonth.month.toString().padLeft(2, '0')}-${pastMonth.day.toString().padLeft(2, '0')}';
// 과거 메시지 생성
final pastMessage = Map<String, dynamic>.from(service);
pastMessage['messageDate'] = formattedPastMonth;
pastMessage['nextBillingDate'] =
service['previousPaymentDate']; // 이전 메시지의 다음 결제일은 현재의 이전 결제일
// 과거 메시지의 이전 결제일 계산
final veryPastMonth = DateTime(
pastMonth.year,
pastMonth.month - 1 > 0
? pastMonth.month - 1
: pastMonth.month - 1 + 12,
pastMonth.day);
final formattedVeryPastMonth =
'${veryPastMonth.year}-${veryPastMonth.month.toString().padLeft(2, '0')}-${veryPastMonth.day.toString().padLeft(2, '0')}';
pastMessage['previousPaymentDate'] = formattedVeryPastMonth;
resultData.add(pastMessage);
}
}
print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}');
return resultData;
}
// 최근 6개월의 월간 지출 데이터를 반환하는 메서드
static List<Map<String, dynamic>> getMonthlyExpenseData() {
final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = [];
// 기본 구독 서비스와 금액 (현재 구독 중인 서비스)
final baseServices = [
{'serviceName': '넷플릭스', 'cost': 9900.0},
{'serviceName': '유튜브프리미엄', 'cost': 14900.0},
{'serviceName': '디즈니플러스', 'cost': 8900.0},
{'serviceName': '애플 iCloud', 'cost': 2900.0},
{'serviceName': '멜론', 'cost': 10900.0},
{'serviceName': '웨이브', 'cost': 7900.0},
{'serviceName': 'Netflix US', 'cost': 9.99}, // 달러 결제 서비스 추가
{'serviceName': 'Spotify Premium', 'cost': 10.99}, // 달러 결제 서비스 추가
{'serviceName': 'GitHub Pro', 'cost': 4.00}, // 달러 결제 서비스 추가
];
// Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제)
final microsoftMonthlyCost = 12800.0 / 12;
// 최근 6개월 데이터 생성
for (int i = 0; i < 6; i++) {
// i개월 전 날짜
final targetMonth = DateTime(now.month - i <= 0 ? now.year - 1 : now.year,
now.month - i <= 0 ? now.month - i + 12 : now.month - i, 1);
// 해당 월의 모든 서비스 데이터 리스트
final List<Map<String, dynamic>> servicesForMonth = [];
// 해당 월의 총 지출
double totalExpense = 0.0;
// 기본 서비스 추가 (일부 서비스는 구독 시작 시점이 다를 수 있음)
for (final service in baseServices) {
// 3개월 전부터 웨이브 구독 시작
if (service['serviceName'] == '웨이브' && i > 3) {
continue;
}
// 2개월 전부터 디즈니플러스 구독 시작
if (service['serviceName'] == '디즈니플러스' && i > 2) {
continue;
}
// 서비스 정보 추가
final Map<String, dynamic> serviceData = {
'serviceName': service['serviceName'],
'cost': service['cost'],
'date':
'${targetMonth.year}-${targetMonth.month.toString().padLeft(2, '0')}-15',
};
servicesForMonth.add(serviceData);
totalExpense += service['cost'] as double;
}
// Microsoft 365는 정확히 4개월 전에 결제됨
if (i == 4) {
servicesForMonth.add({
'serviceName': 'Microsoft 365',
'cost': 12800.0, // 연간 결제 비용
'date':
'${targetMonth.year}-${targetMonth.month.toString().padLeft(2, '0')}-10',
});
totalExpense += 12800.0;
} else {
// 다른 달에는 월 환산 비용으로 계산
totalExpense += microsoftMonthlyCost;
}
// 3개월 전에는 Spotify를 일시적으로 구독했다가 해지
if (i == 3) {
servicesForMonth.add({
'serviceName': 'Spotify',
'cost': 10900.0,
'date':
'${targetMonth.year}-${targetMonth.month.toString().padLeft(2, '0')}-05',
});
totalExpense += 10900.0;
}
// 5개월 전과 4개월 전에는 쿠팡플레이를 구독했다가 해지
if (i == 4 || i == 5) {
servicesForMonth.add({
'serviceName': '쿠팡플레이',
'cost': 4990.0,
'date':
'${targetMonth.year}-${targetMonth.month.toString().padLeft(2, '0')}-18',
});
totalExpense += 4990.0;
}
// 월별 총 지출 데이터 추가
monthlyData.add({
'year': targetMonth.year,
'month': targetMonth.month,
'totalExpense': totalExpense,
'services': servicesForMonth,
'monthName': _getMonthName(targetMonth.month),
});
}
// 최신 달이 먼저 오도록 reverse
return monthlyData.reversed.toList();
}
// 월 숫자를 한글 월 이름으로 변환
static String _getMonthName(int month) {
const monthNames = [
'1월',
'2월',
'3월',
'4월',
'5월',
'6월',
'7월',
'8월',
'9월',
'10월',
'11월',
'12월'
];
return monthNames[month - 1];
}
}

49
lib/theme/app_colors.dart Normal file
View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class AppColors {
// 메인 컬러 (Metronic Tailwind 스타일)
static const primaryColor = Color(0xFF3B82F6); // 메트로닉 블루
static const secondaryColor = Color(0xFF64748B); // 슬레이트 600
static const successColor = Color(0xFF10B981); // 그린
static const infoColor = Color(0xFF6366F1); // 인디고
static const warningColor = Color(0xFFF59E0B); // 앰버
static const dangerColor = Color(0xFFEF4444); // 레드
// 배경색
static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100
static const surfaceColor = Color(0xFFFFFFFF); // 화이트
static const surfaceColorAlt = Color(0xFFF8FAFC); // 슬레이트 50
static const cardColor = Color(0xFFFFFFFF); // 화이트
// 텍스트 컬러
static const textPrimary = Color(0xFF1E293B); // 슬레이트 800
static const textSecondary = Color(0xFF64748B); // 슬레이트 600
static const textMuted = Color(0xFF94A3B8); // 슬레이트 400
static const textLight = Color(0xFFFFFFFF); // 화이트
// 보더 & 디바이더
static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200
static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200
// 그라데이션 컬러 - 다양한 효과를 위한 조합
static const List<Color> blueGradient = [
Color(0xFF3B82F6),
Color(0xFF2563EB)
];
static const List<Color> tealGradient = [
Color(0xFF14B8A6),
Color(0xFF0D9488)
];
static const List<Color> purpleGradient = [
Color(0xFF8B5CF6),
Color(0xFF7C3AED)
];
static const List<Color> amberGradient = [
Color(0xFFF59E0B),
Color(0xFFD97706)
];
static const List<Color> roseGradient = [
Color(0xFFF43F5E),
Color(0xFFE11D48)
];
}

356
lib/theme/app_theme.dart Normal file
View File

@@ -0,0 +1,356 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppTheme {
static ThemeData lightTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.light(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.dangerColor,
background: AppColors.backgroundColor,
surface: AppColors.surfaceColor,
),
// 기본 배경색
scaffoldBackgroundColor: AppColors.backgroundColor,
// 카드 스타일 - 부드러운 그림자, 둥근 모서리
cardTheme: CardTheme(
color: AppColors.cardColor,
elevation: 1,
shadowColor: Colors.black.withOpacity(0.04),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppColors.borderColor, width: 0.5),
),
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
// 앱바 스타일 - 깔끔하고 투명한 디자인
appBarTheme: AppBarTheme(
backgroundColor: AppColors.surfaceColor,
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: AppColors.textPrimary,
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
iconTheme: IconThemeData(
color: AppColors.secondaryColor,
size: 24,
),
),
// 타이포그래피 - Metronic Tailwind 스타일
textTheme: TextTheme(
// 헤드라인 - 페이지 제목
headlineLarge: TextStyle(
color: AppColors.textPrimary,
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: TextStyle(
color: AppColors.textPrimary,
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: TextStyle(
color: AppColors.textPrimary,
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
// 타이틀 - 카드, 섹션 제목
titleLarge: TextStyle(
color: AppColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: TextStyle(
color: AppColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.4,
),
// 본문 텍스트
bodyLarge: TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: TextStyle(
color: AppColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
// 라벨 텍스트
labelLarge: TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
),
labelMedium: TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: TextStyle(
color: AppColors.textMuted,
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
),
// 입력 필드 스타일 - 깔끔하고 현대적인 디자인
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceColorAlt,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.borderColor, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.dangerColor, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.dangerColor, width: 1.5),
),
labelStyle: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
hintStyle: TextStyle(
color: AppColors.textMuted,
fontSize: 14,
fontWeight: FontWeight.w400,
),
errorStyle: TextStyle(
color: AppColors.dangerColor,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
// 버튼 스타일 - 프라이머리 버튼
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
),
// 텍스트 버튼 스타일
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryColor,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
),
// 아웃라인 버튼 스타일
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textPrimary,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: AppColors.borderColor, width: 1),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
),
// FAB 스타일
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2,
extendedPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
extendedTextStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
// 스위치 스타일
switchTheme: SwitchThemeData(
thumbColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.primaryColor;
}
return Colors.white;
}),
trackColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.primaryColor.withOpacity(0.5);
}
return AppColors.borderColor;
}),
),
// 체크박스 스타일
checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.primaryColor;
}
return Colors.transparent;
}),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: BorderSide(color: AppColors.borderColor, width: 1.5),
),
// 라디오 버튼 스타일
radioTheme: RadioThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.primaryColor;
}
return AppColors.borderColor;
}),
),
// 슬라이더 스타일
sliderTheme: SliderThemeData(
activeTrackColor: AppColors.primaryColor,
inactiveTrackColor: AppColors.borderColor,
thumbColor: AppColors.primaryColor,
overlayColor: AppColors.primaryColor.withOpacity(0.2),
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
),
// 탭바 스타일
tabBarTheme: TabBarTheme(
labelColor: AppColors.primaryColor,
unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.primaryColor,
labelStyle: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
unselectedLabelStyle: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
// 디바이더 스타일
dividerTheme: DividerThemeData(
color: AppColors.dividerColor,
thickness: 1,
space: 16,
),
// 페이지 트랜지션
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
},
),
// 스낵바 스타일
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.textPrimary,
contentTextStyle: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
behavior: SnackBarBehavior.floating,
),
);
}

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
/// 애니메이션 컨트롤러 관리를 위한 헬퍼 클래스
class AnimationControllerHelper {
/// 모든 애니메이션 컨트롤러를 초기화하는 메서드
static void initControllers({
required TickerProvider vsync,
required AnimationController fadeController,
required AnimationController scaleController,
required AnimationController rotateController,
required AnimationController slideController,
required AnimationController pulseController,
required AnimationController waveController,
}) {
// 페이드 컨트롤러 초기화
fadeController.duration = const Duration(milliseconds: 600);
fadeController.forward();
// 스케일 컨트롤러 초기화
scaleController.duration = const Duration(milliseconds: 600);
scaleController.forward();
// 회전 컨트롤러 초기화
rotateController.duration = const Duration(seconds: 10);
rotateController.repeat();
// 슬라이드 컨트롤러 초기화
slideController.duration = const Duration(milliseconds: 600);
slideController.forward();
// 펄스 컨트롤러 초기화
pulseController.duration = const Duration(milliseconds: 1500);
pulseController.repeat(reverse: true);
// 웨이브 컨트롤러 초기화
waveController.duration = const Duration(milliseconds: 8000);
waveController.forward();
// 웨이브 애니메이션이 끝나면 다시 처음부터 부드럽게 시작하도록 설정
waveController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
waveController.reset();
waveController.forward();
}
});
}
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드
static void resetAnimations({
required AnimationController fadeController,
required AnimationController scaleController,
required AnimationController slideController,
required AnimationController pulseController,
required AnimationController waveController,
}) {
fadeController.reset();
scaleController.reset();
slideController.reset();
pulseController.repeat(reverse: true);
waveController.repeat();
fadeController.forward();
scaleController.forward();
slideController.forward();
}
/// 모든 애니메이션 컨트롤러를 해제하는 메서드
static void disposeControllers({
required AnimationController fadeController,
required AnimationController scaleController,
required AnimationController rotateController,
required AnimationController slideController,
required AnimationController pulseController,
required AnimationController waveController,
}) {
fadeController.dispose();
scaleController.dispose();
rotateController.dispose();
slideController.dispose();
pulseController.dispose();
waveController.dispose();
}
}

View File

@@ -0,0 +1,37 @@
import 'package:intl/intl.dart';
/// 숫자와 날짜를 포맷팅하는 유틸리티 클래스
class FormatHelper {
/// 통화 형식으로 숫자 포맷팅
static String formatCurrency(double value) {
return NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(value);
}
/// 날짜를 yyyy년 MM월 dd일 형식으로 포맷팅
static String formatDate(DateTime date) {
return '${date.year}${date.month}${date.day}';
}
/// 날짜를 MM.dd 형식으로 포맷팅 (짧은 형식)
static String formatShortDate(DateTime date) {
return '${date.month}.${date.day}';
}
/// 현재 날짜로부터 남은 일 수 계산
static String getRemainingDays(DateTime date) {
final now = DateTime.now();
final difference = date.difference(now).inDays;
if (difference < 0) {
return '${-difference}일 지남';
} else if (difference == 0) {
return '오늘';
} else {
return '$difference일';
}
}
}

View File

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스
class SubscriptionCategoryHelper {
/// 구독 서비스 목록을 카테고리별로 그룹화하여 반환
///
/// [subscriptions] 구독 목록
/// [categoryProvider] 카테고리 제공자
///
/// 반환값: 카테고리 이름을 키로 하고 해당 카테고리에 속하는 구독 목록을 값으로 가지는 Map
static Map<String, List<SubscriptionModel>> categorizeSubscriptions(
List<SubscriptionModel> subscriptions,
CategoryProvider categoryProvider,
) {
final Map<String, List<SubscriptionModel>> categorizedSubscriptions = {};
// 카테고리 ID별로 구독 그룹화
for (final subscription in subscriptions) {
if (subscription.categoryId != null) {
// 카테고리 ID가 있는 경우, 해당 카테고리 이름으로 그룹화
final category =
categoryProvider.getCategoryById(subscription.categoryId!);
if (category != null) {
final categoryName = category.name;
if (!categorizedSubscriptions.containsKey(categoryName)) {
categorizedSubscriptions[categoryName] = [];
}
categorizedSubscriptions[categoryName]!.add(subscription);
continue;
}
}
// 카테고리 ID가 없거나 카테고리를 찾을 수 없는 경우 서비스 이름 기반 분류
// OTT
if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.ottServices)) {
if (!categorizedSubscriptions.containsKey('OTT 서비스')) {
categorizedSubscriptions['OTT 서비스'] = [];
}
categorizedSubscriptions['OTT 서비스']!.add(subscription);
}
// 음악
else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.musicServices)) {
if (!categorizedSubscriptions.containsKey('음악 서비스')) {
categorizedSubscriptions['음악 서비스'] = [];
}
categorizedSubscriptions['음악 서비스']!.add(subscription);
}
// AI
else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.aiServices)) {
if (!categorizedSubscriptions.containsKey('AI 서비스')) {
categorizedSubscriptions['AI 서비스'] = [];
}
categorizedSubscriptions['AI 서비스']!.add(subscription);
}
// 프로그래밍/개발
else if (_isInCategory(subscription.serviceName,
SubscriptionUrlMatcher.programmingServices)) {
if (!categorizedSubscriptions.containsKey('프로그래밍/개발 서비스')) {
categorizedSubscriptions['프로그래밍/개발 서비스'] = [];
}
categorizedSubscriptions['프로그래밍/개발 서비스']!.add(subscription);
}
// 오피스/협업 툴
else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.officeTools)) {
if (!categorizedSubscriptions.containsKey('오피스/협업 툴')) {
categorizedSubscriptions['오피스/협업 툴'] = [];
}
categorizedSubscriptions['오피스/협업 툴']!.add(subscription);
}
// 기타 서비스
else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.otherServices)) {
if (!categorizedSubscriptions.containsKey('기타 서비스')) {
categorizedSubscriptions['기타 서비스'] = [];
}
categorizedSubscriptions['기타 서비스']!.add(subscription);
}
// 미분류된 서비스
else {
if (!categorizedSubscriptions.containsKey('미분류')) {
categorizedSubscriptions['미분류'] = [];
}
categorizedSubscriptions['미분류']!.add(subscription);
}
}
// 빈 카테고리 제거
categorizedSubscriptions.removeWhere((key, value) => value.isEmpty);
return categorizedSubscriptions;
}
/// 서비스 이름이 특정 카테고리에 속하는지 확인
static bool _isInCategory(
String serviceName, Map<String, String> categoryServices) {
final lowerServiceName = serviceName.toLowerCase();
return categoryServices.keys.any((key) =>
lowerServiceName.contains(key.toLowerCase()) ||
(key.isNotEmpty && key.toLowerCase().contains(lowerServiceName)));
}
}

Some files were not shown because too many files have changed in this diff Show More