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>
9
.env.example
Normal 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
@@ -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
@@ -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'
|
||||||
1
analysis_options.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
14
android/.gitignore
vendored
Normal 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
|
||||||
44
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.submanager"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.example.submanager"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
47
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.READ_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||||
|
<application
|
||||||
|
android:label="구독 관리"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.example.submanager
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.Telephony
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity() {
|
||||||
|
private val CHANNEL = "com.submanager/sms"
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"scanSubscriptions" -> {
|
||||||
|
scanSubscriptions(result)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanSubscriptions(result: MethodChannel.Result) {
|
||||||
|
try {
|
||||||
|
// 메시지를 임시 저장할 맵
|
||||||
|
val messageGroups = mutableMapOf<String, MutableList<Map<String, Any>>>()
|
||||||
|
|
||||||
|
val cursor = contentResolver.query(
|
||||||
|
Telephony.Sms.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
Telephony.Sms.ADDRESS,
|
||||||
|
Telephony.Sms.BODY,
|
||||||
|
Telephony.Sms.DATE
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"${Telephony.Sms.DATE} DESC"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor?.use {
|
||||||
|
while (it.moveToNext()) {
|
||||||
|
val address = it.getString(0)
|
||||||
|
val body = it.getString(1)
|
||||||
|
val date = it.getLong(2)
|
||||||
|
|
||||||
|
// 구독 관련 키워드로 필터링
|
||||||
|
if (isSubscriptionMessage(body)) {
|
||||||
|
val parsedMessage = parseSubscriptionMessage(body, address, date)
|
||||||
|
if (parsedMessage != null) {
|
||||||
|
val key = "${address}_${parsedMessage["monthlyCost"]}"
|
||||||
|
if (!messageGroups.containsKey(key)) {
|
||||||
|
messageGroups[key] = mutableListOf()
|
||||||
|
}
|
||||||
|
messageGroups[key]?.add(parsedMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최종 구독 목록
|
||||||
|
val subscriptions = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
|
// 동일한 발신자와 유사한 금액으로 2회 이상 메시지가 있는 경우만 구독으로 판단
|
||||||
|
for ((key, messages) in messageGroups) {
|
||||||
|
if (messages.size >= 2) {
|
||||||
|
// 가장 최근 메시지 정보를 사용
|
||||||
|
val latestMessage = messages.first()
|
||||||
|
|
||||||
|
// 주기성 추가
|
||||||
|
val enhancedMessage = latestMessage.toMutableMap()
|
||||||
|
enhancedMessage["repeatCount"] = messages.size
|
||||||
|
enhancedMessage["isRecurring"] = true
|
||||||
|
|
||||||
|
// 이전 결제일도 추가
|
||||||
|
if (messages.size > 1) {
|
||||||
|
enhancedMessage["previousPaymentDate"] = messages[1]["nextBillingDate"] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions.add(enhancedMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success(subscriptions)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("SMS_SCAN_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSubscriptionMessage(body: String?): Boolean {
|
||||||
|
if (body == null) return false
|
||||||
|
val keywords = listOf(
|
||||||
|
"구독", "결제", "청구", "요금", "월정액", "정기결제",
|
||||||
|
"subscription", "payment", "bill", "fee", "monthly", "recurring"
|
||||||
|
)
|
||||||
|
return keywords.any { body.contains(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubscriptionMessage(
|
||||||
|
body: String,
|
||||||
|
address: String,
|
||||||
|
date: Long
|
||||||
|
): Map<String, Any>? {
|
||||||
|
try {
|
||||||
|
// 서비스명 추출 (예: "넷플릭스", "스포티파이" 등)
|
||||||
|
val serviceName = extractServiceName(address, body)
|
||||||
|
|
||||||
|
// 금액 추출 (예: "9,900원", "₩9,900" 등)
|
||||||
|
val amount = extractAmount(body)
|
||||||
|
|
||||||
|
// 결제 주기 추출 (예: "월간", "연간" 등)
|
||||||
|
val billingCycle = extractBillingCycle(body)
|
||||||
|
|
||||||
|
// 다음 결제일 추출
|
||||||
|
val nextBillingDate = extractNextBillingDate(body, date)
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"serviceName" to serviceName,
|
||||||
|
"monthlyCost" to amount,
|
||||||
|
"billingCycle" to billingCycle,
|
||||||
|
"nextBillingDate" to nextBillingDate,
|
||||||
|
"isRecurring" to false,
|
||||||
|
"repeatCount" to 1,
|
||||||
|
"sender" to address,
|
||||||
|
"messageDate" to formatDate(date)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDate(timestamp: Long): String {
|
||||||
|
val date = Date(timestamp)
|
||||||
|
val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||||
|
return formatter.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractServiceName(address: String, body: String): String {
|
||||||
|
// 주소에서 서비스명 추출 시도
|
||||||
|
val addressPattern = Regex("""([가-힣a-zA-Z]+)""")
|
||||||
|
val addressMatch = addressPattern.find(address)
|
||||||
|
if (addressMatch != null) {
|
||||||
|
return addressMatch.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 본문에서 서비스명 추출 시도
|
||||||
|
val bodyPattern = Regex("""([가-힣a-zA-Z]+)\s*(구독|결제|청구)""")
|
||||||
|
val bodyMatch = bodyPattern.find(body)
|
||||||
|
if (bodyMatch != null) {
|
||||||
|
return bodyMatch.groupValues[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "알 수 없는 서비스"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractAmount(body: String): Double {
|
||||||
|
val pattern = Regex("""(\d{1,3}(?:,\d{3})*)(?:\s*원|\s*₩)""")
|
||||||
|
val match = pattern.find(body)
|
||||||
|
return if (match != null) {
|
||||||
|
match.groupValues[1].replace(",", "").toDouble()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractBillingCycle(body: String): String {
|
||||||
|
return when {
|
||||||
|
body.contains("월간") || body.contains("월정액") -> "월간"
|
||||||
|
body.contains("연간") || body.contains("연정액") -> "연간"
|
||||||
|
body.contains("주간") || body.contains("주정액") -> "주간"
|
||||||
|
else -> "월간"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractNextBillingDate(body: String, messageDate: Long): String {
|
||||||
|
val datePattern = Regex("""(\d{4})[년/.-](\d{1,2})[월/.-](\d{1,2})일?""")
|
||||||
|
val match = datePattern.find(body)
|
||||||
|
if (match != null) {
|
||||||
|
val (year, month, day) = match.destructured
|
||||||
|
return "$year-${month.padStart(2, '0')}-${day.padStart(2, '0')}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜를 찾을 수 없는 경우, 메시지 날짜로부터 1개월 후를 반환
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
calendar.timeInMillis = messageDate
|
||||||
|
calendar.add(Calendar.MONTH, 1)
|
||||||
|
return SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(calendar.time)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
18
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
3
android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
25
android/settings.gradle.kts
Normal 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
411
doc/project_analysis.md
Normal 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
@@ -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
|
||||||
26
ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||||
2
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
2
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
43
ios/Podfile
Normal 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
|
||||||
616
ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
99
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal 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>
|
||||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
13
ios/Runner/AppDelegate.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal 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.
|
||||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal 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
@@ -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>
|
||||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
12
ios/RunnerTests/RunnerTests.swift
Normal 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.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
136
lib/l10n/app_localizations.dart
Normal 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
@@ -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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/models/category_model.dart
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
50
lib/models/category_model.g.dart
Normal 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;
|
||||||
|
}
|
||||||
64
lib/models/subscription.dart
Normal 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;
|
||||||
|
}
|
||||||
101
lib/models/subscription_model.dart
Normal 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
|
||||||
83
lib/models/subscription_model.g.dart
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
171
lib/providers/app_lock_provider.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/providers/category_provider.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
lib/providers/locale_provider.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
263
lib/providers/notification_provider.dart
Normal 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');
|
||||||
|
// 오류가 발생해도 앱 동작에 영향을 주지 않도록 처리
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
246
lib/providers/subscription_provider.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2022
lib/screens/add_subscription_screen.dart
Normal file
1143
lib/screens/analysis_screen.dart
Normal file
63
lib/screens/app_lock_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
lib/screens/category_management_screen.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2232
lib/screens/detail_screen.dart
Normal file
442
lib/screens/main_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
561
lib/screens/settings_screen.dart
Normal 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'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
820
lib/screens/sms_scan_screen.dart
Normal 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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
371
lib/screens/splash_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
lib/services/currency_util.dart
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../models/subscription_model.dart';
|
||||||
|
import 'exchange_rate_service.dart';
|
||||||
|
|
||||||
|
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
||||||
|
class CurrencyUtil {
|
||||||
|
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||||
|
|
||||||
|
/// 구독 목록의 총 월 비용을 계산 (원화로 환산, 이벤트 가격 반영)
|
||||||
|
static Future<double> calculateTotalMonthlyExpense(
|
||||||
|
List<SubscriptionModel> subscriptions) async {
|
||||||
|
double total = 0.0;
|
||||||
|
|
||||||
|
for (var subscription in subscriptions) {
|
||||||
|
// 이벤트 가격이 있으면 currentPrice 사용
|
||||||
|
final price = subscription.currentPrice;
|
||||||
|
|
||||||
|
if (subscription.currency == 'USD') {
|
||||||
|
// USD인 경우 KRW로 변환
|
||||||
|
final krwAmount = await _exchangeRateService
|
||||||
|
.convertUsdToKrw(price);
|
||||||
|
if (krwAmount != null) {
|
||||||
|
total += krwAmount;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// KRW인 경우 그대로 합산
|
||||||
|
total += price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영)
|
||||||
|
static Future<String> formatSubscriptionAmount(
|
||||||
|
SubscriptionModel subscription) async {
|
||||||
|
// 이벤트 가격이 있으면 currentPrice 사용
|
||||||
|
final price = subscription.currentPrice;
|
||||||
|
|
||||||
|
if (subscription.currency == 'USD') {
|
||||||
|
// USD 표시 + 원화 환산 금액
|
||||||
|
final usdFormatted = NumberFormat.currency(
|
||||||
|
locale: 'en_US',
|
||||||
|
symbol: '\$',
|
||||||
|
decimalDigits: 2,
|
||||||
|
).format(price);
|
||||||
|
|
||||||
|
// 원화 환산 금액
|
||||||
|
final krwAmount = await _exchangeRateService
|
||||||
|
.getFormattedKrwAmount(price);
|
||||||
|
|
||||||
|
return '$usdFormatted $krwAmount';
|
||||||
|
} else {
|
||||||
|
// 원화 표시
|
||||||
|
return NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '₩',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 총액을 원화로 표시
|
||||||
|
static String formatTotalAmount(double amount) {
|
||||||
|
return NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '₩',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 환율 정보 텍스트 가져오기
|
||||||
|
static Future<String> getExchangeRateInfo() {
|
||||||
|
return _exchangeRateService.getFormattedExchangeRateInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이벤트로 인한 총 절약액 계산 (원화로 환산)
|
||||||
|
static Future<double> calculateTotalEventSavings(
|
||||||
|
List<SubscriptionModel> subscriptions) async {
|
||||||
|
double total = 0.0;
|
||||||
|
|
||||||
|
for (var subscription in subscriptions) {
|
||||||
|
if (subscription.isCurrentlyInEvent) {
|
||||||
|
final savings = subscription.eventSavings;
|
||||||
|
|
||||||
|
if (subscription.currency == 'USD') {
|
||||||
|
// USD인 경우 KRW로 변환
|
||||||
|
final krwAmount = await _exchangeRateService
|
||||||
|
.convertUsdToKrw(savings);
|
||||||
|
if (krwAmount != null) {
|
||||||
|
total += krwAmount;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// KRW인 경우 그대로 합산
|
||||||
|
total += savings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이벤트 절약액을 표시 형식에 맞게 변환
|
||||||
|
static Future<String> formatEventSavings(
|
||||||
|
SubscriptionModel subscription) async {
|
||||||
|
if (!subscription.isCurrentlyInEvent) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
final savings = subscription.eventSavings;
|
||||||
|
|
||||||
|
if (subscription.currency == 'USD') {
|
||||||
|
// USD 표시 + 원화 환산 금액
|
||||||
|
final usdFormatted = NumberFormat.currency(
|
||||||
|
locale: 'en_US',
|
||||||
|
symbol: '\$',
|
||||||
|
decimalDigits: 2,
|
||||||
|
).format(savings);
|
||||||
|
|
||||||
|
// 원화 환산 금액
|
||||||
|
final krwAmount = await _exchangeRateService
|
||||||
|
.getFormattedKrwAmount(savings);
|
||||||
|
|
||||||
|
return '$usdFormatted $krwAmount';
|
||||||
|
} else {
|
||||||
|
// 원화 표시
|
||||||
|
return NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '₩',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(savings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
lib/services/exchange_rate_service.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// 환율 정보 서비스 클래스
|
||||||
|
class ExchangeRateService {
|
||||||
|
// 싱글톤 인스턴스
|
||||||
|
static final ExchangeRateService _instance = ExchangeRateService._internal();
|
||||||
|
|
||||||
|
// 팩토리 생성자
|
||||||
|
factory ExchangeRateService() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내부 생성자
|
||||||
|
ExchangeRateService._internal();
|
||||||
|
|
||||||
|
// 캐싱된 환율 정보
|
||||||
|
double? _usdToKrwRate;
|
||||||
|
DateTime? _lastUpdated;
|
||||||
|
|
||||||
|
// API 요청 URL (ExchangeRate-API 사용)
|
||||||
|
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD';
|
||||||
|
|
||||||
|
/// 현재 USD to KRW 환율 정보를 가져옵니다.
|
||||||
|
/// 최근 6시간 이내 조회했던 정보가 있다면 캐싱된 정보를 반환합니다.
|
||||||
|
Future<double?> getUsdToKrwRate() async {
|
||||||
|
// 캐싱된 데이터 있고 6시간 이내면 캐싱된 데이터 반환
|
||||||
|
if (_usdToKrwRate != null && _lastUpdated != null) {
|
||||||
|
final difference = DateTime.now().difference(_lastUpdated!);
|
||||||
|
if (difference.inHours < 6) {
|
||||||
|
return _usdToKrwRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// API 요청
|
||||||
|
final response = await http.get(Uri.parse(_apiUrl));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
_usdToKrwRate = data['rates']['KRW'].toDouble();
|
||||||
|
_lastUpdated = DateTime.now();
|
||||||
|
return _usdToKrwRate;
|
||||||
|
} else {
|
||||||
|
// 실패 시 캐싱된 값이라도 반환
|
||||||
|
return _usdToKrwRate;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 오류 발생 시 캐싱된 값이라도 반환
|
||||||
|
return _usdToKrwRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// USD 금액을 KRW로 변환합니다.
|
||||||
|
Future<double?> convertUsdToKrw(double usdAmount) async {
|
||||||
|
final rate = await getUsdToKrwRate();
|
||||||
|
if (rate != null) {
|
||||||
|
return usdAmount * rate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 현재 환율 정보를 포맷팅하여 텍스트로 반환합니다.
|
||||||
|
Future<String> getFormattedExchangeRateInfo() async {
|
||||||
|
final rate = await getUsdToKrwRate();
|
||||||
|
if (rate != null) {
|
||||||
|
final formattedRate = NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '₩',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(rate);
|
||||||
|
return '오늘 기준 환율 : $formattedRate';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||||
|
Future<String> getFormattedKrwAmount(double usdAmount) async {
|
||||||
|
final krwAmount = await convertUsdToKrw(usdAmount);
|
||||||
|
if (krwAmount != null) {
|
||||||
|
final formattedAmount = NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '₩',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(krwAmount);
|
||||||
|
return '($formattedAmount)';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
548
lib/services/notification_service.dart
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
import 'package:timezone/data/latest_all.dart' as tz;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/subscription_model.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
static final FlutterLocalNotificationsPlugin _notifications =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
|
static final _secureStorage = const FlutterSecureStorage();
|
||||||
|
|
||||||
|
static const _notificationEnabledKey = 'notification_enabled';
|
||||||
|
static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
|
||||||
|
static const _reminderDaysKey = 'reminder_days';
|
||||||
|
static const _reminderHourKey = 'reminder_hour';
|
||||||
|
static const _reminderMinuteKey = 'reminder_minute';
|
||||||
|
static const _dailyReminderKey = 'daily_reminder_enabled';
|
||||||
|
|
||||||
|
// 초기화 상태를 추적하기 위한 플래그
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
// 웹 플랫폼 여부 확인 (웹에서는 flutter_local_notifications가 지원되지 않음)
|
||||||
|
static bool get _isWeb => kIsWeb;
|
||||||
|
|
||||||
|
static const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||||
|
'subscription_channel',
|
||||||
|
'Subscription Notifications',
|
||||||
|
description: 'Channel for subscription reminders',
|
||||||
|
importance: Importance.high,
|
||||||
|
enableVibration: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 알림 초기화
|
||||||
|
static Future<void> init() async {
|
||||||
|
try {
|
||||||
|
// 웹 플랫폼인 경우 초기화 건너뛰기
|
||||||
|
if (_isWeb) {
|
||||||
|
debugPrint('웹 플랫폼에서는 로컬 알림이 지원되지 않습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tz.initializeTimeZones();
|
||||||
|
try {
|
||||||
|
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||||
|
} catch (e) {
|
||||||
|
// 타임존 찾기 실패시 UTC 사용
|
||||||
|
tz.setLocalLocation(tz.UTC);
|
||||||
|
debugPrint('타임존 설정 실패, UTC 사용: $e');
|
||||||
|
}
|
||||||
|
const androidSettings =
|
||||||
|
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
|
const iosSettings = DarwinInitializationSettings();
|
||||||
|
const initSettings =
|
||||||
|
InitializationSettings(android: androidSettings, iOS: iosSettings);
|
||||||
|
|
||||||
|
await _notifications.initialize(initSettings);
|
||||||
|
_initialized = true;
|
||||||
|
debugPrint('알림 서비스 초기화 완료');
|
||||||
|
} catch (e) {
|
||||||
|
_initialized = false;
|
||||||
|
debugPrint('알림 서비스 초기화 실패: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> isNotificationEnabled() async {
|
||||||
|
final value = await _secureStorage.read(key: _notificationEnabledKey);
|
||||||
|
return value == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> isPaymentNotificationEnabled() async {
|
||||||
|
final value =
|
||||||
|
await _secureStorage.read(key: _paymentNotificationEnabledKey);
|
||||||
|
return value == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setNotificationEnabled(bool value) async {
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: _notificationEnabledKey,
|
||||||
|
value: value.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setPaymentNotificationEnabled(bool value) async {
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: _paymentNotificationEnabledKey,
|
||||||
|
value: value.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 시점 설정 (1일전, 2일전, 3일전) 가져오기
|
||||||
|
static Future<int> getReminderDays() async {
|
||||||
|
final value = await _secureStorage.read(key: _reminderDaysKey);
|
||||||
|
return value != null ? int.tryParse(value) ?? 3 : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 시간 가져오기
|
||||||
|
static Future<int> getReminderHour() async {
|
||||||
|
final value = await _secureStorage.read(key: _reminderHourKey);
|
||||||
|
return value != null ? int.tryParse(value) ?? 10 : 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<int> getReminderMinute() async {
|
||||||
|
final value = await _secureStorage.read(key: _reminderMinuteKey);
|
||||||
|
return value != null ? int.tryParse(value) ?? 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 반복 알림 설정 가져오기
|
||||||
|
static Future<bool> isDailyReminderEnabled() async {
|
||||||
|
final value = await _secureStorage.read(key: _dailyReminderKey);
|
||||||
|
return value == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 구독의 알림 일정 다시 예약
|
||||||
|
static Future<void> reschedulAllNotifications(
|
||||||
|
List<SubscriptionModel> subscriptions) async {
|
||||||
|
try {
|
||||||
|
// 웹 플랫폼이거나 알림 초기화 실패한 경우 건너뛰기
|
||||||
|
if (_isWeb || !_initialized) {
|
||||||
|
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 알림 모두 취소
|
||||||
|
await cancelAllNotifications();
|
||||||
|
|
||||||
|
// 알림 설정 가져오기
|
||||||
|
final isPaymentEnabled = await isPaymentNotificationEnabled();
|
||||||
|
if (!isPaymentEnabled) return;
|
||||||
|
|
||||||
|
final reminderDays = await getReminderDays();
|
||||||
|
final reminderHour = await getReminderHour();
|
||||||
|
final reminderMinute = await getReminderMinute();
|
||||||
|
final isDailyReminder = await isDailyReminderEnabled();
|
||||||
|
|
||||||
|
// 각 구독에 대해 알림 재설정
|
||||||
|
for (final subscription in subscriptions) {
|
||||||
|
await schedulePaymentReminder(
|
||||||
|
id: subscription.id.hashCode,
|
||||||
|
serviceName: subscription.serviceName,
|
||||||
|
amount: subscription.monthlyCost,
|
||||||
|
billingDate: subscription.nextBillingDate,
|
||||||
|
reminderDays: reminderDays,
|
||||||
|
reminderHour: reminderHour,
|
||||||
|
reminderMinute: reminderMinute,
|
||||||
|
isDailyReminder: isDailyReminder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('알림 일정 재설정 중 오류 발생: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 서비스가 초기화되었는지 확인하는 메서드
|
||||||
|
static bool _isInitialized() {
|
||||||
|
// 웹 플랫폼인 경우 항상 false 반환
|
||||||
|
if (_isWeb) return false;
|
||||||
|
// 초기화 플래그 확인
|
||||||
|
return _initialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> requestPermission() async {
|
||||||
|
final result = await _notifications
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestPermission();
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 스케줄 설정
|
||||||
|
static Future<void> scheduleNotification({
|
||||||
|
required int id,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required DateTime scheduledDate,
|
||||||
|
}) async {
|
||||||
|
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||||
|
if (_isWeb || !_initialized) {
|
||||||
|
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
'subscription_channel',
|
||||||
|
'구독 알림',
|
||||||
|
channelDescription: '구독 관련 알림을 보여줍니다.',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
);
|
||||||
|
|
||||||
|
final iosDetails = const DarwinNotificationDetails();
|
||||||
|
|
||||||
|
// tz.local 초기화 확인 및 재시도
|
||||||
|
tz.Location location;
|
||||||
|
try {
|
||||||
|
location = tz.local;
|
||||||
|
} catch (e) {
|
||||||
|
// tz.local이 초기화되지 않은 경우 재시도
|
||||||
|
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||||
|
try {
|
||||||
|
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||||
|
location = tz.local;
|
||||||
|
} catch (_) {
|
||||||
|
// 그래도 실패하면 UTC 사용
|
||||||
|
debugPrint('타임존 설정 실패, UTC 사용');
|
||||||
|
tz.setLocalLocation(tz.UTC);
|
||||||
|
location = tz.UTC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifications.zonedSchedule(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
tz.TZDateTime.from(scheduledDate, location),
|
||||||
|
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||||
|
androidAllowWhileIdle: true,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('알림 예약 중 오류 발생: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 취소
|
||||||
|
static Future<void> cancelNotification(int id) async {
|
||||||
|
await _notifications.cancel(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 알림 취소
|
||||||
|
static Future<void> cancelAllNotifications() async {
|
||||||
|
try {
|
||||||
|
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||||
|
if (_isWeb || !_initialized) {
|
||||||
|
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 취소할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifications.cancelAll();
|
||||||
|
debugPrint('모든 알림이 취소되었습니다.');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('알림 취소 중 오류 발생: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> scheduleSubscriptionNotification(
|
||||||
|
SubscriptionModel subscription) async {
|
||||||
|
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||||
|
if (_isWeb || !_initialized) {
|
||||||
|
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final notificationId = subscription.id.hashCode;
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
'subscription_channel',
|
||||||
|
'구독 알림',
|
||||||
|
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
);
|
||||||
|
|
||||||
|
final iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final notificationDetails = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
// tz.local 초기화 확인 및 재시도
|
||||||
|
tz.Location location;
|
||||||
|
try {
|
||||||
|
location = tz.local;
|
||||||
|
} catch (e) {
|
||||||
|
// tz.local이 초기화되지 않은 경우 재시도
|
||||||
|
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||||
|
try {
|
||||||
|
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||||
|
location = tz.local;
|
||||||
|
} catch (_) {
|
||||||
|
// 그래도 실패하면 UTC 사용
|
||||||
|
debugPrint('타임존 설정 실패, UTC 사용');
|
||||||
|
tz.setLocalLocation(tz.UTC);
|
||||||
|
location = tz.UTC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifications.zonedSchedule(
|
||||||
|
notificationId,
|
||||||
|
'구독 만료 알림',
|
||||||
|
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
||||||
|
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
||||||
|
notificationDetails,
|
||||||
|
androidAllowWhileIdle: true,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> updateSubscriptionNotification(
|
||||||
|
SubscriptionModel subscription) async {
|
||||||
|
await cancelSubscriptionNotification(subscription);
|
||||||
|
await scheduleSubscriptionNotification(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> cancelSubscriptionNotification(
|
||||||
|
SubscriptionModel subscription) async {
|
||||||
|
final notificationId = subscription.id.hashCode;
|
||||||
|
await _notifications.cancel(notificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> schedulePaymentNotification(
|
||||||
|
SubscriptionModel subscription) async {
|
||||||
|
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||||
|
if (_isWeb || !_initialized) {
|
||||||
|
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final paymentDate = subscription.nextBillingDate;
|
||||||
|
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
||||||
|
|
||||||
|
// tz.local 초기화 확인 및 재시도
|
||||||
|
tz.Location location;
|
||||||
|
try {
|
||||||
|
location = tz.local;
|
||||||
|
} catch (e) {
|
||||||
|
// tz.local이 초기화되지 않은 경우 재시도
|
||||||
|
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||||
|
try {
|
||||||
|
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||||
|
location = tz.local;
|
||||||
|
} catch (_) {
|
||||||
|
// 그래도 실패하면 UTC 사용
|
||||||
|
debugPrint('타임존 설정 실패, UTC 사용');
|
||||||
|
tz.setLocalLocation(tz.UTC);
|
||||||
|
location = tz.UTC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifications.zonedSchedule(
|
||||||
|
subscription.id.hashCode,
|
||||||
|
'구독 결제 예정 알림',
|
||||||
|
'${subscription.serviceName} 결제가 3일 후 예정되어 있습니다.',
|
||||||
|
tz.TZDateTime.from(reminderDate, location),
|
||||||
|
const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'payment_channel',
|
||||||
|
'Payment Notifications',
|
||||||
|
channelDescription: 'Channel for subscription payment reminders',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
androidAllowWhileIdle: true,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> scheduleExpirationNotification(
|
||||||
|
SubscriptionModel subscription) async {
|
||||||
|
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||||
|
if (_isWeb || !_initialized) {
|
||||||
|
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final expirationDate = subscription.nextBillingDate;
|
||||||
|
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
||||||
|
|
||||||
|
// tz.local 초기화 확인 및 재시도
|
||||||
|
tz.Location location;
|
||||||
|
try {
|
||||||
|
location = tz.local;
|
||||||
|
} catch (e) {
|
||||||
|
// tz.local이 초기화되지 않은 경우 재시도
|
||||||
|
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||||
|
try {
|
||||||
|
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||||
|
location = tz.local;
|
||||||
|
} catch (_) {
|
||||||
|
// 그래도 실패하면 UTC 사용
|
||||||
|
debugPrint('타임존 설정 실패, UTC 사용');
|
||||||
|
tz.setLocalLocation(tz.UTC);
|
||||||
|
location = tz.UTC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifications.zonedSchedule(
|
||||||
|
(subscription.id + '_expiration').hashCode,
|
||||||
|
'구독 만료 예정 알림',
|
||||||
|
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
|
||||||
|
tz.TZDateTime.from(reminderDate, location),
|
||||||
|
const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'expiration_channel',
|
||||||
|
'Expiration Notifications',
|
||||||
|
channelDescription: 'Channel for subscription expiration reminders',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
androidAllowWhileIdle: true,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> schedulePaymentReminder({
|
||||||
|
required int id,
|
||||||
|
required String serviceName,
|
||||||
|
required double amount,
|
||||||
|
required DateTime billingDate,
|
||||||
|
int reminderDays = 3,
|
||||||
|
int reminderHour = 10,
|
||||||
|
int reminderMinute = 0,
|
||||||
|
bool isDailyReminder = false,
|
||||||
|
}) async {
|
||||||
|
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||||
|
if (_isWeb || !_initialized) {
|
||||||
|
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// tz.local 초기화 확인 및 재시도
|
||||||
|
tz.Location location;
|
||||||
|
try {
|
||||||
|
location = tz.local;
|
||||||
|
} catch (e) {
|
||||||
|
// tz.local이 초기화되지 않은 경우 재시도
|
||||||
|
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||||
|
try {
|
||||||
|
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||||
|
location = tz.local;
|
||||||
|
} catch (_) {
|
||||||
|
// 그래도 실패하면 UTC 사용
|
||||||
|
debugPrint('타임존 설정 실패, UTC 사용');
|
||||||
|
tz.setLocalLocation(tz.UTC);
|
||||||
|
location = tz.UTC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 알림 예약 (지정된 일수 전)
|
||||||
|
final scheduledDate =
|
||||||
|
billingDate.subtract(Duration(days: reminderDays)).copyWith(
|
||||||
|
hour: reminderHour,
|
||||||
|
minute: reminderMinute,
|
||||||
|
second: 0,
|
||||||
|
millisecond: 0,
|
||||||
|
microsecond: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 남은 일수에 따른 메시지 생성
|
||||||
|
String daysText = '$reminderDays일 후';
|
||||||
|
if (reminderDays == 1) {
|
||||||
|
daysText = '내일';
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifications.zonedSchedule(
|
||||||
|
id,
|
||||||
|
'구독 결제 예정 알림',
|
||||||
|
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.',
|
||||||
|
tz.TZDateTime.from(scheduledDate, location),
|
||||||
|
const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'subscription_channel',
|
||||||
|
'Subscription Notifications',
|
||||||
|
channelDescription: 'Channel for subscription reminders',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
iOS: DarwinNotificationDetails(),
|
||||||
|
),
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
|
||||||
|
if (isDailyReminder && reminderDays >= 2) {
|
||||||
|
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||||
|
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||||
|
final dailyDate = billingDate.subtract(Duration(days: i)).copyWith(
|
||||||
|
hour: reminderHour,
|
||||||
|
minute: reminderMinute,
|
||||||
|
second: 0,
|
||||||
|
millisecond: 0,
|
||||||
|
microsecond: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 남은 일수에 따른 메시지 생성
|
||||||
|
String remainingDaysText = '$i일 후';
|
||||||
|
if (i == 1) {
|
||||||
|
remainingDaysText = '내일';
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifications.zonedSchedule(
|
||||||
|
id + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
||||||
|
'구독 결제 예정 알림',
|
||||||
|
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.',
|
||||||
|
tz.TZDateTime.from(dailyDate, location),
|
||||||
|
const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'subscription_channel',
|
||||||
|
'Subscription Notifications',
|
||||||
|
channelDescription: 'Channel for subscription reminders',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
iOS: DarwinNotificationDetails(),
|
||||||
|
),
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String getNotificationBody(String serviceName, double amount) {
|
||||||
|
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
|
||||||
|
}
|
||||||
|
}
|
||||||
318
lib/services/sms_scanner.dart
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../models/subscription_model.dart';
|
||||||
|
import '../temp/test_sms_data.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||||
|
import '../services/subscription_url_matcher.dart';
|
||||||
|
|
||||||
|
class SmsScanner {
|
||||||
|
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
||||||
|
try {
|
||||||
|
List<dynamic> smsList;
|
||||||
|
print('SmsScanner: 스캔 시작');
|
||||||
|
|
||||||
|
// 디버그 모드에서는 테스트 데이터 사용
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('SmsScanner: 디버그 모드에서 테스트 데이터 사용');
|
||||||
|
smsList = TestSmsData.getTestData();
|
||||||
|
print('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
|
||||||
|
} else {
|
||||||
|
print('SmsScanner: 실제 SMS 데이터 스캔');
|
||||||
|
// 실제 환경에서는 네이티브 코드 호출
|
||||||
|
const platform = MethodChannel('com.submanager/sms');
|
||||||
|
try {
|
||||||
|
smsList = await platform.invokeMethod('scanSubscriptions');
|
||||||
|
print('SmsScanner: 네이티브 호출 성공, SMS 데이터 개수: ${smsList.length}');
|
||||||
|
} catch (e) {
|
||||||
|
print('SmsScanner: 네이티브 호출 실패: $e');
|
||||||
|
// 오류 발생 시 빈 목록 반환
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
||||||
|
final List<SubscriptionModel> subscriptions = [];
|
||||||
|
final Map<String, List<Map<String, dynamic>>> serviceGroups = {};
|
||||||
|
|
||||||
|
// 서비스명별로 SMS 메시지 그룹화
|
||||||
|
for (final sms in smsList) {
|
||||||
|
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||||
|
if (!serviceGroups.containsKey(serviceName)) {
|
||||||
|
serviceGroups[serviceName] = [];
|
||||||
|
}
|
||||||
|
serviceGroups[serviceName]!.add(sms);
|
||||||
|
}
|
||||||
|
|
||||||
|
print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
||||||
|
|
||||||
|
// 그룹화된 데이터로 구독 분석
|
||||||
|
for (final entry in serviceGroups.entries) {
|
||||||
|
print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
|
||||||
|
|
||||||
|
// 2회 이상 반복된 서비스만 구독으로 간주
|
||||||
|
if (entry.value.length >= 2) {
|
||||||
|
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
|
||||||
|
final subscription = _parseSms(serviceSms, entry.value.length);
|
||||||
|
if (subscription != null) {
|
||||||
|
print(
|
||||||
|
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
||||||
|
subscriptions.add(subscription);
|
||||||
|
} else {
|
||||||
|
print('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
||||||
|
return subscriptions;
|
||||||
|
} catch (e) {
|
||||||
|
print('SmsScanner: 예외 발생: $e');
|
||||||
|
throw Exception('SMS 스캔 중 오류 발생: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||||
|
try {
|
||||||
|
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||||
|
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
final billingCycle = sms['billingCycle'] as String? ?? '월간';
|
||||||
|
final nextBillingDateStr = sms['nextBillingDate'] as String?;
|
||||||
|
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
|
||||||
|
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
|
||||||
|
final isRecurring = (sms['isRecurring'] as bool?) ?? (repeatCount >= 2);
|
||||||
|
final message = sms['message'] as String? ?? '';
|
||||||
|
|
||||||
|
// 통화 단위 감지 - 메시지 내용과 서비스명 모두 검사
|
||||||
|
String currency = _detectCurrency(message);
|
||||||
|
|
||||||
|
// 서비스명에 따라 통화 단위 재확인
|
||||||
|
final dollarServices = [
|
||||||
|
'GitHub',
|
||||||
|
'GitHub Pro',
|
||||||
|
'Netflix US',
|
||||||
|
'Spotify',
|
||||||
|
'Spotify Premium'
|
||||||
|
];
|
||||||
|
if (dollarServices.any((service) => serviceName.contains(service))) {
|
||||||
|
print('서비스명 $serviceName으로 USD 통화 단위 확정');
|
||||||
|
currency = 'USD';
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? nextBillingDate;
|
||||||
|
if (nextBillingDateStr != null) {
|
||||||
|
nextBillingDate = DateTime.tryParse(nextBillingDateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? lastPaymentDate;
|
||||||
|
final previousPaymentDateStr = sms['previousPaymentDate'] as String?;
|
||||||
|
if (previousPaymentDateStr != null && previousPaymentDateStr.isNotEmpty) {
|
||||||
|
lastPaymentDate = DateTime.tryParse(previousPaymentDateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
|
||||||
|
DateTime adjustedNextBillingDate = _calculateNextBillingDate(
|
||||||
|
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
|
||||||
|
billingCycle);
|
||||||
|
|
||||||
|
return SubscriptionModel(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
serviceName: serviceName,
|
||||||
|
monthlyCost: monthlyCost,
|
||||||
|
billingCycle: billingCycle,
|
||||||
|
nextBillingDate: adjustedNextBillingDate,
|
||||||
|
isAutoDetected: true,
|
||||||
|
repeatCount: actualRepeatCount,
|
||||||
|
lastPaymentDate: lastPaymentDate,
|
||||||
|
websiteUrl: _extractWebsiteUrl(serviceName),
|
||||||
|
currency: currency, // 통화 단위 설정
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 결제일 계산 (현재 날짜 기준으로 조정)
|
||||||
|
DateTime _calculateNextBillingDate(
|
||||||
|
DateTime billingDate, String billingCycle) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// 결제일이 이미 미래인 경우 그대로 반환
|
||||||
|
if (billingDate.isAfter(now)) {
|
||||||
|
return billingDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결제 주기별 다음 결제일 계산
|
||||||
|
if (billingCycle == '월간') {
|
||||||
|
int month = now.month;
|
||||||
|
int year = now.year;
|
||||||
|
|
||||||
|
// 현재 달의 결제일이 이미 지났으면 다음 달로 이동
|
||||||
|
if (now.day >= billingDate.day) {
|
||||||
|
month = month + 1;
|
||||||
|
if (month > 12) {
|
||||||
|
month = 1;
|
||||||
|
year = year + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime(year, month, billingDate.day);
|
||||||
|
} else if (billingCycle == '연간') {
|
||||||
|
// 올해의 결제일이 지났는지 확인
|
||||||
|
final thisYearBilling =
|
||||||
|
DateTime(now.year, billingDate.month, billingDate.day);
|
||||||
|
if (thisYearBilling.isBefore(now)) {
|
||||||
|
return DateTime(now.year + 1, billingDate.month, billingDate.day);
|
||||||
|
} else {
|
||||||
|
return thisYearBilling;
|
||||||
|
}
|
||||||
|
} else if (billingCycle == '주간') {
|
||||||
|
// 가장 가까운 다음 주 같은 요일 계산
|
||||||
|
final dayDifference = billingDate.weekday - now.weekday;
|
||||||
|
final daysToAdd = dayDifference > 0 ? dayDifference : 7 + dayDifference;
|
||||||
|
return now.add(Duration(days: daysToAdd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 처리: 30일 후
|
||||||
|
return now.add(const Duration(days: 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _extractWebsiteUrl(String serviceName) {
|
||||||
|
// SubscriptionUrlMatcher 서비스를 사용하여 URL 매칭 시도
|
||||||
|
final suggestedUrl = SubscriptionUrlMatcher.findMatchingUrl(serviceName);
|
||||||
|
|
||||||
|
// 매칭된 URL이 있으면 반환, 없으면 기존 매핑 사용
|
||||||
|
if (suggestedUrl != null && suggestedUrl.isNotEmpty) {
|
||||||
|
return suggestedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 하드코딩된 매핑 (필요한 경우 폴백으로 사용)
|
||||||
|
final Map<String, String> serviceUrls = {
|
||||||
|
'넷플릭스': 'https://www.netflix.com',
|
||||||
|
'디즈니플러스': 'https://www.disneyplus.com',
|
||||||
|
'유튜브프리미엄': 'https://www.youtube.com',
|
||||||
|
'YouTube Premium': 'https://www.youtube.com',
|
||||||
|
'애플 iCloud': 'https://www.icloud.com',
|
||||||
|
'Microsoft 365': 'https://www.microsoft.com/microsoft-365',
|
||||||
|
'멜론': 'https://www.melon.com',
|
||||||
|
'웨이브': 'https://www.wavve.com',
|
||||||
|
'Apple Music': 'https://www.apple.com/apple-music',
|
||||||
|
'Netflix': 'https://www.netflix.com',
|
||||||
|
'Disney+': 'https://www.disneyplus.com',
|
||||||
|
'Spotify': 'https://www.spotify.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
return serviceUrls[serviceName];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _containsSubscriptionKeywords(String text) {
|
||||||
|
final keywords = [
|
||||||
|
'구독',
|
||||||
|
'결제',
|
||||||
|
'청구',
|
||||||
|
'정기',
|
||||||
|
'자동',
|
||||||
|
'subscription',
|
||||||
|
'payment',
|
||||||
|
'bill',
|
||||||
|
'invoice'
|
||||||
|
];
|
||||||
|
return keywords
|
||||||
|
.any((keyword) => text.toLowerCase().contains(keyword.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _extractAmount(String text) {
|
||||||
|
final RegExp amountRegex = RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{2})?)');
|
||||||
|
final match = amountRegex.firstMatch(text);
|
||||||
|
if (match != null) {
|
||||||
|
final amountStr = match.group(1)?.replaceAll(',', '');
|
||||||
|
return double.tryParse(amountStr ?? '');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _extractServiceName(String text) {
|
||||||
|
final serviceNames = [
|
||||||
|
'Netflix',
|
||||||
|
'Spotify',
|
||||||
|
'Disney+',
|
||||||
|
'Apple Music',
|
||||||
|
'YouTube Premium',
|
||||||
|
'Amazon Prime',
|
||||||
|
'Microsoft 365',
|
||||||
|
'Google One',
|
||||||
|
'iCloud',
|
||||||
|
'Dropbox'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final name in serviceNames) {
|
||||||
|
if (text.contains(name)) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _extractBillingCycle(String text) {
|
||||||
|
if (text.contains('월') || text.contains('month')) {
|
||||||
|
return 'monthly';
|
||||||
|
} else if (text.contains('년') || text.contains('year')) {
|
||||||
|
return 'yearly';
|
||||||
|
} else if (text.contains('주') || text.contains('week')) {
|
||||||
|
return 'weekly';
|
||||||
|
}
|
||||||
|
return 'monthly'; // 기본값
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _extractNextBillingDate(String text) {
|
||||||
|
final RegExp dateRegex = RegExp(r'(\d{4}[-/]\d{2}[-/]\d{2})');
|
||||||
|
final match = dateRegex.firstMatch(text);
|
||||||
|
if (match != null) {
|
||||||
|
final dateStr = match.group(1);
|
||||||
|
if (dateStr != null) {
|
||||||
|
final date = DateTime.tryParse(dateStr);
|
||||||
|
if (date != null) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DateTime.now().add(const Duration(days: 30)); // 기본값: 30일 후
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메시지에서 통화 단위를 감지하는 함수
|
||||||
|
String _detectCurrency(String message) {
|
||||||
|
final dollarKeywords = [
|
||||||
|
'\$', 'USD', 'dollar', '달러', 'dollars', 'US\$',
|
||||||
|
// 해외 서비스 이름
|
||||||
|
'Netflix US', 'Spotify Premium', 'Apple US', 'Amazon US', 'GitHub'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 특정 서비스명으로 통화 단위 결정
|
||||||
|
final Map<String, String> serviceCurrencyMap = {
|
||||||
|
'Netflix US': 'USD',
|
||||||
|
'Spotify Premium': 'USD',
|
||||||
|
'Spotify': 'USD',
|
||||||
|
'GitHub': 'USD',
|
||||||
|
'GitHub Pro': 'USD',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 서비스명 기반 통화 단위 확인
|
||||||
|
for (final service in serviceCurrencyMap.keys) {
|
||||||
|
if (message.contains(service)) {
|
||||||
|
print('_detectCurrency: ${service}는 USD 서비스로 판별됨');
|
||||||
|
return 'USD';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메시지에 달러 관련 키워드가 있는지 확인
|
||||||
|
for (final keyword in dollarKeywords) {
|
||||||
|
if (message.toLowerCase().contains(keyword.toLowerCase())) {
|
||||||
|
print('_detectCurrency: USD 키워드 발견: $keyword');
|
||||||
|
return 'USD';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값은 원화
|
||||||
|
return 'KRW';
|
||||||
|
}
|
||||||
|
}
|
||||||
35
lib/services/sms_service.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||||
|
|
||||||
|
class SMSService {
|
||||||
|
static const platform = MethodChannel('com.submanager/sms');
|
||||||
|
|
||||||
|
static Future<bool> requestSMSPermission() async {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
final status = await permission.Permission.sms.request();
|
||||||
|
return status.isGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> hasSMSPermission() async {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
final status = await permission.Permission.sms.status;
|
||||||
|
return status.isGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> scanSubscriptions() async {
|
||||||
|
if (kIsWeb) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!await hasSMSPermission()) {
|
||||||
|
throw Exception('SMS 권한이 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<dynamic> result =
|
||||||
|
await platform.invokeMethod('scanSubscriptions');
|
||||||
|
return result.map((item) => item as Map<String, dynamic>).toList();
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception('SMS 스캔 중 오류 발생: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
476
lib/services/subscription_url_matcher.dart
Normal file
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
84
lib/utils/animation_controller_helper.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
lib/utils/format_helper.dart
Normal 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일 후';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
lib/utils/subscription_category_helper.dart
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||