28 Commits

Author SHA1 Message Date
JiWoong Sul
87f82546a4 feat: 알림 재예약 개선과 패키지 업그레이드 2025-09-19 18:10:47 +09:00
JiWoong Sul
e909ba59a4 fix: allow weekend billing dates and restore full-screen alerts 2025-09-19 01:08:09 +09:00
JiWoong Sul
3af9a1f839 fix: ensure notifications use correct channels and dates 2025-09-19 01:06:36 +09:00
JiWoong Sul
44850a53cc feat: adopt material 3 theme and billing adjustments 2025-09-16 14:30:14 +09:00
JiWoong Sul
a01d9092ba docs(pr): summarize notification reliability changes (branch codex/fix-notification-reliability) 2025-09-15 15:38:49 +09:00
JiWoong Sul
3d86316a2b feat(android): add exact alarms permission request entry in Settings\n\n- UI: Settings card shows request when exact alarms not allowed\n- Service: wrap canScheduleExactAlarms/requestExactAlarmsPermission via FLN plugin\n- Keeps changes minimal; no new deps\n\nValidation: scripts/check.sh passed 2025-09-15 15:21:44 +09:00
JiWoong Sul
55e3f67279 fix(notification): improve local notification reliability on iOS/Android\n\n- iOS: set UNUserNotificationCenter delegate and present [.banner,.sound,.badge]\n- Android: create channels on init; use exactAllowWhileIdle; add RECEIVE_BOOT_COMPLETED and SCHEDULE_EXACT_ALARM\n- Dart: ensure iOS present options enabled; fix title variable shadowing\n\nValidation: scripts/check.sh passed (format/analyze/tests)\nRisk: exact alarms require user to allow 'Alarms & reminders' on Android 12+\nRollback: revert manifest perms and switch schedule mode back to inexact 2025-09-15 15:18:45 +09:00
JiWoong Sul
d111b5dd62 fix(sms-permission): re-request on denial and guide permanent denial to app settings
Summary: Improve SMS permission UX so users can request again after denial and are guided to app settings when permanently denied.\nChanges: handle Permission.sms status in controllers, show settings dialog for permanently denied, use kIsWeb guard, context-safety across async.\nValidation: scripts/check.sh passed (analyze/tests OK).\nRisk & Rollback: low; scoped to permission request flow. Revert two controllers if issues.
2025-09-15 11:37:38 +09:00
JiWoong Sul
b944f6967d docs(ads): add AdMob mediation native networks guide with regional strategy and Gradle adapter examples
Summary: Document networks supporting Native ads via AdMob mediation, with regional prioritization, Gradle adapter examples, and setup checklist.\nChanges: adds doc/ads.md.\nValidation: scripts/check.sh passed.\nRisk & Rollback: low; doc-only change. Revert file if needed.
2025-09-15 11:37:32 +09:00
JiWoong Sul
997c2f53a0 feat(assets): 디지털렌트매니저 아이콘(집+체크·스퀴클) PNG 세트 및 생성 스크립트 추가\n\n- 경로: assets/app_icon/house_check/{32..1024}.png\n- 스크립트: scripts/render_icon.py (무의존 PNG 렌더) / scripts/generate_icons.sh
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:37:34 +09:00
JiWoong Sul
79f9aa3eb0 docs: flutter-shadcn-ui 마이그레이션 상세 계획 추가(doc/plan.md)
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:16:09 +09:00
JiWoong Sul
5b72fa196c merge: 'codex/perf-sms-ui-optimizations' 브랜치를 master에 병합
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:00:47 +09:00
JiWoong Sul
6cd3b9720f chore(macos): Flutter GeneratedPluginRegistrant 업데이트\n\n- 플러그인/플러터 변경으로 생성 파일 갱신\n- 의존성 lockfile 동기화(pubspec.lock) 2025-09-10 05:55:59 +09:00
JiWoong Sul
5a7ef8039e refactor: remove unreferenced widgets/utilities and backup file in lib 2025-09-08 14:33:55 +09:00
JiWoong Sul
10069a1800 perf(ui): enable KeepAlive on subscription list, tune prefetch, and reduce list/gesture animations 2025-09-08 14:32:28 +09:00
JiWoong Sul
b034f60510 feat(cache): add SimpleCacheManager and cache formatted rates/amounts in exchange and currency services 2025-09-08 14:31:44 +09:00
JiWoong Sul
eb6691ce6a feat(accessibility): add reduceMotion scaling and minimize animations; apply RepaintBoundary to heavy widgets 2025-09-08 14:30:28 +09:00
JiWoong Sul
10491af55b feat(perf): offload Android SMS parsing to Isolate and wrap pie chart with RepaintBoundary 2025-09-08 14:30:03 +09:00
JiWoong Sul
4673aed281 chore(agents): add Korean response rule to AGENTS.md 2025-09-08 14:21:59 +09:00
JiWoong Sul
84b3fdd530 perf: 파싱/렌더 최적화 다수 적용
- SmsScanner 키워드/정규식 상수화로 반복 컴파일 제거\n- 리스트에 prototypeItem 추가, 카드 RepaintBoundary 적용\n- 차트 영역 RepaintBoundary로 페인트 분리\n- GlassmorphicScaffold 파티클 수를 disableAnimations에 따라 감소\n- 캐시 초기화 플래그를 --dart-define로 제어(CLEAR_CACHE_ON_STARTUP)
2025-09-07 23:28:18 +09:00
JiWoong Sul
d37f66d526 feat(settings): SMS 읽기 권한 상태/요청 위젯 추가 (Android)
- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지

feat(permissions): 최초 실행 시 SMS 권한 온보딩 화면 추가 및 Splash에서 라우팅 (Android)

- 권한 필요 이유/수집 범위 현지화 문구 추가\n- 거부/영구거부 케이스 처리 및 설정 이동

chore(codex): AGENTS.md/체크 스크립트/CI/프롬프트 템플릿 추가

- AGENTS.md, scripts/check.sh, scripts/fix.sh, .github/workflows/flutter_ci.yml, .claude/agents/codex.md, 문서 템플릿 추가

refactor(logging): 경로별 print 제거 후 경량 로거(Log) 도입

- SMS 스캐너/컨트롤러, URL 매처, 데이터 리포지토리, 내비게이션, 메모리/성능 유틸 등 핵심 경로 치환

feat(exchange): 환율 API URL을 --dart-define로 오버라이드 가능 + 폴백 로깅 강화

test: URL 매처/환율 스모크 테스트 추가

chore(android): RECEIVE_SMS 권한 제거 (READ_SMS만 유지)

fix(lints): dart fix + 수동 정리로 경고 대폭 감소, 비동기 context(mounted) 보강

fix(deprecations):\n- flutter_local_notifications의 androidAllowWhileIdle → androidScheduleMode 전환\n- WillPopScope → PopScope 교체

i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
2025-09-07 21:32:16 +09:00
JiWoong Sul
d1a6cb9fe3 style: apply dart format across project 2025-09-07 19:33:11 +09:00
JiWoong Sul
f812d4b9fd feat(permissions): add SMS permission screen and settings button; route from splash on Android 2025-09-07 19:33:11 +09:00
JiWoong Sul
2a90e7c377 chore: add AGENTS.md, helper scripts, codex templates, and CI 2025-09-07 19:33:11 +09:00
JiWoong Sul
9f1d29c99d fix: 플로팅 네비게이션 바 렌더링 문제 해결
- SMS 화면에서 그림자가 제대로 표시되지 않던 문제 수정
- Stack 구조를 Container로 단순화하여 렌더링 최적화
- RenderFlex overflow 오류 해결 (패딩 및 아이콘 크기 조정)
- Android 패키지명 변경 및 빌드 설정 업데이트
2025-07-18 20:39:25 +09:00
JiWoong Sul
58727af659 feat: 알림 권한 처리 개선 및 빌드 시스템 업데이트
- Android NDK 버전을 27.0.12077973으로 업데이트
- Core library desugaring 설정 추가
- POST_NOTIFICATIONS 권한 추가 (Android 13+)
- flutter_local_notifications 17.2.4로 업데이트
- iOS/Android 알림 권한 요청 메서드 개선
- 권한 상태 확인 메서드 추가
2025-07-17 19:34:23 +09:00
JiWoong Sul
a9a715d67c feat: SMS 스캔 패키지를 flutter_sms_inbox로 변경 및 플랫폼별 최적화
- telephony 패키지를 flutter_sms_inbox로 교체
- 플랫폼별 SMS 스캔 로직 구현:
  * Web: mock data 사용
  * Android: flutter_sms_inbox로 실제 SMS 스캔
  * iOS: SMS 기능 비활성화
- iOS에서 SMS 스캔 버튼 숨김 처리
- PlatformHelper 유틸리티 추가로 웹 환경 오류 해결
- Android 네이티브 MethodChannel 코드 제거
2025-07-17 18:30:21 +09:00
JiWoong Sul
a8728eb5f3 fix: SMS 스캔 화면에서 구독 카드 클릭 안되는 문제 수정 2025-07-17 17:46:24 +09:00
163 changed files with 9406 additions and 9158 deletions

13
.claude/agents/codex.md Normal file
View File

@@ -0,0 +1,13 @@
# Project Agent Handoff
Use AGENTS.md at repo root as the source of truth for coding rules and guardrails.
Key Rules
- Code first, concise rationale after. If uncertain, say "Uncertain:".
- Keep diffs minimal; follow existing patterns and `analysis_options.yaml`.
- Validate with `scripts/check.sh` (format/analyze/test) before completion.
- Ask for approval before dependency changes, build config edits, or network access.
Templates
- Task and PR templates are in `AGENTS.md` and `doc/agents/codex_prompt_templates.md`.

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash"
],
"deny": []
}
}

31
.github/workflows/flutter_ci.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Flutter CI
on:
pull_request:
push:
branches: [ main, master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Flutter Pub Get
run: flutter pub get
- name: Format check
run: dart format --output=none --set-exit-if-changed .
- name: Analyze
run: flutter analyze
- name: Test
run: flutter test

70
AGENTS.md Normal file
View File

@@ -0,0 +1,70 @@
Codex Agent Guide for SubManager
Scope
- Applies to the entire repository unless a more specific rule exists deeper in the tree.
- Precedence: project AGENTS.md > project .claude/agents > user ~/.claude > default Codex CLI rules. Direct system/developer instructions always win.
Goals
- Accelerate small, safe changes with consistent quality.
- Keep diffs minimal, focused, and aligned with Flutter best practices.
Guardrails
- Workspace only: modify files within this repo. Ask before adding dependencies or using network.
- Safety: avoid destructive actions (file deletions, rewrites, config changes) unless explicitly requested.
- Responses: be concise; code first, short rationale after. If uncertain, prefix with "Uncertain:". If multiple viable solutions, show the top 2 briefly.
- Planning: for multistep tasks, maintain an update_plan with exactly one in_progress step.
- Language: 기본적으로 한국어로 응답합니다. (필요 시 코드/로그/명령어는 원문 유지)
Coding Standards
- Language: Dart/Flutter (SDK >= 3.0). Respect `analysis_options.yaml` (flutter_lints baseline).
- Style/format: use `dart format .` and keep changes minimal. Avoid oneletter variable names; avoid inline comments unless requested.
- Structure: follow existing file/module patterns and naming. Do not introduce new frameworks or architectural shifts without approval.
- Tests: add or update tests when behavior changes or bugs are fixed (if feasible). Keep tests scoped to the change.
Validation
- Always run local checks via `scripts/check.sh` before proposing completion:
- formatting check: `dart format --set-exit-if-changed .`
- static analysis: `flutter analyze`
- unit/widget tests: `flutter test` (ok if none exist)
- UI changes: include brief description of visual impact; screenshots if readily available by the user.
Sensitive Areas (require explicit approval)
- Android/iOS/macOS build configs, signing, bundle identifiers, Gradle/Kotlin/Swift project settings.
- Dependency graph changes (pubspec.yaml add/remove/upgrade).
- Network access, calling external APIs, or adding secrets.
Operational Conventions
- Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-url-matcher`).
- Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`).
- Git push 후 공유하는 설명/보고는 반드시 한국어로 작성합니다.
- PR description template:
- Summary: what/why
- Changes: key files and decisions
- Validation: how verified (analyze/tests/manual)
- Risk & Rollback: potential impact and quick rollback steps
Task Template (author-provided)
---
Next: <what to do>
Complexity: simple | medium | complex
Context
- Problem / goal:
- Constraints / nongoals:
- Repro or commands:
Done When
- [ ] Behavior verified (`scripts/check.sh` passes)
- [ ] Tests/docs updated if applicable
---
Commands
- Lint/analyze/tests: `scripts/check.sh`
- Autoformat: `scripts/fix.sh`
References & External Facts
- Prefer official docs and codelocal references. If citing sources, include plain URLs or file paths in PR descriptions (avoid footnote citation syntaxes).
Notes from ~/.claude (adapted)
- Fewshot examples improve accuracy; include small before/after or sample input→output when helpful.
- Use structured thinking internally; present only concise, actionable outputs here.

View File

@@ -6,13 +6,14 @@ plugins {
}
android {
namespace = "com.example.submanager"
namespace = "com.naturebridgeai.digitalrentmanager"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@@ -21,7 +22,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.submanager"
applicationId = "com.naturebridgeai.digitalrentmanager"
// 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
@@ -42,3 +43,7 @@ android {
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

View File

@@ -1,8 +1,14 @@
<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" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 재부팅 후 예약 복구를 위해 필요 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 정확 알람(결제/캘린더 등 정확 시각 필요시) 사용 -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
android:label="구독 관리"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -13,7 +19,9 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:showWhenLocked="true"
android:turnScreenOn="true">
<!-- 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
@@ -32,6 +40,24 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Google AdMob App ID -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-6691216385521068~6638409932" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -1,198 +0,0 @@
package com.example.submanager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.Telephony
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.*
class MainActivity: FlutterActivity() {
private val CHANNEL = "com.submanager/sms"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"scanSubscriptions" -> {
scanSubscriptions(result)
}
else -> {
result.notImplemented()
}
}
}
}
private fun scanSubscriptions(result: MethodChannel.Result) {
try {
// 메시지를 임시 저장할 맵
val messageGroups = mutableMapOf<String, MutableList<Map<String, Any>>>()
val cursor = contentResolver.query(
Telephony.Sms.CONTENT_URI,
arrayOf(
Telephony.Sms.ADDRESS,
Telephony.Sms.BODY,
Telephony.Sms.DATE
),
null,
null,
"${Telephony.Sms.DATE} DESC"
)
cursor?.use {
while (it.moveToNext()) {
val address = it.getString(0)
val body = it.getString(1)
val date = it.getLong(2)
// 구독 관련 키워드로 필터링
if (isSubscriptionMessage(body)) {
val parsedMessage = parseSubscriptionMessage(body, address, date)
if (parsedMessage != null) {
val key = "${address}_${parsedMessage["monthlyCost"]}"
if (!messageGroups.containsKey(key)) {
messageGroups[key] = mutableListOf()
}
messageGroups[key]?.add(parsedMessage)
}
}
}
}
// 최종 구독 목록
val subscriptions = mutableListOf<Map<String, Any>>()
// 동일한 발신자와 유사한 금액으로 2회 이상 메시지가 있는 경우만 구독으로 판단
for ((key, messages) in messageGroups) {
if (messages.size >= 2) {
// 가장 최근 메시지 정보를 사용
val latestMessage = messages.first()
// 주기성 추가
val enhancedMessage = latestMessage.toMutableMap()
enhancedMessage["repeatCount"] = messages.size
enhancedMessage["isRecurring"] = true
// 이전 결제일도 추가
if (messages.size > 1) {
enhancedMessage["previousPaymentDate"] = messages[1]["nextBillingDate"] ?: ""
}
subscriptions.add(enhancedMessage)
}
}
result.success(subscriptions)
} catch (e: Exception) {
result.error("SMS_SCAN_ERROR", e.message, null)
}
}
private fun isSubscriptionMessage(body: String?): Boolean {
if (body == null) return false
val keywords = listOf(
"구독", "결제", "청구", "요금", "월정액", "정기결제",
"subscription", "payment", "bill", "fee", "monthly", "recurring"
)
return keywords.any { body.contains(it) }
}
private fun parseSubscriptionMessage(
body: String,
address: String,
date: Long
): Map<String, Any>? {
try {
// 서비스명 추출 (예: "넷플릭스", "스포티파이" 등)
val serviceName = extractServiceName(address, body)
// 금액 추출 (예: "9,900원", "₩9,900" 등)
val amount = extractAmount(body)
// 결제 주기 추출 (예: "월간", "연간" 등)
val billingCycle = extractBillingCycle(body)
// 다음 결제일 추출
val nextBillingDate = extractNextBillingDate(body, date)
return mapOf(
"serviceName" to serviceName,
"monthlyCost" to amount,
"billingCycle" to billingCycle,
"nextBillingDate" to nextBillingDate,
"isRecurring" to false,
"repeatCount" to 1,
"sender" to address,
"messageDate" to formatDate(date)
)
} catch (e: Exception) {
return null
}
}
private fun formatDate(timestamp: Long): String {
val date = Date(timestamp)
val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
return formatter.format(date)
}
private fun extractServiceName(address: String, body: String): String {
// 주소에서 서비스명 추출 시도
val addressPattern = Regex("""([가-힣a-zA-Z]+)""")
val addressMatch = addressPattern.find(address)
if (addressMatch != null) {
return addressMatch.value
}
// 본문에서 서비스명 추출 시도
val bodyPattern = Regex("""([가-힣a-zA-Z]+)\s*(구독|결제|청구)""")
val bodyMatch = bodyPattern.find(body)
if (bodyMatch != null) {
return bodyMatch.groupValues[1]
}
return "알 수 없는 서비스"
}
private fun extractAmount(body: String): Double {
val pattern = Regex("""(\d{1,3}(?:,\d{3})*)(?:\s*원|\s*₩)""")
val match = pattern.find(body)
return if (match != null) {
match.groupValues[1].replace(",", "").toDouble()
} else {
0.0
}
}
private fun extractBillingCycle(body: String): String {
return when {
body.contains("월간") || body.contains("월정액") -> "월간"
body.contains("연간") || body.contains("연정액") -> "연간"
body.contains("주간") || body.contains("주정액") -> "주간"
else -> "월간"
}
}
private fun extractNextBillingDate(body: String, messageDate: Long): String {
val datePattern = Regex("""(\d{4})[년/.-](\d{1,2})[월/.-](\d{1,2})일?""")
val match = datePattern.find(body)
if (match != null) {
val (year, month, day) = match.destructured
return "$year-${month.padStart(2, '0')}-${day.padStart(2, '0')}"
}
// 날짜를 찾을 수 없는 경우, 메시지 날짜로부터 1개월 후를 반환
val calendar = Calendar.getInstance()
calendar.timeInMillis = messageDate
calendar.add(Calendar.MONTH, 1)
return SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(calendar.time)
}
}

View File

@@ -0,0 +1,8 @@
package com.naturebridgeai.digitalrentmanager
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
// flutter_sms_inbox 패키지가 SMS 처리를 담당하므로
// 기존 MethodChannel 코드는 제거되었습니다
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">デジタル月額管理者</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">디지털 월세 관리자</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">数字月租管理器</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Digital Rent Manager</string>
</resources>

View File

@@ -19,7 +19,7 @@ pluginManagement {
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
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -126,7 +126,7 @@
"repeatSubscriptionNotFound": "No repeated subscription information found.",
"newSubscriptionNotFound": "No new subscription SMS found",
"findRepeatSubscriptions": "Find subscriptions paid 2+ times",
"scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.",
"scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions.\nService names and amounts can be extracted for easy subscription addition.\nThis auto-detection feature is still under development, and might miss or misidentify some subscriptions.\nPlease review the detected results and add or edit subscriptions manually if needed.",
"startScanning": "Start Scanning",
"foundSubscription": "Found subscription",
"serviceName": "Service Name",
@@ -147,6 +147,7 @@
"estimatedAnnualCost": "Estimated Annual Cost",
"totalSubscriptionServices": "Total Subscription Services",
"eventDiscountActive": "Event Discount Active",
"eventDiscountEndsBeforeBilling": "Event discount ends before billing date",
"saving": "Saving",
"paymentDueToday": "Payment Due Today",
"paymentDueInDays": "Payment due in @ days",
@@ -199,7 +200,7 @@
"cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.",
"goToCancelPage": "Go to Cancellation Page",
"urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name",
"discountPercent": "@% discount",
"discountPercent": "% discount",
"discountAmountWon": "Save ₩@",
"discountAmountDollar": "Save $@",
"discountAmountYen": "Save ¥@",
@@ -217,6 +218,17 @@
"enterAmount": "Enter amount",
"invalidAmount": "Please enter a valid amount",
"featureComingSoon": "This feature is coming soon"
,
"smsPermissionTitle": "Request SMS Permission",
"smsPermissionReasonTitle": "Why",
"smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.",
"smsPermissionScopeTitle": "Scope",
"smsPermissionScopeBody": "We scan only payment-related SMS patterns (service/amount/date) locally; no data leaves your device.",
"permanentlyDeniedMessage": "Permission is permanently denied. Enable it in Settings.",
"openSettings": "Open Settings",
"later": "Later",
"requesting": "Requesting...",
"smsPermissionLabel": "SMS Permission"
},
"ko": {
"appTitle": "디지털 월세 관리자",
@@ -345,7 +357,7 @@
"repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.",
"newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다",
"findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기",
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.",
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.",
"startScanning": "스캔 시작하기",
"foundSubscription": "다음 구독을 찾았습니다",
"serviceName": "서비스명",
@@ -366,6 +378,7 @@
"estimatedAnnualCost": "예상 연간 구독 비용",
"totalSubscriptionServices": "총 구독 서비스",
"eventDiscountActive": "이벤트 할인 중",
"eventDiscountEndsBeforeBilling": "이벤트 할인이 결제일 전에 종료됩니다",
"saving": "절약",
"paymentDueToday": "오늘 결제 예정",
"paymentDueInDays": "@일 후 결제 예정",
@@ -418,7 +431,7 @@
"cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.",
"goToCancelPage": "해지 페이지로 이동",
"urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다",
"discountPercent": "@% 할인",
"discountPercent": "% 할인",
"discountAmountWon": "₩@원 절약",
"discountAmountDollar": "$@ 절약",
"discountAmountYen": "¥@ 절약",
@@ -436,6 +449,17 @@
"enterAmount": "금액을 입력하세요",
"invalidAmount": "올바른 금액을 입력해주세요",
"featureComingSoon": "이 기능은 곧 출시됩니다"
,
"smsPermissionTitle": "SMS 권한 요청",
"smsPermissionReasonTitle": "이유",
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
"smsPermissionScopeTitle": "수집 범위",
"smsPermissionScopeBody": "결제 관련 문자 메시지의 패턴(서비스명/금액/날짜)만 로컬에서 처리하며, 외부로 전송하지 않습니다.",
"permanentlyDeniedMessage": "권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.",
"openSettings": "설정 열기",
"later": "나중에 하기",
"requesting": "요청 중...",
"smsPermissionLabel": "SMS 권한"
},
"ja": {
"appTitle": "デジタル月額管理者",
@@ -564,7 +588,7 @@
"repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。",
"newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません",
"findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索",
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。サービス名と金額を抽出して簡単にサブスクリプションを追加できます。",
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。",
"startScanning": "スキャン開始",
"foundSubscription": "サブスクリプションが見つかりました",
"serviceName": "サービス名",
@@ -585,6 +609,7 @@
"estimatedAnnualCost": "予想年間サブスクリプション費用",
"totalSubscriptionServices": "総サブスクリプションサービス",
"eventDiscountActive": "イベント割引中",
"eventDiscountEndsBeforeBilling": "請求日前にイベント割引が終了します",
"saving": "節約",
"paymentDueToday": "本日支払い予定",
"paymentDueInDays": "@日後に支払い予定",
@@ -637,7 +662,7 @@
"cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。",
"goToCancelPage": "解約ページへ移動",
"urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます",
"discountPercent": "@%割引",
"discountPercent": "%割引",
"discountAmountWon": "₩@節約",
"discountAmountDollar": "$@節約",
"discountAmountYen": "¥@節約",
@@ -783,7 +808,7 @@
"repeatSubscriptionNotFound": "未找到重复付款的订阅信息。",
"newSubscriptionNotFound": "未找到新订阅短信",
"findRepeatSubscriptions": "查找支付2次以上的订阅",
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。可以提取服务名称和金额,轻松添加订阅。",
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额,轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果并在需要时手动添加或修改。",
"startScanning": "开始扫描",
"foundSubscription": "找到订阅",
"serviceName": "服务名称",
@@ -804,6 +829,7 @@
"estimatedAnnualCost": "预计年度订阅费用",
"totalSubscriptionServices": "总订阅服务",
"eventDiscountActive": "活动折扣中",
"eventDiscountEndsBeforeBilling": "活动折扣将在账单日之前结束",
"saving": "节省",
"paymentDueToday": "今日付款到期",
"paymentDueInDays": "@天后付款到期",
@@ -856,7 +882,7 @@
"cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。",
"goToCancelPage": "前往取消页面",
"urlAutoMatchInfo": "如果URL为空将根据服务名称自动匹配",
"discountPercent": "@%折扣",
"discountPercent": "%折扣",
"discountAmountWon": "节省₩@",
"discountAmountDollar": "节省$@",
"discountAmountYen": "节省¥@",
@@ -875,4 +901,4 @@
"invalidAmount": "请输入有效的金额",
"featureComingSoon": "此功能即将推出"
}
}
}

123
doc/ads.md Normal file
View File

@@ -0,0 +1,123 @@
# AdMob 미디에이션 네이티브 광고 네트워크 (Android)
아래 네트워크들은 AdMob 미디에이션을 통해 Android에서 네이티브(Native) 광고를 지원합니다. 실제 지원 범위(포맷/통합 방식)는 지역/계정/버전 등에 따라 달라질 수 있으므로 AdMob 콘솔에서 해당 미디에이션 그룹의 포맷 선택 가능 여부로 최종 확인하세요.
## 권장 후보
- Meta Audience Network (FAN)
- 통합: Bidding 전용
- 포맷: Native, Native Banner
- 문서: https://developers.google.com/admob/android/mediation/meta
- InMobi
- 통합: Waterfall(네이티브는 Waterfall만), Bidding(다른 포맷)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/inmobi
- Pangle (ByteDance/TikTok)
- 통합: Bidding + Waterfall
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/pangle
- Mintegral
- 통합: Bidding + Waterfall
- 포맷: Native
- 메모: 네이티브는 “Native (Custom Rendering)” 선택 지침이 있음
- 문서: https://developers.google.com/admob/android/mediation/mintegral
- DT Exchange (Fyber)
- 통합: Waterfall, Bidding(클로즈드 베타)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/dt-exchange
- Moloco
- 통합: Bidding
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/moloco
- ironSource Ads
- 통합: Waterfall(네이티브는 Waterfall만)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/ironsource
- Unity Ads
- 통합: Waterfall, Bidding(오픈 베타)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/unity
- LINE Ads Platform (일본 중심)
- 통합: Bidding(네이티브는 클로즈드 베타)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/line
- myTarget (RU/CIS 중심)
- 통합: Waterfall
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/mytarget
## 참고 및 주의사항
- 지역성/수요: Pangle(아시아), LINE(일본), myTarget(RU/CIS) 등은 지역별 수요 차이가 큼. 타겟 지역 기준으로 우선순위 구성 권장.
- 통합 방식: 일부는 네이티브가 Waterfall만 지원(InMobi, ironSource), 일부는 Bidding만(Meta), 혼합 지원(Pangle, Mintegral, Unity). 비딩/워터폴 여부에 따라 콘솔 설정이 상이함.
- SDK/어댑터: Android Gradle에 각 네트워크 SDK/어댑터 추가가 필요하며, AdMob UI에서 해당 네트워크를 미디에이션 그룹의 “Native” 포맷으로 매핑해야 함. 개인정보/동의 메시징(US State Privacy, GDPR 등)도 파트너 추가 필요.
- 템플릿/표시: 대부분 Unified Native 기반 에셋을 제공하나 네트워크별 에셋 세트가 달라 `NativeTemplateStyle` 기반 템플릿 레이아웃 조정이 필요할 수 있음.
- AppLovin 유의: 문서상 포맷 표에 Native가 보이더라도 어댑터 변경 이력에 “Native 지원 제거”가 기록되어 있습니다. 실제 지원은 AdMob 콘솔(미디에이션 그룹)에서 포맷 선택 가능 여부로 재확인하세요. 문서: https://developers.google.com/admob/android/mediation/applovin
- Flutter 연동: `google_mobile_ads``NativeAd` 로드/리스너/`AdWidget` 사용 패턴은 동일. 네트워크 추가는 네이티브(Android) 쪽 SDK/어댑터 및 콘솔 설정이 핵심.
## 빠른 적용 체크리스트
- [ ] 타겟 지역에 맞는 네트워크 선정(2~5개)
- [ ] Android 의존성 추가(네트워크 SDK/어댑터)
- [ ] AdMob 콘솔: 미디에이션 그룹 생성(포맷=Native), 각 네트워크 매핑
- [ ] 테스트 모드/테스트 광고 확인(네트워크별 테스트 설정 있음)
- [ ] 앱 내 네이티브 광고 UI 검수(템플릿/에셋 배치, 정책 준수)
---
## 지역별 우선순위 제안(예시)
아래는 일반적인 트래픽·수요 기준의 스타트 세트 예시입니다. 실제 퍼포먼스는 앱 카테고리/유저 페르소나/국가별 규제에 따라 달라질 수 있으므로 A/B로 조합을 검증하세요.
- 한국/일본(KR/JP)
- 1군: Meta(FAN, Bidding) + Pangle(Bidding/Waterfall) + LINE(JP, Bidding/Closed Beta for Native)
- 보강: Mintegral, InMobi, Unity
- 북미/유럽(NA/EU)
- 1군: Meta(FAN) + InMobi + Unity + Chartboost
- 보강: DT Exchange(Fyber), Moloco
- 동남아/인도(SEA/IN)
- 1군: InMobi + Pangle + Mintegral + Meta(FAN)
- 보강: Unity, DT Exchange
- CIS/러시아권
- 1군: myTarget
- 보강: Mintegral, Unity
참고: Chartboost는 네이티브 포맷 지원. 지역/장르에 따라 성과 편차가 있어 NA/EU 게임 카테고리에서 보강용으로 고려.
문서:
- Chartboost: https://developers.google.com/admob/android/mediation/chartboost
---
## Android Gradle 의존성(예시)
Flutter에서 `google_mobile_ads`를 사용해도, 미디에이션 파트너의 Android SDK/어댑터는 Gradle에 직접 추가해야 합니다. 아래 스니펫은 예시이며, “정확한 최신 버전”은 각 네트워크 문서의 Adapter 섹션(Changelog/Artifacts)에서 확인 후 고정하세요.
프로젝트 수준 `settings.gradle`/리포지토리 설정은 기본 `google()`/`mavenCentral()`이면 충분합니다.
`android/app/build.gradle` (dependencies 블록)
```gradle
dependencies {
// Google Mobile Ads SDK (보통 어댑터가 transitive로 끌어오지만 명시해도 무방)
implementation 'com.google.android.gms:play-services-ads:24.6.0' // 최신 권장 버전으로 교체
// Mediation adapters (예시 버전; 실제 최신 버전으로 교체)
implementation 'com.google.ads.mediation:facebook:6.16.0.0' // Meta Audience Network
implementation 'com.google.ads.mediation:pangle:5.5.0.4.0' // Pangle
implementation 'com.google.ads.mediation:mintegral:16.5.91.1' // Mintegral
implementation 'com.google.ads.mediation:inmobi:10.6.3.0' // InMobi
implementation 'com.google.ads.mediation:fyber:8.3.8.0' // DT Exchange(Fyber)
implementation 'com.google.ads.mediation:moloco:3.8.0.0' // Moloco
implementation 'com.google.ads.mediation:ironsource:8.5.0.1' // ironSource
implementation 'com.google.ads.mediation:unity:4.16.0.1' // Unity Ads
implementation 'com.google.ads.mediation:mytarget:5.20.0.0' // myTarget
// implementation 'com.google.ads.mediation:chartboost:<version>' // Chartboost (필요 시)
}
```
버전 확인 팁:
- 각 네트워크 가이드의 “Supported integrations and ad formats”/“Changelog”에서 최소/최신 어댑터 버전 확인
- Maven Central에서 `com.google.ads.mediation:<artifact>` 검색하여 최신 릴리스 확인
- AdMob 콘솔에서 해당 네트워크 추가 시 표시되는 가이드/버전 주석 참조
설정 체크:
- ProGuard/R8 규칙이 필요한 네트워크의 경우 가이드에 명시된 keep 규칙 추가
- COPPA/유럽·미국 주 개인정보법 관련 consent 전달(UMP SDK 또는 자체 메시징) 및 파트너 동기화
- 테스트: 네트워크 콘솔에서 테스트 모드 또는 테스트 디바이스 ID 설정 후 실제 단말에서 `NativeAd` 로드 확인

View File

@@ -0,0 +1,70 @@
Codex Prompt Templates
Note
- Keep prompts concise and specific. Include before/after or small input→output examples when helpful.
- Use the Task Template in AGENTS.md for clarity and a crisp definition of Done.
Next Task Format (from ~/.claude)
---
Next: <what to do>
Complexity: simple | medium | complex
---
Bugfix Prompt
---
Context
- Problem: <symptoms and scope>
- Repro: <steps/command>
- Observed: <actual result>
- Expected: <desired result>
- Constraints / Nongoals: <limits>
Done When
- scripts/check.sh passes; behavior verified via repro
- Tests/docs updated if applicable
---
Small Feature Prompt
---
Context
- Goal: <uservisible behavior>
- Entry points: <screens/routes/widgets>
- Data/State impact: <provider/models/side effects>
- Constraints / Nongoals: <limits>
Done When
- Feature is reachable and works
- scripts/check.sh passes; minimal tests if feasible
---
Refactor Prompt (No Behavior Change)
---
Context
- Target: <files/modules>
- Motivation: <readability/duplication/perf>
- Safety: <no logic change; add tests if risky>
Done When
- Same behavior; cleaner structure
- scripts/check.sh passes
---
UI Change Prompt
---
Context
- Screen/Widget: <where>
- Visual Goal: <what changes>
- Theming/Adaptivity: <light/dark/platform>
Done When
- Visual change implemented; screenshots added in PR by human
- scripts/check.sh passes
---
Code Review Aid
---
- Summarize intent and key diffs
- Verify formatting, analysis, and tests pass
- Flag risks; suggest targeted followups
---

View File

@@ -1,79 +1,208 @@
# 구독관리 앱 글래스모피어즘 컬러 & 텍스트 컬러 가이드
# SubManager 컬러/테마 가이드 v4 (Glass 제거, 완전 Material 3)
구독관리 앱에 글래스모피어즘을 적용할 때, **신뢰성, 편안함, 트렌드함**을 모두 잡으면서도 **텍스트 가독성**을 최우선으로 고려한 컬러 팔레트와 활용법을 안내합니다.
목표: 글래스모피어즘(반투명/블러/그라데이션)을 전면 제거하고, 전 화면/버튼/팝업을 Material 3(ColorScheme/typography/shape/elevation) 기준으로 재정렬합니다. 버튼 나열 UI를 드롭다운으로 바꾸지 않습니다. 설정 화면에 라이트/다크/시스템 모드 선택 UI를 추가합니다.
## 1. 컬러 팔레트 제안
## 0) 현재 상태 진단(요약)
- 전역 테마: M3 사용 중(`useMaterial3: true`). 라이트/다크/OLED/고대비 테마 존재.
- 이슈: `ColorScheme.error`가 핑크(danger)에 매핑 → 오류색으로 부적합(레드 필요).
- Glass 사용처 다수(요약/분석/네비/빈상태 등): 반투명+블러+경계. 다크/저성능 장치에서 가독성·성능 저하 가능.
- 곳곳의 하드코딩 텍스트 컬러(`AppColors.darkNavy`, `Color(0xFF...)`) 존재 → 다크에서 대비 문제 소지.
| 용도 | 컬러명 | Hex 코드 | 설명/느낌 |
|--------------|--------------|--------------|--------------------------|
| 메인 | Deep Blue | #2563eb | 신뢰, 포인트 |
| 서브 | Sky Blue | #60a5fa | 트렌디, 그라디언트 |
| 포인트 | Soft Mint | #38bdf8 | 상쾌함, 포인트 |
| 배경 | Light Gray | #f1f5f9 | 편안함, 밝은 배경 |
| 글래스 효과 | White Glass | #ffffff(투명)| 반투명 글래스 효과 |
| 포인트 | Pink Accent | #f472b6 | 트렌디, 액센트 |
| 그림자 | Shadow Black | rgba(0,0,0,0.08) | 깊이감 부여 |
## 1) 원칙(신뢰·접근성·일관성)
- 신뢰: Primary는 딥 블루(#2563EB). 과장된 장식 대신 명확한 위계/역할색 사용.
- 접근성: 본문 대비 WCAG AA(4.5:1) 충족. on-colors(onPrimary/onSurface/onError…) 일관 적용.
- 일관성: 전역 ColorScheme/typography/shape/elevation 우선, 로컬 styleFrom 최소화.
- 성능/가독성: Glass 제거 → 불투명 Surface + elevation/outline 중심으로 레이어 구분.
## 2. 텍스트 색상 가이드
## 2) 팔레트(최종)
- Primary: #2563EB / onPrimary: #FFFFFF
- Secondary: #60A5FA / onSecondary: #0B1B31(또는 onSurface)
- Tertiary(Info): #6366F1 / onTertiary: #FFFFFF
- Error: #EF4444 / onError: #FFFFFF
- Success: #22C55E / Warning: #F59E0B (둘은 ColorScheme 외 확장 토큰으로 관리)
- Light: Background #F1F5F9 / Surface #FFFFFF / SurfaceVariant #F8FAFC / OnSurface #1E293B / OnSurfaceVariant #334155 / Outline #E2E8F0
- Dark: Background #121212 / Surface #1E1E1E / OnSurface #F5F5F6 / OnSurfaceVariant #94A3B8 / Outline #3F3F46
밝은 배경(예: #f1f5f9, #ffffff(투명)) 위에는 **어두운 텍스트**를,
진한 컬러(예: #2563eb, #38bdf8) 위에는 **밝은 텍스트**를 사용해야 가독성이 좋습니다.
## 3) 타입·라디우스·간격·음영 스케일
- Typography(권장):
- displayLarge 48 / displayMedium 40 / displaySmall 34
- headlineLarge 32 / headlineMedium 28 / headlineSmall 24
- titleLarge 20 / titleMedium 18 / titleSmall 16
- bodyLarge 16 / bodyMedium 14 / bodySmall 12
- labelLarge 14 / labelMedium 12 / labelSmall 11
- Line-height: 1.3~1.5, Letter-spacing: 헤드라인(-0.2~-0.5), 본문(+0.1)
- Shape: 4(칩/태그) / 8(스위치/토글) / 12(버튼/입력) / 16(카드/시트)
- Elevation: 0(평면) / 1(구분) / 3(카드) / 6(상부 시트/다이얼로그)
- Spacing: 4 단위(8/12/16/24/32)로 수직 리듬 고정
| 배경 컬러 | 추천 텍스트 컬러 | 용도/설명 |
|------------------|----------------------|-----------------------------------|
| Light Gray (#f1f5f9) | Dark Navy (#1e293b) | 메인 텍스트, 타이틀, 버튼 |
| White Glass (투명) | Deep Blue (#2563eb) | 강조 텍스트, 버튼 |
| Deep Blue (#2563eb) | Pure White (#ffffff) | 버튼, 반전 텍스트 |
| Sky Blue (#60a5fa) | Navy Gray (#334155) | 서브 텍스트, 부가 설명 |
| Soft Mint (#38bdf8) | Navy Gray (#334155) | 포인트 텍스트 |
| Pink Accent (#f472b6)| Deep Blue (#2563eb) | 강조, 포인트 텍스트 |
## 4) Glass 제거 및 대체 규칙
- `lib/widgets/glassmorphism_card.dart` 사용부 전면 치환:
- 대체: `Card(elevation: 3, color: colorScheme.surface, shape: 16)`
- 경계: `Outline` 기반(라이트 #E2E8F0, 다크 #3F3F46, 투명도 60~80%)
- 섀도우: 라이트만 약하게(8~12), 다크는 outline 위주
- 내부 텍스트: 항상 `colorScheme.onSurface` 또는 전역 `textTheme` 사용(하드코딩 금지)
- 그라데이션/반투명 배경 삭제(필요 시 Hero/그림·아이콘 등으로 시각적 흥미 보완)
## 3. 실전 적용 예시
## 5) 컴포넌트별 가이드(누락 없음)
- AppBar: 배경=surface, 제목/아이콘=onSurface, 높이=56, 타이틀 글꼴=titleLarge
- Navigation(하단): 배경=surface, 활성 아이콘/라벨=primary, 비활성=onSurfaceVariant, 반경=16
- FAB: 배경=primary, 아이콘=onPrimary, 반경=16, elevation=6
- Buttons(Elevated/Text/Outlined): minHeight=48, 반경=12, primary=onPrimary, outline=outline, text=onSurface
- IconButton: 기본 onSurface, 강조 상태는 primary 80~90%
- Inputs(TextField/Selectors): filled 라이트=surfaceVariant, 다크=#2A2A2A, 포커스라인=primary 1.5, 에러=error 1.5~2
- Chips/Badges: 배경=역할색(primary/success/warning/error), 텍스트=onX, 반경=8
- Cards: elevation=3, 반경=16, 배경=surface, 텍스트=onSurface
- Lists/Tiles: 제목=onSurface, 보조=onSurfaceVariant, divider=outline, 타일 반경=12
- Dialogs/Sheets: 배경=surface, 제목=titleLarge, 본문=bodyMedium, 버튼=역할색+onX, elevation=6, 반경=20
- Snackbar: 배경=역할색(primary/success/warning/error), 텍스트/아이콘=onX, 모서리=12, floating
- Tooltips: 배경=onSurface, 텍스트=surface, 반경=8
- Progress: primary 사용, 트랙=onSurfaceVariant
- Charts/Analysis: 팔레트 [primary, tertiary(info), success, warning, error, secondary], 라벨=onSurface
- Categories/SMS: 카테고리 배경 위 텍스트/아이콘은 대비 계산(white 또는 onSurface) 적용
- **배경**: Light Gray (#f1f5f9)
- **글래스 카드**: White Glass (rgba(255,255,255,0.2)), 테두리 Deep Blue (#2563eb)
- **메인 텍스트**: Dark Navy (#1e293b)
- **서브/설명 텍스트**: Navy Gray (#334155)
- **버튼 배경**: Deep Blue (#2563eb)
- **버튼 텍스트**: Pure White (#ffffff)
- **포인트/액센트**: Soft Mint (#38bdf8), Pink Accent (#f472b6)
## 4. 그라디언트 및 글래스 효과 예시
## 6) 설정 화면에 모드 선택 UI 추가(계획)
- 위치: `lib/screens/settings_screen.dart`
- 섹션명: Appearance(또는 테마)
- 구성: `Theme Mode` 라디오 그룹(시스템 / 라이트 / 다크)
- RadioListTile 3개(버튼 나열 유지, 드롭다운 금지)
- 값: `AppThemeMode.system|light|dark`
- 동작: `context.read<ThemeProvider>().setThemeMode(mode)` 호출
- 추가 토글(유지): 큰 텍스트/모션 감소/고대비(현 Provider 연동)
샘플 코드
```dart
// Flutter 예시 (Dart)
LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF2563eb),
Color(0xFF60a5fa),
Color(0xFFe0e7ef),
],
)
final themeProvider = context.read<ThemeProvider>();
Column(children: [
ListTile(title: Text('Theme Mode')),
RadioListTile(
title: Text('System'),
value: AppThemeMode.system,
groupValue: themeProvider.themeMode,
onChanged: (v) => themeProvider.setThemeMode(v!),
),
RadioListTile(
title: Text('Light'),
value: AppThemeMode.light,
groupValue: themeProvider.themeMode,
onChanged: (v) => themeProvider.setThemeMode(v!),
),
RadioListTile(
title: Text('Dark'),
value: AppThemeMode.dark,
groupValue: themeProvider.themeMode,
onChanged: (v) => themeProvider.setThemeMode(v!),
),
]);
```
- 글래스 카드 배경: rgba(255,255,255,0.2) + blur + border(Deep Blue)
- 텍스트: #1e293b(진한 네이비) 또는 #2563eb(딥블루) 사용
## 5. 디자인 팁
## 7) 적용 순서(리스크 최소)
1) 전역 스킴 교정: `ColorScheme.error` 레드로, textTheme onSurface 정렬
2) Glass 제거: `GlassmorphismCard``Card` 치환(화면 단위 PR: 홈→분석→설정→세부)
3) 버튼/입력/스낵바/다이얼로그 on-colors 정렬, 하드코딩 텍스트 제거
4) 모드 선택 UI 추가(설정 화면 라디오 그룹)
5) 카테고리/차트 대비 보정 유틸 적용
6) 회귀·접근성 검증(라이트/다크/시스템)
- **텍스트 대비**를 항상 체크하세요.
밝은 배경에는 어두운 텍스트, 진한 배경에는 밝은 텍스트!
- **포인트 컬러**는 버튼, 아이콘, 강조 텍스트에만 제한적으로 사용하면 세련됨이 살아납니다.
- **글래스 효과**는 투명도와 블러, 그리고 경계선 컬러(예: #2563eb, #60a5fa)로 깊이감을 더하세요.
## 8) 검증
- 스크립트: `scripts/check.sh` (format/analyze/test)
- 시각: 모든 화면에서 텍스트 대비(AA) 확인, 상태(Hover/Pressed/Disabled) 점검
- 성능: Glass 제거 후 저사양 단말 스크롤/애니메이션 프레임 확인
## 6. 컬러/텍스트 조합 요약
## 9) 요약
- Glass 제거 + 완전 Material 3 전환으로 신뢰감, 가독성, 성능을 함께 강화합니다.
- 오류색은 레드로 통일, on-colors로 대비를 보장합니다.
- 설정에 시스템/라이트/다크 선택을 제공하고, 버튼 나열 UI는 유지합니다.
| 배경색 | 텍스트색 | 용도 예시 |
|------------------|------------------|--------------------|
| #f1f5f9 | #1e293b | 메인 타이틀, 내용 |
| #ffffff(투명) | #2563eb | 카드 내 강조 |
| #2563eb | #ffffff | 버튼, 반전 강조 |
| #60a5fa | #334155 | 서브, 설명 |
| #38bdf8 | #334155 | 포인트, 서브텍스트 |
## 진행 현황(Work Log)
- [완료] 전역 스킴 교정: `ColorScheme.error`를 레드(#EF4444)로 교정 (라이트/다크)
- [완료] 스낵바 오류색 정렬: `AppSnackBar.showError``colorScheme.error` 사용
- [완료] 설정 화면 테마 모드 UI: System/Light/Dark SegmentedButton 추가(드롭다운/라디오 대체, M3 준수)
- [완료] Glass 제거(설정 화면): `GlassmorphismCard``Card` 치환
- [완료] Glass 제거(빈 상태 위젯): `EmptyStateWidget``Card` 기반으로 재구성
- [완료] Glass 제거(홈 요약 카드): `MainScreenSummaryCard` 외곽 → `Card`
- [완료] Glass 제거(분석 카드): 월간 지출/총지출/파이차트 카드 → `Card`
- [완료] Glass 제거(광고 카드): `NativeAdWidget``Card`
- [완료] Glass 제거(추가 폼 섹션): `AddSubscriptionForm``Card`
- [완료] Glass 제거(SMS 권한 화면): 설명 카드 → `Card`
- [완료] Glass 제거(네비게이션): Floating Navigation Bar → Container + Padding(Material 기준)
- [완료] Glass 제거(메인 스캐폴드): `GlassmorphicScaffold` → Stack+Scaffold(배경 그라디언트+M3)
- [진행] Glass 제거(기타): 일부 카드(예: SubscriptionCard) 잔여 사용처 점진 치환 예정
- [완료] Glass 제거(구독 카드): SubscriptionCard 래퍼를 Material Card+InkWell로 대체
- [진행] 하드코딩 텍스트 컬러 제거: 메인 요약/URL 섹션/네비/홈 로딩 인디케이터 등 onSurface/onSurfaceVariant로 정렬
- [진행] 하드코딩 컬러 정리(추가): 카테고리 관리/앱 잠금/이벤트·URL 상세 섹션 컨테이너와 텍스트를 M3(`surface`, `outline`, `onSurface`)로 정렬
- [진행] 폼/셀렉터 M3 정렬: DatePickerField/CurrencySelector 색을 `onSurface`/`primary`/`surfaceVariant`로 통일
- [진행] Selectors: Category/BillingCycle 선택 컴포넌트의 배경/텍스트를 `primary`/`onSurface`로 정렬
- [진행] 공통 입력/라벨: BaseTextField/DatePickerField 라벨·힌트·값을 `onSurface`/`onSurfaceVariant`로 정렬
- [진행] 삭제 다이얼로그: Glass 제거, Material Dialog(표면/elevation) + on-colors 적용
- [진행] 추가 화면: 이벤트 섹션 타이틀/설명을 onSurface로 정렬
- [진행] 날짜 필드(DatePicker/Range): 라벨/값/아이콘/컨테이너를 M3 surface/outline/onSurface 계열로 치환
- [진행] 분석 카드/리스트: 보조 텍스트/경계/아이콘을 onSurfaceVariant/primary 계열로 정리
- [진행] 설정 화면: 텍스트/아이콘 색을 onSurface/onSurfaceVariant로 정리
- [진행] SMS 권한 화면: 아이콘/제목/본문을 primary/onSurface/onSurfaceVariant로 정리
- [진행] 추가 화면 AppBar/저장 버튼: 색을 onSurface/primary로 정리
- [다음] 버튼/입력/다이얼로그/스낵바의 on-colors 재점검 및 하드코딩 텍스트 컬러 제거
### 2025-09-10 작업 메모(Incremental)
- [완료] Settings 화면: `AppColors.*` 제거 → `colorScheme.primary/onSurface/onSurfaceVariant` 적용. 알림 반복 SwitchListTile의 `activeColor` 비사용(신 API `activeThumbColor/activeTrackColor`)로 교체.
- [완료] AddSubscriptionForm: CurrencySelector / BillingCycleSelector / CategorySelector의 `isGlassmorphism` 플래그 비활성(기본 M3 경량 스타일 사용).
- [완료] MainSummaryCard: 이벤트 절약액 텍스트 색상을 `colorScheme.primary`로 정렬.
- [완료] MonthlyExpenseChartCard: 툴팁 배경/텍스트를 `inverseSurface/onInverseSurface`로 교체(가독성 향상).
- [완료] Light Theme 카드/입력: `lib/theme/app_theme.dart`의 카드 테마에서 글래스 컬러/보더 제거, elevation=1·radius=16 유지. InputDecorationTheme는 `surfaceVariant`(light 대체 토큰) + `outline/primary/error` 경계로 전환.
- [완료] TotalExpenseSummaryCard: 아이콘 캡슐 배경을 `surfaceContainerHighest`+`outline`로 교체, 아이콘 컬러는 `primary` 사용. 복사 스낵바의 글래스 배경 제거.
- [완료] DetailFormSection: 글래스 박스 → `surface` + `outline` 컨테이너로 교체, Currency/BillingCycle/Category 셀렉터의 `isGlassmorphism` 비활성.
- [완료] SMS Scan SubscriptionCard: `Card(elevation:1, outline)`로 교체, forceDark 텍스트 제거, 입력 `fillColor``surface`로 통일, 카테고리 셀렉터 글래스 비활성.
- [완료] SecondaryButton: Hover 배경을 `onSurface` 6%로, 보더/텍스트를 `outline/primary`로 정렬.
- [완료] CategoryManagement: AppBar `primary/onPrimary` 적용, Dropdown `value→initialValue`(비권장 API 해결), 텍스트 onSurface 정렬.
- [완료] Primary/SecondaryButton hover 트랜스폼: `Matrix4.scale` 제거 → `diagonal3Values` 또는 `Transform.scale`로 대체(비권장 API 해결).
- [완료] RotatePageRoute 전환: `Matrix4.scale` 제거 → 중첩 `Transform.scale`로 전환.
- [완료] ThemedText: AppColors 의존 제거, 대비 색상 결정을 `colorScheme.onSurface` 기반으로 단순화.
- [완료] 글래스 파일 제거: `lib/widgets/glassmorphism_card.dart`, `lib/widgets/glassmorphic_scaffold.dart` 삭제(미참조 확인).
- [완료] Light Theme 텍스트·컴포넌트 정렬: `app_theme.dart`에서 textTheme를 M3 기본 + `onSurface` 컬러로 일괄 정렬. Switch/Checkbox/Radio/Slider/TabBar/Divider를 `ColorScheme` 기반으로 리팩터.
- [완료] AddSubscriptionAppBar: const 적용(경고 제거), `scripts/check.sh` 전체 통과 확인.
- [완료] Dark/OLED 테마 정리: `adaptive_theme.dart`에서 다크 텍스트·컴포넌트(M3 on-colors) 정렬, Input/Buttons/TabBar/Divider/Switch/Checkbox/Radio/Slider를 ColorScheme 기준으로 통일. OLED는 surface/배경만 블랙 톤으로 보정.
- [완료] ThemedText: Glass 마커 제거(Indicator/Wrapper 삭제), 대비 로직 단순화.
- [완료] Charts: 월간 바차트 색상 `ColorScheme.primary/secondary`로 전환, 그리드/백바 `onSurfaceVariant` 사용. 파이차트 팔레트는 `ColorScheme(primary/secondary/tertiary/error)+success/warning 상수`로 정리.
- [완료] Settings/SubscriptionCard: 글래스 위젯 의존 제거 → Material Card + InkWell로 치환(중첩 Padding은 ListTile의 `contentPadding` 사용).
- [완료] Settings 색 정리 마무리: 모든 텍스트/아이콘/보더/드롭다운을 `onSurface/onSurfaceVariant/primary/surface`로 통일.
- [완료] 전역 그라데이션 제거: EmptyState/FloatingNav Add/MainSummary 이벤트 배지/Detail Header/Detail 편집 안내/SubscriptionCard 헤더·이벤트 배지/Add 화면 헤더/Splash 배경, 로고/파티클 장식 등 모든 Linear/Radial gradient 삭제. 단색은 `primary`/`surface`/`surfaceContainer*`/semantic(error, warning)로 대체.
- [완료] 차트 막대 그라데이션 제거: 단색 `primary`로 통일.
- [검증] `scripts/check.sh` 실행: 포맷 자동 적용 후 정적 분석 info 수준 경고만 존재(주요 `activeColor` 비권장 항목 해결됨).
## 결론
### 2025-09-11 작업 메모(Incremental)
- [완료] BillingCycleSelector: 선택 배경=primary, 텍스트=onPrimary, 비선택 배경=surface, 보더=outline(60%); glass/gradient 파라미터는 비사용 처리(호환 유지).
- [완료] CurrencySelector: 동일한 M3 패턴으로 정리(표면/윤곽선/온컬러), isGlassmorphism 무시.
- [완료] CategorySelector: 선택 시 baseColor가 있으면 사용, 없으면 primary; 나머지는 surface/outline/onSurface.
- [완료] AnalysisBadge: AppColors 제거 → surface 배경 + outline 보더 + 은은한 블랙 섀도(8%).
- [완료] SubscriptionCard:
- 상단 스트립: event=error, 결제 임박=warning, 그 외=카테고리 색.
- 가격: 이벤트 원가=onSurfaceVariant 취소선, 현재가=error, 일반가=primary.
- 결제 예정 뱃지: success/warning(확장 토큰) 사용, 배경은 10% 알파.
- 결제 주기 뱃지: surface + outline, 텍스트 onSurfaceVariant.
- [검증] `scripts/check.sh` 전체 통과(Format/Analyze/Test OK).
- **블루+화이트+민트** 조합과, **밝은 배경+어두운 텍스트** 원칙으로 신뢰성, 편안함, 트렌드함, 가독성 모두 챙길 수 있습니다.
- 실제 앱에 적용할 때는 위 표를 참고해 각 상황별로 텍스트 컬러를 꼭 맞춰주세요.
- 글래스모피어즘 효과와 대비 높은 텍스트 조합으로, 세련되고 사용성 좋은 구독관리 앱을 완성할 수 있습니다.
### 2025-09-11 추가 배치
- [완료] AppLock/Main 화면 스낵바: ColorScheme.error/success + onPrimary 텍스트로 통일.
- [완료] AddSubscriptionEventSection: info 박스 `tertiary`로, 아이콘도 동일 컬러.
- [완료] DetailEventSection: 초록 상수 제거 → `colorScheme.success`/onPrimary.
- [완료] SMS Scan 위젯: 로딩 인디케이터/버튼을 `primary` 기반으로.
- [완료] SubscriptionPieChartCard: AppColors 제거, 팔레트는 `primary/success/warning/error/tertiary/secondary` + 화이트 라벨. 환율 배지는 `primary` 소프트 톤.
- [완료] EventAnalysisCard: 현재가/할인율 배지 색을 `success/error`로 정리.
- [완료] TotalExpenseSummaryCard: 아이콘을 `success`로 정리.
- [완료] Splash: overlay/파티클/타이틀/서브타이틀/인디케이터를 ColorScheme 기반으로 단순화(파티클 색은 렌더 시 `primary`).
- [검증] `scripts/check.sh` 재실행 통과.
### 2025-09-11 Dark Theme 정리
- [완료] adaptive_theme.dart 다크 테마를 전면 ColorScheme 기반으로 재정렬:
- InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant.
- Elevated/Switch/Checkbox/Radio/Slider/TabBar/Divider: scheme 값 사용.
- AppBar/Card: 배경=surface, 전경/테두리=scheme on/outline.
- OLED 테마는 surface만 더 어둡게 덮어쓰기.
- [검증] `scripts/check.sh` 통과.
### 2025-09-11 Light Theme 추가 정리
- [완료] app_theme.dart 라이트 테마를 ColorScheme 기반으로 정리:
- InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant.
- Elevated/Text/Outlined/FAB: primary/onPrimary, Outlined 보더=outline.
- SnackBarTheme: primary/onPrimary.
- Scaffold 배경은 기존 디자인(#F1F5F9)을 유지(직접 지정).
- [검증] `scripts/check.sh` 재실행 통과.

113
doc/plan.md Normal file
View File

@@ -0,0 +1,113 @@
# SubManager UI 리디자인/리팩터링 계획 (flutter-shadcn-ui 기반)
## 개요
- 목적: 앱 전반 UI를 `flutter-shadcn-ui`로 표준화하고, 라이트/다크 테마와 의미 있는 컬러 체계를 구축. 사용하지 않는 코드/파일 정리, 복잡한 알고리즘을 동등 효과의 단순한 구현으로 교체.
- 원칙: 색채심리학/게슈탈트 심리학/피츠의 법칙/마이크로인터랙션을 반영. 화면 간 일관성 유지. 사이드 이펙트 최소화(동작/데이터 모델 변경 없이 UI 중심).
- 범위: `lib/screens`, `lib/widgets`, `lib/theme` 전반. 일부 `services/*` 단순화 대상 포함(동일 기능 유지).
## 사전 승인 필요(착수 전)
1) 의존성 추가: `flutter_shadcn_ui` (pubspec.yaml).
2) 테마 구조 재구성: 기존 `app_theme.dart`, `app_colors.dart` → shadcn 토큰/스케일 중심으로 정리.
3) 불용 파일 삭제: 기존 커스텀 위젯·스타일(예: 글라스모피즘 계열 등) 제거.
4) 점진적 마이그레이션 방식 선택(권장) 또는 일괄 치환(위험도 높음).
## 접근 전략(옵션)
- 옵션 A: 점진적 이행(권장)
1단계 토대(테마/토큰/기본 컴포넌트) → 2단계 주요 화면 치환 → 3단계 잔여 위젯/정리. 리스크 낮고 롤백 용이.
- 옵션 B: 일괄 치환
모든 화면/컴포넌트를 한 번에 교체. 속도는 빠르나 충돌/리스크 큼. 권장하지 않음.
이 계획서는 옵션 A를 기준으로 작성합니다.
## 테마·컬러 설계
- 토큰: primary, secondary, success, warning, danger, info, background, foreground, muted, accent, border, card, popover, ring, overlay.
- 라이트/다크 지원: 동일 의미 색상(semantics)을 양 테마에 매핑. 최소 WCAG 4.5:1 대비.
- 색채심리학 반영(과장 금지, 절제된 사용):
- info: 블루(신뢰/안정),
- success: 그린(완료/안도),
- warning: 앰버(주의 환기),
- danger: 레드(중단/삭제),
- neutral: 슬레이트/징크 계열(콘텐츠 중심).
- 게슈탈트: 시각적 그룹화(카드/섹션/간격 체계), 시선 흐름(타이포·계층), 근접성·유사성 활용.
- 피츠의 법칙: 주요 액션 버튼 터치 타깃 ≥ 44dp, 간격 여유.
- 마이크로인터랙션: 진입/전환 120200ms, 물리 기반 커브, Reduced Motion 설정 반영(`utils/reduce_motion.dart` 유지/연동).
구현 포인트(코드 단계에서 적용):
- `ShadcnTheme` 확장 혹은 테마 브리지 레이어 생성(예: `lib/theme/shadcn_theme.dart`) 후 기존 `ThemeData`와 연결.
- `TextTheme`/`ColorScheme`를 shadcn 토큰으로 역매핑해 타 3rd-party 위젯과도 일관성 유지.
## 컴포넌트 매핑(현행 → shadcn)
- 버튼: `common/buttons/(primary|secondary)_button.dart``Button(variant: primary/secondary)`
- 카드: 다수의 카드형 위젯 → `Card` + `CardHeader/Content/Footer`
- 다이얼로그: `dialogs/*``Dialog`/`AlertDialog` + 의미 색상(위험=red)
- 스낵바: `app_snackbar.dart``Toast` 또는 `Inline Alert`(상황별)
- 입력: `base_text_field.dart`, `currency_input_field.dart`, `date_picker_field.dart`, `selector`류 → `Input`, `Select`, `Popover+Calendar`(날짜)
- 네비게이션: `floating_navigation_bar.dart` → shadcn 스타일 버튼/탭/세그먼트 조합(기능은 Navigator 유지)
- 리스트/아이템: `subscription_*_card(_widget).dart``Card`+`List` 조합, 의미 색상 배지 사용
- 배지/상태: `analysis_badge.dart``Badge`(success/warning/info)
차트는 기존 라이브러리 유지, `Card`/토큰 색상만 적용.
## 화면별 리디자인 가이드
- 메인(`main_screen.dart`): 상단 요약(카드), 탭형 네비게이션, FAB 대신 우선작업 배치(피츠 법칙 반영).
- 구독 추가(`add_subscription_screen.dart`): 단계적 폼(섹션 카드), 필수/보조 액션 분리, 에러/힌트 색상 표준화.
- 상세(`detail_screen.dart`): 정보/행동 분리, 위험 액션은 `danger` 톤, url 영역은 `info` 톤.
- 분석(`analysis_screen.dart`): KPI 카드 3열(태블릿), 1열(폰), 차트 색상은 의미 기반 팔레트.
- 카테고리 관리(`category_management_screen.dart`): 리스트+인라인 편집, 확인/취소 분리, 경고 색상 남용 금지.
- 설정(`settings_screen.dart`): 토글/셀리스트 일관, 테마 전환 즉시 반영, 접근성 강조.
- SMS 권한(`sms_permission_screen.dart`): 단일 초점 화면, primary 호출-행동 버튼 + 보조 링크.
- 스플래시/잠금: 단순한 브랜드/배경, 과도한 애니메이션 제거.
## 정리/삭제 대상(마이그레이션 완료 후)
- 강한 시각효과 위젯: `animated_wave_background.dart`, `glassmorphism_card.dart`, `glassmorphic_scaffold.dart`
- 중복/대체 가능: 커스텀 버튼/카드/스낵바/다이얼로그 구현체(치환 완료 후)
- 사용되지 않는 유틸: 실사용 참조 0인 파일 전부
- 임시/백업: 오래된 백업/실험 파일
삭제는 단계별 PR에서 “치환 완료 확인 → 삭제” 순으로 안전하게 진행.
## 알고리즘 단순화(동일 효과 유지)
- SMS 스캔(`services/sms_scanner.dart`): 필터→파서→정규화 단일 파이프라인(순수 함수)로 재구성, 캐시/메모리 최적화 과잉 제거.
- URL 매처(`services/url_matcher/*`): 정규식 테이블 기반 단일 매칭기로 단순화(사전컴파일 RegExp), 서비스 데이터는 레포지토리 1곳에서 주입.
- 환율(`exchange_rate_service.dart`): `CacheManager` TTL 캐시 단일 책임, 만료 시 새로고침. 중복 포맷터/파서 제거.
- 알림(`notification_service.dart`): 스케줄/권한 체크를 단일 파사드로 노출, 내부 분기 축소.
- 성능 유틸(`performance_optimizer.dart`, `memory_manager.dart`): 체감·유지보수 이점 낮은 미세 최적화 제거, 프레임 드랍 유발 가능 애니메이션 단순화.
모든 변경은 퍼블릭 API/데이터 모델을 유지해 사이드 이펙트 방지.
## 테스트/검증
- 스크립트: `scripts/check.sh` 전 단계 실행(포맷/분석/테스트). 기존 deprecation 경고는 별 PR로 정리.
- 위젯/골든 테스트: 핵심 화면(메인/추가/상세/분석/설정) 라이트/다크 2종 캡처 비교.
- 유닛 테스트: URL 매처/환율 캐시/SMS 파이프라인.
- 접근성: 대비·포커스·터치 타깃 수동 점검 체크리스트.
## 작업 단위/PR 계획
1) 토대 구축: 의존성 추가 + 테마 브리지 + 핵심 컴포넌트(Button/Input/Card/Dialog) 도입.
2) 공용 UI 치환: 스낵바/다이얼로그/폼 필드/카드 템플릿 적용.
3) 화면별 리디자인: 메인→추가→상세→분석→설정 순.
4) 불용 코드 삭제: 치환 완료 파일 제거.
5) 알고리즘 단순화: sms/url/환율/알림 순으로 단일화 + 테스트.
6) 마감: 디테일 조정/접근성/성능 점검.
- 브랜치: `codex/feat-shadcn-migration-*` (단계별).
- 커밋: Conventional Commits + 한국어 본문.
- 롤백: 각 단계는 기능 플래그/치환 전후 비교가 쉬운 최소 단위로 유지.
## 위험 및 완화
- 리소스 색상/테마 충돌 → 토큰 브리지로 양방향 매핑, 미호환 위젯은 유지.
- 3rd-party 차트/네이티브 UI → 표면 색/텍스트만 토큰 적용.
- 분석 실패(deprecation) → 별 PR로 API 교체(`activeColor` 등), 마이그레이션과 분리 처리.
## 승인 체크리스트(Yes/No)
- [ ] `flutter_shadcn_ui` 의존성 추가 승인이 필요합니다.
- [ ] 테마 구조(shadcn 토큰 중심) 재구성 승인.
- [ ] 단계별 불용 파일 삭제 승인.
- [ ] 점진적 이행(옵션 A)로 진행 승인.
## 완료 기준(각 단계)
- `scripts/check.sh` 무사 통과(분석 경고 해결 내역은 별 PR 또는 병행).
- 라이트/다크 스냅샷 비교 이상 없음.
- 대상 화면/컴포넌트 치환 100% 및 구식 코드 제거.
---
작성자 메모: 본 계획은 코드 변경 없이 문서만 추가되었습니다. 승인 후 단계별 구현을 진행합니다.

70
doc/plan_color.md Normal file
View File

@@ -0,0 +1,70 @@
# Color & Theme Plan (Material 3)
Goals
- Remove Glassmorphism. Use Material 3 ColorScheme/typography/shape/elevation consistently.
- Ensure light/dark/system modes with accessible contrast; no dark-on-dark text.
- Semantic colors: primary/secondary/info/success/warning/error.
Phases
1) Audit + Baseline
- Inventory AppColors and Glass usages; map to ColorScheme.
- Set `ColorScheme.error=#EF4444` (light/dark) and verify Snackbar uses.
2) Core Components
- Settings: unify to `onSurface/onSurfaceVariant/primary` and fix Switch deprecations.
- Home Summary: surface/elevation + on-colors; badges use surfaceContainer variants.
- Add Subscription: selectors/fields to M3; disable glass flags.
3) Analysis & Lists
- Charts: grid/labels to onSurfaceVariant; tooltips to inverseSurface.
- Event/Detail sections: containers to surface + outline; text/icons to onSurface.
4) Theme & Cleanup
- Refactor `app_theme.dart` to remove glass defaults; prefer ColorScheme-driven themes.
- Replace remaining hard-coded colors (AppColors.*) with scheme; keep gradients sparingly.
- Resolve lints: const constructors, deprecated APIs (activeColor, scale).
Validation
- Run `scripts/check.sh` every change.
- Visual check in light/dark/system; confirm no low-contrast text.
Current Status (2025-09-10)
- Settings screen updated to ColorScheme; Switch deprecations fixed.
- AddSubscription selectors use M3 (glass flags off).
- MainSummaryCard event-savings text = primary.
- Monthly chart tooltips use inverseSurface/onInverseSurface.
- Next: theme/app_theme.dart cleanup; remaining AppColors usages; chart palette alignment.
Current Status (2025-09-11)
- Billing/Currency/Category selectors: use ColorScheme (selected=primary/onPrimary, unselected=surface+outline, text=onSurface). Glass/grad props deprecated and ignored.
- AnalysisBadge: remove AppColors, use surface + outline + subtle shadow.
- SubscriptionCard: header strip uses error/warning/category; price and badges use ColorScheme (error/primary/onSurfaceVariant); due-chip uses success/warning extension; removed hard-coded reds/grays.
- Checks: scripts/check.sh passes (format/analyze/test).
- Next: migrate remaining AppColors usages (detail sections, snackbars, splash), reduce hard-coded Colors in adaptive_theme.dart, optional: revisit success/warning harmonization.
Update (2025-09-11, PM)
- DetailEventSection: replaced green constants with ColorScheme.success; onPrimary for pill text.
- AddSubscriptionEventSection: info boxes use tertiary; removed AppColors.
- SMS Scan widgets: progress/button now use ColorScheme.primary.
- SubscriptionPieChartCard: no AppColors; chart palette uses scheme.success/warning; in-chart labels are white; exchange-rate chip uses primary soft background/border.
- EventAnalysisCard: discount/current price and discount badge use scheme.success/error.
- TotalExpenseSummaryCard: success icon uses scheme.success.
- AppLock/Main screen SnackBars: unified to scheme.error/success with onPrimary text.
- Splash: overlay/particles/title/subtitle/progress use ColorScheme; particle color bound to scheme.primary.
- Checks: scripts/check.sh passes.
Update (2025-09-11, PM-2)
- Dark Theme(adaptive_theme.dart): replaced hard-coded widget colors with ColorScheme-driven values.
- Inputs: fill=surface, borders=outline/primary/error, labels/hints=onSurfaceVariant.
- Buttons/Switch/Checkbox/Radio/Slider/TabBar/Divider: all use scheme tokens.
- AppBar/Card: background=surface, foreground/on-colors from scheme.
- OLED: inherits dark with surface override only.
- Checks: scripts/check.sh passes (no issues).
Update (2025-09-11, PM-3)
- Light Theme(app_theme.dart): AppColors 의존을 ColorScheme 사용으로 축소.
- InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant.
- Buttons/FAB: primary/onPrimary, Outlined side=outline.
- SnackBarTheme: primary/onPrimary.
- Scaffold background 유지(#F1F5F9) — ColorScheme.background 대신 직접 지정.
- Checks: scripts/check.sh passes.

View File

@@ -0,0 +1,43 @@
Summary
- Improve local notification reliability on iOS and Android without adding dependencies.
- Keep diffs minimal: platform tweaks + small service/UI updates.
Changes
- iOS
- ios/Runner/AppDelegate.swift
- Set UNUserNotificationCenter delegate on launch.
- Implement willPresent to show [.banner, .sound, .badge] while in foreground.
- Android
- android/app/src/main/AndroidManifest.xml
- Add RECEIVE_BOOT_COMPLETED for reboot rescheduling by plugin.
- Add SCHEDULE_EXACT_ALARM to allow exact timing on Android 12+.
- lib/services/notification_service.dart
- Create Android channels on init (subscription_channel, expiration_channel).
- Use AndroidScheduleMode.exactAllowWhileIdle for scheduled notifications.
- Ensure iOS DarwinNotificationDetails always present alert/sound/badge.
- Fix local variable overshadowing method parameter (title).
- Add canScheduleExactAlarms()/requestExactAlarmsPermission() wrappers.
- lib/screens/settings_screen.dart
- Add UI entry to request exact alarms permission when not granted (Android 12+).
Validation
- Ran scripts/check.sh
- Formatting check: OK
- flutter analyze: No issues
- flutter test: All tests passed
- Manual assertions
- Foreground iOS notifications display banners/sounds.
- Android channels created proactively to avoid muted/low-importance.
Risk & Rollback
- Risk
- Android 12+: Exact alarms require user approval in Settings > Special access > Alarms & reminders.
- Slight battery impact from exact alarms.
- Rollback
- Remove SCHEDULE_EXACT_ALARM and RECEIVE_BOOT_COMPLETED from AndroidManifest.
- Switch schedule mode back to AndroidScheduleMode.inexact in NotificationService.
Notes
- No dependency changes.
- Reboot rescheduling relies on flutter_local_notifications standard behavior with RECEIVE_BOOT_COMPLETED.

View File

@@ -495,7 +495,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -512,7 +512,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -530,7 +530,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -546,7 +546,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -677,7 +677,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -699,7 +699,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@@ -1,5 +1,6 @@
import Flutter
import UIKit
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -7,7 +8,17 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// //
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound, .badge])
}
}

View File

@@ -47,5 +47,8 @@
<true/>
<key>NSMessageUsageDescription</key>
<string>구독 결제 정보를 자동으로 추가하기 위해 SMS 접근이 필요합니다.</string>
<!-- Google AdMob App ID -->
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-6691216385521068~6638409932</string>
</dict>
</plist>

View File

@@ -0,0 +1,3 @@
/* Localized display name */
"CFBundleDisplayName" = "Digital Rent Manager";

View File

@@ -0,0 +1,3 @@
/* ローカライズされたアプリ表示名 */
"CFBundleDisplayName" = "デジタル月額管理者";

View File

@@ -0,0 +1,3 @@
/* 로컬라이즈된 앱 표시 이름 */
"CFBundleDisplayName" = "디지털 월세 관리자";

View File

@@ -0,0 +1,3 @@
/* 本地化的应用显示名称 */
"CFBundleDisplayName" = "数字月租管理器";

View File

@@ -2,40 +2,41 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../models/subscription_model.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController {
final BuildContext context;
// Form Key
final formKey = GlobalKey<FormState>();
// Text Controllers
final serviceNameController = TextEditingController();
final monthlyCostController = TextEditingController();
final nextBillingDateController = TextEditingController();
final websiteUrlController = TextEditingController();
final eventPriceController = TextEditingController();
// Form State
String billingCycle = 'monthly';
String currency = 'KRW';
DateTime? nextBillingDate;
bool isLoading = false;
String? selectedCategoryId;
// Event State
bool isEventActive = false;
DateTime? eventStartDate = DateTime.now();
DateTime? eventEndDate = DateTime.now().add(const Duration(days: 30));
// Focus Nodes
final serviceNameFocus = FocusNode();
final monthlyCostFocus = FocusNode();
@@ -44,20 +45,20 @@ class AddSubscriptionController {
final websiteUrlFocus = FocusNode();
final categoryFocus = FocusNode();
final currencyFocus = FocusNode();
// Animation Controller
AnimationController? animationController;
Animation<double>? fadeAnimation;
Animation<Offset>? slideAnimation;
// Scroll Controller
final ScrollController scrollController = ScrollController();
double scrollOffset = 0;
// UI State
int currentEditingField = -1;
bool isSaveHovered = false;
// Gradient Colors
final List<Color> gradientColors = [
const Color(0xFF3B82F6),
@@ -71,19 +72,19 @@ class AddSubscriptionController {
void initialize({required TickerProvider vsync}) {
// 결제일 기본값을 오늘 날짜로 설정
nextBillingDate = DateTime.now();
// 서비스명 컨트롤러에 리스너 추가
serviceNameController.addListener(onServiceNameChanged);
// 웹사이트 URL 컨트롤러에 리스너 추가
websiteUrlController.addListener(onWebsiteUrlChanged);
// 애니메이션 컨트롤러 초기화
animationController = AnimationController(
vsync: vsync,
duration: const Duration(milliseconds: 800),
);
fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -91,7 +92,7 @@ class AddSubscriptionController {
parent: animationController!,
curve: Curves.easeIn,
));
slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.2),
end: Offset.zero,
@@ -99,12 +100,32 @@ class AddSubscriptionController {
parent: animationController!,
curve: Curves.easeOut,
));
// 스크롤 리스너
scrollController.addListener(() {
scrollOffset = scrollController.offset;
});
// 언어별 기본 통화 설정
try {
final lang = Localizations.localeOf(context).languageCode;
switch (lang) {
case 'ko':
currency = 'KRW';
break;
case 'ja':
currency = 'JPY';
break;
case 'zh':
currency = 'CNY';
break;
default:
currency = 'USD';
}
} catch (_) {
// Localizations가 아직 준비되지 않은 경우 기본값 유지
}
// 애니메이션 시작
animationController!.forward();
}
@@ -117,7 +138,7 @@ class AddSubscriptionController {
nextBillingDateController.dispose();
websiteUrlController.dispose();
eventPriceController.dispose();
// Focus Nodes
serviceNameFocus.dispose();
monthlyCostFocus.dispose();
@@ -126,10 +147,10 @@ class AddSubscriptionController {
websiteUrlFocus.dispose();
categoryFocus.dispose();
currencyFocus.dispose();
// Animation
animationController?.dispose();
// Scroll
scrollController.dispose();
}
@@ -138,48 +159,52 @@ class AddSubscriptionController {
void onServiceNameChanged() {
autoSelectCategory();
}
/// 웹사이트 URL 변경시 호출
void onWebsiteUrlChanged() async {
final url = websiteUrlController.text.trim();
// URL이 비어있거나 너무 짧으면 무시
if (url.isEmpty || url.length < 5) return;
// 이미 서비스명이 입력되어 있으면 자동 매칭하지 않음
if (serviceNameController.text.isNotEmpty) return;
try {
// URL로 서비스 정보 찾기
final serviceInfo = await SubscriptionUrlMatcher.findServiceByUrl(url);
if (serviceInfo != null && context.mounted) {
// 서비스명 자동 입력
serviceNameController.text = serviceInfo.serviceName;
// 카테고리 자동 선택
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories;
// 카테고리 ID로 매칭
final matchedCategory = categories.firstWhere(
(cat) => cat.name == serviceInfo.categoryNameKr ||
cat.name == serviceInfo.categoryNameEn,
(cat) =>
cat.name == serviceInfo.categoryNameKr ||
cat.name == serviceInfo.categoryNameEn,
orElse: () => categories.first,
);
selectedCategoryId = matchedCategory.id;
// 스낵바로 알림
if (context.mounted) {
AppSnackBar.showSuccess(
context: context,
message: AppLocalizations.of(context).serviceRecognized(serviceInfo.serviceName),
message: AppLocalizations.of(context)
.serviceRecognized(serviceInfo.serviceName),
);
}
}
} catch (e) {
if (kDebugMode) {
// ignore: avoid_print
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
}
}
@@ -187,17 +212,18 @@ class AddSubscriptionController {
/// 카테고리 자동 선택
void autoSelectCategory() {
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories;
final serviceName = serviceNameController.text.toLowerCase();
// 서비스명에 기반한 카테고리 매칭 로직
dynamic matchedCategory;
// 엔터테인먼트 관련 키워드
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
serviceName.contains('disney') ||
serviceName.contains('왓챠') ||
serviceName.contains('티빙') ||
@@ -210,64 +236,64 @@ class AddSubscriptionController {
);
}
// 음악 관련 키워드
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'music',
orElse: () => categories.first,
);
}
// 생산성 관련 키워드
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '생산성',
orElse: () => categories.first,
);
}
// 게임 관련 키워드
else if (serviceName.contains('xbox') ||
serviceName.contains('playstation') ||
serviceName.contains('nintendo') ||
serviceName.contains('steam') ||
serviceName.contains('게임')) {
else if (serviceName.contains('xbox') ||
serviceName.contains('playstation') ||
serviceName.contains('nintendo') ||
serviceName.contains('steam') ||
serviceName.contains('게임')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '게임',
orElse: () => categories.first,
);
}
// 교육 관련 키워드
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '교육',
orElse: () => categories.first,
);
}
// 쇼핑 관련 키워드
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '쇼핑',
orElse: () => categories.first,
);
}
if (matchedCategory != null) {
selectedCategoryId = matchedCategory.id;
}
@@ -276,75 +302,109 @@ class AddSubscriptionController {
/// SMS 스캔
Future<void> scanSMS({required Function setState}) async {
if (kIsWeb) return;
setState(() => isLoading = true);
try {
final ctx = context;
if (!await SMSService.hasSMSPermission()) {
final granted = await SMSService.requestSMSPermission();
if (!ctx.mounted) return;
if (!granted) {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).smsPermissionRequired,
);
if (ctx.mounted) {
// 영구 거부 여부 확인 후 설정 화면 안내
final status = await permission.Permission.sms.status;
if (!ctx.mounted) return;
if (status.isPermanentlyDenied) {
await showDialog(
context: ctx,
builder: (_) => AlertDialog(
title: Text(AppLocalizations.of(ctx).smsPermissionRequired),
content:
Text(AppLocalizations.of(ctx).permanentlyDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(AppLocalizations.of(ctx).cancel),
),
TextButton(
onPressed: () async {
await permission.openAppSettings();
if (ctx.mounted) Navigator.of(ctx).pop();
},
child: Text(AppLocalizations.of(ctx).openSettings),
),
],
),
);
} else {
AppSnackBar.showError(
context: ctx,
message: AppLocalizations.of(ctx).smsPermissionRequired,
);
}
}
return;
}
}
final subscriptions = await SMSService.scanSubscriptions();
if (!ctx.mounted) return;
if (subscriptions.isEmpty) {
if (context.mounted) {
if (ctx.mounted) {
AppSnackBar.showWarning(
context: context,
message: AppLocalizations.of(context).noSubscriptionSmsFound,
context: ctx,
message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
);
}
return;
}
final subscription = subscriptions.first;
// SMS에서 서비스 정보 추출 시도
ServiceInfo? serviceInfo;
final smsContent = subscription['smsContent'] ?? '';
if (smsContent.isNotEmpty) {
try {
serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
serviceInfo =
await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
} catch (e) {
if (kDebugMode) {
// ignore: avoid_print
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
}
}
}
setState(() {
// 서비스 정보가 있으면 우선 사용, 없으면 SMS에서 추출한 정보 사용
if (serviceInfo != null) {
serviceNameController.text = serviceInfo.serviceName;
websiteUrlController.text = serviceInfo.serviceUrl ?? '';
// 카테고리 자동 선택
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories;
final matchedCategory = categories.firstWhere(
(cat) => cat.name == serviceInfo!.categoryNameKr ||
cat.name == serviceInfo.categoryNameEn,
(cat) =>
cat.name == serviceInfo!.categoryNameKr ||
cat.name == serviceInfo.categoryNameEn,
orElse: () => categories.first,
);
selectedCategoryId = matchedCategory.id;
} else {
// 기존 로직 사용
serviceNameController.text = subscription['serviceName'] ?? '';
}
// 비용 처리 및 통화 단위 자동 감지
final costValue = subscription['monthlyCost']?.toString() ?? '';
if (costValue.isNotEmpty) {
// 달러 표시가 있거나 소수점이 있으면 달러로 판단
if (costValue.contains('\$') || costValue.contains('.')) {
@@ -353,41 +413,41 @@ class AddSubscriptionController {
if (!numericValue.contains('.')) {
numericValue = '$numericValue.00';
}
final double parsedValue =
final double parsedValue =
double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0;
monthlyCostController.text =
monthlyCostController.text =
NumberFormat('#,##0.00').format(parsedValue);
} else {
currency = 'KRW';
String numericValue =
String numericValue =
costValue.replaceAll('', '').replaceAll(',', '').trim();
final int parsedValue = int.tryParse(numericValue) ?? 0;
monthlyCostController.text =
monthlyCostController.text =
NumberFormat.decimalPattern().format(parsedValue);
}
} else {
monthlyCostController.text = '';
}
billingCycle = subscription['billingCycle'] ?? '월간';
nextBillingDate = subscription['nextBillingDate'] != null
? DateTime.parse(subscription['nextBillingDate'])
: DateTime.now();
// 서비스 정보가 없고 서비스명이 있으면 URL 자동 매칭 시도
if (serviceInfo == null &&
subscription['serviceName'] != null &&
if (serviceInfo == null &&
subscription['serviceName'] != null &&
subscription['serviceName'].isNotEmpty) {
final suggestedUrl =
final suggestedUrl =
SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']);
if (suggestedUrl != null && websiteUrlController.text.isEmpty) {
websiteUrlController.text = suggestedUrl;
}
// 서비스명 기반으로 카테고리 자동 선택
autoSelectCategory();
}
// 애니메이션 재생
animationController!.reset();
animationController!.forward();
@@ -396,7 +456,8 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()),
message: AppLocalizations.of(context)
.smsScanErrorWithMessage(e.toString()),
);
}
} finally {
@@ -412,26 +473,34 @@ class AddSubscriptionController {
setState(() {
isLoading = true;
});
try {
// 콤마 제거하고 숫자만 추출
final monthlyCost =
final monthlyCost =
double.parse(monthlyCostController.text.replaceAll(',', ''));
// 이벤트 가격 파싱
double? eventPrice;
if (isEventActive && eventPriceController.text.isNotEmpty) {
eventPrice = double.tryParse(
eventPriceController.text.replaceAll(',', '')
);
eventPrice =
double.tryParse(eventPriceController.text.replaceAll(',', ''));
}
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
final originalDateOnly = DateTime(
nextBillingDate!.year,
nextBillingDate!.month,
nextBillingDate!.day,
);
var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
await Provider.of<SubscriptionProvider>(context, listen: false)
.addSubscription(
serviceName: serviceNameController.text.trim(),
monthlyCost: monthlyCost,
billingCycle: billingCycle,
nextBillingDate: nextBillingDate!,
nextBillingDate: adjustedNext,
websiteUrl: websiteUrlController.text.trim(),
categoryId: selectedCategoryId,
currency: currency,
@@ -440,7 +509,17 @@ class AddSubscriptionController {
eventEndDate: eventEndDate,
eventPrice: eventPrice,
);
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
);
}
}
if (context.mounted) {
Navigator.pop(context, true); // 성공 여부 반환
}
@@ -448,11 +527,12 @@ class AddSubscriptionController {
setState(() {
isLoading = false;
});
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
message:
AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
);
}
}
@@ -464,4 +544,4 @@ class AddSubscriptionController {
);
}
}
}
}

View File

@@ -12,22 +12,23 @@ import 'package:intl/intl.dart';
import '../widgets/dialogs/delete_confirmation_dialog.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController extends ChangeNotifier {
final BuildContext context;
final SubscriptionModel subscription;
// Text Controllers
late TextEditingController serviceNameController;
late TextEditingController monthlyCostController;
late TextEditingController websiteUrlController;
late TextEditingController eventPriceController;
// Display Names
String? _displayName;
String? get displayName => _displayName;
// Form State
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
late String _billingCycle;
@@ -35,12 +36,12 @@ class DetailScreenController extends ChangeNotifier {
String? _selectedCategoryId;
late String _currency;
bool _isLoading = false;
// Event State
late bool _isEventActive;
DateTime? _eventStartDate;
DateTime? _eventEndDate;
// Getters
String get billingCycle => _billingCycle;
DateTime get nextBillingDate => _nextBillingDate;
@@ -50,7 +51,7 @@ class DetailScreenController extends ChangeNotifier {
bool get isEventActive => _isEventActive;
DateTime? get eventStartDate => _eventStartDate;
DateTime? get eventEndDate => _eventEndDate;
// Setters
set billingCycle(String value) {
if (_billingCycle != value) {
@@ -58,21 +59,21 @@ class DetailScreenController extends ChangeNotifier {
notifyListeners();
}
}
set nextBillingDate(DateTime value) {
if (_nextBillingDate != value) {
_nextBillingDate = value;
notifyListeners();
}
}
set selectedCategoryId(String? value) {
if (_selectedCategoryId != value) {
_selectedCategoryId = value;
notifyListeners();
}
}
set currency(String value) {
if (_currency != value) {
_currency = value;
@@ -80,35 +81,35 @@ class DetailScreenController extends ChangeNotifier {
notifyListeners();
}
}
set isLoading(bool value) {
if (_isLoading != value) {
_isLoading = value;
notifyListeners();
}
}
set isEventActive(bool value) {
if (_isEventActive != value) {
_isEventActive = value;
notifyListeners();
}
}
set eventStartDate(DateTime? value) {
if (_eventStartDate != value) {
_eventStartDate = value;
notifyListeners();
}
}
set eventEndDate(DateTime? value) {
if (_eventEndDate != value) {
_eventEndDate = value;
notifyListeners();
}
}
// Focus Nodes
final serviceNameFocus = FocusNode();
final monthlyCostFocus = FocusNode();
@@ -117,7 +118,7 @@ class DetailScreenController extends ChangeNotifier {
final websiteUrlFocus = FocusNode();
final categoryFocus = FocusNode();
final currencyFocus = FocusNode();
// UI State
final ScrollController scrollController = ScrollController();
double scrollOffset = 0;
@@ -125,7 +126,7 @@ class DetailScreenController extends ChangeNotifier {
bool isDeleteHovered = false;
bool isSaveHovered = false;
bool isCancelHovered = false;
// Animation Controller
AnimationController? animationController;
Animation<double>? fadeAnimation;
@@ -140,42 +141,45 @@ class DetailScreenController extends ChangeNotifier {
/// 초기화
void initialize({required TickerProvider vsync}) {
// Text Controllers 초기화
serviceNameController = TextEditingController(text: subscription.serviceName);
monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString());
websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? '');
serviceNameController =
TextEditingController(text: subscription.serviceName);
monthlyCostController =
TextEditingController(text: subscription.monthlyCost.toString());
websiteUrlController =
TextEditingController(text: subscription.websiteUrl ?? '');
eventPriceController = TextEditingController();
// Form State 초기화
_billingCycle = subscription.billingCycle;
_nextBillingDate = subscription.nextBillingDate;
_selectedCategoryId = subscription.categoryId;
_currency = subscription.currency;
// Event State 초기화
_isEventActive = subscription.isEventActive;
_eventStartDate = subscription.eventStartDate;
_eventEndDate = subscription.eventEndDate;
// 이벤트 가격 초기화
if (subscription.eventPrice != null) {
if (currency == 'KRW') {
eventPriceController.text = NumberFormat.decimalPattern()
.format(subscription.eventPrice!.toInt());
} else {
eventPriceController.text =
eventPriceController.text =
NumberFormat('#,##0.00').format(subscription.eventPrice!);
}
}
// 통화 단위에 따른 금액 표시 형식 조정
_updateMonthlyCostFormat();
// 애니메이션 초기화
animationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: vsync,
);
fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -183,7 +187,7 @@ class DetailScreenController extends ChangeNotifier {
parent: animationController!,
curve: Curves.easeInOut,
));
slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.3),
end: Offset.zero,
@@ -191,7 +195,7 @@ class DetailScreenController extends ChangeNotifier {
parent: animationController!,
curve: Curves.easeOutCubic,
));
rotateAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -199,16 +203,16 @@ class DetailScreenController extends ChangeNotifier {
parent: animationController!,
curve: Curves.easeOutCubic,
));
// 애니메이션 시작
animationController!.forward();
// 로케일에 맞는 서비스명 로드
_loadDisplayName();
// 서비스명 변경 감지 리스너
serviceNameController.addListener(onServiceNameChanged);
// 스크롤 리스너
scrollController.addListener(() {
scrollOffset = scrollController.offset;
@@ -219,16 +223,16 @@ class DetailScreenController extends ChangeNotifier {
Future<void> _loadDisplayName() async {
final localeProvider = context.read<LocaleProvider>();
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscription.serviceName,
locale: locale,
);
_displayName = displayName;
notifyListeners();
}
/// 리소스 정리
@override
void dispose() {
@@ -237,7 +241,7 @@ class DetailScreenController extends ChangeNotifier {
monthlyCostController.dispose();
websiteUrlController.dispose();
eventPriceController.dispose();
// Focus Nodes
serviceNameFocus.dispose();
monthlyCostFocus.dispose();
@@ -246,13 +250,13 @@ class DetailScreenController extends ChangeNotifier {
websiteUrlFocus.dispose();
categoryFocus.dispose();
currencyFocus.dispose();
// Animation
animationController?.dispose();
// Scroll
scrollController.dispose();
super.dispose();
}
@@ -261,10 +265,12 @@ class DetailScreenController extends ChangeNotifier {
if (_currency == 'KRW') {
// 원화는 소수점 없이 표시
final intValue = subscription.monthlyCost.toInt();
monthlyCostController.text = NumberFormat.decimalPattern().format(intValue);
monthlyCostController.text =
NumberFormat.decimalPattern().format(intValue);
} else {
// 달러는 소수점 2자리까지 표시
monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost);
monthlyCostController.text =
NumberFormat('#,##0.00').format(subscription.monthlyCost);
}
}
@@ -275,17 +281,18 @@ class DetailScreenController extends ChangeNotifier {
/// 카테고리 자동 선택
void autoSelectCategory() {
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories;
final serviceName = serviceNameController.text.toLowerCase();
// 서비스명에 기반한 카테고리 매칭 로직
CategoryModel? matchedCategory;
// 엔터테인먼트 관련 키워드
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
serviceName.contains('disney') ||
serviceName.contains('왓챠') ||
serviceName.contains('티빙') ||
@@ -298,64 +305,64 @@ class DetailScreenController extends ChangeNotifier {
);
}
// 음악 관련 키워드
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'music',
orElse: () => categories.first,
);
}
// 생산성 관련 키워드
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first,
);
}
// AI 관련 키워드
else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') ||
serviceName.contains('gemini') ||
serviceName.contains('copilot') ||
serviceName.contains('midjourney')) {
else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') ||
serviceName.contains('gemini') ||
serviceName.contains('copilot') ||
serviceName.contains('midjourney')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'aiService',
orElse: () => categories.first,
);
}
// 교육 관련 키워드
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'programming',
orElse: () => categories.first,
);
}
// 쇼핑 관련 키워드
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categories.first,
);
}
if (matchedCategory != null) {
selectedCategoryId = matchedCategory.id;
}
@@ -371,47 +378,54 @@ class DetailScreenController extends ChangeNotifier {
);
return;
}
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
String? websiteUrl = websiteUrlController.text;
if (websiteUrl.isEmpty) {
websiteUrl = SubscriptionUrlMatcher.suggestUrl(serviceNameController.text);
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(serviceNameController.text);
}
// 구독 정보 업데이트
// 콤마 제거하고 숫자만 추출
double monthlyCost = 0.0;
try {
monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', ''));
monthlyCost =
double.parse(monthlyCostController.text.replaceAll(',', ''));
} catch (e) {
// 파싱 오류 발생 시 기본값 사용
monthlyCost = subscription.monthlyCost;
}
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
'${subscription.serviceName}${serviceNameController.text}, '
'금액: ${subscription.monthlyCost}$monthlyCost ${_currency}');
'금액: $subscription.monthlyCost → $monthlyCost $_currency');
subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost;
subscription.websiteUrl = websiteUrl;
subscription.billingCycle = _billingCycle;
subscription.nextBillingDate = _nextBillingDate;
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장
final originalDateOnly = DateTime(
_nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day);
var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
subscription.nextBillingDate = adjustedNext;
subscription.categoryId = _selectedCategoryId;
subscription.currency = _currency;
// 이벤트 정보 업데이트
subscription.isEventActive = _isEventActive;
subscription.eventStartDate = _eventStartDate;
subscription.eventEndDate = _eventEndDate;
// 이벤트 가격 파싱
if (_isEventActive && eventPriceController.text.isNotEmpty) {
try {
subscription.eventPrice =
subscription.eventPrice =
double.parse(eventPriceController.text.replaceAll(',', ''));
} catch (e) {
subscription.eventPrice = null;
@@ -419,20 +433,28 @@ class DetailScreenController extends ChangeNotifier {
} else {
subscription.eventPrice = null;
}
debugPrint('[DetailScreenController] 업데이트 정보: '
'현재가격=${subscription.currentPrice}, '
'이벤트활성=${subscription.isEventActive}');
// 구독 업데이트
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
);
}
await provider.updateSubscription(subscription);
if (context.mounted) {
AppSnackBar.showSuccess(
context: context,
message: AppLocalizations.of(context).subscriptionUpdated,
);
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
await Future.delayed(const Duration(milliseconds: 100));
if (context.mounted) {
@@ -445,30 +467,35 @@ class DetailScreenController extends ChangeNotifier {
Future<void> deleteSubscription() async {
if (context.mounted) {
// 로케일에 맞는 서비스명 가져오기
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
final localeProvider =
Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscription.serviceName,
locale: locale,
);
if (!context.mounted) return;
// 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
if (!context.mounted) return;
if (!shouldDelete) return;
// 사용자가 확인한 경우에만 삭제 진행
if (context.mounted) {
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
await provider.deleteSubscription(subscription.id);
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).subscriptionDeleted(displayName),
message:
AppLocalizations.of(context).subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded,
);
Navigator.of(context).pop();
@@ -482,19 +509,22 @@ class DetailScreenController extends ChangeNotifier {
try {
// 1. 현재 언어 설정 가져오기
final locale = Localizations.localeOf(context).languageCode;
// 2. 해지 안내 URL 찾기
String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl(
String? cancellationUrl =
await SubscriptionUrlMatcher.findCancellationUrl(
serviceName: subscription.serviceName,
websiteUrl: subscription.websiteUrl,
locale: locale == 'ko' ? 'kr' : 'en',
);
// 3. 해지 안내 URL이 없으면 구글 검색
if (cancellationUrl == null) {
final searchQuery = '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
cancellationUrl = 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
final searchQuery =
'${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
cancellationUrl =
'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
@@ -502,7 +532,7 @@ class DetailScreenController extends ChangeNotifier {
);
}
}
// 4. URL 열기
final Uri url = Uri.parse(cancellationUrl);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
@@ -515,11 +545,12 @@ class DetailScreenController extends ChangeNotifier {
}
} catch (e) {
if (kDebugMode) {
// ignore: avoid_print
print('DetailScreenController: 해지 페이지 열기 실패 - $e');
}
// 오류 발생시 일반 웹사이트로 폴백
if (subscription.websiteUrl != null &&
if (subscription.websiteUrl != null &&
subscription.websiteUrl!.isNotEmpty) {
final Uri url = Uri.parse(subscription.websiteUrl!);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
@@ -554,19 +585,9 @@ class DetailScreenController extends ChangeNotifier {
const Color(0xFF0EA5E9), // 하늘
const Color(0xFFEC4899), // 분홍
];
return colors[hash % colors.length];
}
/// 그라데이션 가져오기
LinearGradient getGradient(Color baseColor) {
return LinearGradient(
colors: [
baseColor,
baseColor.withValues(alpha: 0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
}
// getGradient 제거됨 (그라데이션 미사용)
}

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart';
import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/logger.dart';
import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart';
@@ -57,19 +59,48 @@ class SmsScanController extends ChangeNotifier {
notifyListeners();
try {
// Android에서 SMS 권한 확인 및 요청
final ctx = context;
if (!kIsWeb) {
final smsStatus = await permission.Permission.sms.status;
if (!smsStatus.isGranted) {
if (smsStatus.isPermanentlyDenied) {
// 설정 유도 다이얼로그 표시
if (!ctx.mounted) return;
await _showPermissionSettingsDialog(ctx);
_isLoading = false;
notifyListeners();
return;
}
final req = await permission.Permission.sms.request();
if (!ctx.mounted) return;
if (!req.isGranted) {
// 거부됨: 안내 후 종료
if (!ctx.mounted) return;
_errorMessage = AppLocalizations.of(ctx).smsPermissionRequired;
_isLoading = false;
notifyListeners();
return;
}
}
}
// SMS 스캔 실행
print('SMS 스캔 시작');
final scannedSubscriptionModels = await _smsScanner.scanForSubscriptions();
print('스캔된 구독: ${scannedSubscriptionModels.length}');
Log.i('SMS 스캔 시작');
final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) {
print('첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
}
if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) {
print('스캔된 구독이 없음');
Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false;
notifyListeners();
@@ -77,18 +108,21 @@ class SmsScanController extends ChangeNotifier {
}
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions = _converter.convertModelsToSubscriptions(scannedSubscriptionModels);
final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions = _filter.filterByRepeatCount(scannedSubscriptions, 2);
print('반복 결제된 구독: ${repeatSubscriptions.length}');
final repeatSubscriptions =
_filter.filterByRepeatCount(scannedSubscriptions, 2);
Log.d('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) {
print('첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
Log.d(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
}
if (repeatSubscriptions.isEmpty) {
print('반복 결제된 구독이 없음');
Log.i('반복 결제된 구독이 없음');
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
_isLoading = false;
notifyListeners();
@@ -96,21 +130,24 @@ class SmsScanController extends ChangeNotifier {
}
// 구독 목록 가져오기
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions;
print('기존 구독: ${existingSubscriptions.length}');
Log.d('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링
final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}');
final filteredSubscriptions =
_filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) {
print('첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
Log.d(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
// 중복 제거 후 신규 구독이 없는 경우
if (filteredSubscriptions.isEmpty) {
print('중복 제거 후 신규 구독이 없음');
Log.i('중복 제거 후 신규 구독이 없음');
_isLoading = false;
notifyListeners();
return;
@@ -121,33 +158,63 @@ class SmsScanController extends ChangeNotifier {
websiteUrlController.text = ''; // URL 입력 필드 초기화
notifyListeners();
} catch (e) {
print('SMS 스캔 중 오류 발생: $e');
Log.e('SMS 스캔 중 오류 발생', e);
if (context.mounted) {
_errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
_errorMessage =
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
_isLoading = false;
notifyListeners();
}
}
}
Future<void> _showPermissionSettingsDialog(BuildContext context) async {
final loc = AppLocalizations.of(context);
await showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text(loc.smsPermissionRequired),
content: Text(loc.permanentlyDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.cancel),
),
TextButton(
onPressed: () async {
await permission.openAppSettings();
if (context.mounted) Navigator.of(context).pop();
},
child: Text(loc.openSettings),
),
],
),
);
}
Future<void> addCurrentSubscription(BuildContext context) async {
if (_currentIndex >= _scannedSubscriptions.length) return;
final subscription = _scannedSubscriptions[_currentIndex];
try {
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ?? subscription.category ?? getDefaultCategoryId(categoryProvider);
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ??
subscription.category ??
getDefaultCategoryId(categoryProvider);
// websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
? websiteUrlController.text.trim()
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
? websiteUrlController.text.trim()
: subscription.websiteUrl;
print('구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
Log.d(
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
// addSubscription 호출
await provider.addSubscription(
serviceName: subscription.serviceName,
@@ -161,20 +228,21 @@ class SmsScanController extends ChangeNotifier {
categoryId: finalCategoryId,
currency: subscription.currency,
);
print('구독 추가 성공: ${subscription.serviceName}');
Log.i('구독 추가 성공: ${subscription.serviceName}');
if (!context.mounted) return;
moveToNextSubscription(context);
} catch (e) {
print('구독 추가 중 오류 발생: $e');
Log.e('구독 추가 중 오류 발생', e);
// 오류가 있어도 다음 구독으로 이동
if (!context.mounted) return;
moveToNextSubscription(context);
}
}
void skipCurrentSubscription(BuildContext context) {
final subscription = _scannedSubscriptions[_currentIndex];
print('구독 건너뛰기: ${subscription.serviceName}');
Log.i('구독 건너뛰기: ${subscription.serviceName}');
moveToNextSubscription(context);
}
@@ -187,13 +255,14 @@ class SmsScanController extends ChangeNotifier {
if (_currentIndex >= _scannedSubscriptions.length) {
navigateToHome(context);
}
notifyListeners();
}
void navigateToHome(BuildContext context) {
// NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0);
}
@@ -209,7 +278,7 @@ class SmsScanController extends ChangeNotifier {
(cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first,
);
print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
return otherCategory.id;
}
@@ -221,4 +290,4 @@ class SmsScanController extends ChangeNotifier {
}
}
}
}
}

View File

@@ -14,22 +14,21 @@ class AppLocalizations {
// JSON 파일에서 번역 데이터 로드
Future<void> load() async {
String jsonString =
await rootBundle.loadString('assets/data/text.json');
String jsonString = await rootBundle.loadString('assets/data/text.json');
Map<String, dynamic> jsonMap = json.decode(jsonString);
_localizedStrings = jsonMap[locale.languageCode];
}
String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager';
String get appSubtitle => _localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily';
String get appSubtitle =>
_localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily';
String get subscriptionManagement =>
_localizedStrings['subscriptionManagement'] ?? 'Subscription Management';
String get addSubscription =>
_localizedStrings['addSubscription'] ?? 'Add Subscription';
String get subscriptionName =>
_localizedStrings['subscriptionName'] ?? 'Service Name';
String get monthlyCost =>
_localizedStrings['monthlyCost'] ?? 'Monthly Cost';
String get monthlyCost => _localizedStrings['monthlyCost'] ?? 'Monthly Cost';
String get billingCycle =>
_localizedStrings['billingCycle'] ?? 'Billing Cycle';
String get nextBillingDate =>
@@ -55,29 +54,50 @@ class AppLocalizations {
_localizedStrings['categoryManagement'] ?? 'Category Management';
String get categoryName =>
_localizedStrings['categoryName'] ?? 'Category Name';
String get selectColor =>
_localizedStrings['selectColor'] ?? 'Select Color';
String get selectIcon =>
_localizedStrings['selectIcon'] ?? 'Select Icon';
String get addCategory =>
_localizedStrings['addCategory'] ?? 'Add Category';
String get selectColor => _localizedStrings['selectColor'] ?? 'Select Color';
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
String get settings => _localizedStrings['settings'] ?? 'Settings';
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
String get language => _localizedStrings['language'] ?? 'Language';
String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
// SMS 권한 온보딩/설정
String get smsPermissionTitle =>
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
String get smsPermissionReasonTitle =>
_localizedStrings['smsPermissionReasonTitle'] ?? 'Why';
String get smsPermissionReasonBody =>
_localizedStrings['smsPermissionReasonBody'] ??
'We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.';
String get smsPermissionScopeTitle =>
_localizedStrings['smsPermissionScopeTitle'] ?? 'Scope';
String get smsPermissionScopeBody =>
_localizedStrings['smsPermissionScopeBody'] ??
'We scan only payment-related SMS patterns (service/amount/date) locally; no data leaves your device.';
String get permanentlyDeniedMessage =>
_localizedStrings['permanentlyDeniedMessage'] ??
'Permission is permanently denied. Enable it in Settings.';
String get openSettings =>
_localizedStrings['openSettings'] ?? 'Open Settings';
String get later => _localizedStrings['later'] ?? 'Later';
String get requesting => _localizedStrings['requesting'] ?? 'Requesting...';
String get smsPermissionLabel =>
_localizedStrings['smsPermissionLabel'] ?? 'SMS Permission';
// 알림 설정
String get notificationPermission =>
_localizedStrings['notificationPermission'] ?? 'Notification Permission';
String get notificationPermissionDesc =>
_localizedStrings['notificationPermissionDesc'] ?? 'Permission is required to receive notifications';
_localizedStrings['notificationPermissionDesc'] ??
'Permission is required to receive notifications';
String get requestPermission =>
_localizedStrings['requestPermission'] ?? 'Request Permission';
String get paymentNotification =>
_localizedStrings['paymentNotification'] ?? 'Payment Due Notification';
String get paymentNotificationDesc =>
_localizedStrings['paymentNotificationDesc'] ?? 'Receive notification on payment due date';
_localizedStrings['paymentNotificationDesc'] ??
'Receive notification on payment due date';
String get notificationTiming =>
_localizedStrings['notificationTiming'] ?? 'Notification Timing';
String get notificationTime =>
@@ -85,14 +105,18 @@ class AppLocalizations {
String get dailyReminder =>
_localizedStrings['dailyReminder'] ?? 'Daily Reminder';
String get dailyReminderEnabled =>
_localizedStrings['dailyReminderEnabled'] ?? 'Receive daily notifications until payment date';
_localizedStrings['dailyReminderEnabled'] ??
'Receive daily notifications until payment date';
String get dailyReminderDisabled =>
_localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment';
_localizedStrings['dailyReminderDisabled'] ??
'Receive notification @ day(s) before payment';
String get notificationPermissionDenied =>
_localizedStrings['notificationPermissionDenied'] ?? 'Notification permission denied';
_localizedStrings['notificationPermissionDenied'] ??
'Notification permission denied';
// 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version';
String get openStore => _localizedStrings['openStore'] ?? 'Open Store';
String get appDescription =>
_localizedStrings['appDescription'] ?? 'Subscription Management App';
String get developer => _localizedStrings['developer'] ?? 'Developer';
@@ -102,7 +126,8 @@ class AppLocalizations {
String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light';
String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark';
String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black';
String get systemTheme => _localizedStrings['systemTheme'] ?? 'System Default';
String get systemTheme =>
_localizedStrings['systemTheme'] ?? 'System Default';
// 기타 메시지
String get subscriptionAdded =>
_localizedStrings['subscriptionAdded'] ?? 'Subscription added';
@@ -112,190 +137,305 @@ class AppLocalizations {
String get japanese => _localizedStrings['japanese'] ?? '日本語';
String get chinese => _localizedStrings['chinese'] ?? '中文';
// 날짜
String get oneDayBefore => _localizedStrings['oneDayBefore'] ?? '1 day before';
String get twoDaysBefore => _localizedStrings['twoDaysBefore'] ?? '2 days before';
String get threeDaysBefore => _localizedStrings['threeDaysBefore'] ?? '3 days before';
String get oneDayBefore =>
_localizedStrings['oneDayBefore'] ?? '1 day before';
String get twoDaysBefore =>
_localizedStrings['twoDaysBefore'] ?? '2 days before';
String get threeDaysBefore =>
_localizedStrings['threeDaysBefore'] ?? '3 days before';
// 추가 메시지
String get requiredFieldsError => _localizedStrings['requiredFieldsError'] ?? 'Please fill in all required fields';
String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated';
String get officialCancelPageNotFound => _localizedStrings['officialCancelPageNotFound'] ?? 'Official cancellation page not found. Redirecting to Google search.';
String get cannotOpenWebsite => _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website';
String get noWebsiteInfo => _localizedStrings['noWebsiteInfo'] ?? 'No website information available. Please cancel through the website.';
String get requiredFieldsError =>
_localizedStrings['requiredFieldsError'] ??
'Please fill in all required fields';
String get subscriptionUpdated =>
_localizedStrings['subscriptionUpdated'] ??
'Subscription information has been updated';
String get officialCancelPageNotFound =>
_localizedStrings['officialCancelPageNotFound'] ??
'Official cancellation page not found. Redirecting to Google search.';
String get cannotOpenWebsite =>
_localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website';
String get noWebsiteInfo =>
_localizedStrings['noWebsiteInfo'] ??
'No website information available. Please cancel through the website.';
String get editMode => _localizedStrings['editMode'] ?? 'Edit Mode';
String get changesAppliedAfterSave => _localizedStrings['changesAppliedAfterSave'] ?? 'Changes will be applied after saving';
String get changesAppliedAfterSave =>
_localizedStrings['changesAppliedAfterSave'] ??
'Changes will be applied after saving';
String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes';
String get monthlyExpense => _localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
String get monthlyExpense =>
_localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL';
String get websiteUrlOptional => _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
String get websiteUrlOptional =>
_localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
String get eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price';
String get eventPriceHint => _localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
String get eventPriceRequired => _localizedStrings['eventPriceRequired'] ?? 'Please enter event price';
String get invalidPrice => _localizedStrings['invalidPrice'] ?? 'Please enter a valid price';
String get eventPriceHint =>
_localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
String get eventPriceRequired =>
_localizedStrings['eventPriceRequired'] ?? 'Please enter event price';
String get invalidPrice =>
_localizedStrings['invalidPrice'] ?? 'Please enter a valid price';
String get smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS';
String get home => _localizedStrings['home'] ?? 'Home';
String get analysis => _localizedStrings['analysis'] ?? 'Analysis';
String get back => _localizedStrings['back'] ?? 'Back';
String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App';
String get exitAppConfirm => _localizedStrings['exitAppConfirm'] ?? 'Are you sure you want to exit SubManager?';
String get exitAppConfirm =>
_localizedStrings['exitAppConfirm'] ??
'Are you sure you want to exit SubManager?';
String get exit => _localizedStrings['exit'] ?? 'Exit';
String get pageNotFound => _localizedStrings['pageNotFound'] ?? 'Page not found';
String get serviceNameExample => _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
String get urlExample => _localizedStrings['urlExample'] ?? 'https://example.com';
String get appLockDesc => _localizedStrings['appLockDesc'] ?? 'App lock with biometric authentication';
String get unlockWithBiometric => _localizedStrings['unlockWithBiometric'] ?? 'Unlock with biometric authentication';
String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.';
String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
String get noSubscriptionSmsFound => _localizedStrings['noSubscriptionSmsFound'] ?? 'No subscription related SMS found';
String get smsScanError => _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan';
String get saveError => _localizedStrings['saveError'] ?? 'Error occurred while saving';
String get newSubscriptionSmsNotFound => _localizedStrings['newSubscriptionSmsNotFound'] ?? 'No new subscription SMS found';
String get subscriptionAddError => _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription';
String get allSubscriptionsProcessed => _localizedStrings['allSubscriptionsProcessed'] ?? 'All subscriptions have been processed.';
String get websiteUrlExtracted => _localizedStrings['websiteUrlExtracted'] ?? 'Website URL (Auto-extracted)';
String get pageNotFound =>
_localizedStrings['pageNotFound'] ?? 'Page not found';
String get serviceNameExample =>
_localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
String get urlExample =>
_localizedStrings['urlExample'] ?? 'https://example.com';
String get appLockDesc =>
_localizedStrings['appLockDesc'] ??
'App lock with biometric authentication';
String get unlockWithBiometric =>
_localizedStrings['unlockWithBiometric'] ??
'Unlock with biometric authentication';
String get authenticationFailed =>
_localizedStrings['authenticationFailed'] ??
'Authentication failed. Please try again.';
String get smsPermissionRequired =>
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
String get noSubscriptionSmsFound =>
_localizedStrings['noSubscriptionSmsFound'] ??
'No subscription related SMS found';
String get smsScanError =>
_localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan';
String get saveError =>
_localizedStrings['saveError'] ?? 'Error occurred while saving';
String get newSubscriptionSmsNotFound =>
_localizedStrings['newSubscriptionSmsNotFound'] ??
'No new subscription SMS found';
String get subscriptionAddError =>
_localizedStrings['subscriptionAddError'] ?? 'Error adding subscription';
String get allSubscriptionsProcessed =>
_localizedStrings['allSubscriptionsProcessed'] ??
'All subscriptions have been processed.';
String get websiteUrlExtracted =>
_localizedStrings['websiteUrlExtracted'] ??
'Website URL (Auto-extracted)';
String get startDate => _localizedStrings['startDate'] ?? 'Start Date';
String get endDate => _localizedStrings['endDate'] ?? 'End Date';
// 새로 추가된 항목들
String get monthlyTotalSubscriptionCost => _localizedStrings['monthlyTotalSubscriptionCost'] ?? 'Total Monthly Subscription Cost';
String get todaysExchangeRate => _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
String get monthlyTotalSubscriptionCost =>
_localizedStrings['monthlyTotalSubscriptionCost'] ??
'Total Monthly Subscription Cost';
String get todaysExchangeRate =>
_localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
String get won => _localizedStrings['won'] ?? 'KRW';
String get estimatedAnnualCost => _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
String get totalSubscriptionServices => _localizedStrings['totalSubscriptionServices'] ?? 'Total Subscription Services';
String get estimatedAnnualCost =>
_localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
String get totalSubscriptionServices =>
_localizedStrings['totalSubscriptionServices'] ??
'Total Subscription Services';
String get services => _localizedStrings['services'] ?? 'services';
String get eventDiscountActive => _localizedStrings['eventDiscountActive'] ?? 'Event Discount Active';
String get eventDiscountActive =>
_localizedStrings['eventDiscountActive'] ?? 'Event Discount Active';
String get saving => _localizedStrings['saving'] ?? 'Saving';
String get paymentDueToday => _localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
String get paymentInfoNeeded => _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
String get paymentDueToday =>
_localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
String get paymentInfoNeeded =>
_localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
String get event => _localizedStrings['event'] ?? 'Event';
// 카테고리 getter들
String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music';
String get categoryOttVideo => _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
String get categoryStorageCloud => _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
String get categoryTelecomInternetTv => _localizedStrings['categoryTelecomInternetTv'] ?? 'Telecom · Internet · TV';
String get categoryLifestyle => _localizedStrings['categoryLifestyle'] ?? 'Lifestyle';
String get categoryShoppingEcommerce => _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce';
String get categoryProgramming => _localizedStrings['categoryProgramming'] ?? 'Programming';
String get categoryCollaborationOffice => _localizedStrings['categoryCollaborationOffice'] ?? 'Collaboration/Office';
String get categoryAiService => _localizedStrings['categoryAiService'] ?? 'AI Service';
String get categoryOttVideo =>
_localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
String get categoryStorageCloud =>
_localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
String get categoryTelecomInternetTv =>
_localizedStrings['categoryTelecomInternetTv'] ??
'Telecom · Internet · TV';
String get categoryLifestyle =>
_localizedStrings['categoryLifestyle'] ?? 'Lifestyle';
String get categoryShoppingEcommerce =>
_localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce';
String get categoryProgramming =>
_localizedStrings['categoryProgramming'] ?? 'Programming';
String get categoryCollaborationOffice =>
_localizedStrings['categoryCollaborationOffice'] ??
'Collaboration/Office';
String get categoryAiService =>
_localizedStrings['categoryAiService'] ?? 'AI Service';
String get categoryOther => _localizedStrings['categoryOther'] ?? 'Other';
// 동적 메시지 생성 메서드
String daysBefore(int days) {
return '$days${_localizedStrings['daysBefore'] ?? 'day(s) before'}';
}
String dailyReminderDisabledWithDays(int days) {
final template = _localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment';
final template = _localizedStrings['dailyReminderDisabled'] ??
'Receive notification @ day(s) before payment';
return template.replaceAll('@', days.toString());
}
String subscriptionAddedWithName(String serviceName) {
final template = _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
final template =
_localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
return template.replaceAll('@', serviceName);
}
String subscriptionDeleted(String serviceName) {
final template = _localizedStrings['subscriptionDeleted'] ?? '@ subscription has been deleted';
final template = _localizedStrings['subscriptionDeleted'] ??
'@ subscription has been deleted';
return template.replaceAll('@', serviceName);
}
String totalExpenseCopied(String amount) {
final template = _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
final template =
_localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
return template.replaceAll('@', amount);
}
String serviceRecognized(String serviceName) {
final template = _localizedStrings['serviceRecognized'] ?? '@ service has been recognized automatically.';
final template = _localizedStrings['serviceRecognized'] ??
'@ service has been recognized automatically.';
return template.replaceAll('@', serviceName);
}
String smsScanErrorWithMessage(String error) {
final template = _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan: @';
final template = _localizedStrings['smsScanError'] ??
'Error occurred during SMS scan: @';
return template.replaceAll('@', error);
}
String saveErrorWithMessage(String error) {
final template = _localizedStrings['saveError'] ?? 'Error occurred while saving: @';
final template =
_localizedStrings['saveError'] ?? 'Error occurred while saving: @';
return template.replaceAll('@', error);
}
String subscriptionAddErrorWithMessage(String error) {
final template = _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription: @';
final template = _localizedStrings['subscriptionAddError'] ??
'Error adding subscription: @';
return template.replaceAll('@', error);
}
String subscriptionSkipped(String serviceName) {
final template = _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
final template =
_localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
return template.replaceAll('@', serviceName);
}
// 홈화면 관련
String get mySubscriptions => _localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
String get mySubscriptions =>
_localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
String subscriptionCount(int count) {
if (locale.languageCode == 'ko') {
return '${count}';
return '$count개';
} else if (locale.languageCode == 'ja') {
return '${count}';
return '$count個';
} else if (locale.languageCode == 'zh') {
return '${count}';
return '$count个';
} else {
return count.toString();
}
}
// 분석화면 관련
String get monthlyExpenseTitle => _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
String get recentSixMonthsTrend => _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
String get monthlySubscriptionExpense => _localizedStrings['monthlySubscriptionExpense'] ?? 'Monthly subscription expense';
String get subscriptionServiceRatio => _localizedStrings['subscriptionServiceRatio'] ?? 'Subscription Service Ratio';
String get monthlyExpenseBasis => _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense';
String get noSubscriptionServices => _localizedStrings['noSubscriptionServices'] ?? 'No subscription services';
String get totalExpenseSummary => _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary';
String get monthlyTotalAmount => _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount';
String get totalExpense => _localizedStrings['totalExpense'] ?? 'Total Expense';
String get totalServices => _localizedStrings['totalServices'] ?? 'Total Services';
String get monthlyExpenseTitle =>
_localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
String get recentSixMonthsTrend =>
_localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
String get monthlySubscriptionExpense =>
_localizedStrings['monthlySubscriptionExpense'] ??
'Monthly subscription expense';
String get subscriptionServiceRatio =>
_localizedStrings['subscriptionServiceRatio'] ??
'Subscription Service Ratio';
String get monthlyExpenseBasis =>
_localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense';
String get noSubscriptionServices =>
_localizedStrings['noSubscriptionServices'] ?? 'No subscription services';
String get totalExpenseSummary =>
_localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary';
String get monthlyTotalAmount =>
_localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount';
String get totalExpense =>
_localizedStrings['totalExpense'] ?? 'Total Expense';
String get totalServices =>
_localizedStrings['totalServices'] ?? 'Total Services';
String get servicesUnit => _localizedStrings['servicesUnit'] ?? 'services';
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress';
String get monthlySavingAmount => _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress';
String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount';
String get eventDiscountStatus =>
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get eventDiscountEndsBeforeBilling =>
_localizedStrings['eventDiscountEndsBeforeBilling'] ??
'Event discount ends before billing date';
String get inProgressUnit =>
_localizedStrings['inProgressUnit'] ?? 'in progress';
String get monthlySavingAmount =>
_localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
String get eventsInProgress =>
_localizedStrings['eventsInProgress'] ?? 'Events in Progress';
String get discountPercent =>
_localizedStrings['discountPercent'] ?? '% discount';
String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW';
// SMS 스캔 관련
String get scanningMessages => _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
String get findingSubscriptions => _localizedStrings['findingSubscriptions'] ?? 'Finding subscription services';
String get subscriptionNotFound => _localizedStrings['subscriptionNotFound'] ?? 'Subscription information not found.';
String get repeatSubscriptionNotFound => _localizedStrings['repeatSubscriptionNotFound'] ?? 'No repeated subscription information found.';
String get newSubscriptionNotFound => _localizedStrings['newSubscriptionNotFound'] ?? 'No new subscription SMS found';
String get findRepeatSubscriptions => _localizedStrings['findRepeatSubscriptions'] ?? 'Find subscriptions paid 2+ times';
String get scanTextMessages => _localizedStrings['scanTextMessages'] ?? 'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.';
String get startScanning => _localizedStrings['startScanning'] ?? 'Start Scanning';
String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription';
String get scanningMessages =>
_localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
String get findingSubscriptions =>
_localizedStrings['findingSubscriptions'] ??
'Finding subscription services';
String get subscriptionNotFound =>
_localizedStrings['subscriptionNotFound'] ??
'Subscription information not found.';
String get repeatSubscriptionNotFound =>
_localizedStrings['repeatSubscriptionNotFound'] ??
'No repeated subscription information found.';
String get newSubscriptionNotFound =>
_localizedStrings['newSubscriptionNotFound'] ??
'No new subscription SMS found';
String get findRepeatSubscriptions =>
_localizedStrings['findRepeatSubscriptions'] ??
'Find subscriptions paid 2+ times';
String get scanTextMessages =>
_localizedStrings['scanTextMessages'] ??
'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.';
String get startScanning =>
_localizedStrings['startScanning'] ?? 'Start Scanning';
String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get nextBillingDateLabel => _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get nextBillingDateLabel =>
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get category => _localizedStrings['category'] ?? 'Category';
String get websiteUrlAuto => _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)';
String get websiteUrlHint => _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty';
String get websiteUrlAuto =>
_localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)';
String get websiteUrlHint =>
_localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty';
String get skip => _localizedStrings['skip'] ?? 'Skip';
String get add => _localizedStrings['add'] ?? 'Add';
String get nextBillingDateRequired => _localizedStrings['nextBillingDateRequired'] ?? 'Next billing date verification required';
String get nextBillingDateRequired =>
_localizedStrings['nextBillingDateRequired'] ??
'Next billing date verification required';
String nextBillingDateEstimated(String date, int days) {
final template = _localizedStrings['nextBillingDateEstimated'] ?? 'Next estimated billing date: @ (# days later)';
final template = _localizedStrings['nextBillingDateEstimated'] ??
'Next estimated billing date: @ (# days later)';
return template.replaceAll('@', date).replaceAll('#', days.toString());
}
String nextBillingDateInfo(String date, int days) {
final template = _localizedStrings['nextBillingDateInfo'] ?? 'Next billing date: @ (# days later)';
final template = _localizedStrings['nextBillingDateInfo'] ??
'Next billing date: @ (# days later)';
return template.replaceAll('@', date).replaceAll('#', days.toString());
}
String get nextBillingDatePastRequired => _localizedStrings['nextBillingDatePastRequired'] ?? 'Next billing date verification required (past date)';
String get nextBillingDatePastRequired =>
_localizedStrings['nextBillingDatePastRequired'] ??
'Next billing date verification required (past date)';
String formatDate(DateTime date) {
if (locale.languageCode == 'ko') {
return '${date.year}${date.month}${date.day}';
@@ -304,113 +444,165 @@ class AppLocalizations {
} else if (locale.languageCode == 'zh') {
return '${date.year}${date.month}${date.day}';
} else {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
final months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
}
String repeatCountDetected(int count) {
final template = _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected';
final template =
_localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected';
return template.replaceAll('@', count.toString());
}
String servicesInProgress(int count) {
if (locale.languageCode == 'ko') {
return '${count} 진행중';
return '$count 진행중';
} else if (locale.languageCode == 'ja') {
return '${count}個進行中';
return '$count個進行中';
} else if (locale.languageCode == 'zh') {
return '${count}个进行中';
return '$count个进行中';
} else {
return '$count in progress';
}
}
// 새로 추가된 동적 메서드들
String paymentDueInDays(int days) {
final template = _localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days';
final template =
_localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days';
return template.replaceAll('@', days.toString());
}
String daysRemaining(int days) {
final template = _localizedStrings['daysRemaining'] ?? '@ days remaining';
return template.replaceAll('@', days.toString());
}
String exchangeRateFormat(String rate) {
final template = _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
final template =
_localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
return template.replaceAll('@', rate);
}
// 결제 주기 결제 메시지
String get billingCyclePayment => _localizedStrings['billingCyclePayment'] ?? '@ Payment';
String get billingCyclePayment =>
_localizedStrings['billingCyclePayment'] ?? '@ Payment';
// 할인 금액 표시 getter들
String get discountAmountWon => _localizedStrings['discountAmountWon'] ?? 'Save ₩@';
String get discountAmountDollar => _localizedStrings['discountAmountDollar'] ?? 'Save \$@';
String get discountAmountYen => _localizedStrings['discountAmountYen'] ?? 'Save ¥@';
String get discountAmountYuan => _localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
String get discountAmountWon =>
_localizedStrings['discountAmountWon'] ?? 'Save @';
String get discountAmountDollar =>
_localizedStrings['discountAmountDollar'] ?? 'Save \$@';
String get discountAmountYen =>
_localizedStrings['discountAmountYen'] ?? 'Save ¥@';
String get discountAmountYuan =>
_localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
// 결제 주기 관련 getter
String get monthly => _localizedStrings['monthly'] ?? 'Monthly';
String get weekly => _localizedStrings['weekly'] ?? 'Weekly';
String get yearly => _localizedStrings['yearly'] ?? 'Yearly';
String get billingCycleMonthly => _localizedStrings['billingCycleMonthly'] ?? 'Monthly';
String get billingCycleQuarterly => _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
String get billingCycleHalfYearly => _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
String get billingCycleYearly => _localizedStrings['billingCycleYearly'] ?? 'Yearly';
String get billingCycleMonthly =>
_localizedStrings['billingCycleMonthly'] ?? 'Monthly';
String get billingCycleQuarterly =>
_localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
String get billingCycleHalfYearly =>
_localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
String get billingCycleYearly =>
_localizedStrings['billingCycleYearly'] ?? 'Yearly';
// 색상 관련 getter
String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue';
String get colorGreen => _localizedStrings['colorGreen'] ?? 'Green';
String get colorOrange => _localizedStrings['colorOrange'] ?? 'Orange';
String get colorRed => _localizedStrings['colorRed'] ?? 'Red';
String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple';
// 날짜 형식 관련 getter
String get dateFormatFull => _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
String get dateFormatFull =>
_localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd';
// USD 환율 표시 형식
String get exchangeRateDisplay => _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
String get exchangeRateDisplay =>
_localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
// 라벨 및 힌트 텍스트
String get labelServiceName => _localizedStrings['labelServiceName'] ?? 'Service Name';
String get hintServiceName => _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
String get labelMonthlyExpense => _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
String get labelNextBillingDate => _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date';
String get labelWebsiteUrl => _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)';
String get hintWebsiteUrl => _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com';
String get labelEventPrice => _localizedStrings['labelEventPrice'] ?? 'Event Price';
String get hintEventPrice => _localizedStrings['hintEventPrice'] ?? 'Enter discounted price';
String get labelServiceName =>
_localizedStrings['labelServiceName'] ?? 'Service Name';
String get hintServiceName =>
_localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
String get labelMonthlyExpense =>
_localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
String get labelNextBillingDate =>
_localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date';
String get labelWebsiteUrl =>
_localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)';
String get hintWebsiteUrl =>
_localizedStrings['hintWebsiteUrl'] ?? 'https://example.com';
String get labelEventPrice =>
_localizedStrings['labelEventPrice'] ?? 'Event Price';
String get hintEventPrice =>
_localizedStrings['hintEventPrice'] ?? 'Enter discounted price';
String get labelCategory => _localizedStrings['labelCategory'] ?? 'Category';
// 기타 번역
String get subscription => _localizedStrings['subscription'] ?? 'Subscription';
String get subscription =>
_localizedStrings['subscription'] ?? 'Subscription';
String get movie => _localizedStrings['movie'] ?? 'Movie';
String get music => _localizedStrings['music'] ?? 'Music';
String get music => _localizedStrings['music'] ?? 'Music';
String get exercise => _localizedStrings['exercise'] ?? 'Exercise';
String get shopping => _localizedStrings['shopping'] ?? 'Shopping';
String get currency => _localizedStrings['currency'] ?? 'Currency';
String get websiteInfo => _localizedStrings['websiteInfo'] ?? 'Website Information';
String get cancelGuide => _localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
String get cancelServiceGuide => _localizedStrings['cancelServiceGuide'] ?? 'To cancel this service, please go to the cancellation page through the link below.';
String get goToCancelPage => _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page';
String get urlAutoMatchInfo => _localizedStrings['urlAutoMatchInfo'] ?? 'If URL is empty, it will be automatically matched based on the service name';
String get websiteInfo =>
_localizedStrings['websiteInfo'] ?? 'Website Information';
String get cancelGuide =>
_localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
String get cancelServiceGuide =>
_localizedStrings['cancelServiceGuide'] ??
'To cancel this service, please go to the cancellation page through the link below.';
String get goToCancelPage =>
_localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page';
String get urlAutoMatchInfo =>
_localizedStrings['urlAutoMatchInfo'] ??
'If URL is empty, it will be automatically matched based on the service name';
String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select';
// 새로 추가된 getter들
String get serviceInfo => _localizedStrings['serviceInfo'] ?? 'Service Information';
String get newSubscriptionAdd => _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
String get enterServiceInfo => _localizedStrings['enterServiceInfo'] ?? 'Enter service information';
String get addSubscriptionButton => _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription';
String get serviceNameRequired => _localizedStrings['serviceNameRequired'] ?? 'Please enter service name';
String get amountRequired => _localizedStrings['amountRequired'] ?? 'Please enter amount';
String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
String get serviceInfo =>
_localizedStrings['serviceInfo'] ?? 'Service Information';
String get newSubscriptionAdd =>
_localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
String get enterServiceInfo =>
_localizedStrings['enterServiceInfo'] ?? 'Enter service information';
String get addSubscriptionButton =>
_localizedStrings['addSubscriptionButton'] ?? 'Add Subscription';
String get serviceNameRequired =>
_localizedStrings['serviceNameRequired'] ?? 'Please enter service name';
String get amountRequired =>
_localizedStrings['amountRequired'] ?? 'Please enter amount';
String get subscriptionDetail =>
_localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
String get invalidAmount =>
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon =>
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
// 결제 주기를 키값으로 변환하여 번역된 이름 반환
String getBillingCycleName(String billingCycleKey) {
switch (billingCycleKey) {
@@ -433,7 +625,7 @@ class AppLocalizations {
return billingCycleKey; // 매칭되지 않으면 원본 반환
}
}
// 카테고리 이름을 키로 변환하여 번역된 이름 반환
String getCategoryName(String categoryKey) {
switch (categoryKey) {
@@ -467,7 +659,8 @@ class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const AppLocalizationsDelegate();
@override
bool isSupported(Locale locale) => ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode);
bool isSupported(Locale locale) =>
['en', 'ko', 'ja', 'zh'].contains(locale.languageCode);
@override
Future<AppLocalizations> load(Locale locale) async {

View File

@@ -20,16 +20,21 @@ import 'navigation/app_navigation_observer.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform;
import 'dart:async' show unawaited;
import 'utils/memory_manager.dart';
import 'utils/logger.dart';
import 'utils/performance_optimizer.dart';
import 'navigator_key.dart';
// AdMob 활성화 플래그 (개발 중 false, 프로덕션 시 true로 변경)
const bool enableAdMob = true;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만)
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
await MobileAds.instance.initialize();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && enableAdMob) {
unawaited(MobileAds.instance.initialize());
}
// 성능 최적화 설정
@@ -40,16 +45,23 @@ Future<void> main() async {
try {
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
await DefaultCacheManager().emptyCache();
// 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화
// 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요.
const bool clearCacheOnStartup = bool.fromEnvironment(
'CLEAR_CACHE_ON_STARTUP',
defaultValue: false,
);
if (clearCacheOnStartup) {
await DefaultCacheManager().emptyCache();
}
if (kDebugMode) {
print('이미지 캐시 관리 초기화 완료');
Log.d('이미지 캐시 관리 초기화 완료');
PerformanceOptimizer.checkConstOptimization();
}
} catch (e) {
if (kDebugMode) {
print('캐시 초기화 오류: $e');
Log.e('캐시 초기화 오류', e);
}
}
@@ -121,7 +133,9 @@ class SubManagerApp extends StatelessWidget {
return MaterialApp(
key: ValueKey(localeProvider.locale),
title: 'Digital Rent Manager',
// Localizations는 MaterialApp 내부에서 초기화되므로
// onGenerateTitle을 사용해 로딩 이후 로컬라이즈된 타이틀을 설정합니다.
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
debugShowCheckedModeBanner: false,
theme: themeProvider.getTheme(context),
locale: localeProvider.locale,

View File

@@ -75,7 +75,7 @@ class SubscriptionModel extends HiveObject {
if (!isEventActive || eventStartDate == null || eventEndDate == null) {
return false;
}
final now = DateTime.now();
return now.isAfter(eventStartDate!) && now.isBefore(eventEndDate!);
}
@@ -98,7 +98,7 @@ class SubscriptionModel extends HiveObject {
// 원래 가격 (이벤트와 관계없이 항상 정상 가격)
double get originalPrice => monthlyCost;
// 결제 주기를 영어 키값으로 정규화
static String normalizeBillingCycle(String cycle) {
switch (cycle.toLowerCase()) {
@@ -121,7 +121,7 @@ class SubscriptionModel extends HiveObject {
return 'monthly'; // 기본값은 monthly
}
}
// 결제 주기를 영어 키값으로 반환 (내부 사용)
String get billingCycleKey => normalizeBillingCycle(billingCycle);
}

View File

@@ -37,22 +37,24 @@ class AppNavigationObserver extends NavigatorObserver {
if (newRoute != null) {
_updateNavigationState(newRoute);
}
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
debugPrint(
'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
}
void _updateNavigationState(Route<dynamic> route) {
if (navigator?.context == null) return;
final routeName = route.settings.name;
if (routeName == null) return;
// build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return;
try {
final context = navigator!.context;
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateByRoute(routeName);
} catch (e) {
debugPrint('Failed to update navigation state: $e');
@@ -62,18 +64,19 @@ class AppNavigationObserver extends NavigatorObserver {
void _handlePopWithProvider() {
if (navigator?.context == null) return;
// build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return;
try {
final context = navigator!.context;
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.pop();
} catch (e) {
debugPrint('Failed to handle pop with provider: $e');
}
});
}
}
}

View File

@@ -28,14 +28,14 @@ class CategoryProvider extends ChangeNotifier {
sortedCategories.sort((a, b) {
final aIndex = _categoryOrder.indexOf(a.name);
final bIndex = _categoryOrder.indexOf(b.name);
// 순서 목록에 없는 카테고리는 맨 뒤로
if (aIndex == -1) return 1;
if (bIndex == -1) return -1;
return aIndex.compareTo(bIndex);
});
return sortedCategories;
}
@@ -59,9 +59,17 @@ class CategoryProvider extends ChangeNotifier {
{'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'},
{'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'},
{'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'},
{'name': 'shoppingEcommerce', 'color': '#FF9800', 'icon': 'shopping_cart'},
{
'name': 'shoppingEcommerce',
'color': '#FF9800',
'icon': 'shopping_cart'
},
{'name': 'programming', 'color': '#795548', 'icon': 'code'},
{'name': 'collaborationOffice', 'color': '#607D8B', 'icon': 'business_center'},
{
'name': 'collaborationOffice',
'color': '#607D8B',
'icon': 'business_center'
},
{'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'},
{'name': 'other', 'color': '#9E9E9E', 'icon': 'category'},
];
@@ -117,7 +125,7 @@ class CategoryProvider extends ChangeNotifier {
return null;
}
}
// 카테고리 이름을 현재 언어에 맞게 반환
String getLocalizedCategoryName(BuildContext context, String categoryKey) {
final localizations = AppLocalizations.of(context);

View File

@@ -5,24 +5,24 @@ import 'dart:ui' as ui;
class LocaleProvider extends ChangeNotifier {
late Box<String> _localeBox;
Locale _locale = const Locale('ko');
static const List<String> supportedLanguages = ['en', 'ko', 'ja', 'zh'];
Locale get locale => _locale;
Future<void> init() async {
_localeBox = await Hive.openBox<String>('locale');
// 저장된 언어 설정 확인
final savedLocale = _localeBox.get('locale');
if (savedLocale != null) {
// 저장된 언어가 있으면 사용
_locale = Locale(savedLocale);
} else {
// 저장된 언어가 없으면 시스템 언어 감지
final systemLocale = ui.PlatformDispatcher.instance.locale;
// 시스템 언어가 지원되는 언어인지 확인
if (supportedLanguages.contains(systemLocale.languageCode)) {
_locale = Locale(systemLocale.languageCode);
@@ -30,11 +30,11 @@ class LocaleProvider extends ChangeNotifier {
// 지원되지 않는 언어면 영어 사용
_locale = const Locale('en');
}
// 감지된 언어 저장
await _localeBox.put('locale', _locale.languageCode);
}
notifyListeners();
}

View File

@@ -36,25 +36,25 @@ class NavigationProvider extends ChangeNotifier {
void updateCurrentIndex(int index, {bool addToHistory = true}) {
if (_currentIndex == index) return;
_currentIndex = index;
_currentRoute = indexToRoute[index] ?? '/';
_currentTitle = indexToTitle[index] ?? 'home';
if (addToHistory && index >= 0) {
_navigationHistory.add(index);
if (_navigationHistory.length > 10) {
_navigationHistory.removeAt(0);
}
}
notifyListeners();
}
void updateByRoute(String route) {
final index = routeToIndex[route] ?? 0;
_currentRoute = route;
if (index >= 0) {
_currentIndex = index;
_currentTitle = indexToTitle[index] ?? 'home';
@@ -70,7 +70,7 @@ class NavigationProvider extends ChangeNotifier {
_currentTitle = 'home';
}
}
notifyListeners();
}
@@ -103,4 +103,4 @@ class NavigationProvider extends ChangeNotifier {
_navigationHistory.add(0);
notifyListeners();
}
}
}

View File

@@ -86,12 +86,12 @@ class NotificationProvider extends ChangeNotifier {
try {
_isEnabled = value;
await NotificationService.setNotificationEnabled(value);
// 첫 권한 부여 시 기본 설정 적용
if (value) {
await initializeDefaultSettingsOnFirstPermission();
}
notifyListeners();
} catch (e) {
debugPrint('알림 활성화 설정 중 오류 발생: $e');
@@ -114,6 +114,23 @@ class NotificationProvider extends ChangeNotifier {
// 알림이 활성화된 경우에만 알림 재예약 (비활성화 시에는 필요 없음)
if (value) {
final hasPermission = await NotificationService.checkPermission();
if (!hasPermission) {
final granted = await NotificationService.requestPermission();
if (!granted) {
debugPrint('알림 권한이 부여되지 않았습니다. 일부 알림이 제한될 수 있습니다.');
}
}
final canExact = await NotificationService.canScheduleExactAlarms();
if (!canExact) {
final exactGranted =
await NotificationService.requestExactAlarmsPermission();
if (!exactGranted) {
debugPrint('정확 알람 권한이 없어 근사 알림으로 예약됩니다.');
}
}
// 알림 설정 변경 시 모든 구독의 알림 재예약
// 지연 실행으로 UI 응답성 향상
Future.microtask(() => _rescheduleNotificationsIfNeeded());
@@ -270,15 +287,17 @@ class NotificationProvider extends ChangeNotifier {
// 첫 권한 부여 시 기본 설정 초기화
Future<void> initializeDefaultSettingsOnFirstPermission() async {
try {
final firstGranted = await _secureStorage.read(key: _firstPermissionGrantedKey);
final firstGranted =
await _secureStorage.read(key: _firstPermissionGrantedKey);
if (firstGranted != 'true') {
// 첫 권한 부여 시 기본값 설정
await setReminderDays(2); // 2일 전 알림
await setDailyReminderEnabled(true); // 반복 알림 활성화
await setPaymentEnabled(true); // 결제 예정 알림 활성화
// 첫 권한 부여 플래그 저장
await _secureStorage.write(key: _firstPermissionGrantedKey, value: 'true');
await _secureStorage.write(
key: _firstPermissionGrantedKey, value: 'true');
}
} catch (e) {
debugPrint('기본 설정 초기화 중 오류 발생: $e');

View File

@@ -19,23 +19,24 @@ class SubscriptionProvider extends ChangeNotifier {
double get totalMonthlyExpense {
final exchangeRateService = ExchangeRateService();
final rate = exchangeRateService.cachedUsdToKrwRate ??
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
final rate = exchangeRateService.cachedUsdToKrwRate ??
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
final total = _subscriptions.fold(
0.0,
(sum, subscription) {
final price = subscription.currentPrice;
if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$${price} ×$rate = ₩${price * rate}');
'\$$price ×$rate = ₩${price * rate}');
return sum + (price * rate);
}
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
debugPrint(
'[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
return sum + price;
},
);
debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: '
'${_subscriptions.length}개 구독, 총액 ₩$total');
return total;
@@ -69,10 +70,10 @@ class SubscriptionProvider extends ChangeNotifier {
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
await refreshSubscriptions();
// categoryId 마이그레이션
await _migrateCategoryIds();
// 앱 시작 시 이벤트 상태 확인
await checkAndUpdateEventStatus();
@@ -90,11 +91,11 @@ class SubscriptionProvider extends ChangeNotifier {
try {
_subscriptions = _subscriptionBox.values.toList()
..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: '
'${_subscriptions.length}개 구독, '
'총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners();
} catch (e) {
debugPrint('구독 목록 새로고침 중 오류 발생: $e');
@@ -102,6 +103,14 @@ class SubscriptionProvider extends ChangeNotifier {
}
}
Future<void> _reschedulePaymentNotifications() async {
try {
await NotificationService.reschedulAllNotifications(_subscriptions);
} catch (e) {
debugPrint('결제 알림 재예약 중 오류 발생: $e');
}
}
Future<void> addSubscription({
required String serviceName,
required double monthlyCost,
@@ -139,11 +148,13 @@ class SubscriptionProvider extends ChangeNotifier {
await _subscriptionBox.put(subscription.id, subscription);
await refreshSubscriptions();
// 이벤트가 활성화된 경우 알림 스케줄 재설정
if (isEventActive && eventEndDate != null) {
await _scheduleEventEndNotification(subscription);
}
await _reschedulePaymentNotifications();
} catch (e) {
debugPrint('구독 추가 중 오류 발생: $e');
rethrow;
@@ -175,6 +186,8 @@ class SubscriptionProvider extends ChangeNotifier {
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners();
await _reschedulePaymentNotifications();
} catch (e) {
debugPrint('구독 업데이트 중 오류 발생: $e');
rethrow;
@@ -185,13 +198,14 @@ class SubscriptionProvider extends ChangeNotifier {
try {
await _subscriptionBox.delete(id);
await refreshSubscriptions();
await _reschedulePaymentNotifications();
} catch (e) {
debugPrint('구독 삭제 중 오류 발생: $e');
rethrow;
}
}
Future<void> clearAllSubscriptions() async {
_isLoading = true;
notifyListeners();
@@ -213,18 +227,22 @@ class SubscriptionProvider extends ChangeNotifier {
} finally {
_isLoading = false;
notifyListeners();
await _reschedulePaymentNotifications();
}
}
/// 이벤트 종료 알림을 스케줄링합니다.
Future<void> _scheduleEventEndNotification(SubscriptionModel subscription) async {
if (subscription.eventEndDate != null &&
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!,
channelId: NotificationService.expirationChannelId,
);
}
}
@@ -232,19 +250,18 @@ class SubscriptionProvider extends ChangeNotifier {
/// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다.
Future<void> checkAndUpdateEventStatus() async {
bool hasChanges = false;
for (var subscription in _subscriptions) {
// 이벤트가 종료되었지만 아직 활성화되어 있는 경우
if (subscription.isEventActive &&
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();
}
@@ -253,70 +270,73 @@ class SubscriptionProvider extends ChangeNotifier {
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
Future<double> calculateTotalExpense({String? locale}) async {
if (_subscriptions.isEmpty) return 0.0;
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = locale != null
? CurrencyUtil.getDefaultCurrency(locale)
: 'KRW'; // 기본값
final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
double total = 0.0;
for (final subscription in _subscriptions) {
final currentPrice = subscription.currentPrice;
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
final converted = await ExchangeRateService().convertBetweenCurrencies(
currentPrice,
subscription.currency,
targetCurrency,
);
total += converted ?? currentPrice;
}
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency');
return total;
}
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({String? locale}) async {
Future<List<Map<String, dynamic>>> getMonthlyExpenseData(
{String? locale}) async {
final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = [];
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = locale != null
? CurrencyUtil.getDefaultCurrency(locale)
: 'KRW'; // 기본값
final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
// 최근 6개월 데이터 생성
for (int i = 5; i >= 0; i--) {
final month = DateTime(now.year, now.month - i, 1);
double monthTotal = 0.0;
// 현재 월인지 확인
final isCurrentMonth = (month.year == now.year && month.month == now.month);
final isCurrentMonth =
(month.year == now.year && month.month == now.month);
if (isCurrentMonth) {
debugPrint('[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
debugPrint(
'[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
}
// 해당 월에 활성화된 구독 계산
for (final subscription in _subscriptions) {
if (isCurrentMonth) {
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
final cost = subscription.currentPrice;
debugPrint('[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
debugPrint(
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
// 통화 변환
final converted = await ExchangeRateService().convertBetweenCurrencies(
final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
} else {
// 과거 월인 경우: 기존 로직 유지
@@ -324,46 +344,50 @@ class SubscriptionProvider extends ChangeNotifier {
final subscriptionStartDate = subscription.nextBillingDate.subtract(
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
);
if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) &&
if (subscriptionStartDate
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.nextBillingDate.isAfter(month)) {
// 해당 월의 비용 계산 (이벤트 가격 고려)
double cost;
if (subscription.isEventActive &&
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
// 이벤트 기간과 해당 월이 겹치는지 확인
subscription.eventStartDate!.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.eventStartDate!
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.eventEndDate!.isAfter(month)) {
cost = subscription.eventPrice ?? subscription.monthlyCost;
} else {
cost = subscription.monthlyCost;
}
// 통화 변환
final converted = await ExchangeRateService().convertBetweenCurrencies(
final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
}
}
}
if (isCurrentMonth) {
debugPrint('[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
debugPrint(
'[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
}
monthlyData.add({
'month': month,
'totalExpense': monthTotal,
'monthName': _getMonthLabel(month, locale ?? 'en'),
});
}
return monthlyData;
}
@@ -409,98 +433,109 @@ class SubscriptionProvider extends ChangeNotifier {
/// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당
Future<void> _migrateCategoryIds() async {
debugPrint('❎ CategoryId 마이그레이션 시작...');
final categoryProvider = CategoryProvider();
await categoryProvider.init();
final categories = categoryProvider.categories;
int migratedCount = 0;
for (var subscription in _subscriptions) {
if (subscription.categoryId == null) {
final serviceName = subscription.serviceName.toLowerCase();
String? categoryId;
debugPrint('🔍 ${subscription.serviceName} 카테고리 매칭 시도...');
// OTT 서비스
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
serviceName.contains('disney') ||
serviceName.contains('왓차') ||
serviceName.contains('티빙') ||
serviceName.contains('디즈니') ||
serviceName.contains('넷플릭스')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'OTT 서비스',
orElse: () => categories.first,
).id;
categoryId = categories
.firstWhere(
(cat) => cat.name == 'OTT 서비스',
orElse: () => categories.first,
)
.id;
}
// 음악 서비스
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벡스')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'music',
orElse: () => categories.first,
).id;
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벡스')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'music',
orElse: () => categories.first,
)
.id;
}
// AI 서비스
else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') ||
serviceName.contains('midjourney') ||
serviceName.contains('copilot')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'aiService',
orElse: () => categories.first,
).id;
else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') ||
serviceName.contains('midjourney') ||
serviceName.contains('copilot')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'aiService',
orElse: () => categories.first,
)
.id;
}
// 프로그래밍/개발
else if (serviceName.contains('github') ||
serviceName.contains('intellij') ||
serviceName.contains('webstorm') ||
serviceName.contains('jetbrains')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'programming',
orElse: () => categories.first,
).id;
else if (serviceName.contains('github') ||
serviceName.contains('intellij') ||
serviceName.contains('webstorm') ||
serviceName.contains('jetbrains')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'programming',
orElse: () => categories.first,
)
.id;
}
// 오피스/협업 툴
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('slack') ||
serviceName.contains('figma') ||
serviceName.contains('icloud') ||
serviceName.contains('아이클라우드')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first,
).id;
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('slack') ||
serviceName.contains('figma') ||
serviceName.contains('icloud') ||
serviceName.contains('아이클라우드')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first,
)
.id;
}
// 기타 서비스 (기본값)
else {
categoryId = categories.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categories.first,
).id;
}
if (categoryId != null) {
subscription.categoryId = categoryId;
await subscription.save();
migratedCount++;
final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name;
debugPrint('${subscription.serviceName}$categoryName');
categoryId = categories
.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categories.first,
)
.id;
}
subscription.categoryId = categoryId;
await subscription.save();
migratedCount++;
final categoryName =
categories.firstWhere((cat) => cat.id == categoryId).name;
debugPrint('${subscription.serviceName}$categoryName');
}
}
if (migratedCount > 0) {
debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료');
debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료');
await refreshSubscriptions();
} else {
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');

View File

@@ -7,24 +7,24 @@ import '../theme/adaptive_theme.dart';
class ThemeProvider extends ChangeNotifier {
static const String _themeBoxName = 'theme_settings';
static const String _themeKey = 'theme_settings';
late Box<Map> _themeBox;
ThemeSettings _themeSettings = const ThemeSettings();
ThemeSettings get themeSettings => _themeSettings;
AppThemeMode get themeMode => _themeSettings.mode;
bool get useSystemColors => _themeSettings.useSystemColors;
bool get largeText => _themeSettings.largeText;
bool get reduceMotion => _themeSettings.reduceMotion;
bool get highContrast => _themeSettings.highContrast;
/// Provider 초기화
Future<void> initialize() async {
_themeBox = await Hive.openBox<Map>(_themeBoxName);
await _loadThemeSettings();
}
/// 저장된 테마 설정 로드
Future<void> _loadThemeSettings() async {
final savedSettings = _themeBox.get(_themeKey);
@@ -35,53 +35,53 @@ class ThemeProvider extends ChangeNotifier {
notifyListeners();
}
}
/// 테마 설정 저장
Future<void> _saveThemeSettings() async {
await _themeBox.put(_themeKey, _themeSettings.toJson());
}
/// 테마 모드 변경
Future<void> setThemeMode(AppThemeMode mode) async {
_themeSettings = _themeSettings.copyWith(mode: mode);
await _saveThemeSettings();
notifyListeners();
}
/// 시스템 색상 사용 설정
Future<void> setUseSystemColors(bool value) async {
_themeSettings = _themeSettings.copyWith(useSystemColors: value);
await _saveThemeSettings();
notifyListeners();
}
/// 큰 텍스트 설정
Future<void> setLargeText(bool value) async {
_themeSettings = _themeSettings.copyWith(largeText: value);
await _saveThemeSettings();
notifyListeners();
}
/// 모션 감소 설정
Future<void> setReduceMotion(bool value) async {
_themeSettings = _themeSettings.copyWith(reduceMotion: value);
await _saveThemeSettings();
notifyListeners();
}
/// 고대비 설정
Future<void> setHighContrast(bool value) async {
_themeSettings = _themeSettings.copyWith(highContrast: value);
await _saveThemeSettings();
notifyListeners();
}
/// 현재 설정에 따른 테마 가져오기
ThemeData getTheme(BuildContext context) {
final platformBrightness = MediaQuery.of(context).platformBrightness;
ThemeData baseTheme;
switch (_themeSettings.mode) {
case AppThemeMode.light:
baseTheme = AdaptiveTheme.lightTheme;
@@ -98,7 +98,7 @@ class ThemeProvider extends ChangeNotifier {
: AdaptiveTheme.lightTheme;
break;
}
// 접근성 설정 적용
return AdaptiveTheme.getAccessibleTheme(
baseTheme,
@@ -107,11 +107,11 @@ class ThemeProvider extends ChangeNotifier {
highContrast: _themeSettings.highContrast,
);
}
/// 현재 테마가 다크 모드인지 확인
bool isDarkMode(BuildContext context) {
final platformBrightness = MediaQuery.of(context).platformBrightness;
switch (_themeSettings.mode) {
case AppThemeMode.light:
return false;
@@ -122,7 +122,7 @@ class ThemeProvider extends ChangeNotifier {
return platformBrightness == Brightness.dark;
}
}
/// 테마 토글 (라이트/다크)
Future<void> toggleTheme() async {
if (_themeSettings.mode == AppThemeMode.light) {
@@ -137,7 +137,7 @@ class ThemeProvider extends ChangeNotifier {
class AnimatedThemeBuilder extends StatelessWidget {
final Widget Function(BuildContext, ThemeData) builder;
final Duration duration;
const AnimatedThemeBuilder({
super.key,
required this.builder,
@@ -148,7 +148,7 @@ class AnimatedThemeBuilder extends StatelessWidget {
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final theme = themeProvider.getTheme(context);
return AnimatedTheme(
data: theme,
duration: duration,
@@ -164,7 +164,7 @@ class ThemedColor extends StatelessWidget {
final Color lightColor;
final Color darkColor;
final Widget child;
const ThemedColor({
super.key,
required this.lightColor,
@@ -175,7 +175,7 @@ class ThemedColor extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = context.read<ThemeProvider>().isDarkMode(context);
return Theme(
data: Theme.of(context).copyWith(
primaryColor: isDark ? darkColor : lightColor,
@@ -183,4 +183,4 @@ class ThemedColor extends StatelessWidget {
child: child,
);
}
}
}

View File

@@ -6,6 +6,7 @@ import 'package:submanager/screens/sms_scan_screen.dart';
import 'package:submanager/screens/analysis_screen.dart';
import 'package:submanager/screens/settings_screen.dart';
import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/screens/sms_permission_screen.dart';
import 'package:submanager/models/subscription_model.dart';
class AppRoutes {
@@ -16,6 +17,7 @@ class AppRoutes {
static const String smsScanner = '/sms-scanner';
static const String analysis = '/analysis';
static const String settings = '/settings';
static const String smsPermission = '/sms-permission';
static Map<String, WidgetBuilder> getRoutes() {
return {
@@ -25,6 +27,7 @@ class AppRoutes {
smsScanner: (context) => const SmsScanScreen(),
analysis: (context) => const AnalysisScreen(),
settings: (context) => const SettingsScreen(),
smsPermission: (context) => const SmsPermissionScreen(),
};
}
@@ -32,29 +35,33 @@ class AppRoutes {
switch (routeSettings.name) {
case splash:
return _buildRoute(const SplashScreen(), routeSettings);
case main:
return _buildRoute(const MainScreen(), routeSettings);
case addSubscription:
return _buildRoute(const AddSubscriptionScreen(), routeSettings);
case subscriptionDetail:
final subscription = routeSettings.arguments as SubscriptionModel?;
if (subscription != null) {
return _buildRoute(DetailScreen(subscription: subscription), routeSettings);
return _buildRoute(
DetailScreen(subscription: subscription), routeSettings);
}
return _errorRoute();
case smsScanner:
return _buildRoute(const SmsScanScreen(), routeSettings);
case analysis:
return _buildRoute(const AnalysisScreen(), routeSettings);
case settings:
return _buildRoute(const SettingsScreen(), routeSettings);
case smsPermission:
return _buildRoute(const SmsPermissionScreen(), routeSettings);
default:
return _errorRoute();
}
@@ -77,15 +84,18 @@ class AppRoutes {
);
}
static void navigateTo(BuildContext context, String routeName, {Object? arguments}) {
static void navigateTo(BuildContext context, String routeName,
{Object? arguments}) {
Navigator.pushNamed(context, routeName, arguments: arguments);
}
static void navigateAndReplace(BuildContext context, String routeName, {Object? arguments}) {
static void navigateAndReplace(BuildContext context, String routeName,
{Object? arguments}) {
Navigator.pushReplacementNamed(context, routeName, arguments: arguments);
}
static void navigateAndRemoveUntil(BuildContext context, String routeName, {Object? arguments}) {
static void navigateAndRemoveUntil(BuildContext context, String routeName,
{Object? arguments}) {
Navigator.pushNamedAndRemoveUntil(
context,
routeName,
@@ -103,4 +113,4 @@ class AppRoutes {
static bool canPop(BuildContext context) {
return Navigator.canPop(context);
}
}
}

View File

@@ -5,11 +5,11 @@ import '../widgets/add_subscription/add_subscription_header.dart';
import '../widgets/add_subscription/add_subscription_form.dart';
import '../widgets/add_subscription/add_subscription_event_section.dart';
import '../widgets/add_subscription/add_subscription_save_button.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
/// 새로운 구독을 추가하는 화면
class AddSubscriptionScreen extends StatefulWidget {
const AddSubscriptionScreen({Key? key}) : super(key: key);
const AddSubscriptionScreen({super.key});
@override
State<AddSubscriptionScreen> createState() => _AddSubscriptionScreenState();
@@ -45,7 +45,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
_controller.scrollController.addListener(_onScroll);
return Scaffold(
backgroundColor: AppColors.backgroundColor,
backgroundColor: Theme.of(context).colorScheme.surface,
extendBodyBehindAppBar: true,
appBar: AddSubscriptionAppBar(
controller: _controller,
@@ -62,14 +62,14 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: MediaQuery.of(context).padding.top + 60),
// 헤더 섹션
AddSubscriptionHeader(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
// 서비스 정보 폼
AddSubscriptionForm(
controller: _controller,
@@ -78,7 +78,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
setState: setState,
),
const SizedBox(height: 16),
// 이벤트/할인 섹션
AddSubscriptionEventSection(
controller: _controller,
@@ -87,7 +87,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
setState: setState,
),
const SizedBox(height: 32),
// 저장 버튼
AddSubscriptionSaveButton(
controller: _controller,
@@ -101,4 +101,4 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
),
);
}
}
}

View File

@@ -43,12 +43,14 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// Provider 변경 감지
final provider = Provider.of<SubscriptionProvider>(context);
final currentHash = _calculateDataHash(provider);
debugPrint('[AnalysisScreen] didChangeDependencies: '
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) {
if (currentHash != _lastDataHash &&
!_isLoading &&
_lastDataHash.isNotEmpty) {
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
_loadData();
}
@@ -65,15 +67,16 @@ class _AnalysisScreenState extends State<AnalysisScreen>
String _calculateDataHash(SubscriptionProvider provider) {
final subscriptions = provider.subscriptions;
final buffer = StringBuffer();
buffer.write(subscriptions.length);
buffer.write('_');
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
for (final sub in subscriptions) {
buffer.write('_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
buffer.write(
'_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
}
return buffer.toString();
}
@@ -148,7 +151,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
),
// 네이티브 광고 위젯
SliverToBoxAdapter(
child: _buildAnimatedAd(),
@@ -166,7 +169,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 2. 총 지출 요약 카드
TotalExpenseSummaryCard(
key: ValueKey('total_expense_${_lastDataHash}'),
key: ValueKey('total_expense_$_lastDataHash'),
subscriptions: subscriptions,
totalExpense: _totalExpense,
animationController: _animationController,
@@ -176,7 +179,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 3. 월별 지출 차트
MonthlyExpenseChartCard(
key: ValueKey('monthly_expense_${_lastDataHash}'),
key: ValueKey('monthly_expense_$_lastDataHash'),
monthlyData: _monthlyData,
animationController: _animationController,
),
@@ -197,4 +200,4 @@ class _AnalysisScreenState extends State<AnalysisScreen>
],
);
}
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
class AppLockScreen extends StatelessWidget {
const AppLockScreen({super.key});
@@ -16,7 +16,7 @@ class AppLockScreen extends StatelessWidget {
Icon(
Icons.lock_outline,
size: 80,
color: AppColors.navyGray,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 24),
Text(
@@ -24,7 +24,7 @@ class AppLockScreen extends StatelessWidget {
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 16),
@@ -32,7 +32,7 @@ class AppLockScreen extends StatelessWidget {
'생체 인증으로 잠금을 해제하세요',
style: TextStyle(
fontSize: 16,
color: AppColors.navyGray,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
@@ -41,15 +41,16 @@ class AppLockScreen extends StatelessWidget {
final appLock = context.read<AppLockProvider>();
final success = await appLock.authenticate();
if (!success && context.mounted) {
final cs = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'인증에 실패했습니다. 다시 시도해주세요.',
style: TextStyle(
color: AppColors.pureWhite,
color: cs.onPrimary,
),
),
backgroundColor: AppColors.dangerColor,
backgroundColor: cs.error,
),
);
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/category_provider.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart';
class CategoryManagementScreen extends StatefulWidget {
@@ -45,11 +45,11 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
appBar: AppBar(
title: Text(
'카테고리 관리',
style: TextStyle(
color: AppColors.pureWhite,
),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
),
backgroundColor: AppColors.primaryColor,
backgroundColor: Theme.of(context).colorScheme.primary,
),
body: Consumer<CategoryProvider>(
builder: (context, provider, child) {
@@ -69,7 +69,9 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
decoration: InputDecoration(
labelText: '카테고리 이름',
labelStyle: TextStyle(
color: AppColors.navyGray,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
validator: (value) {
@@ -81,24 +83,56 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedColor,
initialValue: _selectedColor,
decoration: InputDecoration(
labelText: '색상 선택',
labelStyle: TextStyle(
color: AppColors.navyGray,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
items: [
DropdownMenuItem(
value: '#1976D2', child: Text(AppLocalizations.of(context).colorBlue, style: TextStyle(color: AppColors.darkNavy))),
value: '#1976D2',
child: Text(
AppLocalizations.of(context).colorBlue,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: '#4CAF50', child: Text(AppLocalizations.of(context).colorGreen, style: TextStyle(color: AppColors.darkNavy))),
value: '#4CAF50',
child: Text(
AppLocalizations.of(context).colorGreen,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: '#FF9800', child: Text(AppLocalizations.of(context).colorOrange, style: TextStyle(color: AppColors.darkNavy))),
value: '#FF9800',
child: Text(
AppLocalizations.of(context).colorOrange,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: '#F44336', child: Text(AppLocalizations.of(context).colorRed, style: TextStyle(color: AppColors.darkNavy))),
value: '#F44336',
child: Text(
AppLocalizations.of(context).colorRed,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: '#9C27B0', child: Text(AppLocalizations.of(context).colorPurple, style: TextStyle(color: AppColors.darkNavy))),
value: '#9C27B0',
child: Text(
AppLocalizations.of(context).colorPurple,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
],
onChanged: (value) {
setState(() {
@@ -108,23 +142,51 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedIcon,
initialValue: _selectedIcon,
decoration: InputDecoration(
labelText: '아이콘 선택',
labelStyle: TextStyle(
color: AppColors.navyGray,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
items: [
DropdownMenuItem(
value: 'subscriptions', child: Text('구독', style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem(value: 'movie', child: Text('영화', style: TextStyle(color: AppColors.darkNavy))),
value: 'subscriptions',
child: Text('구독',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'music_note', child: Text('음악', style: TextStyle(color: AppColors.darkNavy))),
value: 'movie',
child: Text('영화',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'fitness_center', child: Text('운동', style: TextStyle(color: AppColors.darkNavy))),
value: 'music_note',
child: Text('음악',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'shopping_cart', child: Text('쇼핑', style: TextStyle(color: AppColors.darkNavy))),
value: 'fitness_center',
child: Text('운동',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'shopping_cart',
child: Text('쇼핑',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
],
onChanged: (value) {
setState(() {
@@ -135,12 +197,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16),
ElevatedButton(
onPressed: _addCategory,
child: Text(
'카테고리 추가',
style: TextStyle(
color: AppColors.pureWhite,
),
),
child: const Text('카테고리 추가'),
),
],
),
@@ -163,9 +220,10 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
int.parse(category.color.replaceAll('#', '0xFF'))),
),
title: Text(
provider.getLocalizedCategoryName(context, category.name),
provider.getLocalizedCategoryName(
context, category.name),
style: TextStyle(
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
trailing: IconButton(

View File

@@ -7,7 +7,7 @@ import '../widgets/detail/detail_form_section.dart';
import '../widgets/detail/detail_event_section.dart';
import '../widgets/detail/detail_url_section.dart';
import '../widgets/detail/detail_action_buttons.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart';
/// 구독 상세 정보를 표시하고 편집할 수 있는 화면
@@ -43,7 +43,6 @@ class _DetailScreenState extends State<DetailScreen>
super.dispose();
}
@override
Widget build(BuildContext context) {
final baseColor = _controller.getCardColor();
@@ -51,113 +50,113 @@ class _DetailScreenState extends State<DetailScreen>
return ChangeNotifierProvider<DetailScreenController>.value(
value: _controller,
child: Scaffold(
backgroundColor: AppColors.backgroundColor,
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(
controller: _controller.scrollController,
slivers: [
// 상단 헤더 섹션
SliverToBoxAdapter(
child: DetailHeaderSection(
subscription: widget.subscription,
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
rotateAnimation: _controller.rotateAnimation!,
),
),
// 본문 콘텐츠
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// 편집 모드 안내
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
baseColor.withValues(alpha: 0.15),
baseColor.withValues(alpha: 0.08),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: baseColor.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.edit_rounded,
color: baseColor,
size: 20,
),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).editMode,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: baseColor,
),
),
const Spacer(),
Text(
AppLocalizations.of(context).changesAppliedAfterSave,
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
),
),
],
),
),
const SizedBox(height: 16),
// 기본 정보 폼 섹션
DetailFormSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 이벤트 가격 섹션
DetailEventSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 웹사이트 URL 섹션
DetailUrlSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 32),
// 액션 버튼
DetailActionButtons(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
],
controller: _controller.scrollController,
slivers: [
// 상단 헤더 섹션
SliverToBoxAdapter(
child: DetailHeaderSection(
subscription: widget.subscription,
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
rotateAnimation: _controller.rotateAnimation!,
),
),
),
],
// 본문 콘텐츠
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// 편집 모드 안내
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.edit_rounded,
color: baseColor,
size: 20,
),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).editMode,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: baseColor,
),
),
const Spacer(),
Text(
AppLocalizations.of(context)
.changesAppliedAfterSave,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
const SizedBox(height: 16),
// 기본 정보 폼 섹션
DetailFormSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 이벤트 가격 섹션
DetailEventSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 웹사이트 URL 섹션
DetailUrlSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 32),
// 액션 버튼
DetailActionButtons(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
],
),
),
),
],
),
),
);
}
}
}

View File

@@ -3,7 +3,8 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
import '../theme/color_scheme_ext.dart';
import '../routes/app_routes.dart';
import 'analysis_screen.dart';
import 'app_lock_screen.dart';
@@ -11,9 +12,9 @@ import 'settings_screen.dart';
import 'sms_scan_screen.dart';
import '../utils/animation_controller_helper.dart';
import '../widgets/floating_navigation_bar.dart';
import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/home_content.dart';
import '../l10n/app_localizations.dart';
import '../utils/platform_helper.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@@ -32,7 +33,7 @@ class _MainScreenState extends State<MainScreen>
late AnimationController _waveController;
late ScrollController _scrollController;
late FloatingNavBarScrollController _navBarScrollController;
// 화면 목록
late final List<Widget> _screens;
@@ -62,14 +63,14 @@ class _MainScreenState extends State<MainScreen>
);
_scrollController = ScrollController();
_navBarScrollController = FloatingNavBarScrollController(
scrollController: _scrollController,
onHide: () {},
onShow: () {},
);
// 화면 목록 초기화
// 화면 목록 초기화 (iOS에서는 SMS 스캔 제외)
_screens = [
HomeContent(
fadeController: _fadeController,
@@ -82,7 +83,7 @@ class _MainScreenState extends State<MainScreen>
),
const AnalysisScreen(),
Container(), // 추가 버튼은 별도 처리
const SmsScanScreen(),
if (!PlatformHelper.isIOS) const SmsScanScreen(),
const SettingsScreen(),
];
}
@@ -156,38 +157,39 @@ class _MainScreenState extends State<MainScreen>
AppRoutes.addSubscription,
).then((result) {
_resetAnimations();
// 구독이 성공적으로 추가된 경우
if (result == true) {
// 상단에 스낵바 표시
if (!context.mounted) return;
final cs = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(
Icon(
Icons.check_circle,
color: AppColors.pureWhite,
color: cs.onPrimary,
size: 20,
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).subscriptionAdded,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.pureWhite,
color: cs.onPrimary,
),
),
],
),
backgroundColor: AppColors.successColor,
backgroundColor: cs.success,
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 8, // 더 상단으로
top: MediaQuery.of(context).padding.top + 8,
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 100, // 더 상단으로
bottom: MediaQuery.of(context).size.height - 100,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -202,18 +204,18 @@ class _MainScreenState extends State<MainScreen>
void _handleNavigation(int index, BuildContext context) {
final navigationProvider = context.read<NavigationProvider>();
// 이미 같은 인덱스면 무시
if (navigationProvider.currentIndex == index) {
return;
}
// 추가 버튼은 별도 처리
if (index == 2) {
_navigateToAddSubscription(context);
return;
}
// 인덱스 업데이트
navigationProvider.updateCurrentIndex(index);
}
@@ -221,9 +223,8 @@ class _MainScreenState extends State<MainScreen>
@override
Widget build(BuildContext context) {
final navigationProvider = context.watch<NavigationProvider>();
// 메인 그라데이션 사용
List<Color> backgroundGradient = AppColors.mainGradient;
// 그라데이션 제거: 단색 배경 사용
// 현재 인덱스가 유효한지 확인
int currentIndex = navigationProvider.currentIndex;
@@ -231,19 +232,31 @@ class _MainScreenState extends State<MainScreen>
currentIndex = 0; // 추가 버튼은 홈으로 표시
}
return GlassmorphicScaffold(
body: IndexedStack(
index: currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex,
children: _screens,
),
backgroundGradient: backgroundGradient,
useFloatingNavBar: true,
floatingNavBarIndex: navigationProvider.currentIndex,
onFloatingNavBarTapped: (index) {
_handleNavigation(index, context);
},
enableParticles: false,
enableWaveAnimation: false,
return Stack(
children: [
Positioned.fill(
child: Container(color: Theme.of(context).colorScheme.surface),
),
Scaffold(
extendBody: true,
extendBodyBehindAppBar: true,
body: IndexedStack(
index: PlatformHelper.isIOS
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
: (currentIndex == 3
? 3
: currentIndex == 4
? 4
: currentIndex), // Android: 기존 로직
children: _screens,
),
),
FloatingNavigationBar(
selectedIndex: navigationProvider.currentIndex,
isVisible: true,
onItemTapped: (index) => _handleNavigation(index, context),
),
],
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
// Material colors only
// Glass 제거: Material 3 Card 사용
import '../routes/app_routes.dart';
import '../l10n/app_localizations.dart';
import '../services/sms_service.dart';
import '../utils/platform_helper.dart';
class SmsPermissionScreen extends StatefulWidget {
const SmsPermissionScreen({super.key});
@override
State<SmsPermissionScreen> createState() => _SmsPermissionScreenState();
}
class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
bool _requesting = false;
Future<void> _handleRequest() async {
if (_requesting) return;
setState(() => _requesting = true);
try {
if (!PlatformHelper.isAndroid) {
// iOS/Web은 권한 흐름 없이 메인으로 이동
if (mounted) {
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
}
return;
}
final status = await permission.Permission.sms.status;
if (status.isGranted) {
if (mounted) {
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
}
return;
}
// 권한 요청
final granted = await SMSService.requestSMSPermission();
if (mounted) {
if (granted) {
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
} else {
final newStatus = await permission.Permission.sms.status;
if (newStatus.isPermanentlyDenied) {
// 설정 열기 유도
_showSettingsDialog();
}
// 거부지만 영구 거부가 아니라면 그대로 대기 (사용자가 다시 시도 가능)
}
}
} finally {
if (mounted) setState(() => _requesting = false);
}
}
void _showSettingsDialog() {
final loc = AppLocalizations.of(context);
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text(loc.smsPermissionRequired),
content: Text(loc.permanentlyDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).cancel),
),
TextButton(
onPressed: () async {
await permission.openAppSettings();
if (mounted) Navigator.of(context).pop();
},
child: Text(loc.openSettings),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.sms,
size: 64, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
Text(
loc.smsPermissionTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
loc.smsPermissionRequired,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
const SizedBox(height: 16),
Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loc.smsPermissionReasonTitle,
style:
const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(loc.smsPermissionReasonBody),
const SizedBox(height: 12),
Text(loc.smsPermissionScopeTitle,
style:
const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(loc.smsPermissionScopeBody),
],
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _requesting ? null : _handleRequest,
icon: const Icon(Icons.lock_open),
label: Text(
_requesting ? loc.requesting : loc.requestPermission),
),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.of(context)
.pushReplacementNamed(AppRoutes.main),
child: Text(loc.later),
)
],
),
),
),
),
);
}
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/sms_scan_controller.dart';
import '../widgets/sms_scan/scan_loading_widget.dart';
import '../widgets/sms_scan/scan_initial_widget.dart';
@@ -17,18 +16,21 @@ class SmsScanScreen extends StatefulWidget {
class _SmsScanScreenState extends State<SmsScanScreen> {
late SmsScanController _controller;
late final ScrollController _scrollController;
@override
void initState() {
super.initState();
_controller = SmsScanController();
_controller.addListener(_handleControllerUpdate);
_scrollController = ScrollController();
}
@override
void dispose() {
_controller.removeListener(_handleControllerUpdate);
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
@@ -75,7 +77,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
);
}
final currentSubscription = _controller.scannedSubscriptions[_controller.currentIndex];
final currentSubscription =
_controller.scannedSubscriptions[_controller.currentIndex];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -93,16 +96,37 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
websiteUrlController: _controller.websiteUrlController,
selectedCategoryId: _controller.selectedCategoryId,
onCategoryChanged: _controller.setSelectedCategoryId,
onAdd: () => _controller.addCurrentSubscription(context),
onSkip: () => _controller.skipCurrentSubscription(context),
onAdd: _handleAddSubscription,
onSkip: _handleSkipSubscription,
),
],
);
}
Future<void> _handleAddSubscription() async {
await _controller.addCurrentSubscription(context);
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop());
}
void _handleSkipSubscription() {
_controller.skipCurrentSubscription(context);
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop());
}
void _scrollToTop() {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.zero,
child: Column(
children: [
@@ -119,4 +143,4 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
),
);
}
}
}

View File

@@ -1,9 +1,12 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
import '../services/sms_service.dart';
import '../utils/platform_helper.dart';
import '../routes/app_routes.dart';
import '../l10n/app_localizations.dart';
import '../utils/reduce_motion.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@@ -63,7 +66,8 @@ class _SplashScreenState extends State<SplashScreen>
));
// 랜덤 파티클 생성
_generateParticles();
// 접근성(모션 축소) 고려한 파티클 생성
_generateParticles(reduced: ReduceMotion.platform());
_animationController.forward();
@@ -73,19 +77,19 @@ class _SplashScreenState extends State<SplashScreen>
});
}
void _generateParticles() {
void _generateParticles({bool reduced = false}) {
final random = DateTime.now().millisecondsSinceEpoch;
final total = reduced ? 6 : 20;
for (int i = 0; i < 20; i++) {
for (int i = 0; i < total; 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 duration = (random % 10) / 10 * (reduced ? 1800 : 3000) +
(reduced ? 1200 : 2000); // 축소 시 더 짧게
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
int colorIndex = (random + i) % AppColors.blueGradient.length;
_particles.add({
'size': size,
'x': x,
@@ -93,14 +97,25 @@ class _SplashScreenState extends State<SplashScreen>
'opacity': opacity,
'duration': duration,
'delay': delay,
'color': AppColors.blueGradient[colorIndex],
// color computed at render from ColorScheme.primary
});
}
}
void navigateToNextScreen() {
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
// 모든 이전 라우트를 제거하고 홈으로 이동
Future<void> navigateToNextScreen() async {
// Android에서 SMS 권한이 없으면 권한 안내 화면으로 이동
if (PlatformHelper.isAndroid) {
final hasPermission = await SMSService.hasSMSPermission();
if (!hasPermission && mounted) {
Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.smsPermission,
(route) => false,
);
return;
}
}
if (!mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.main,
(route) => false,
@@ -120,23 +135,15 @@ class _SplashScreenState extends State<SplashScreen>
return Scaffold(
body: Stack(
children: [
// 배경 그라디언트
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.dayGradient[0],
AppColors.dayGradient[1],
],
),
),
),
// 단색 배경
Container(color: Theme.of(context).colorScheme.surface),
// 글래스모피즘 오버레이
Container(
decoration: BoxDecoration(
color: AppColors.pureWhite.withValues(alpha: 0.05),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
),
),
Stack(
@@ -163,11 +170,14 @@ class _SplashScreenState extends State<SplashScreen>
width: particle['size'],
height: particle['size'],
decoration: BoxDecoration(
color: particle['color'],
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: particle['color'].withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
blurRadius: 10,
spreadRadius: 1,
),
@@ -176,45 +186,25 @@ class _SplashScreenState extends State<SplashScreen>
),
),
);
}).toList(),
}),
// 상단 원형 그라데이션
// 상단 원형 장식 제거(단색 배경 유지)
Positioned(
top: -size.height * 0.2,
right: -size.width * 0.2,
child: Container(
child: SizedBox(
width: size.width * 0.8,
height: size.width * 0.8,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.pureWhite.withValues(alpha: 0.1),
AppColors.pureWhite.withValues(alpha: 0.0),
],
stops: const [0.2, 1.0],
),
),
),
),
// 하단 원형 그라데이션
// 하단 원형 장식 제거
Positioned(
bottom: -size.height * 0.1,
left: -size.width * 0.3,
child: Container(
child: SizedBox(
width: size.width * 0.9,
height: size.width * 0.9,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.pureWhite.withValues(alpha: 0.07),
AppColors.pureWhite.withValues(alpha: 0.0),
],
stops: const [0.4, 1.0],
),
),
),
),
@@ -244,61 +234,42 @@ class _SplashScreenState extends State<SplashScreen>
BorderRadius.circular(30),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 20, sigmaY: 20),
sigmaX: ReduceMotion.scale(
context,
normal: 20,
reduced: 8),
sigmaY: ReduceMotion.scale(
context,
normal: 20,
reduced: 8)),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.pureWhite
.withValues(alpha: 0.2),
AppColors.pureWhite
.withValues(alpha: 0.1),
],
),
color: Theme.of(context)
.colorScheme
.surface
.withValues(alpha: 0.6),
borderRadius:
BorderRadius.circular(30),
border: Border.all(
color: AppColors.pureWhite
.withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.2),
width: 1.5,
),
boxShadow: [
BoxShadow(
color:
AppColors.shadowBlack,
spreadRadius: 0,
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: Center(
child: AnimatedBuilder(
animation:
_animationController,
builder: (context, _) {
return ShaderMask(
blendMode:
BlendMode.srcIn,
shaderCallback: (bounds) =>
const LinearGradient(
colors: AppColors
.blueGradient,
begin:
Alignment.topLeft,
end: Alignment
.bottomRight,
).createShader(bounds),
child: Icon(
Icons
.subscriptions_outlined,
size: 64,
color:
Theme.of(context)
.primaryColor,
),
return Icon(
Icons
.subscriptions_outlined,
size: 64,
color: Theme.of(context)
.colorScheme
.primary,
);
}),
),
@@ -328,7 +299,9 @@ class _SplashScreenState extends State<SplashScreen>
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: AppColors.primaryColor
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.9),
letterSpacing: 1.2,
),
@@ -354,7 +327,9 @@ class _SplashScreenState extends State<SplashScreen>
AppLocalizations.of(context).appSubtitle,
style: TextStyle(
fontSize: 16,
color: AppColors.primaryColor
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7),
letterSpacing: 0.5,
),
@@ -376,18 +351,22 @@ class _SplashScreenState extends State<SplashScreen>
height: 60,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.pureWhite
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50),
border: Border.all(
color: AppColors.pureWhite
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.2),
width: 1,
),
),
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.pureWhite),
color:
Theme.of(context).colorScheme.primary,
strokeWidth: 3,
),
),
@@ -408,7 +387,10 @@ class _SplashScreenState extends State<SplashScreen>
'© 2025 NatureBridgeAI. All rights reserved.',
style: TextStyle(
fontSize: 12,
color: AppColors.pureWhite.withValues(alpha: 0.6),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
letterSpacing: 0.5,
),
),

View File

@@ -0,0 +1,97 @@
class _CacheEntry<T> {
final T value;
final DateTime expiresAt;
final int size;
_CacheEntry(
{required this.value, required this.expiresAt, required this.size});
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
/// 간단한 메모리 기반 캐시 매니저 (TTL/최대 개수/용량 제한)
class SimpleCacheManager<T> {
final int maxEntries;
final int maxBytes;
final Duration ttl;
final Map<String, _CacheEntry<T>> _store = <String, _CacheEntry<T>>{};
int _currentBytes = 0;
// 간단한 메트릭
int _hits = 0;
int _misses = 0;
int _puts = 0;
int _evictions = 0;
SimpleCacheManager({
this.maxEntries = 128,
this.maxBytes = 1024 * 1024, // 1MB
this.ttl = const Duration(minutes: 30),
});
T? get(String key) {
final entry = _store.remove(key);
if (entry == null) return null;
if (entry.isExpired) {
_currentBytes -= entry.size;
_misses++;
return null;
}
// LRU 갱신: 재삽입으로 가장 최근으로 이동
_store[key] = entry;
_hits++;
return entry.value;
}
void set(String key, T value, {int size = 1, Duration? customTtl}) {
final expiresAt = DateTime.now().add(customTtl ?? ttl);
final existing = _store.remove(key);
if (existing != null) {
_currentBytes -= existing.size;
}
_store[key] = _CacheEntry(value: value, expiresAt: expiresAt, size: size);
_currentBytes += size;
_puts++;
_evictIfNeeded();
}
void invalidate(String key) {
final removed = _store.remove(key);
if (removed != null) {
_currentBytes -= removed.size;
}
}
void clear() {
_store.clear();
_currentBytes = 0;
}
void _evictIfNeeded() {
// 개수/용량 제한을 넘으면 오래된 것부터 제거
while (_store.length > maxEntries || _currentBytes > maxBytes) {
if (_store.isEmpty) break;
final firstKey = _store.keys.first;
final removed = _store.remove(firstKey);
if (removed != null) {
_currentBytes -= removed.size;
_evictions++;
}
}
}
Map<String, num> dumpMetrics() {
final totalGets = _hits + _misses;
final hitRate = totalGets == 0 ? 0 : _hits / totalGets;
return {
'entries': _store.length,
'bytes': _currentBytes,
'hits': _hits,
'misses': _misses,
'hitRate': hitRate,
'puts': _puts,
'evictions': _evictions,
};
}
}

View File

@@ -1,10 +1,17 @@
import 'package:intl/intl.dart';
import '../models/subscription_model.dart';
import 'exchange_rate_service.dart';
import 'cache_manager.dart';
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
class CurrencyUtil {
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
static final SimpleCacheManager<String> _fmtCache =
SimpleCacheManager<String>(
maxEntries: 256,
maxBytes: 256 * 1024,
ttl: const Duration(minutes: 15),
);
/// 언어에 따른 기본 통화 반환
static String getDefaultCurrency(String locale) {
@@ -66,7 +73,7 @@ class CurrencyUtil {
final locale = _getLocaleForCurrency(currency);
final symbol = getCurrencySymbol(currency);
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
return NumberFormat.currency(
locale: locale,
symbol: symbol,
@@ -80,30 +87,46 @@ class CurrencyUtil {
String currency,
String locale,
) async {
// 캐시 조회
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
final key = 'fmt:$locale:$currency:${amount.toStringAsFixed(decimals)}';
final cached = _fmtCache.get(key);
if (cached != null) return cached;
final defaultCurrency = getDefaultCurrency(locale);
// 입력 통화가 기본 통화인 경우
if (currency == defaultCurrency) {
return _formatSingleCurrency(amount, currency);
final result = _formatSingleCurrency(amount, currency);
_fmtCache.set(key, result, size: result.length);
return result;
}
// USD 입력인 경우 - 기본 통화로 변환하여 표시
if (currency == 'USD' && defaultCurrency != 'USD') {
final convertedAmount = await _exchangeRateService.convertUsdToTarget(amount, defaultCurrency);
final convertedAmount = await _exchangeRateService.convertUsdToTarget(
amount, defaultCurrency);
if (convertedAmount != null) {
final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency);
final primaryFormatted =
_formatSingleCurrency(convertedAmount, defaultCurrency);
final usdFormatted = _formatSingleCurrency(amount, 'USD');
return '$primaryFormatted ($usdFormatted)';
final result = '$primaryFormatted ($usdFormatted)';
_fmtCache.set(key, result, size: result.length);
return result;
}
}
// 영어 사용자가 KRW 선택한 경우
if (locale == 'en' && currency == 'KRW') {
return _formatSingleCurrency(amount, currency);
final result = _formatSingleCurrency(amount, currency);
_fmtCache.set(key, result, size: result.length);
return result;
}
// 기타 통화 입력인 경우
return _formatSingleCurrency(amount, currency);
final result = _formatSingleCurrency(amount, currency);
_fmtCache.set(key, result, size: result.length);
return result;
}
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
@@ -116,13 +139,13 @@ class CurrencyUtil {
for (var subscription in subscriptions) {
final price = subscription.currentPrice;
final converted = await _exchangeRateService.convertBetweenCurrencies(
price,
subscription.currency,
defaultCurrency,
);
total += converted ?? price;
}
@@ -139,7 +162,20 @@ class CurrencyUtil {
static Future<String> formatSubscriptionAmountWithLocale(
SubscriptionModel subscription, String locale) async {
final price = subscription.currentPrice;
return formatAmountWithLocale(price, subscription.currency, locale);
// 구독 단위 캐시 키 (통화/가격/locale + id)
final decimals =
(subscription.currency == 'KRW' || subscription.currency == 'JPY')
? 0
: 2;
final key =
'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}';
final cached = _fmtCache.get(key);
if (cached != null) return cached;
final result =
await formatAmountWithLocale(price, subscription.currency, locale);
_fmtCache.set(key, result, size: result.length);
return result;
}
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지
@@ -178,13 +214,13 @@ class CurrencyUtil {
for (var subscription in subscriptions) {
if (subscription.isCurrentlyInEvent) {
final savings = subscription.eventSavings;
final converted = await _exchangeRateService.convertBetweenCurrencies(
savings,
subscription.currency,
defaultCurrency,
);
total += converted ?? savings;
}
}
@@ -204,7 +240,7 @@ class CurrencyUtil {
if (!subscription.isCurrentlyInEvent) {
return '';
}
final savings = subscription.eventSavings;
return formatAmountWithLocale(savings, subscription.currency, locale);
}
@@ -225,4 +261,4 @@ class CurrencyUtil {
static Future<String> formatAmount(double amount, String currency) async {
return formatAmountWithCurrencyAndLocale(amount, currency, 'ko');
}
}
}

View File

@@ -1,6 +1,8 @@
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:intl/intl.dart';
import '../utils/logger.dart';
import 'cache_manager.dart';
/// 환율 정보 서비스 클래스
class ExchangeRateService {
@@ -15,18 +17,34 @@ class ExchangeRateService {
// 내부 생성자
ExchangeRateService._internal();
// 포맷된 환율 문자열 캐시 (언어별)
static final SimpleCacheManager<String> _fmtCache =
SimpleCacheManager<String>(
maxEntries: 64,
maxBytes: 64 * 1024,
ttl: const Duration(minutes: 30),
);
// 캐싱된 환율 정보
double? _usdToKrwRate;
double? _usdToJpyRate;
double? _usdToCnyRate;
DateTime? _lastUpdated;
// API 요청 URL (ExchangeRate-API 사용)
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD';
// API 요청 URL (ExchangeRate-API 등) - 빌드 타임 오버라이드 가능
static const String _defaultApiUrl =
'https://api.exchangerate-api.com/v4/latest/USD';
final String _apiUrl = const String.fromEnvironment(
'EXCHANGE_RATE_API_URL',
defaultValue: _defaultApiUrl,
);
// 기본 환율 상수
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_KRW_RATE = 1350.0;
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_JPY_RATE = 150.0;
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_CNY_RATE = 7.2;
// 캐싱된 환율 반환 (동기적)
@@ -44,18 +62,28 @@ class ExchangeRateService {
}
try {
// API 요청
// API 요청 (네트워크 불가 환경에서는 예외 발생 가능)
final response = await http.get(Uri.parse(_apiUrl));
if (response.statusCode == 200) {
final data = json.decode(response.body);
_usdToKrwRate = data['rates']['KRW']?.toDouble();
_usdToJpyRate = data['rates']['JPY']?.toDouble();
_usdToCnyRate = data['rates']['CNY']?.toDouble();
_usdToKrwRate = (data['rates']['KRW'] as num?)?.toDouble();
_usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
_usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
_lastUpdated = DateTime.now();
// 환율 갱신 시 포맷 캐시 무효화
_fmtCache.clear();
Log.d(
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
return;
} else {
Log.w(
'환율 API 응답 코드: ${response.statusCode} (${response.reasonPhrase})');
}
} catch (e) {
// 오류 발생 시 기본값 사용
} catch (e, st) {
// 네트워크 실패 시 캐시/기본값 폴백
Log.w('환율 API 요청 실패. 캐시/기본값 사용');
Log.e('환율 API 에러', e, st);
}
}
@@ -75,9 +103,10 @@ class ExchangeRateService {
}
/// USD 금액을 지정된 통화로 변환합니다.
Future<double?> convertUsdToTarget(double usdAmount, String targetCurrency) async {
Future<double?> convertUsdToTarget(
double usdAmount, String targetCurrency) async {
await _fetchAllRatesIfNeeded();
switch (targetCurrency) {
case 'KRW':
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
@@ -96,9 +125,10 @@ class ExchangeRateService {
}
/// 지정된 통화를 USD로 변환합니다.
Future<double?> convertTargetToUsd(double amount, String sourceCurrency) async {
Future<double?> convertTargetToUsd(
double amount, String sourceCurrency) async {
await _fetchAllRatesIfNeeded();
switch (sourceCurrency) {
case 'KRW':
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
@@ -118,25 +148,22 @@ class ExchangeRateService {
/// 두 통화 간 변환을 수행합니다. (USD를 거쳐서 변환)
Future<double?> convertBetweenCurrencies(
double amount,
String fromCurrency,
String toCurrency
) async {
double amount, String fromCurrency, String toCurrency) async {
if (fromCurrency == toCurrency) {
return amount;
}
// fromCurrency → USD → toCurrency
double? usdAmount;
if (fromCurrency == 'USD') {
usdAmount = amount;
} else {
usdAmount = await convertTargetToUsd(amount, fromCurrency);
}
if (usdAmount == null) return null;
if (toCurrency == 'USD') {
return usdAmount;
} else {
@@ -161,32 +188,45 @@ class ExchangeRateService {
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
await _fetchAllRatesIfNeeded();
// 캐시 키 (locale 기준)
final key = 'fx:fmt:$locale';
final cached = _fmtCache.get(key);
if (cached != null) return cached;
String result = '';
switch (locale) {
case 'ko':
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
return NumberFormat.currency(
result = NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(rate);
break;
case 'ja':
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
return NumberFormat.currency(
result = NumberFormat.currency(
locale: 'ja_JP',
symbol: '¥',
decimalDigits: 0,
).format(rate);
break;
case 'zh':
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
return NumberFormat.currency(
result = NumberFormat.currency(
locale: 'zh_CN',
symbol: '¥',
decimalDigits: 2,
).format(rate);
break;
default:
return '';
result = '';
break;
}
// 대략적인 사이즈(문자 길이)로 캐시 저장
_fmtCache.set(key, result, size: result.length);
return result;
}
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
@@ -204,12 +244,13 @@ class ExchangeRateService {
}
/// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다.
Future<String> getFormattedAmountForLocale(double usdAmount, String locale) async {
Future<String> getFormattedAmountForLocale(
double usdAmount, String locale) async {
String targetCurrency;
String localeCode;
String symbol;
int decimalDigits;
switch (locale) {
case 'ko':
targetCurrency = 'KRW';
@@ -232,7 +273,7 @@ class ExchangeRateService {
default:
return '\$$usdAmount';
}
final convertedAmount = await convertUsdToTarget(usdAmount, targetCurrency);
if (convertedAmount != null) {
final formattedAmount = NumberFormat.currency(

View File

@@ -1,15 +1,18 @@
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 'dart:io' show Platform;
import '../models/subscription_model.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../navigator_key.dart';
import '../l10n/app_localizations.dart';
import '../services/currency_util.dart';
class NotificationService {
static final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
static final _secureStorage = const FlutterSecureStorage();
static const _secureStorage = FlutterSecureStorage();
static const _notificationEnabledKey = 'notification_enabled';
static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
@@ -17,6 +20,24 @@ class NotificationService {
static const _reminderHourKey = 'reminder_hour';
static const _reminderMinuteKey = 'reminder_minute';
static const _dailyReminderKey = 'daily_reminder_enabled';
static const int _maxDailyReminderSlots = 7;
static const String _paymentPayloadPrefix = 'payment:';
static const String _paymentChannelId = 'subscription_channel_v2';
static const String _expirationChannelId = 'expiration_channel_v2';
static String get paymentChannelId => _paymentChannelId;
static String get expirationChannelId => _expirationChannelId;
static String _paymentPayload(String subscriptionId) =>
'$_paymentPayloadPrefix$subscriptionId';
static bool _matchesPaymentPayload(String? payload) =>
payload != null && payload.startsWith(_paymentPayloadPrefix);
static String? _subscriptionIdFromPaymentPayload(String? payload) =>
_matchesPaymentPayload(payload)
? payload!.substring(_paymentPayloadPrefix.length)
: null;
// 초기화 상태를 추적하기 위한 플래그
static bool _initialized = false;
@@ -56,6 +77,33 @@ class NotificationService {
InitializationSettings(android: androidSettings, iOS: iosSettings);
await _notifications.initialize(initSettings);
// Android 채널을 선제적으로 생성하여 중요도/진동이 확실히 적용되도록 함
if (Platform.isAndroid) {
final androidImpl =
_notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidImpl != null) {
try {
await androidImpl
.createNotificationChannel(const AndroidNotificationChannel(
_paymentChannelId,
'Subscription Notifications',
description: 'Channel for subscription reminders',
importance: Importance.high,
));
await androidImpl
.createNotificationChannel(const AndroidNotificationChannel(
_expirationChannelId,
'Expiration Notifications',
description: 'Channel for subscription expiration reminders',
importance: Importance.high,
));
} catch (e) {
debugPrint('안드로이드 채널 생성 실패: $e');
}
}
}
_initialized = true;
debugPrint('알림 서비스 초기화 완료');
} catch (e) {
@@ -122,20 +170,32 @@ class NotificationService {
return;
}
// 기존 알림 모두 취소
await cancelAllNotifications();
final pendingRequests =
await _notifications.pendingNotificationRequests();
// 알림 설정 가져오기
final isPaymentEnabled = await isPaymentNotificationEnabled();
if (!isPaymentEnabled) return;
if (!isPaymentEnabled) {
await _cancelOrphanedPaymentReminderNotifications(
const <String>{},
pendingRequests,
);
return;
}
final reminderDays = await getReminderDays();
final reminderHour = await getReminderHour();
final reminderMinute = await getReminderMinute();
final isDailyReminder = await isDailyReminderEnabled();
// 각 구독에 대해 알림 재설정
final activeSubscriptionIds =
subscriptions.map((subscription) => subscription.id).toSet();
for (final subscription in subscriptions) {
await _cancelPaymentReminderNotificationsForSubscription(
subscription,
pendingRequests,
);
await schedulePaymentReminder(
subscription: subscription,
reminderDays: reminderDays,
@@ -144,18 +204,201 @@ class NotificationService {
isDailyReminder: isDailyReminder,
);
}
await _cancelOrphanedPaymentReminderNotifications(
activeSubscriptionIds,
pendingRequests,
);
} catch (e) {
debugPrint('알림 일정 재설정 중 오류 발생: $e');
}
}
static Future<void> _cancelPaymentReminderNotificationsForSubscription(
SubscriptionModel subscription,
List<PendingNotificationRequest> pendingRequests,
) async {
final baseId = subscription.id.hashCode;
final payload = _paymentPayload(subscription.id);
final idsToCancel = <int>{};
for (final request in pendingRequests) {
final matchesPayload = request.payload == payload;
final matchesIdPattern = request.id == baseId ||
(request.id > baseId &&
request.id <= baseId + _maxDailyReminderSlots);
if (matchesPayload || matchesIdPattern) {
idsToCancel.add(request.id);
}
}
for (final id in idsToCancel) {
try {
await _notifications.cancel(id);
} catch (e) {
debugPrint('결제 알림 취소 중 오류 발생: $e');
}
}
if (idsToCancel.isNotEmpty) {
pendingRequests
.removeWhere((request) => idsToCancel.contains(request.id));
}
}
static Future<void> _cancelOrphanedPaymentReminderNotifications(
Set<String> activeSubscriptionIds,
List<PendingNotificationRequest> pendingRequests,
) async {
final idsToCancel = <int>{};
for (final request in pendingRequests) {
final subscriptionId = _subscriptionIdFromPaymentPayload(request.payload);
if (subscriptionId != null &&
!activeSubscriptionIds.contains(subscriptionId)) {
idsToCancel.add(request.id);
}
}
for (final id in idsToCancel) {
try {
await _notifications.cancel(id);
} catch (e) {
debugPrint('고아 결제 알림 취소 중 오류 발생: $e');
}
}
if (idsToCancel.isNotEmpty) {
pendingRequests
.removeWhere((request) => idsToCancel.contains(request.id));
}
}
static Future<bool> requestPermission() async {
final result = await _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestPermission();
return result ?? false;
// 웹 플랫폼인 경우 false 반환
if (_isWeb) return false;
// iOS 처리
if (Platform.isIOS) {
final iosImplementation =
_notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
if (iosImplementation != null) {
final granted = await iosImplementation.requestPermissions(
alert: true,
badge: true,
sound: true,
);
return granted ?? false;
}
}
// Android 처리
if (Platform.isAndroid) {
final androidImplementation =
_notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidImplementation != null) {
final granted =
await androidImplementation.requestNotificationsPermission();
return granted ?? false;
}
}
return false;
}
// 권한 상태 확인
static Future<bool> checkPermission() async {
// 웹 플랫폼인 경우 false 반환
if (_isWeb) return false;
// Android 처리
if (Platform.isAndroid) {
final androidImplementation =
_notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidImplementation != null) {
// Android 13 이상에서만 권한 확인 필요
final isEnabled = await androidImplementation.areNotificationsEnabled();
return isEnabled ?? false;
}
}
// iOS 처리
if (Platform.isIOS) {
final iosImplementation =
_notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
if (iosImplementation != null) {
final settings = await iosImplementation.checkPermissions();
return settings?.isEnabled ?? false;
}
}
return true; // 기본값
}
// Android: 정확 알람 권한 가능 여부 확인 (S+)
static Future<bool> canScheduleExactAlarms() async {
if (_isWeb) return false;
if (Platform.isAndroid) {
final android = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (android != null) {
final can = await android.canScheduleExactNotifications();
return can ?? true; // 하위 버전은 true 간주
}
}
return true;
}
// Android: 정확 알람 권한 요청 (Android 12+에서 설정 화면으로 이동)
static Future<bool> requestExactAlarmsPermission() async {
if (_isWeb) return false;
if (Platform.isAndroid) {
final android = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (android != null) {
final granted = await android.requestExactAlarmsPermission();
return granted ?? false;
}
}
return false;
}
static Future<AndroidScheduleMode> _resolveAndroidScheduleMode() async {
if (_isWeb) {
return AndroidScheduleMode.exactAllowWhileIdle;
}
if (Platform.isAndroid) {
try {
final canExact = await canScheduleExactAlarms();
if (kDebugMode) {
debugPrint(
'[NotificationService] canScheduleExactAlarms result: $canExact');
}
if (!canExact) {
if (kDebugMode) {
debugPrint(
'[NotificationService] exact alarm unavailable → use inexact mode');
}
return AndroidScheduleMode.inexactAllowWhileIdle;
}
} catch (e) {
debugPrint('정확 알람 권한 확인 중 오류 발생: $e');
return AndroidScheduleMode.inexactAllowWhileIdle;
}
}
return AndroidScheduleMode.exactAllowWhileIdle;
}
// 알림 스케줄 설정
@@ -164,24 +407,45 @@ class NotificationService {
required String title,
required String body,
required DateTime scheduledDate,
String? payload,
String? channelId,
}) async {
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
if (_isWeb || !_initialized) {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return;
}
try {
const androidDetails = AndroidNotificationDetails(
'subscription_channel',
'구독 알림',
channelDescription: '구독 관련 알림을 보여줍니다.',
final ctx = navigatorKey.currentContext;
String channelName;
if (channelId == _expirationChannelId) {
channelName = ctx != null
? AppLocalizations.of(ctx).expirationReminder
: 'Expiration Notifications';
} else {
channelName = ctx != null
? AppLocalizations.of(ctx).notifications
: 'Subscription Notifications';
}
final effectiveChannelId = channelId ?? _paymentChannelId;
final androidDetails = AndroidNotificationDetails(
effectiveChannelId,
channelName,
channelDescription: channelName,
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
final iosDetails = const DarwinNotificationDetails();
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
@@ -200,15 +464,26 @@ class NotificationService {
}
}
// 과거 시각 방지: 최소 1분 뒤로 조정
final nowTz = tz.TZDateTime.now(location);
var target = tz.TZDateTime.from(scheduledDate, location);
if (!target.isAfter(nowTz)) {
target = nowTz.add(const Duration(minutes: 1));
}
final scheduleMode = await _resolveAndroidScheduleMode();
if (kDebugMode) {
debugPrint(
'[NotificationService] scheduleNotification scheduleMode=$scheduleMode');
}
await _notifications.zonedSchedule(
id,
title,
body,
tz.TZDateTime.from(scheduledDate, location),
target,
NotificationDetails(android: androidDetails, iOS: iosDetails),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: scheduleMode,
payload: payload,
);
} catch (e) {
debugPrint('알림 예약 중 오류 발생: $e');
@@ -243,29 +518,31 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return;
}
try {
final notificationId = subscription.id.hashCode;
const androidDetails = AndroidNotificationDetails(
'subscription_channel',
'구독 알림',
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
importance: Importance.high,
priority: Priority.high,
final ctx = navigatorKey.currentContext;
final title = ctx != null
? AppLocalizations.of(ctx).expirationReminder
: 'Expiration Reminder';
final notificationDetails = NotificationDetails(
android: AndroidNotificationDetails(
_paymentChannelId,
title,
channelDescription: title,
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
@@ -284,15 +561,35 @@ class NotificationService {
}
}
final nowTz = tz.TZDateTime.now(location);
var fireAt = tz.TZDateTime.from(subscription.nextBillingDate, location);
if (kDebugMode) {
debugPrint('[NotificationService] scheduleSubscriptionNotification'
' id=${subscription.id.hashCode} tz=${location.name}'
' now=$nowTz target=$fireAt service=${subscription.serviceName}');
}
if (!fireAt.isAfter(nowTz)) {
// 이미 지난 시각이면 예약 생략
if (kDebugMode) {
debugPrint(
'[NotificationService] skip scheduleSubscriptionNotification (past)');
}
return;
}
final scheduleMode = await _resolveAndroidScheduleMode();
if (kDebugMode) {
debugPrint(
'[NotificationService] scheduleSubscriptionNotification scheduleMode=$scheduleMode');
}
await _notifications.zonedSchedule(
notificationId,
'구독 만료 알림',
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
tz.TZDateTime.from(subscription.nextBillingDate, location),
title,
_buildExpirationBody(subscription),
fireAt,
notificationDetails,
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: scheduleMode,
);
} catch (e) {
debugPrint('구독 알림 예약 중 오류 발생: $e');
@@ -313,55 +610,18 @@ class NotificationService {
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');
}
if (_isWeb || !_initialized) return;
final reminderDays = await getReminderDays();
final hour = await getReminderHour();
final minute = await getReminderMinute();
final daily = await isDailyReminderEnabled();
await schedulePaymentReminder(
subscription: subscription,
reminderDays: reminderDays,
reminderHour: hour,
reminderMinute: minute,
isDailyReminder: daily,
);
}
static Future<void> scheduleExpirationNotification(
@@ -371,11 +631,11 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return;
}
try {
final expirationDate = subscription.nextBillingDate;
final reminderDate = expirationDate.subtract(const Duration(days: 7));
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
@@ -395,22 +655,26 @@ class NotificationService {
}
await _notifications.zonedSchedule(
(subscription.id + '_expiration').hashCode,
('${subscription.id}_expiration').hashCode,
'구독 만료 예정 알림',
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
tz.TZDateTime.from(reminderDate, location),
const NotificationDetails(
android: AndroidNotificationDetails(
'expiration_channel',
_expirationChannelId,
'Expiration Notifications',
channelDescription: 'Channel for subscription expiration reminders',
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: await _resolveAndroidScheduleMode(),
);
} catch (e) {
debugPrint('만료 알림 예약 중 오류 발생: $e');
@@ -431,6 +695,9 @@ class NotificationService {
}
try {
final locale = _getLocaleCode();
final title = _paymentReminderTitle(locale);
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
@@ -448,111 +715,127 @@ class NotificationService {
location = tz.UTC;
}
}
// 기본 알림 예약 (지정된 일수 전)
final scheduledDate =
subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
// 남은 일수에 따른 메시지 생성
String daysText = '$reminderDays일';
if (reminderDays == 1) {
daysText = '내일';
// 기본 알림 예약 (지정된 일수 전)
final baseLocal = subscription.nextBillingDate
.subtract(Duration(days: reminderDays))
.copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
final nowTz = tz.TZDateTime.now(location);
var scheduledDate = tz.TZDateTime.from(baseLocal, location);
if (kDebugMode) {
debugPrint('[NotificationService] schedulePaymentReminder(base)'
' id=${subscription.id.hashCode} tz=${location.name}'
' now=$nowTz requested=$baseLocal scheduled=$scheduledDate'
' days=$reminderDays time=${reminderHour.toString().padLeft(2, '0')}:${reminderMinute.toString().padLeft(2, '0')}'
' service=${subscription.serviceName}');
}
if (!scheduledDate.isAfter(nowTz)) {
// 지정일이 과거면 최소 1분 뒤로
scheduledDate = nowTz.add(const Duration(minutes: 1));
if (kDebugMode) {
debugPrint(
'[NotificationService] schedulePaymentReminder(base) adjusted to $scheduledDate');
}
}
// 남은 일수에 따른 메시지 생성
final daysText = _daysInText(locale, reminderDays);
// 이벤트 종료로 인한 가격 변동 확인
String notificationBody;
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
// 이벤트가 결제일 전에 종료되는 경우
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost;
notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else {
// 일반 알림
final currentPrice = subscription.currentPrice;
notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
final body = await _buildPaymentBody(subscription, daysText);
final scheduleMode = await _resolveAndroidScheduleMode();
if (kDebugMode) {
debugPrint(
'[NotificationService] schedulePaymentReminder(base) scheduleMode=$scheduleMode');
}
await _notifications.zonedSchedule(
subscription.id.hashCode,
'구독 결제 예정 알림',
notificationBody,
tz.TZDateTime.from(scheduledDate, location),
const NotificationDetails(
title,
body,
scheduledDate,
NotificationDetails(
android: AndroidNotificationDetails(
'subscription_channel',
'Subscription Notifications',
channelDescription: 'Channel for subscription reminders',
_paymentChannelId,
title,
channelDescription: title,
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
iOS: DarwinNotificationDetails(),
),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
androidScheduleMode: scheduleMode,
payload: _paymentPayload(subscription.id),
);
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
if (isDailyReminder && reminderDays >= 2) {
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
for (int i = reminderDays - 1; i >= 1; i--) {
final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
final dailyLocal =
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
final dailyDate = tz.TZDateTime.from(dailyLocal, location);
if (kDebugMode) {
debugPrint('[NotificationService] schedulePaymentReminder(daily)'
' id=${subscription.id.hashCode + i} tz=${location.name}'
' now=$nowTz requested=$dailyLocal scheduled=$dailyDate'
' daysLeft=$i');
}
if (!dailyDate.isAfter(nowTz)) {
// 과거면 건너뜀
if (kDebugMode) {
debugPrint('[NotificationService] skip daily (past)');
}
continue;
}
// 남은 일수에 따른 메시지 생성
String remainingDaysText = '$i일';
if (i == 1) {
remainingDaysText = '내일';
}
final remainingDaysText = _daysInText(locale, i);
// 각 날짜에 대한 이벤트 종료 확인
String dailyNotificationBody;
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost;
dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else {
final currentPrice = subscription.currentPrice;
dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
}
final dailyNotificationBody =
await _buildPaymentBody(subscription, remainingDaysText);
await _notifications.zonedSchedule(
subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
'구독 결제 예정 알림',
title,
dailyNotificationBody,
tz.TZDateTime.from(dailyDate, location),
const NotificationDetails(
dailyDate,
NotificationDetails(
android: AndroidNotificationDetails(
'subscription_channel',
'Subscription Notifications',
channelDescription: 'Channel for subscription reminders',
_paymentChannelId,
title,
channelDescription: title,
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
iOS: DarwinNotificationDetails(),
),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
androidScheduleMode: scheduleMode,
payload: _paymentPayload(subscription.id),
);
}
}
@@ -561,7 +844,109 @@ class NotificationService {
}
}
// 디버그 테스트용: 즉시 결제 알림을 보여줍니다.
static Future<void> showTestPaymentNotification() async {
if (_isWeb || !_initialized) return;
try {
final locale = _getLocaleCode();
final title = _paymentReminderTitle(locale);
final amountText =
await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale);
final body = '테스트 구독 • $amountText';
await _notifications.show(
DateTime.now().millisecondsSinceEpoch.remainder(1 << 31),
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
'subscription_channel',
title,
channelDescription: title,
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
);
} catch (e) {
debugPrint('테스트 결제 알림 표시 실패: $e');
}
}
static String getNotificationBody(String serviceName, double amount) {
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
}
static Future<String> _buildPaymentBody(
SubscriptionModel subscription, String daysText) async {
final ctx = navigatorKey.currentContext;
final locale =
ctx != null ? AppLocalizations.of(ctx).locale.languageCode : 'en';
final warnText = ctx != null
? AppLocalizations.of(ctx).eventDiscountEndsBeforeBilling
: 'Event discount ends before billing date';
final amountText = await CurrencyUtil.formatAmountWithLocale(
subscription.currentPrice, subscription.currency, locale);
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
return '${subscription.serviceName}$amountText$daysText\n⚠️ $warnText';
}
// 일반 알림
if (ctx != null) {
return '${subscription.serviceName}$amountText$daysText';
}
return '${subscription.serviceName}$amountText$daysText';
}
static String _buildExpirationBody(SubscriptionModel subscription) {
final ctx = navigatorKey.currentContext;
if (ctx != null) {
final date =
AppLocalizations.of(ctx).formatDate(subscription.nextBillingDate);
return '${subscription.serviceName}$date';
}
return '${subscription.serviceName}${subscription.nextBillingDate.toLocal()}';
}
static String _getLocaleCode() {
final ctx = navigatorKey.currentContext;
if (ctx != null) {
return AppLocalizations.of(ctx).locale.languageCode;
}
return 'en';
}
static String _paymentReminderTitle(String locale) {
switch (locale) {
case 'ko':
return '결제 예정 알림';
case 'ja':
return '支払い予定の通知';
case 'zh':
return '付款提醒';
default:
return 'Payment Reminder';
}
}
static String _daysInText(String locale, int days) {
switch (locale) {
case 'ko':
return '$days일';
case 'ja':
return '$days日後';
case 'zh':
return '$days天后';
default:
return 'in $days day(s)';
}
}
}

View File

@@ -3,16 +3,21 @@ import '../../models/subscription_model.dart';
class SubscriptionConverter {
// SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> convertModelsToSubscriptions(List<SubscriptionModel> models) {
List<Subscription> convertModelsToSubscriptions(
List<SubscriptionModel> models) {
final result = <Subscription>[];
for (var model in models) {
try {
final subscription = _convertSingle(model);
result.add(subscription);
print('모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
// 개발 편의를 위한 디버그 로그
// ignore: avoid_print
print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
} catch (e) {
// ignore: avoid_print
print('모델 변환 중 오류 발생: $e');
}
}
@@ -76,4 +81,4 @@ class SubscriptionConverter {
return 'monthly'; // 기본값
}
}
}
}

View File

@@ -1,11 +1,13 @@
import '../../models/subscription.dart';
import '../../models/subscription_model.dart';
import '../../utils/logger.dart';
class SubscriptionFilter {
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
List<Subscription> filterDuplicates(
List<Subscription> scanned, List<SubscriptionModel> existing) {
print('_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
Log.d(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링
return scanned.where((scannedSub) {
@@ -16,7 +18,8 @@ class SubscriptionFilter {
final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost;
if (isSameName && isSameCost) {
print('중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
Log.d(
'중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
return true;
}
return false;
@@ -27,7 +30,8 @@ class SubscriptionFilter {
}
// 반복 횟수 기반 필터링
List<Subscription> filterByRepeatCount(List<Subscription> subscriptions, int minCount) {
List<Subscription> filterByRepeatCount(
List<Subscription> subscriptions, int minCount) {
return subscriptions.where((sub) => sub.repeatCount >= minCount).toList();
}
@@ -44,7 +48,8 @@ class SubscriptionFilter {
List<Subscription> filterByPriceRange(
List<Subscription> subscriptions, double minPrice, double maxPrice) {
return subscriptions
.where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
.where(
(sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
.toList();
}
@@ -52,9 +57,9 @@ class SubscriptionFilter {
List<Subscription> filterByCategories(
List<Subscription> subscriptions, List<String> categoryIds) {
if (categoryIds.isEmpty) return subscriptions;
return subscriptions.where((sub) {
return sub.category != null && categoryIds.contains(sub.category);
}).toList();
}
}
}

View File

@@ -1,32 +1,39 @@
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart' show kIsWeb, compute;
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
import '../models/subscription_model.dart';
import '../utils/logger.dart';
import '../temp/test_sms_data.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
class SmsScanner {
final SmsQuery _query = SmsQuery();
Future<List<SubscriptionModel>> scanForSubscriptions() async {
try {
List<dynamic> smsList;
print('SmsScanner: 스캔 시작');
Log.d('SmsScanner: 스캔 시작');
// 디버그 모드에서는 테스트 데이터 사용
if (kDebugMode) {
print('SmsScanner: 디버그 모드에서 테스트 데이터 사용');
// 플랫폼별 분기 처리
if (kIsWeb) {
// 웹 환경: 테스트 데이터 사용
Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용');
smsList = TestSmsData.getTestData();
print('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
Log.d('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
} else if (PlatformHelper.isIOS) {
// iOS: SMS 접근 불가, 빈 리스트 반환
Log.w('SmsScanner: iOS에서는 SMS 스캔 불가');
return [];
} else if (PlatformHelper.isAndroid) {
// Android: flutter_sms_inbox 사용
Log.i('SmsScanner: Android에서 실제 SMS 스캔');
smsList = await _scanAndroidSms();
Log.d('SmsScanner: 스캔된 SMS 개수: ${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 [];
}
// 기타 플랫폼
Log.w('SmsScanner: 지원하지 않는 플랫폼');
return [];
}
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
@@ -42,41 +49,124 @@ class SmsScanner {
serviceGroups[serviceName]!.add(sms);
}
print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
// 그룹화된 데이터로 구독 분석
for (final entry in serviceGroups.entries) {
print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
// 2회 이상 반복된 서비스만 구독으로 간주
if (entry.value.length >= 2) {
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
// 결제일 패턴 유추를 위해 최근 2개의 결제일을 사용
final messages = [...entry.value];
messages.sort((a, b) {
final da = DateTime.tryParse(a['previousPaymentDate'] ?? '') ??
DateTime(1970);
final db = DateTime.tryParse(b['previousPaymentDate'] ?? '') ??
DateTime(1970);
return db.compareTo(da); // desc
});
final mostRecent = messages.first;
DateTime? recentDate =
DateTime.tryParse(mostRecent['previousPaymentDate'] ?? '');
DateTime? prevDate = messages.length > 1
? DateTime.tryParse(messages[1]['previousPaymentDate'] ?? '')
: null;
// 기본 결제 일자(일단위) 추정: 가장 최근 결제의 일자
int baseDay = recentDate?.day ?? DateTime.now().day;
// 이전 결제가 주말 이월로 보이는 패턴인지 검사하여 baseDay 보정
if (recentDate != null && prevDate != null) {
final candidate = DateTime(prevDate.year, prevDate.month, baseDay);
if (BusinessDayUtil.isWeekend(candidate)) {
final diff = prevDate.difference(candidate).inDays;
if (diff >= 1 && diff <= 3) {
// 예: 12일(토)→14일(월)
baseDay = baseDay; // 유지
} else {
// 차이가 크면 이전 달의 일자를 채택
baseDay = prevDate.day;
}
}
}
// 다음 결제일 계산: 기준 일자를 바탕으로 다음 달 또는 이번 달로 설정 후 영업일 보정
final DateTime now = DateTime.now();
int year = now.year;
int month = now.month;
if (now.day >= baseDay) {
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = baseDay.clamp(1, dim);
DateTime nextBilling = DateTime(year, month, day);
nextBilling = BusinessDayUtil.nextBusinessDay(nextBilling);
// 가장 최근 SMS 맵에 override 값으로 주입
final serviceSms = Map<String, dynamic>.from(mostRecent);
serviceSms['overrideNextBillingDate'] = nextBilling.toIso8601String();
final subscription = _parseSms(serviceSms, entry.value.length);
if (subscription != null) {
print(
Log.i(
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
subscriptions.add(subscription);
} else {
print('SmsScanner: 구독 파싱 실패: ${entry.key}');
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
}
} else {
print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
}
}
print('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
return subscriptions;
} catch (e) {
print('SmsScanner: 예외 발생: $e');
Log.e('SmsScanner: 예외 발생', e);
throw Exception('SMS 스캔 중 오류 발생: $e');
}
}
// Android에서 flutter_sms_inbox를 사용한 SMS 스캔
Future<List<dynamic>> _scanAndroidSms() async {
try {
final messages = await _query.getAllSms;
// Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가)
final serialized = <Map<String, dynamic>>[];
for (final message in messages) {
serialized.add({
'body': message.body ?? '',
'address': message.address ?? '',
'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch,
});
}
// 대량 파싱은 별도 Isolate로 오프로딩
final List<Map<String, dynamic>> smsList =
await compute(_parseRawSmsBatch, serialized);
return smsList;
} catch (e) {
Log.e('SmsScanner: Android SMS 스캔 실패', e);
return [];
}
}
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
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 = SubscriptionModel.normalizeBillingCycle(sms['billingCycle'] as String? ?? 'monthly');
final billingCycle = SubscriptionModel.normalizeBillingCycle(
sms['billingCycle'] as String? ?? 'monthly');
final nextBillingDateStr = sms['nextBillingDate'] as String?;
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
@@ -94,12 +184,16 @@ class SmsScanner {
'Spotify Premium'
];
if (dollarServices.any((service) => serviceName.contains(service))) {
print('서비스명 $serviceName으로 USD 통화 단위 확정');
Log.d('서비스명 $serviceName으로 USD 통화 단위 확정');
currency = 'USD';
}
DateTime? nextBillingDate;
if (nextBillingDateStr != null) {
// 외부에서 계산된 다음 결제일이 있으면 우선 사용
final overrideNext = sms['overrideNextBillingDate'] as String?;
if (overrideNext != null) {
nextBillingDate = DateTime.tryParse(overrideNext);
} else if (nextBillingDateStr != null) {
nextBillingDate = DateTime.tryParse(nextBillingDateStr);
}
@@ -111,8 +205,12 @@ class SmsScanner {
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
DateTime adjustedNextBillingDate = _calculateNextBillingDate(
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
billingCycle);
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
billingCycle,
);
// 주말/공휴일 보정
adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
return SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
@@ -155,7 +253,9 @@ class SmsScanner {
}
}
return DateTime(year, month, billingDate.day);
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = billingDate.day.clamp(1, dim);
return DateTime(year, month, day);
} else if (billingCycle == 'yearly') {
// 올해의 결제일이 지났는지 확인
final thisYearBilling =
@@ -204,8 +304,6 @@ class SmsScanner {
return serviceUrls[serviceName];
}
// 메시지에서 통화 단위를 감지하는 함수
String _detectCurrency(String message) {
final dollarKeywords = [
@@ -226,7 +324,7 @@ class SmsScanner {
// 서비스명 기반 통화 단위 확인
for (final service in serviceCurrencyMap.keys) {
if (message.contains(service)) {
print('_detectCurrency: ${service} USD 서비스로 판별됨');
Log.d('_detectCurrency: $service USD 서비스로 판별됨');
return 'USD';
}
}
@@ -234,7 +332,7 @@ class SmsScanner {
// 메시지에 달러 관련 키워드가 있는지 확인
for (final keyword in dollarKeywords) {
if (message.toLowerCase().contains(keyword.toLowerCase())) {
print('_detectCurrency: USD 키워드 발견: $keyword');
Log.d('_detectCurrency: USD 키워드 발견: $keyword');
return 'USD';
}
}
@@ -243,3 +341,148 @@ class SmsScanner {
return 'KRW';
}
}
// ===== Isolate 오프로딩용 Top-level 파서 =====
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
List<Map<String, dynamic>> _parseRawSmsBatch(
List<Map<String, dynamic>> messages) {
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
const subscriptionKeywords = [
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'subscription',
'payment',
'billing',
'charge',
'넷플릭스',
'Netflix',
'유튜브',
'YouTube',
'Spotify',
'멜론',
'웨이브',
'Disney+',
'디즈니플러스',
'Apple',
'Microsoft',
'GitHub',
'Adobe',
'Amazon'
];
final amountPatterns = <RegExp>[
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
];
final results = <Map<String, dynamic>>[];
for (final m in messages) {
final body = (m['body'] as String?) ?? '';
final sender = (m['address'] as String?) ?? '';
final dateMillis =
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
final lowerBody = body.toLowerCase();
final lowerSender = sender.toLowerCase();
final isSubscription = subscriptionKeywords.any((k) =>
lowerBody.contains(k.toLowerCase()) ||
lowerSender.contains(k.toLowerCase()));
if (!isSubscription) continue;
final serviceName = _isoExtractServiceName(body, sender);
final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0;
final billingCycle = _isoExtractBillingCycle(body);
final nextBillingDate =
_isoCalculateNextBillingFromDate(date, billingCycle);
results.add({
'serviceName': serviceName,
'monthlyCost': amount,
'billingCycle': billingCycle,
'message': body,
'nextBillingDate': nextBillingDate.toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
});
}
return results;
}
String _isoExtractServiceName(String body, String sender) {
final servicePatterns = {
'netflix': '넷플릭스',
'youtube': '유튜브 프리미엄',
'spotify': 'Spotify',
'disney': '디즈니플러스',
'apple': 'Apple',
'microsoft': 'Microsoft',
'github': 'GitHub',
'adobe': 'Adobe',
'멜론': '멜론',
'웨이브': '웨이브',
};
final combined = '$body $sender'.toLowerCase();
for (final e in servicePatterns.entries) {
if (combined.contains(e.key)) return e.value;
}
return _isoExtractServiceNameFromSender(sender);
}
String _isoExtractServiceNameFromSender(String sender) {
if (RegExp(r'^\d+$').hasMatch(sender)) {
return '알 수 없는 서비스';
}
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
}
double? _isoExtractAmount(String body, List<RegExp> patterns) {
for (final pattern in patterns) {
final match = pattern.firstMatch(body);
if (match != null) {
var amountStr = match.group(1) ?? '';
amountStr = amountStr.replaceAll(',', '');
final parsed = double.tryParse(amountStr);
if (parsed != null) return parsed;
}
}
return null;
}
String _isoExtractBillingCycle(String body) {
if (body.contains('') ||
body.toLowerCase().contains('monthly') ||
body.contains('매월')) {
return 'monthly';
} else if (body.contains('') ||
body.toLowerCase().contains('yearly') ||
body.toLowerCase().contains('annual')) {
return 'yearly';
} else if (body.contains('') || body.toLowerCase().contains('weekly')) {
return 'weekly';
}
return 'monthly';
}
DateTime _isoCalculateNextBillingFromDate(
DateTime lastDate, String billingCycle) {
switch (billingCycle) {
case 'monthly':
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
case 'yearly':
return DateTime(lastDate.year + 1, lastDate.month, lastDate.day);
case 'weekly':
return lastDate.add(const Duration(days: 7));
default:
return lastDate.add(const Duration(days: 30));
}
}

View File

@@ -1,20 +1,35 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/platform_helper.dart';
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;
// 웹이나 iOS에서는 SMS 권한 불필요
if (kIsWeb || PlatformHelper.isIOS) return true;
// Android에서만 권한 요청
if (PlatformHelper.isAndroid) {
final status = await permission.Permission.sms.request();
return status.isGranted;
}
return false;
}
static Future<bool> hasSMSPermission() async {
if (kIsWeb) return false;
final status = await permission.Permission.sms.status;
return status.isGranted;
// 웹이나 iOS에서는 항상 true 반환 (권한 불필요)
if (kIsWeb || PlatformHelper.isIOS) return true;
// Android에서만 실제 권한 확인
if (PlatformHelper.isAndroid) {
final status = await permission.Permission.sms.status;
return status.isGranted;
}
return false;
}
static Future<List<Map<String, dynamic>>> scanSubscriptions() async {

View File

@@ -17,39 +17,40 @@ class SubscriptionUrlMatcher {
static CancellationUrlService? _cancellationService;
static ServiceNameResolver? _nameResolver;
static SmsExtractorService? _smsExtractor;
/// 서비스 초기화
static Future<void> initialize() async {
if (_dataRepository != null && _dataRepository!.isInitialized) return;
// 1. 데이터 저장소 초기화
_dataRepository = ServiceDataRepository();
await _dataRepository!.initialize();
// 2. 서비스 초기화
_categoryMapper = CategoryMapperService(_dataRepository!);
_urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!);
_cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!);
_cancellationService =
CancellationUrlService(_dataRepository!, _urlMatcher!);
_nameResolver = ServiceNameResolver(_dataRepository!);
_smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!);
}
/// 도메인 추출 (www와 TLD 제외)
static String? extractDomain(String url) {
return _urlMatcher?.extractDomain(url);
}
/// URL로 서비스 찾기
static Future<ServiceInfo?> findServiceByUrl(String url) async {
await initialize();
return _urlMatcher?.findServiceByUrl(url);
}
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
static String? suggestUrl(String serviceName) {
return _urlMatcher?.suggestUrl(serviceName);
}
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
static Future<String?> findCancellationUrl({
String? serviceName,
@@ -63,19 +64,20 @@ class SubscriptionUrlMatcher {
locale: locale,
);
}
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
await initialize();
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false;
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ??
false;
}
/// 서비스명으로 카테고리 찾기
static Future<String?> findCategoryByServiceName(String serviceName) async {
await initialize();
return _categoryMapper?.findCategoryByServiceName(serviceName);
}
/// 현재 로케일에 따라 서비스 표시명 가져오기
static Future<String> getServiceDisplayName({
required String serviceName,
@@ -83,17 +85,18 @@ class SubscriptionUrlMatcher {
}) async {
await initialize();
return await _nameResolver?.getServiceDisplayName(
serviceName: serviceName,
locale: locale,
) ?? serviceName;
serviceName: serviceName,
locale: locale,
) ??
serviceName;
}
/// SMS에서 URL과 서비스 정보 추출
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
await initialize();
return _smsExtractor?.extractServiceFromSms(smsText);
}
/// URL이 알려진 서비스 URL인지 확인
static Future<bool> isKnownServiceUrl(String url) async {
await initialize();
@@ -104,4 +107,4 @@ class SubscriptionUrlMatcher {
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch);
}
}
}

View File

@@ -1,941 +0,0 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
/// 서비스 정보를 담는 데이터 클래스
class ServiceInfo {
final String serviceId;
final String serviceName;
final String? serviceUrl;
final String? cancellationUrl;
final String categoryId;
final String categoryNameKr;
final String categoryNameEn;
ServiceInfo({
required this.serviceId,
required this.serviceName,
this.serviceUrl,
this.cancellationUrl,
required this.categoryId,
required this.categoryNameKr,
required this.categoryNameEn,
});
}
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스
class SubscriptionUrlMatcher {
static Map<String, dynamic>? _servicesData;
static bool _isInitialized = false;
// 레거시 데이터 (JSON 로드 실패시 폴백)
// 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',
};
// 저장 (클라우드/파일) 서비스
static final Map<String, String> storageServices = {
'google drive': 'https://www.google.com/drive/',
'구글 드라이브': 'https://www.google.com/drive/',
'dropbox': 'https://www.dropbox.com',
'드롭박스': 'https://www.dropbox.com',
'onedrive': 'https://www.onedrive.com',
'원드라이브': 'https://www.onedrive.com',
'icloud': 'https://www.icloud.com',
'아이클라우드': 'https://www.icloud.com',
'box': 'https://www.box.com',
'박스': 'https://www.box.com',
'pcloud': 'https://www.pcloud.com',
'mega': 'https://mega.nz',
'메가': 'https://mega.nz',
'naver mybox': 'https://mybox.naver.com',
'네이버 마이박스': 'https://mybox.naver.com',
};
// 통신 · 인터넷 · TV 서비스
static final Map<String, String> telecomServices = {
'skt': 'https://www.sktelecom.com',
'sk텔레콤': 'https://www.sktelecom.com',
'kt': 'https://www.kt.com',
'lgu+': 'https://www.lguplus.com',
'lg유플러스': 'https://www.lguplus.com',
'olleh tv': 'https://www.kt.com/olleh_tv',
'올레 tv': 'https://www.kt.com/olleh_tv',
'b tv': 'https://www.skbroadband.com',
'비티비': 'https://www.skbroadband.com',
'u+모바일tv': 'https://www.lguplus.com',
'유플러스모바일tv': 'https://www.lguplus.com',
};
// 생활/라이프스타일 서비스
static final Map<String, String> lifestyleServices = {
'네이버 플러스': 'https://plus.naver.com',
'naver plus': 'https://plus.naver.com',
'카카오 구독': 'https://subscribe.kakao.com',
'kakao subscribe': 'https://subscribe.kakao.com',
'쿠팡 와우': 'https://www.coupang.com/np/coupangplus',
'coupang wow': 'https://www.coupang.com/np/coupangplus',
'스타벅스 버디': 'https://www.starbucks.co.kr',
'starbucks buddy': 'https://www.starbucks.co.kr',
'cu 구독': 'https://cu.bgfretail.com',
'gs25 구독': 'https://gs25.gsretail.com',
'현대차 구독': 'https://www.hyundai.com/kr/ko/eco/vehicle-subscription',
'lg전자 구독': 'https://www.lge.co.kr',
'삼성전자 구독': 'https://www.samsung.com/sec',
'다이슨 케어': 'https://www.dyson.co.kr',
'dyson care': 'https://www.dyson.co.kr',
'마켓컬리': 'https://www.kurly.com',
'kurly': 'https://www.kurly.com',
'헬로네이처': 'https://www.hellonature.com',
'hello nature': 'https://www.hellonature.com',
'이마트 트레이더스': 'https://www.emarttraders.co.kr',
'홈플러스': 'https://www.homeplus.co.kr',
'hellofresh': 'https://www.hellofresh.com',
'헬로프레시': 'https://www.hellofresh.com',
'bespoke post': 'https://www.bespokepost.com',
};
// 쇼핑/이커머스 서비스
static final Map<String, String> shoppingServices = {
'amazon prime': 'https://www.amazon.com/prime',
'아마존 프라임': 'https://www.amazon.com/prime',
'walmart+': 'https://www.walmart.com/plus',
'월마트플러스': 'https://www.walmart.com/plus',
'chewy': 'https://www.chewy.com',
'츄이': 'https://www.chewy.com',
'dollar shave club': 'https://www.dollarshaveclub.com',
'달러셰이브클럽': 'https://www.dollarshaveclub.com',
'instacart': 'https://www.instacart.com',
'인스타카트': 'https://www.instacart.com',
'shipt': 'https://www.shipt.com',
'십트': 'https://www.shipt.com',
'grove': 'https://grove.co',
'그로브': 'https://grove.co',
'cratejoy': 'https://www.cratejoy.com',
'shopify': 'https://www.shopify.com',
'쇼피파이': 'https://www.shopify.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,
...storageServices,
...aiServices,
...programmingServices,
...officeTools,
...lifestyleServices,
...shoppingServices,
...telecomServices,
...otherServices,
};
/// JSON 데이터 초기화
static Future<void> initialize() async {
if (_isInitialized) return;
try {
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
_servicesData = json.decode(jsonString);
_isInitialized = true;
print('SubscriptionUrlMatcher: JSON 데이터 로드 완료');
} catch (e) {
print('SubscriptionUrlMatcher: JSON 로드 실패 - $e');
// 로드 실패시 기존 하드코딩 데이터 사용
_isInitialized = true;
}
}
/// 도메인 추출 (www와 TLD 제외)
static String? extractDomain(String url) {
try {
final uri = Uri.parse(url);
final host = uri.host.toLowerCase();
// 도메인 부분 추출
var parts = host.split('.');
// www 제거
if (parts.isNotEmpty && parts[0] == 'www') {
parts = parts.sublist(1);
}
// 서브도메인 처리 (예: music.youtube.com)
if (parts.length >= 3) {
// 서브도메인 포함 전체 도메인 반환
return parts.sublist(0, parts.length - 1).join('.');
} else if (parts.length >= 2) {
// 메인 도메인만 반환
return parts[0];
}
return null;
} catch (e) {
print('SubscriptionUrlMatcher: 도메인 추출 실패 - $e');
return null;
}
}
/// URL로 서비스 찾기
static Future<ServiceInfo?> findServiceByUrl(String url) async {
await initialize();
final domain = extractDomain(url);
if (domain == null) return null;
// JSON 데이터가 있으면 JSON에서 찾기
if (_servicesData != null) {
final categories = _servicesData!['categories'] as Map<String, dynamic>;
for (final categoryEntry in categories.entries) {
final categoryId = categoryEntry.key;
final categoryData = categoryEntry.value as Map<String, dynamic>;
final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceEntry in services.entries) {
final serviceId = serviceEntry.key;
final serviceData = serviceEntry.value as Map<String, dynamic>;
final domains = List<String>.from(serviceData['domains'] ?? []);
// 도메인이 일치하는지 확인
for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
final names = List<String>.from(serviceData['names'] ?? []);
final urls = serviceData['urls'] as Map<String, dynamic>?;
return ServiceInfo(
serviceId: serviceId,
serviceName: names.isNotEmpty ? names[0] : serviceId,
serviceUrl: urls?['kr'] ?? urls?['en'],
cancellationUrl: null,
categoryId: _getCategoryIdByKey(categoryId),
categoryNameKr: categoryData['nameKr'] ?? '',
categoryNameEn: categoryData['nameEn'] ?? '',
);
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
for (final entry in allServices.entries) {
final serviceUrl = entry.value;
final serviceDomain = extractDomain(serviceUrl);
if (serviceDomain != null &&
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
return ServiceInfo(
serviceId: entry.key,
serviceName: entry.key,
serviceUrl: serviceUrl,
cancellationUrl: null,
categoryId: _getCategoryForLegacyService(entry.key),
categoryNameKr: '',
categoryNameEn: '',
);
}
}
return null;
}
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
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 찾기 (개선된 버전)
static Future<String?> findCancellationUrl({
String? serviceName,
String? websiteUrl,
String locale = 'kr',
}) async {
await initialize();
// JSON 데이터가 있으면 JSON에서 찾기
if (_servicesData != null) {
final categories = _servicesData!['categories'] as Map<String, dynamic>;
// 1. 서비스명으로 찾기
if (serviceName != null && serviceName.isNotEmpty) {
final lowerName = serviceName.toLowerCase().trim();
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) {
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
}
}
}
}
}
}
// 2. URL로 찾기
if (websiteUrl != null && websiteUrl.isNotEmpty) {
final domain = extractDomain(websiteUrl);
if (domain != null) {
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) {
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
}
}
}
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
}
/// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시)
static String? _findCancellationUrlLegacy(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;
}
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
// 새로운 JSON 기반 방식으로 확인
final cancellationUrl = await findCancellationUrl(
serviceName: serviceNameOrUrl,
websiteUrl: serviceNameOrUrl,
);
return cancellationUrl != null;
}
/// 서비스명으로 카테고리 찾기
static Future<String?> findCategoryByServiceName(String serviceName) async {
await initialize();
if (serviceName.isEmpty) return null;
final lowerName = serviceName.toLowerCase().trim();
// JSON 데이터가 있으면 JSON에서 찾기
if (_servicesData != null) {
final categories = _servicesData!['categories'] as Map<String, dynamic>;
for (final categoryEntry in categories.entries) {
final categoryId = categoryEntry.key;
final categoryData = categoryEntry.value as Map<String, dynamic>;
final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
return _getCategoryIdByKey(categoryId);
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
return _getCategoryForLegacyService(serviceName);
}
/// 현재 로케일에 따라 서비스 표시명 가져오기
static Future<String> getServiceDisplayName({
required String serviceName,
required String locale,
}) async {
await initialize();
if (_servicesData == null) {
return serviceName;
}
final lowerName = serviceName.toLowerCase().trim();
final categories = _servicesData!['categories'] as Map<String, dynamic>;
// JSON에서 서비스 찾기
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final data = serviceData as Map<String, dynamic>;
final names = List<String>.from(data['names'] ?? []);
// names 배열에 있는지 확인
for (final name in names) {
if (lowerName == name.toLowerCase() ||
lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
// 로케일에 따라 적절한 이름 반환
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
} else {
return data['nameEn'] ?? serviceName;
}
}
}
// nameKr/nameEn에 직접 매칭 확인
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
if (lowerName == nameKr || lowerName == nameEn) {
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
} else {
return data['nameEn'] ?? serviceName;
}
}
}
}
// 찾지 못한 경우 원래 이름 반환
return serviceName;
}
/// 카테고리 키를 실제 카테고리 ID로 매핑
static String _getCategoryIdByKey(String key) {
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
// 임시로 카테고리명 기반 매핑
switch (key) {
case 'music':
return 'music_streaming';
case 'ott':
return 'ott_services';
case 'storage':
return 'cloud_storage';
case 'ai':
return 'ai_services';
case 'programming':
return 'dev_tools';
case 'office':
return 'office_tools';
case 'lifestyle':
return 'lifestyle';
case 'shopping':
return 'shopping';
case 'gaming':
return 'gaming';
case 'telecom':
return 'telecom';
default:
return 'other';
}
}
/// 레거시 서비스명으로 카테고리 추측
static String _getCategoryForLegacyService(String serviceName) {
final lowerName = serviceName.toLowerCase();
if (ottServices.containsKey(lowerName)) return 'ott_services';
if (musicServices.containsKey(lowerName)) return 'music_streaming';
if (storageServices.containsKey(lowerName)) return 'cloud_storage';
if (aiServices.containsKey(lowerName)) return 'ai_services';
if (programmingServices.containsKey(lowerName)) return 'dev_tools';
if (officeTools.containsKey(lowerName)) return 'office_tools';
if (lifestyleServices.containsKey(lowerName)) return 'lifestyle';
if (shoppingServices.containsKey(lowerName)) return 'shopping';
if (telecomServices.containsKey(lowerName)) return 'telecom';
return 'other';
}
/// SMS에서 URL과 서비스 정보 추출
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
await initialize();
// URL 패턴 찾기
final urlPattern = RegExp(
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
caseSensitive: false,
);
final matches = urlPattern.allMatches(smsText);
for (final match in matches) {
final url = match.group(0);
if (url != null) {
final serviceInfo = await findServiceByUrl(url);
if (serviceInfo != null) {
return serviceInfo;
}
}
}
// URL로 못 찾았으면 서비스명으로 시도
final lowerSms = smsText.toLowerCase();
// 모든 서비스명 검사
for (final entry in allServices.entries) {
if (lowerSms.contains(entry.key.toLowerCase())) {
final categoryId = await findCategoryByServiceName(entry.key) ?? 'other';
return ServiceInfo(
serviceId: entry.key,
serviceName: entry.key,
serviceUrl: entry.value,
cancellationUrl: null,
categoryId: categoryId,
categoryNameKr: '',
categoryNameEn: '',
);
}
}
return null;
}
/// URL이 알려진 서비스 URL인지 확인
static Future<bool> isKnownServiceUrl(String url) async {
final serviceInfo = await findServiceByUrl(url);
return serviceInfo != null;
}
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성)
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;
}
}

View File

@@ -336,22 +336,22 @@ class LegacyServiceData {
// 모든 서비스 매핑을 합친 맵
static Map<String, String> get allServices => {
...ottServices,
...musicServices,
...storageServices,
...aiServices,
...programmingServices,
...officeTools,
...lifestyleServices,
...shoppingServices,
...telecomServices,
...otherServices,
};
...ottServices,
...musicServices,
...storageServices,
...aiServices,
...programmingServices,
...officeTools,
...lifestyleServices,
...shoppingServices,
...telecomServices,
...otherServices,
};
/// 서비스 카테고리 찾기
static String? getCategoryForService(String serviceName) {
final lowerName = serviceName.toLowerCase();
if (ottServices.containsKey(lowerName)) return 'ott';
if (musicServices.containsKey(lowerName)) return 'music';
if (storageServices.containsKey(lowerName)) return 'storage';
@@ -362,7 +362,7 @@ class LegacyServiceData {
if (shoppingServices.containsKey(lowerName)) return 'shopping';
if (telecomServices.containsKey(lowerName)) return 'telecom';
if (otherServices.containsKey(lowerName)) return 'other';
return null;
}
}
}

View File

@@ -1,30 +1,32 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../../../utils/logger.dart';
/// 서비스 데이터를 관리하는 저장소 클래스
class ServiceDataRepository {
Map<String, dynamic>? _servicesData;
bool _isInitialized = false;
/// JSON 데이터 초기화
Future<void> initialize() async {
if (_isInitialized) return;
try {
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
final jsonString =
await rootBundle.loadString('assets/data/subscription_services.json');
_servicesData = json.decode(jsonString);
_isInitialized = true;
print('ServiceDataRepository: JSON 데이터 로드 완료');
Log.i('ServiceDataRepository: JSON 데이터 로드 완료');
} catch (e) {
print('ServiceDataRepository: JSON 로드 실패 - $e');
Log.w('ServiceDataRepository: JSON 로드 실패 - $e');
// 로드 실패시 기존 하드코딩 데이터 사용
_isInitialized = true;
}
}
/// 서비스 데이터 가져오기
Map<String, dynamic>? getServicesData() => _servicesData;
/// 초기화 여부 확인
bool get isInitialized => _isInitialized;
}
}

View File

@@ -7,7 +7,7 @@ class ServiceInfo {
final String categoryId;
final String categoryNameKr;
final String categoryNameEn;
ServiceInfo({
required this.serviceId,
required this.serviceName,
@@ -17,4 +17,4 @@ class ServiceInfo {
required this.categoryNameKr,
required this.categoryNameEn,
});
}
}

View File

@@ -6,9 +6,9 @@ import 'url_matcher_service.dart';
class CancellationUrlService {
final ServiceDataRepository _dataRepository;
final UrlMatcherService _urlMatcher;
CancellationUrlService(this._dataRepository, this._urlMatcher);
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
Future<String?> findCancellationUrl({
String? serviceName,
@@ -19,47 +19,55 @@ class CancellationUrlService {
final servicesData = _dataRepository.getServicesData();
if (servicesData != null) {
final categories = servicesData['categories'] as Map<String, dynamic>;
// 1. 서비스명으로 찾기
if (serviceName != null && serviceName.isNotEmpty) {
final lowerName = serviceName.toLowerCase().trim();
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
final services = (categoryData as Map<String, dynamic>)['services']
as Map<String, dynamic>;
for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
final names = List<String>.from(
(serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
final cancellationUrls =
serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) {
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
}
}
}
}
}
}
// 2. URL로 찾기
if (websiteUrl != null && websiteUrl.isNotEmpty) {
final domain = _urlMatcher.extractDomain(websiteUrl);
if (domain != null) {
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
final services = (categoryData as Map<String, dynamic>)['services']
as Map<String, dynamic>;
for (final serviceData in services.values) {
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
final domains = List<String>.from(
(serviceData as Map<String, dynamic>)['domains'] ?? []);
for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (domain.contains(serviceDomain) ||
serviceDomain.contains(domain)) {
final cancellationUrls =
serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) {
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
}
}
}
@@ -68,7 +76,7 @@ class CancellationUrlService {
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
}
@@ -126,4 +134,4 @@ class CancellationUrlService {
);
return cancellationUrl != null;
}
}
}

View File

@@ -4,41 +4,43 @@ import '../data/legacy_service_data.dart';
/// 카테고리 매핑 관련 기능을 제공하는 서비스 클래스
class CategoryMapperService {
final ServiceDataRepository _dataRepository;
CategoryMapperService(this._dataRepository);
/// 서비스명으로 카테고리 찾기
Future<String?> findCategoryByServiceName(String serviceName) async {
if (serviceName.isEmpty) return null;
final lowerName = serviceName.toLowerCase().trim();
// JSON 데이터가 있으면 JSON에서 찾기
final servicesData = _dataRepository.getServicesData();
if (servicesData != null) {
final categories = servicesData['categories'] as Map<String, dynamic>;
for (final categoryEntry in categories.entries) {
final categoryId = categoryEntry.key;
final categoryData = categoryEntry.value as Map<String, dynamic>;
final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
final names = List<String>.from(
(serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
if (lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
return getCategoryIdByKey(categoryId);
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
return getCategoryForLegacyService(serviceName);
}
/// 카테고리 키를 실제 카테고리 ID로 매핑
String getCategoryIdByKey(String key) {
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
@@ -68,21 +70,39 @@ class CategoryMapperService {
return 'other';
}
}
/// 레거시 서비스명으로 카테고리 추측
String getCategoryForLegacyService(String serviceName) {
final lowerName = serviceName.toLowerCase();
if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services';
if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming';
if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage';
if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services';
if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools';
if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools';
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle';
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping';
if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom';
if (LegacyServiceData.ottServices.containsKey(lowerName)) {
return 'ott_services';
}
if (LegacyServiceData.musicServices.containsKey(lowerName)) {
return 'music_streaming';
}
if (LegacyServiceData.storageServices.containsKey(lowerName)) {
return 'cloud_storage';
}
if (LegacyServiceData.aiServices.containsKey(lowerName)) {
return 'ai_services';
}
if (LegacyServiceData.programmingServices.containsKey(lowerName)) {
return 'dev_tools';
}
if (LegacyServiceData.officeTools.containsKey(lowerName)) {
return 'office_tools';
}
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) {
return 'lifestyle';
}
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) {
return 'shopping';
}
if (LegacyServiceData.telecomServices.containsKey(lowerName)) {
return 'telecom';
}
return 'other';
}
}
}

View File

@@ -3,9 +3,9 @@ import '../data/service_data_repository.dart';
/// 서비스명 관련 기능을 제공하는 서비스 클래스
class ServiceNameResolver {
final ServiceDataRepository _dataRepository;
ServiceNameResolver(this._dataRepository);
/// 현재 로케일에 따라 서비스 표시명 가져오기
Future<String> getServiceDisplayName({
required String serviceName,
@@ -15,22 +15,23 @@ class ServiceNameResolver {
if (servicesData == null) {
return serviceName;
}
final lowerName = serviceName.toLowerCase().trim();
final categories = servicesData['categories'] as Map<String, dynamic>;
// JSON에서 서비스 찾기
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
final services = (categoryData as Map<String, dynamic>)['services']
as Map<String, dynamic>;
for (final serviceData in services.values) {
final data = serviceData as Map<String, dynamic>;
final names = List<String>.from(data['names'] ?? []);
// names 배열에 있는지 확인
for (final name in names) {
if (lowerName == name.toLowerCase() ||
lowerName.contains(name.toLowerCase()) ||
if (lowerName == name.toLowerCase() ||
lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
// 로케일에 따라 적절한 이름 반환
if (locale == 'ko' || locale == 'kr') {
@@ -40,11 +41,11 @@ class ServiceNameResolver {
}
}
}
// nameKr/nameEn에 직접 매칭 확인
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
if (lowerName == nameKr || lowerName == nameEn) {
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
@@ -54,8 +55,8 @@ class ServiceNameResolver {
}
}
}
// 찾지 못한 경우 원래 이름 반환
return serviceName;
}
}
}

View File

@@ -7,9 +7,9 @@ import 'category_mapper_service.dart';
class SmsExtractorService {
final UrlMatcherService _urlMatcher;
final CategoryMapperService _categoryMapper;
SmsExtractorService(this._urlMatcher, this._categoryMapper);
/// SMS에서 URL과 서비스 정보 추출
Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
// URL 패턴 찾기
@@ -17,9 +17,9 @@ class SmsExtractorService {
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
caseSensitive: false,
);
final matches = urlPattern.allMatches(smsText);
for (final match in matches) {
final url = match.group(0);
if (url != null) {
@@ -29,15 +29,17 @@ class SmsExtractorService {
}
}
}
// URL로 못 찾았으면 서비스명으로 시도
final lowerSms = smsText.toLowerCase();
// 모든 서비스명 검사
for (final entry in LegacyServiceData.allServices.entries) {
if (lowerSms.contains(entry.key.toLowerCase())) {
final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other';
final categoryId =
await _categoryMapper.findCategoryByServiceName(entry.key) ??
'other';
return ServiceInfo(
serviceId: entry.key,
serviceName: entry.key,
@@ -49,7 +51,7 @@ class SmsExtractorService {
);
}
}
return null;
}
}
}

View File

@@ -2,28 +2,29 @@ import '../models/service_info.dart';
import '../data/service_data_repository.dart';
import '../data/legacy_service_data.dart';
import 'category_mapper_service.dart';
import '../../../utils/logger.dart';
/// URL 매칭 관련 기능을 제공하는 서비스 클래스
class UrlMatcherService {
final ServiceDataRepository _dataRepository;
final CategoryMapperService _categoryMapper;
UrlMatcherService(this._dataRepository, this._categoryMapper);
/// 도메인 추출 (www와 TLD 제외)
String? extractDomain(String url) {
try {
final uri = Uri.parse(url);
final host = uri.host.toLowerCase();
// 도메인 부분 추출
var parts = host.split('.');
// www 제거
if (parts.isNotEmpty && parts[0] == 'www') {
parts = parts.sublist(1);
}
// 서브도메인 처리 (예: music.youtube.com)
if (parts.length >= 3) {
// 서브도메인 포함 전체 도메인 반환
@@ -32,40 +33,41 @@ class UrlMatcherService {
// 메인 도메인만 반환
return parts[0];
}
return null;
} catch (e) {
print('UrlMatcherService: 도메인 추출 실패 - $e');
Log.e('UrlMatcherService: 도메인 추출 실패', e);
return null;
}
}
/// URL로 서비스 찾기
Future<ServiceInfo?> findServiceByUrl(String url) async {
final domain = extractDomain(url);
if (domain == null) return null;
// JSON 데이터가 있으면 JSON에서 찾기
final servicesData = _dataRepository.getServicesData();
if (servicesData != null) {
final categories = servicesData['categories'] as Map<String, dynamic>;
for (final categoryEntry in categories.entries) {
final categoryId = categoryEntry.key;
final categoryData = categoryEntry.value as Map<String, dynamic>;
final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceEntry in services.entries) {
final serviceId = serviceEntry.key;
final serviceData = serviceEntry.value as Map<String, dynamic>;
final domains = List<String>.from(serviceData['domains'] ?? []);
// 도메인이 일치하는지 확인
for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
if (domain.contains(serviceDomain) ||
serviceDomain.contains(domain)) {
final names = List<String>.from(serviceData['names'] ?? []);
final urls = serviceData['urls'] as Map<String, dynamic>?;
return ServiceInfo(
serviceId: serviceId,
serviceName: names.isNotEmpty ? names[0] : serviceId,
@@ -80,13 +82,13 @@ class UrlMatcherService {
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
for (final entry in LegacyServiceData.allServices.entries) {
final serviceUrl = entry.value;
final serviceDomain = extractDomain(serviceUrl);
if (serviceDomain != null &&
if (serviceDomain != null &&
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
return ServiceInfo(
serviceId: entry.key,
@@ -99,14 +101,14 @@ class UrlMatcherService {
);
}
}
return null;
}
/// 서비스명으로 URL 찾기
String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) {
print('UrlMatcherService: 빈 serviceName');
Log.w('UrlMatcherService: 빈 serviceName');
return null;
}
@@ -117,7 +119,7 @@ class UrlMatcherService {
// 정확한 매칭을 먼저 시도
for (final entry in LegacyServiceData.allServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}');
Log.d('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
@@ -125,7 +127,7 @@ class UrlMatcherService {
// OTT 서비스 검사
for (final entry in LegacyServiceData.ottServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
Log.d('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
@@ -133,7 +135,7 @@ class UrlMatcherService {
// 음악 서비스 검사
for (final entry in LegacyServiceData.musicServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
Log.d('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
@@ -141,7 +143,7 @@ class UrlMatcherService {
// AI 서비스 검사
for (final entry in LegacyServiceData.aiServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}');
Log.d('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
@@ -149,7 +151,7 @@ class UrlMatcherService {
// 프로그래밍 서비스 검사
for (final entry in LegacyServiceData.programmingServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}');
Log.d('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
@@ -157,7 +159,7 @@ class UrlMatcherService {
// 오피스 툴 검사
for (final entry in LegacyServiceData.officeTools.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
Log.d('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
@@ -165,7 +167,7 @@ class UrlMatcherService {
// 기타 서비스 검사
for (final entry in LegacyServiceData.otherServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
Log.d('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
@@ -174,19 +176,19 @@ class UrlMatcherService {
for (final entry in LegacyServiceData.allServices.entries) {
final key = entry.key.toLowerCase();
if (key.contains(lowerName) || lowerName.contains(key)) {
print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
Log.d('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
print('UrlMatcherService: 매칭 실패 - $lowerName');
Log.d('UrlMatcherService: 매칭 실패 - $lowerName');
return null;
} catch (e) {
print('UrlMatcherService: suggestUrl 에러 - $e');
Log.e('UrlMatcherService: suggestUrl 에러', e);
return null;
}
}
/// URL이 알려진 서비스 URL인지 확인
Future<bool> isKnownServiceUrl(String url) async {
final serviceInfo = await findServiceByUrl(url);
@@ -232,4 +234,4 @@ class UrlMatcherService {
return null;
}
}
}

View File

@@ -1,2 +1,2 @@
/// URL Matcher 패키지의 export 파일
export 'models/service_info.dart';
// URL Matcher 패키지의 export 파일
export 'models/service_info.dart';

View File

@@ -210,6 +210,7 @@ class TestSmsData {
}
}
// ignore: avoid_print
print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}');
return resultData;
}
@@ -233,7 +234,7 @@ class TestSmsData {
];
// Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제)
final microsoftMonthlyCost = 12800.0 / 12;
const microsoftMonthlyCost = 12800.0 / 12;
// 최근 6개월 데이터 생성
for (int i = 0; i < 6; i++) {

View File

@@ -7,155 +7,155 @@ import 'app_theme.dart';
class AdaptiveTheme {
/// 라이트 테마
static ThemeData get lightTheme => AppTheme.lightTheme;
/// 다크 테마
static ThemeData get darkTheme {
const scheme = ColorScheme.dark(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.errorColor,
surface: Color(0xFF1E1E1E),
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: const ColorScheme.dark(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.dangerColor,
background: const Color(0xFF121212),
surface: const Color(0xFF1E1E1E),
),
colorScheme: scheme,
scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: CardTheme(
color: const Color(0xFF1E1E1E),
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.3),
cardTheme: CardThemeData(
color: scheme.surface,
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5),
side: BorderSide(
color: const Color(0xFFFFFFFF).withValues(alpha: 0.08),
width: 1,
),
),
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFF1E1E1E),
foregroundColor: Colors.white,
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
iconTheme: IconThemeData(
color: Colors.white.withValues(alpha: 0.9),
size: 24,
),
// title/icon colors inherit from foregroundColor
),
textTheme: TextTheme(
headlineLarge: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
titleLarge: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.4,
),
bodyLarge: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
),
textTheme: ThemeData.dark(useMaterial3: true)
.textTheme
.copyWith(
headlineLarge: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
titleLarge: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
),
bodyLarge: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
labelLarge: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
),
labelMedium: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
)
.apply(bodyColor: scheme.onSurface, displayColor: scheme.onSurface),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF2A2A2A),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
fillColor: scheme.surface,
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: Colors.white.withValues(alpha: 0.1), width: 1),
borderSide: BorderSide(color: scheme.outline, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5),
borderSide: BorderSide(color: scheme.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
borderSide: BorderSide(color: scheme.error, width: 1),
),
labelStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
color: scheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w500,
),
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
color: scheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
@@ -164,35 +164,87 @@ class AdaptiveTheme {
elevation: 0,
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary.withValues(alpha: 0.5);
}
return scheme.surfaceContainerHighest.withValues(alpha: 0.5);
}),
),
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return Colors.transparent;
}),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: BorderSide(color: scheme.outline, width: 1.5),
),
radioTheme: RadioThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
),
sliderTheme: SliderThemeData(
activeTrackColor: scheme.primary,
inactiveTrackColor: scheme.onSurfaceVariant,
thumbColor: scheme.primary,
overlayColor: scheme.primary.withValues(alpha: 0.5),
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
),
tabBarTheme: TabBarThemeData(
labelColor: scheme.primary,
unselectedLabelColor: scheme.onSurfaceVariant,
indicatorColor: scheme.primary,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
dividerTheme: DividerThemeData(
color: Colors.white.withValues(alpha: 0.1),
color: scheme.outline,
thickness: 1,
space: 16,
),
);
}
/// OLED 최적화 다크 테마
static ThemeData get oledTheme {
return darkTheme.copyWith(
final base = darkTheme;
const oledSurface = Color(0xFF0A0A0A);
return base.copyWith(
scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith(
background: Colors.black,
surface: const Color(0xFF0A0A0A),
),
cardTheme: darkTheme.cardTheme.copyWith(
color: const Color(0xFF0A0A0A),
),
appBarTheme: darkTheme.appBarTheme.copyWith(
backgroundColor: Colors.black,
),
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
fillColor: const Color(0xFF0A0A0A),
colorScheme: base.colorScheme.copyWith(surface: oledSurface),
cardTheme: base.cardTheme.copyWith(color: oledSurface),
appBarTheme: base.appBarTheme.copyWith(backgroundColor: Colors.black),
inputDecorationTheme: base.inputDecorationTheme.copyWith(
fillColor: oledSurface,
),
);
}
/// 고대비 테마
static ThemeData get highContrastTheme {
return ThemeData(
@@ -203,10 +255,8 @@ class AdaptiveTheme {
secondary: Colors.black87,
tertiary: Colors.black54,
error: Colors.red,
background: Colors.white,
surface: Colors.white,
),
textTheme: const TextTheme(
headlineLarge: TextStyle(
color: Colors.black,
@@ -234,15 +284,13 @@ class AdaptiveTheme {
fontWeight: FontWeight.w500,
),
),
cardTheme: CardTheme(
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: const BorderSide(color: Colors.black, width: 2),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
@@ -255,31 +303,28 @@ class AdaptiveTheme {
),
);
}
/// 시스템 테마에 따른 상태바 스타일 적용
static void applySystemUIOverlay(BuildContext context) {
final brightness = Theme.of(context).brightness;
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
statusBarBrightness: brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
systemNavigationBarColor: isOled
? Colors.black
: (brightness == Brightness.dark
? const Color(0xFF121212)
statusBarIconBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
statusBarBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isOled
? Colors.black
: (brightness == Brightness.dark
? const Color(0xFF121212)
: Colors.white),
systemNavigationBarIconBrightness: brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
systemNavigationBarIconBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
));
}
/// 접근성 설정에 따른 테마 조정
static ThemeData getAccessibleTheme(
ThemeData baseTheme, {
@@ -290,9 +335,9 @@ class AdaptiveTheme {
if (highContrast) {
return highContrastTheme;
}
ThemeData theme = baseTheme;
if (largeText) {
theme = theme.copyWith(
textTheme: theme.textTheme.apply(
@@ -300,7 +345,7 @@ class AdaptiveTheme {
),
);
}
if (reduceMotion) {
theme = theme.copyWith(
pageTransitionsTheme: const PageTransitionsTheme(
@@ -311,7 +356,7 @@ class AdaptiveTheme {
),
);
}
return theme;
}
}
@@ -331,7 +376,7 @@ class ThemeSettings {
final bool largeText;
final bool reduceMotion;
final bool highContrast;
const ThemeSettings({
this.mode = AppThemeMode.system,
this.useSystemColors = false,
@@ -339,7 +384,7 @@ class ThemeSettings {
this.reduceMotion = false,
this.highContrast = false,
});
ThemeSettings copyWith({
AppThemeMode? mode,
bool? useSystemColors,
@@ -355,15 +400,15 @@ class ThemeSettings {
highContrast: highContrast ?? this.highContrast,
);
}
Map<String, dynamic> toJson() => {
'mode': mode.name,
'useSystemColors': useSystemColors,
'largeText': largeText,
'reduceMotion': reduceMotion,
'highContrast': highContrast,
};
'mode': mode.name,
'useSystemColors': useSystemColors,
'largeText': largeText,
'reduceMotion': reduceMotion,
'highContrast': highContrast,
};
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings(
mode: AppThemeMode.values.firstWhere(
@@ -376,4 +421,4 @@ class ThemeSettings {
highContrast: json['highContrast'] ?? false,
);
}
}
}

View File

@@ -7,7 +7,8 @@ class AppColors {
static const successColor = Color(0xFF38BDF8); // 소프트 민트
static const infoColor = Color(0xFF6366F1); // 인디고
static const warningColor = Color(0xFFF59E0B); // 앰버
static const dangerColor = Color(0xFFF472B6); // 핑크 액센트
static const dangerColor = Color(0xFFF472B6); // 핑크 액센트 (액센트 용도)
static const errorColor = Color(0xFFEF4444); // 레드 (오류 용도)
// 배경색
static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100
@@ -27,31 +28,11 @@ class AppColors {
// 보더 & 디바이더
static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200
static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200
// 그림자 (color.md 가이드)
static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity
// 그라데이션 컬러 - 다양한 효과를 위한 조합
static const List<Color> blueGradient = [
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA) // 스카이 블루
];
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)
];
// (그라데이션 컬러 제거됨)
// Glassmorphism 효과를 위한 색상
static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity)
@@ -59,54 +40,16 @@ class AppColors {
static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity)
static const glassBorder = Color(0xFF2563EB); // 딥 블루 테두리
static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity)
// 다크 모드용 Glassmorphism 색상
static const glassSurfaceDark = Color(0x0F000000); // 매우 연한 검정 (6% opacity)
static const glassBackgroundDark = Color(0x1A000000); // 연한 검정 (10% opacity)
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity)
static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity)
// 백드롭 블러 효과를 위한 그라디언트
static const List<Color> glassGradient = [
Color(0x33FFFFFF), // 20% white
Color(0x1AFFFFFF), // 10% white
];
static const List<Color> glassGradientDark = [
Color(0x1A000000), // 10% black
Color(0x0F000000), // 6% black
];
// 메인 그라데이션
static const List<Color> mainGradient = [
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA), // 스카이 블루
Color(0xFFE0E7EF), // 라이트 그레이
];
static const List<Color> accentGradient = [
Color(0xFF38BDF8), // 소프트 민트
Color(0xFF60A5FA), // 스카이 블루
];
// 시간대별 배경 그라디언트
static const List<Color> morningGradient = [
Color(0xFFFED7AA), // 따뜻한 오렌지
Color(0xFFFBBF24), // 부드러운 노랑
];
static const List<Color> dayGradient = [
Color(0xFFDDEAFC), // 연한 하늘색
Color(0xFFBFDBFE), // 맑은 파랑
];
static const List<Color> eveningGradient = [
Color(0xFFFCA5A5), // 부드러운 핑크
Color(0xFFC084FC), // 연한 보라
];
static const List<Color> nightGradient = [
Color(0xFF4338CA), // 깊은 인디고
Color(0xFF1E1B4B), // 다크 네이비
];
// (백드롭 블러 그라데이션 제거됨)
// (메인/액센트 그라데이션 제거됨)
// (시간대별 배경 그라데이션 제거됨)
}

View File

@@ -2,355 +2,320 @@ import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppTheme {
static ThemeData lightTheme = ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.light(
static ThemeData lightTheme = (() {
// Color scheme for light theme
const scheme = ColorScheme.light(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.dangerColor,
background: AppColors.backgroundColor,
error: AppColors.errorColor,
surface: AppColors.surfaceColor,
),
);
// 기본 배경색
scaffoldBackgroundColor: AppColors.backgroundColor,
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
// 카드 스타일 - 글래스모피즘 효과
cardTheme: CardTheme(
color: AppColors.glassCard,
elevation: 0,
shadowColor: AppColors.shadowBlack,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.glassBorder, width: 1),
),
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
// 기본 배경색
scaffoldBackgroundColor: AppColors.backgroundColor,
// 앱바 스타일 - 글래스모피즘 디자인
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: false,
titleTextStyle: const TextStyle(
color: AppColors.textPrimary,
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
iconTheme: const IconThemeData(
color: AppColors.primaryColor,
size: 24,
),
),
// 타이포그래피 - Metronic Tailwind 스타일
textTheme: const TextTheme(
// 헤드라인 - 페이지 제목
headlineLarge: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
// 타이틀 - 카드, 섹션 제목
titleLarge: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.4,
),
// 본문 텍스트
bodyLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
// 라벨 텍스트
labelLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
),
labelMedium: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
),
// 입력 필드 스타일 - 글래스모피즘 디자인
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.glassBackground,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.textSecondary, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1.5),
),
labelStyle: const TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
hintStyle: const TextStyle(
color: AppColors.textMuted,
fontSize: 14,
fontWeight: FontWeight.w400,
),
errorStyle: const 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),
// 카드 스타일 - Material 3 표면 중심
cardTheme: CardThemeData(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
),
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
// 앱바 스타일 - 기본 M3 사용(투명 배경 유지)
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
textStyle: const TextStyle(
centerTitle: false,
),
// 타이포그래피 - Material 3 + onSurface 정렬
textTheme: ThemeData.light(useMaterial3: true)
.textTheme
.copyWith(
headlineLarge: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
titleLarge: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
),
bodyLarge: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
labelLarge: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
),
labelMedium: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
)
.apply(
// 본문/헤드라인 공통 색상은 onSurface로 적용
bodyColor: scheme.onSurface,
displayColor: scheme.onSurface,
),
// 입력 필드 스타일 - M3 surface/outline 기반
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surface,
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: scheme.outline, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.error, width: 1.5),
),
labelStyle: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w500,
),
hintStyle: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w400,
),
errorStyle: TextStyle(
color: scheme.error,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
// 버튼 스타일 - 프라이머리 버튼
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
),
// 텍스트 버튼 스타일
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: scheme.primary,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
// Text style inherits from theme.labelLarge
),
),
// 아웃라인 버튼 스타일
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.primary,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: scheme.outline, width: 1),
),
),
// FAB 스타일
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
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,
),
),
),
// 텍스트 버튼 스타일
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryColor,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// 스위치 스타일 (공통 테마)
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant; // OFF 썸을 명확하게
}),
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary.withValues(alpha: 0.5);
}
// OFF 트랙 대비 강화
return scheme.surfaceContainerHighest.withValues(alpha: 0.5);
}),
),
// 체크박스 스타일
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return Colors.transparent;
}),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(4),
),
textStyle: const TextStyle(
side: BorderSide(color: scheme.outline, width: 1.5),
),
// 라디오 버튼 스타일
radioTheme: RadioThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
),
// 슬라이더 스타일
sliderTheme: SliderThemeData(
activeTrackColor: scheme.primary,
inactiveTrackColor: scheme.onSurfaceVariant,
thumbColor: scheme.primary,
overlayColor: scheme.primary.withValues(alpha: 0.3),
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
),
// 탭바 스타일
tabBarTheme: TabBarThemeData(
labelColor: scheme.primary,
unselectedLabelColor: scheme.onSurfaceVariant,
indicatorColor: scheme.primary,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
),
// 아웃라인 버튼 스타일
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryColor,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: const BorderSide(color: AppColors.secondaryColor, width: 1),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
),
// FAB 스타일
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
// 디바이더 스타일
dividerTheme: DividerThemeData(
color: scheme.outline,
thickness: 1,
space: 16,
),
elevation: 2,
extendedPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
extendedTextStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
// 페이지 트랜지션
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
},
),
),
// 스위치 스타일
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.secondaryColor.withValues(alpha: 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),
// 스낵바 스타일 (기본 유지)
snackBarTheme: SnackBarThemeData(
backgroundColor: scheme.primary,
contentTextStyle: TextStyle(
color: scheme.onPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
behavior: SnackBarBehavior.floating,
),
side: const BorderSide(color: AppColors.secondaryColor, width: 1.5),
),
// 라디오 버튼 스타일
radioTheme: RadioThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.primaryColor;
}
return AppColors.textSecondary;
}),
),
// 슬라이더 스타일
sliderTheme: SliderThemeData(
activeTrackColor: AppColors.primaryColor,
inactiveTrackColor: AppColors.textSecondary,
thumbColor: AppColors.primaryColor,
overlayColor: AppColors.primaryColor.withValues(alpha: 0.3),
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
),
// 탭바 스타일
tabBarTheme: const TabBarTheme(
labelColor: AppColors.primaryColor,
unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.primaryColor,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
// 디바이더 스타일
dividerTheme: const 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: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
behavior: SnackBarBehavior.floating,
),
);
);
})();
}

View File

@@ -0,0 +1,8 @@
import 'package:flutter/material.dart';
extension AppColorRoles on ColorScheme {
// Semantic roles not present in ColorScheme by default
Color get success => const Color(0xFF22C55E); // green 600
Color get warning => const Color(0xFFF59E0B); // amber 600
Color get info => tertiary; // map info to tertiary
}

View File

@@ -0,0 +1,7 @@
class UIConstants {
static const double pageHorizontalPadding = 16;
static const double adVerticalPadding = 12;
static const double adCardHeight = 88;
static const double cardRadius = 16;
static const double cardOutlineAlpha = 0.5; // for outline color alpha
}

View File

@@ -32,17 +32,9 @@ class AnimationControllerHelper {
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();
}
});
waveController.repeat();
}
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드

View File

@@ -0,0 +1,103 @@
import 'business_day_util.dart';
/// 결제 주기 및 결제일 계산 유틸리티
class BillingDateUtil {
/// 결제 주기를 표준 키로 정규화합니다.
/// 반환값 예: 'monthly' | 'quarterly' | 'half-yearly' | 'yearly' | 'weekly'
static String normalizeCycle(String cycle) {
final c = cycle.trim().toLowerCase();
// 영어 우선 매핑
if (c.contains('monthly')) return 'monthly';
if (c.contains('quarter')) return 'quarterly';
if (c.contains('half') || c.contains('half-year')) return 'half-yearly';
if (c.contains('year')) return 'yearly';
if (c.contains('week')) return 'weekly';
// 한국어
if (cycle.contains('매월') || cycle.contains('월간')) return 'monthly';
if (cycle.contains('분기')) return 'quarterly';
if (cycle.contains('반기')) return 'half-yearly';
if (cycle.contains('매년') || cycle.contains('연간')) return 'yearly';
if (cycle.contains('주간')) return 'weekly';
// 일본어
if (cycle.contains('毎月')) return 'monthly';
if (cycle.contains('四半期')) return 'quarterly';
if (cycle.contains('半年')) return 'half-yearly';
if (cycle.contains('年間')) return 'yearly';
if (cycle.contains('週間')) return 'weekly';
// 중국어(간체/번체 공통 표현 대응)
if (cycle.contains('每月')) return 'monthly';
if (cycle.contains('每季度')) return 'quarterly';
if (cycle.contains('每半年')) return 'half-yearly';
if (cycle.contains('每年')) return 'yearly';
if (cycle.contains('每周') || cycle.contains('每週')) return 'weekly';
// 기본값
return 'monthly';
}
/// 선택된 날짜가 오늘(또는 과거)이면, 결제 주기에 맞춰 다음 회차 날짜로 보정합니다.
/// 이미 미래라면 해당 날짜를 그대로 반환합니다.
static DateTime ensureFutureDate(DateTime selected, String cycle) {
final normalized = normalizeCycle(cycle);
final selectedDateOnly =
DateTime(selected.year, selected.month, selected.day);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
if (selectedDateOnly.isAfter(today)) return selectedDateOnly;
DateTime next = selectedDateOnly;
switch (normalized) {
case 'weekly':
while (!next.isAfter(today)) {
next = next.add(const Duration(days: 7));
}
break;
case 'quarterly':
while (!next.isAfter(today)) {
next = _addMonthsClamped(next, 3);
}
break;
case 'half-yearly':
while (!next.isAfter(today)) {
next = _addMonthsClamped(next, 6);
}
break;
case 'yearly':
while (!next.isAfter(today)) {
next = _addYearsClamped(next, 1);
}
break;
case 'monthly':
default:
while (!next.isAfter(today)) {
next = _addMonthsClamped(next, 1);
}
break;
}
return next;
}
/// month 단위 가산 시, 대상 월의 말일을 넘지 않도록 day를 클램프합니다.
static DateTime _addMonthsClamped(DateTime base, int months) {
final totalMonths = base.month - 1 + months;
final year = base.year + totalMonths ~/ 12;
final month = totalMonths % 12 + 1;
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = base.day.clamp(1, dim);
return DateTime(year, month, day);
}
/// year 단위 가산 시, 대상 월의 말일을 넘지 않도록 day를 클램프합니다.
static DateTime _addYearsClamped(DateTime base, int years) {
final year = base.year + years;
final dim = BusinessDayUtil.daysInMonth(year, base.month);
final day = base.day.clamp(1, dim);
return DateTime(year, base.month, day);
}
}

View File

@@ -0,0 +1,39 @@
/// 영업일 계산 유틸리티
/// - 주말(토/일)과 일부 고정 공휴일을 제외하고 다음 영업일을 계산합니다.
/// - 음력 기반 공휴일(설/추석 등)은 포함하지 않습니다. 필요 시 외부 소스 연동을 고려하세요.
class BusinessDayUtil {
static bool isWeekend(DateTime date) =>
date.weekday == DateTime.saturday || date.weekday == DateTime.sunday;
/// 고정일 한국 공휴일(대체공휴일 미포함)
static const List<String> _fixedHolidays = [
'01-01', // 신정
'03-01', // 삼일절
'05-05', // 어린이날
'06-06', // 현충일
'08-15', // 광복절
'10-03', // 개천절
'10-09', // 한글날
'12-25', // 성탄절
];
static bool isFixedKoreanHoliday(DateTime date) {
final key = '${_two(date.month)}-${_two(date.day)}';
return _fixedHolidays.contains(key);
}
static String _two(int n) => n.toString().padLeft(2, '0');
/// 입력 날짜가 주말/고정 공휴일이면 다음 영업일로 전진합니다.
static DateTime nextBusinessDay(DateTime date) {
var d = DateTime(date.year, date.month, date.day);
while (isWeekend(d) || isFixedKoreanHoliday(d)) {
d = d.add(const Duration(days: 1));
}
return d;
}
/// 대상 월의 말일을 반환합니다.
static int daysInMonth(int year, int month) =>
DateTime(year, month + 1, 0).day;
}

View File

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

View File

@@ -4,42 +4,42 @@ import 'dart:io' show Platform;
/// 햅틱 피드백을 관리하는 헬퍼 클래스
class HapticFeedbackHelper {
static bool _isEnabled = true;
/// 햅틱 피드백 활성화 여부 설정
static void setEnabled(bool enabled) {
_isEnabled = enabled;
}
/// 가벼운 햅틱 피드백
static Future<void> lightImpact() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.lightImpact();
}
/// 중간 강도 햅틱 피드백
static Future<void> mediumImpact() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.mediumImpact();
}
/// 강한 햅틱 피드백
static Future<void> heavyImpact() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.heavyImpact();
}
/// 선택 햅틱 피드백 (iOS의 경우 Taptic Engine)
static Future<void> selectionClick() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.selectionClick();
}
/// 진동 패턴 (Android)
static Future<void> vibrate({int duration = 50}) async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.vibrate();
}
/// 성공 피드백 패턴
static Future<void> success() async {
if (!_isEnabled || !_isPlatformSupported()) return;
@@ -47,7 +47,7 @@ class HapticFeedbackHelper {
await Future.delayed(const Duration(milliseconds: 100));
await HapticFeedback.lightImpact();
}
/// 에러 피드백 패턴
static Future<void> error() async {
if (!_isEnabled || !_isPlatformSupported()) return;
@@ -55,13 +55,13 @@ class HapticFeedbackHelper {
await Future.delayed(const Duration(milliseconds: 100));
await HapticFeedback.heavyImpact();
}
/// 경고 피드백 패턴
static Future<void> warning() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.mediumImpact();
}
/// 플랫폼이 햅틱 피드백을 지원하는지 확인
static bool _isPlatformSupported() {
try {
@@ -71,4 +71,4 @@ class HapticFeedbackHelper {
return false;
}
}
}
}

27
lib/utils/logger.dart Normal file
View File

@@ -0,0 +1,27 @@
import 'package:flutter/foundation.dart';
/// 단순 로거 헬퍼
/// - 디버그/프로파일 모드에서만 상세 로그 출력
/// - 릴리스 모드에서는 중요한 경고/에러만 축약 출력
class Log {
static bool get _verbose => !kReleaseMode;
static void d(String message) {
if (_verbose) debugPrint(message);
}
static void i(String message) {
if (_verbose) debugPrint(' $message');
}
static void w(String message) {
// 경고는 릴리스에서도 간단히 남김
debugPrint('⚠️ $message');
}
static void e(String message, [Object? error, StackTrace? stack]) {
final suffix = error != null ? ' | $error' : '';
debugPrint('$message$suffix');
if (_verbose && stack != null) debugPrint(stack.toString());
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'logger.dart';
import 'dart:async';
/// 메모리 관리를 위한 헬퍼 클래스
@@ -7,19 +8,19 @@ class MemoryManager {
static final MemoryManager _instance = MemoryManager._internal();
factory MemoryManager() => _instance;
MemoryManager._internal();
// 캐시 관리
final Map<String, _CacheEntry> _cache = {};
final int _maxCacheSize = 100;
final Duration _defaultTTL = const Duration(minutes: 5);
// 이미지 캐시 관리
static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB
static const int maxImageCacheCount = 100;
// 위젯 참조 추적
final Map<String, WeakReference<State>> _widgetReferences = {};
/// 캐시에 데이터 저장
void cacheData<T>({
required String key,
@@ -27,86 +28,85 @@ class MemoryManager {
Duration? ttl,
}) {
_cleanupExpiredCache();
if (_cache.length >= _maxCacheSize) {
_evictOldestEntry();
}
_cache[key] = _CacheEntry(
data: data,
timestamp: DateTime.now(),
ttl: ttl ?? _defaultTTL,
);
}
/// 캐시에서 데이터 가져오기
T? getCachedData<T>(String key) {
final entry = _cache[key];
if (entry == null) return null;
if (entry.isExpired) {
_cache.remove(key);
return null;
}
entry.lastAccess = DateTime.now();
return entry.data as T?;
}
/// 캐시 비우기
void clearCache() {
_cache.clear();
if (kDebugMode) {
print('🧹 메모리 캐시가 비워졌습니다.');
Log.d('🧹 메모리 캐시가 비워졌습니다.');
}
}
/// 특정 패턴의 캐시 제거
void clearCacheByPattern(String pattern) {
final keysToRemove = _cache.keys
.where((key) => key.contains(pattern))
.toList();
final keysToRemove =
_cache.keys.where((key) => key.contains(pattern)).toList();
for (final key in keysToRemove) {
_cache.remove(key);
}
}
/// 만료된 캐시 정리
void _cleanupExpiredCache() {
final expiredKeys = _cache.entries
.where((entry) => entry.value.isExpired)
.map((entry) => entry.key)
.toList();
for (final key in expiredKeys) {
_cache.remove(key);
}
}
/// 가장 오래된 캐시 항목 제거
void _evictOldestEntry() {
if (_cache.isEmpty) return;
var oldestKey = _cache.keys.first;
var oldestTime = _cache[oldestKey]!.lastAccess;
for (final entry in _cache.entries) {
if (entry.value.lastAccess.isBefore(oldestTime)) {
oldestKey = entry.key;
oldestTime = entry.value.lastAccess;
}
}
_cache.remove(oldestKey);
}
/// 이미지 캐시 최적화
static void optimizeImageCache() {
PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount;
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
}
/// 이미지 캐시 상태 확인
static ImageCacheStatus getImageCacheStatus() {
final cache = PaintingBinding.instance.imageCache;
@@ -117,33 +117,31 @@ class MemoryManager {
maximumSizeBytes: cache.maximumSizeBytes,
);
}
/// 이미지 캐시 비우기
static void clearImageCache() {
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
if (kDebugMode) {
print('🖼️ 이미지 캐시가 비워졌습니다.');
Log.d('🖼️ 이미지 캐시가 비워졌습니다.');
}
}
/// 위젯 참조 추적
void trackWidget(String key, State widget) {
_widgetReferences[key] = WeakReference(widget);
}
/// 위젯 참조 제거
void untrackWidget(String key) {
_widgetReferences.remove(key);
}
/// 살아있는 위젯 수 확인
int getAliveWidgetCount() {
return _widgetReferences.values
.where((ref) => ref.target != null)
.length;
return _widgetReferences.values.where((ref) => ref.target != null).length;
}
/// 메모리 압박 시 대응
void handleMemoryPressure() {
// 캐시 50% 제거
@@ -151,43 +149,43 @@ class MemoryManager {
for (final key in keysToRemove) {
_cache.remove(key);
}
// 이미지 캐시 축소
final imageCache = PaintingBinding.instance.imageCache;
imageCache.maximumSize = maxImageCacheCount ~/ 2;
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
if (kDebugMode) {
print('⚠️ 메모리 압박 대응: 캐시 크기 감소');
Log.w('메모리 압박 대응: 캐시 크기 감소');
}
}
/// 자동 메모리 정리 시작
Timer? _cleanupTimer;
void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) {
_cleanupTimer?.cancel();
_cleanupTimer = Timer.periodic(interval, (_) {
_cleanupExpiredCache();
// 죽은 위젯 참조 제거
final deadKeys = _widgetReferences.entries
.where((entry) => entry.value.target == null)
.map((entry) => entry.key)
.toList();
for (final key in deadKeys) {
_widgetReferences.remove(key);
}
});
}
/// 자동 메모리 정리 중지
void stopAutoCleanup() {
_cleanupTimer?.cancel();
_cleanupTimer = null;
}
/// 메모리 사용량 리포트
Map<String, dynamic> getMemoryReport() {
return {
@@ -206,13 +204,13 @@ class _CacheEntry {
final DateTime timestamp;
final Duration ttl;
DateTime lastAccess;
_CacheEntry({
required this.data,
required this.timestamp,
required this.ttl,
}) : lastAccess = timestamp;
bool get isExpired => DateTime.now().difference(timestamp) > ttl;
}
@@ -222,25 +220,26 @@ class ImageCacheStatus {
final int maximumSize;
final int currentSizeBytes;
final int maximumSizeBytes;
ImageCacheStatus({
required this.currentSize,
required this.maximumSize,
required this.currentSizeBytes,
required this.maximumSizeBytes,
});
double get sizeUsagePercentage => (currentSize / maximumSize) * 100;
double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100;
double get bytesUsagePercentage =>
(currentSizeBytes / maximumSizeBytes) * 100;
Map<String, dynamic> toJson() => {
'currentSize': currentSize,
'maximumSize': maximumSize,
'currentSizeBytes': currentSizeBytes,
'maximumSizeBytes': maximumSizeBytes,
'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2),
'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2),
};
'currentSize': currentSize,
'maximumSize': maximumSize,
'currentSizeBytes': currentSizeBytes,
'maximumSizeBytes': maximumSizeBytes,
'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2),
'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2),
};
}
/// 메모리 효율적인 리스트 뷰
@@ -249,7 +248,7 @@ class MemoryEfficientListView<T> extends StatefulWidget {
final Widget Function(BuildContext, T) itemBuilder;
final int cacheExtent;
final ScrollPhysics? physics;
const MemoryEfficientListView({
super.key,
required this.items,
@@ -257,23 +256,21 @@ class MemoryEfficientListView<T> extends StatefulWidget {
this.cacheExtent = 250,
this.physics,
});
@override
State<MemoryEfficientListView<T>> createState() =>
State<MemoryEfficientListView<T>> createState() =>
_MemoryEfficientListViewState<T>();
}
class _MemoryEfficientListViewState<T>
extends State<MemoryEfficientListView<T>>
class _MemoryEfficientListViewState<T> extends State<MemoryEfficientListView<T>>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => false;
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
itemCount: widget.items.length,
cacheExtent: widget.cacheExtent.toDouble(),
@@ -283,4 +280,4 @@ class _MemoryEfficientListViewState<T>
},
);
}
}
}

View File

@@ -1,23 +1,25 @@
import 'package:flutter/foundation.dart';
import 'logger.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'dart:async';
/// 성능 최적화를 위한 유틸리티 클래스
class PerformanceOptimizer {
static final PerformanceOptimizer _instance = PerformanceOptimizer._internal();
static final PerformanceOptimizer _instance =
PerformanceOptimizer._internal();
factory PerformanceOptimizer() => _instance;
PerformanceOptimizer._internal();
// 프레임 타이밍 정보
final List<FrameTiming> _frameTimings = [];
bool _isMonitoring = false;
/// 프레임 성능 모니터링 시작
void startFrameMonitoring() {
if (_isMonitoring) return;
_isMonitoring = true;
SchedulerBinding.instance.addTimingsCallback((timings) {
_frameTimings.addAll(timings);
// 최근 100개 프레임만 유지
@@ -26,27 +28,27 @@ class PerformanceOptimizer {
}
});
}
/// 프레임 성능 모니터링 중지
void stopFrameMonitoring() {
if (!_isMonitoring) return;
_isMonitoring = false;
SchedulerBinding.instance.addTimingsCallback((_) {});
}
/// 평균 FPS 계산
double getAverageFPS() {
if (_frameTimings.isEmpty) return 0.0;
double totalDuration = 0;
for (final timing in _frameTimings) {
totalDuration += timing.totalSpan.inMicroseconds;
}
final averageDuration = totalDuration / _frameTimings.length;
return 1000000 / averageDuration; // microseconds to FPS
}
/// 메모리 사용량 모니터링
static Future<MemoryInfo> getMemoryInfo() async {
// Flutter에서는 직접적인 메모리 사용량 측정이 제한적이므로
@@ -57,7 +59,7 @@ class PerformanceOptimizer {
capacity: imageCache.maximumSizeBytes,
);
}
/// 위젯 재빌드 최적화를 위한 데바운서
static Timer? _debounceTimer;
static void debounce(
@@ -67,7 +69,7 @@ class PerformanceOptimizer {
_debounceTimer?.cancel();
_debounceTimer = Timer(delay, callback);
}
/// 스로틀링 - 지정된 시간 간격으로만 실행
static DateTime? _lastThrottleTime;
static void throttle(
@@ -81,7 +83,7 @@ class PerformanceOptimizer {
callback();
}
}
/// 무거운 연산을 별도 Isolate에서 실행
static Future<T> runInIsolate<T>(
ComputeCallback<dynamic, T> callback,
@@ -89,7 +91,7 @@ class PerformanceOptimizer {
) async {
return await compute(callback, parameter);
}
/// 레이지 로딩을 위한 페이지네이션 헬퍼
static List<T> paginate<T>({
required List<T> items,
@@ -98,13 +100,14 @@ class PerformanceOptimizer {
}) {
final startIndex = page * pageSize;
final endIndex = (startIndex + pageSize).clamp(0, items.length);
if (startIndex >= items.length) return [];
return items.sublist(startIndex, endIndex);
}
/// 이미지 최적화 - 메모리 효율적인 크기로 조정
static double getOptimalImageSize(BuildContext context, {
static double getOptimalImageSize(
BuildContext context, {
required double originalSize,
double maxSize = 1000,
}) {
@@ -113,53 +116,53 @@ class PerformanceOptimizer {
final maxDimension = screenSize.width > screenSize.height
? screenSize.width
: screenSize.height;
final optimalSize = (maxDimension * devicePixelRatio).clamp(100.0, maxSize);
return optimalSize < originalSize ? optimalSize : originalSize;
}
/// 위젯 키 최적화
static Key generateOptimizedKey(String prefix, dynamic identifier) {
return ValueKey('${prefix}_$identifier');
}
/// 애니메이션 최적화 - 보이지 않는 애니메이션 중지
static bool shouldAnimateWidget(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return !mediaQuery.disableAnimations && mediaQuery.accessibleNavigation;
}
/// 스크롤 성능 최적화
static ScrollPhysics getOptimizedScrollPhysics() {
return const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
);
}
/// 빌드 최적화를 위한 const 위젯 권장사항 체크
static void checkConstOptimization() {
if (kDebugMode) {
print('💡 성능 최적화 팁:');
print('1. 가능한 모든 위젯에 const 사용');
print('2. StatelessWidget 대신 const 생성자 사용');
print('3. 큰 리스트는 ListView.builder 사용');
print('4. 이미지는 캐싱과 함께 적절한 크기로 로드');
print('5. 애니메이션은 AnimatedBuilder 사용');
Log.i('💡 성능 최적화 팁:\n'
'1. 가능한 모든 위젯에 const 사용\n'
'2. StatelessWidget 대신 const 생성자 사용\n'
'3. 큰 리스트는 ListView.builder 사용\n'
'4. 이미지는 캐싱과 함께 적절한 크기로 로드\n'
'5. 애니메이션은 AnimatedBuilder 사용');
}
}
/// 메모리 누수 감지 헬퍼
static final Map<String, int> _widgetCounts = {};
static void trackWidget(String widgetName, bool isCreated) {
if (!kDebugMode) return;
_widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) +
(isCreated ? 1 : -1);
_widgetCounts[widgetName] =
(_widgetCounts[widgetName] ?? 0) + (isCreated ? 1 : -1);
// 위젯이 비정상적으로 많이 생성되면 경고
if ((_widgetCounts[widgetName] ?? 0) > 100) {
print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
Log.w('경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
}
}
}
@@ -168,16 +171,18 @@ class PerformanceOptimizer {
class MemoryInfo {
final int currentUsage;
final int capacity;
MemoryInfo({
required this.currentUsage,
required this.capacity,
});
double get usagePercentage => (currentUsage / capacity) * 100;
String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
String get formattedUsage =>
'${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
String get formattedCapacity =>
'${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
}
/// 성능 측정 데코레이터
@@ -187,17 +192,17 @@ class PerformanceMeasure {
required Future<T> Function() operation,
}) async {
if (!kDebugMode) return await operation();
final stopwatch = Stopwatch()..start();
try {
final result = await operation();
stopwatch.stop();
print('$name 완료: ${stopwatch.elapsedMilliseconds}ms');
Log.d('$name 완료: ${stopwatch.elapsedMilliseconds}ms');
return result;
} catch (e) {
stopwatch.stop();
print('$name 실패: ${stopwatch.elapsedMilliseconds}ms - $e');
Log.e('$name 실패: ${stopwatch.elapsedMilliseconds}ms', e);
rethrow;
}
}
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/foundation.dart';
class PlatformHelper {
static bool get isWeb => kIsWeb;
static bool get isIOS {
if (kIsWeb) return false;
return defaultTargetPlatform == TargetPlatform.iOS;
}
static bool get isAndroid {
if (kIsWeb) return false;
return defaultTargetPlatform == TargetPlatform.android;
}
static bool get isMobile => isIOS || isAndroid;
static bool get isDesktop {
if (kIsWeb) return false;
return defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows;
}
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/widgets.dart';
/// 접근성 설정에 따른 모션 축소 여부 헬퍼
class ReduceMotion {
/// 플랫폼 접근성 설정을 기반으로 모션 축소 여부 반환 (context 없이 사용)
static bool platform() {
final features =
WidgetsBinding.instance.platformDispatcher.accessibilityFeatures;
// disableAnimations 신뢰
return features.disableAnimations;
}
/// MediaQuery/플랫폼 정보를 활용해 런타임에서 모션 축소 여부 반환
static bool isEnabled(BuildContext context) {
final mq = MediaQuery.maybeOf(context);
if (mq != null) {
// accessibleNavigation == 사용자가 단순한 네비게이션/애니메이션 선호
if (mq.accessibleNavigation) return true;
}
return platform();
}
/// 모션 강도 스케일 유틸리티
static double scale(BuildContext context,
{required double normal, required double reduced}) {
return isEnabled(context) ? reduced : normal;
}
/// 파티클 개수 등 정수 스케일링
static int count(BuildContext context,
{required int normal, required int reduced}) {
return isEnabled(context) ? reduced : normal;
}
}

View File

@@ -50,4 +50,4 @@ class CategoryIconMapper {
return 16.0;
}
}
}
}

View File

@@ -26,7 +26,7 @@ class SmsDateFormatter {
) {
// 주기에 따라 다음 결제일 예측
DateTime? predictedDate = _predictNextBillingDate(date, billingCycle, now);
if (predictedDate != null) {
final daysUntil = predictedDate.difference(now).inDays;
return AppLocalizations.of(context).nextBillingDateEstimated(
@@ -34,7 +34,7 @@ class SmsDateFormatter {
daysUntil,
);
}
return '다음 결제일 확인 필요 (과거 날짜)';
}
@@ -78,7 +78,7 @@ class SmsDateFormatter {
// 다음 월간 결제일 계산
static DateTime _getNextMonthlyDate(DateTime lastDate, DateTime now) {
int day = lastDate.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
@@ -101,7 +101,7 @@ class SmsDateFormatter {
// 다음 연간 결제일 계산
static DateTime _getNextYearlyDate(DateTime lastDate, DateTime now) {
int day = lastDate.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, lastDate.month + 1, 0).day;
if (day > lastDay) {
@@ -162,4 +162,4 @@ class SmsDateFormatter {
static String getRepeatCountText(BuildContext context, int count) {
return AppLocalizations.of(context).repeatCountDetected(count);
}
}
}

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/url_matcher/data/legacy_service_data.dart';
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스
@@ -86,8 +85,8 @@ class SubscriptionCategoryHelper {
categorizedSubscriptions['shoppingEcommerce']!.add(subscription);
}
// 프로그래밍
else if (_isInCategory(subscription.serviceName,
LegacyServiceData.programmingServices)) {
else if (_isInCategory(
subscription.serviceName, LegacyServiceData.programmingServices)) {
if (!categorizedSubscriptions.containsKey('programming')) {
categorizedSubscriptions['programming'] = [];
}

View File

@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
// import 'package:flutter/foundation.dart' show kIsWeb;
// import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'dart:math' as math;
import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 App Bar
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget {
class AddSubscriptionAppBar extends StatelessWidget
implements PreferredSizeWidget {
final AddSubscriptionController controller;
final double scrollOffset;
final VoidCallback onScanSMS;
@@ -25,9 +26,11 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
Widget build(BuildContext context) {
final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100));
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: appBarOpacity),
// Color adapts to current theme (light/dark)
color: scheme.surface.withValues(alpha: appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
@@ -42,10 +45,10 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
child: SafeArea(
child: AppBar(
leading: IconButton(
icon: const Icon(
icon: Icon(
Icons.chevron_left,
size: 28,
color: Color(0xFF1E293B),
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () => Navigator.of(context).pop(),
),
@@ -56,7 +59,7 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: const Color(0xFF1E293B),
color: Theme.of(context).colorScheme.onSurface,
shadows: appBarOpacity > 0.6
? [
Shadow(
@@ -70,35 +73,10 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
if (!kIsWeb)
controller.isLoading
? const Padding(
padding: EdgeInsets.only(right: 16.0),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF3B82F6)),
),
),
),
)
: IconButton(
icon: const FaIcon(
FontAwesomeIcons.message,
size: 20,
color: Color(0xFF3B82F6),
),
onPressed: onScanSMS,
tooltip: AppLocalizations.of(context).scanTextMessages,
),
],
// SMS 스캔 버튼 제거: 우측 액션 비움
actions: const [],
),
),
);
}
}
}

View File

@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
// import '../../theme/app_colors.dart';
/// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget {
@@ -41,19 +40,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.glassCard,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -66,7 +59,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: controller.gradientColors[0].withValues(alpha: 0.1),
color: controller.gradientColors[0]
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
@@ -95,10 +89,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
}
return Text(
titleText,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
);
},
@@ -118,11 +112,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
}
});
},
activeColor: controller.gradientColors[0],
activeThumbColor: controller.gradientColors[0],
activeTrackColor:
controller.gradientColors[0].withValues(alpha: 0.5),
),
],
),
// 이벤트 활성화 시 추가 필드 표시
AnimatedContainer(
duration: const Duration(milliseconds: 300),
@@ -137,10 +133,16 @@ class AddSubscriptionEventSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08),
color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.3),
width: 1,
),
),
@@ -148,14 +150,15 @@ class AddSubscriptionEventSection extends StatelessWidget {
children: [
Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
color: Theme.of(context).colorScheme.tertiary,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Builder(
builder: (context) {
final locale = Localizations.localeOf(context);
final locale =
Localizations.localeOf(context);
String infoText;
switch (locale.languageCode) {
case 'ko':
@@ -168,13 +171,16 @@ class AddSubscriptionEventSection extends StatelessWidget {
infoText = '设置折扣或促销价格';
break;
default:
infoText = 'Set up discount or promotion price';
infoText =
'Set up discount or promotion price';
}
return Text(
infoText,
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
color: Theme.of(context)
.colorScheme
.onSurface,
fontWeight: FontWeight.w500,
),
);
@@ -185,7 +191,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
),
),
const SizedBox(height: 20),
// 이벤트 기간
Builder(
builder: (context) {
@@ -216,8 +222,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
setState(() {
controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30));
if (date != null &&
controller.eventEndDate == null) {
controller.eventEndDate =
date.add(const Duration(days: 30));
}
});
},
@@ -233,17 +241,18 @@ class AddSubscriptionEventSection extends StatelessWidget {
},
),
const SizedBox(height: 20),
// 이벤트 가격
Builder(
builder: (BuildContext innerContext) {
// 현재 로케일 확인
final currentLocale = Localizations.localeOf(innerContext);
final currentLocale =
Localizations.localeOf(innerContext);
// 로케일에 따라 직접 텍스트 설정
String eventPriceLabel;
String eventPriceHint;
switch (currentLocale.languageCode) {
case 'ko':
eventPriceLabel = '이벤트 가격';
@@ -261,12 +270,16 @@ class AddSubscriptionEventSection extends StatelessWidget {
eventPriceLabel = 'Event Price';
eventPriceHint = 'Enter discounted price';
}
return CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: eventPriceLabel,
hintText: eventPriceHint,
enabled: controller.isEventActive,
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
validator:
controller.isEventActive ? null : (_) => null,
);
},
),
@@ -280,4 +293,4 @@ class AddSubscriptionEventSection extends StatelessWidget {
),
);
}
}
}

View File

@@ -7,11 +7,11 @@ import '../../l10n/app_localizations.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_selector.dart';
import '../common/form_fields/currency_dropdown_field.dart';
import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../glassmorphism_card.dart';
import '../../theme/app_colors.dart';
// Glass 제거: Material 3 Card 사용
// Material colors only
/// 구독 추가 화면의 폼 섹션
class AddSubscriptionForm extends StatelessWidget {
@@ -45,8 +45,15 @@ class AddSubscriptionForm extends StatelessWidget {
parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
)),
child: GlassmorphismCard(
backgroundColor: AppColors.glassCard,
child: Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -55,26 +62,19 @@ class AddSubscriptionForm extends StatelessWidget {
// 헤더
Row(
children: [
ShaderMask(
shaderCallback: (bounds) => LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(bounds),
child: const Icon(
FontAwesomeIcons.fileLines,
size: 20,
color: Colors.white,
),
Icon(
FontAwesomeIcons.fileLines,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).serviceInfo,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: Color(0xFF1E293B),
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -136,9 +136,8 @@ class AddSubscriptionForm extends StatelessWidget {
),
),
const SizedBox(height: 8),
CurrencySelector(
CurrencyDropdownField(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
setState(() {
controller.currency = value;
@@ -158,8 +157,8 @@ class AddSubscriptionForm extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).billingCycle,
style: const TextStyle(
color: AppColors.textPrimary,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
@@ -168,7 +167,6 @@ class AddSubscriptionForm extends StatelessWidget {
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (value) {
setState(() {
controller.billingCycle = value;
@@ -203,7 +201,7 @@ class AddSubscriptionForm extends StatelessWidget {
keyboardType: TextInputType.url,
prefixIcon: Icon(
Icons.link_rounded,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
@@ -226,7 +224,6 @@ class AddSubscriptionForm extends StatelessWidget {
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (categoryId) {
setState(() {
controller.selectedCategoryId = categoryId;

View File

@@ -26,19 +26,7 @@ class AddSubscriptionHeader extends StatelessWidget {
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: controller.gradientColors[0].withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 0,
offset: const Offset(0, 8),
),
],
color: Theme.of(context).colorScheme.primary,
),
child: Row(
children: [
@@ -48,10 +36,10 @@ class AddSubscriptionHeader extends StatelessWidget {
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
child: Icon(
Icons.add_rounded,
size: 32,
color: Colors.white,
color: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 16),
@@ -61,20 +49,23 @@ class AddSubscriptionHeader extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).newSubscriptionAdd,
style: const TextStyle(
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
color: Theme.of(context).colorScheme.onPrimary,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context).enterServiceInfo,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white70,
color: Theme.of(context)
.colorScheme
.onPrimary
.withValues(alpha: 0.7),
),
),
],
@@ -86,4 +77,4 @@ class AddSubscriptionHeader extends StatelessWidget {
),
);
}
}
}

View File

@@ -40,14 +40,14 @@ class AddSubscriptionSaveButton extends StatelessWidget {
child: PrimaryButton(
text: AppLocalizations.of(context).addSubscriptionButton,
icon: Icons.add_circle_outline,
onPressed: controller.isLoading
? null
onPressed: controller.isLoading
? null
: () => controller.saveSubscription(setState: setState),
isLoading: controller.isLoading,
backgroundColor: const Color(0xFF3B82F6),
backgroundColor: Theme.of(context).colorScheme.primary,
),
),
),
);
}
}
}

View File

@@ -4,8 +4,7 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
// import '../../theme/app_colors.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget {
@@ -27,7 +26,7 @@ class AnalysisBadge extends StatelessWidget {
width: size,
height: size,
decoration: BoxDecoration(
color: AppColors.pureWhite,
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
@@ -35,7 +34,7 @@ class AnalysisBadge extends StatelessWidget {
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10,
spreadRadius: 2,
),
@@ -49,10 +48,10 @@ class AnalysisBadge extends StatelessWidget {
subscription.serviceName.length > 5
? '${subscription.serviceName.substring(0, 5)}...'
: subscription.serviceName,
style: const TextStyle(
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 0),
@@ -69,19 +68,23 @@ class AnalysisBadge extends StatelessWidget {
String displayText = amountText;
if (amountText.length > 12) {
// 괄호 안의 내용 제거
displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
displayText =
amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
}
if (displayText.length > 10) {
// 통화 기호만 남기고 숫자만 표시
final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency);
displayText = displayText.replaceAll(currencySymbol, '').trim();
displayText = '$currencySymbol${displayText.substring(0, 6)}...';
final currencySymbol =
CurrencyUtil.getCurrencySymbol(subscription.currency);
displayText =
displayText.replaceAll(currencySymbol, '').trim();
displayText =
'$currencySymbol${displayText.substring(0, 6)}...';
}
return Text(
displayText,
style: const TextStyle(
style: TextStyle(
fontSize: 7,
color: AppColors.navyGray,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
@@ -93,4 +96,4 @@ class AnalysisBadge extends StatelessWidget {
),
);
}
}
}

View File

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
/// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리
class AnalysisScreenSpacer extends StatelessWidget {
final double height;
const AnalysisScreenSpacer({
super.key,
this.height = 24,
@@ -16,4 +16,4 @@ class AnalysisScreenSpacer extends StatelessWidget {
child: SizedBox(height: height),
);
}
}
}

View File

@@ -3,10 +3,10 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart';
import '../../services/currency_util.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/color_scheme_ext.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget {
@@ -38,20 +38,29 @@ class EventAnalysisCard extends StatelessWidget {
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: AppLocalizations.of(context).eventDiscountStatus,
text: AppLocalizations.of(context)
.eventDiscountStatus,
style: const TextStyle(
fontSize: 18,
),
@@ -62,28 +71,31 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFFFF6B6B),
Color(0xFFFE7E7E),
],
),
color:
Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const FaIcon(
FaIcon(
FontAwesomeIcons.fire,
size: 12,
color: AppColors.pureWhite,
color: Theme.of(context)
.colorScheme
.onError,
),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length),
style: const TextStyle(
AppLocalizations.of(context)
.servicesInProgress(provider
.activeEventSubscriptions
.length),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
color: Theme.of(context)
.colorScheme
.onError,
),
),
],
@@ -95,33 +107,35 @@ class EventAnalysisCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFFF6B6B).withValues(alpha: 0.1),
const Color(0xFFFF8787).withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFF6B6B).withValues(alpha: 0.3),
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.3),
),
),
child: Row(
children: [
const Icon(
Icon(
Icons.savings,
color: Color(0xFFFF6B6B),
color:
Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context).monthlySavingAmount,
AppLocalizations.of(context)
.monthlySavingAmount,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@@ -132,10 +146,12 @@ class EventAnalysisCard extends StatelessWidget {
CurrencyUtil.formatTotalAmount(
provider.calculateTotalSavings(),
),
style: const TextStyle(
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B),
color: Theme.of(context)
.colorScheme
.error,
),
),
],
@@ -154,24 +170,33 @@ class EventAnalysisCard extends StatelessWidget {
),
const SizedBox(height: 12),
...provider.activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100).round();
final savings = sub.originalPrice -
(sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100)
.round();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.darkNavy.withValues(alpha: 0.05),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.darkNavy.withValues(alpha: 0.1),
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText(
sub.serviceName,
@@ -184,19 +209,23 @@ class EventAnalysisCard extends StatelessWidget {
Row(
children: [
FutureBuilder<String>(
future: CurrencyUtil
.formatAmount(
future:
CurrencyUtil.formatAmount(
sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
decoration: TextDecoration
.lineThrough,
color: AppColors.navyGray,
decoration:
TextDecoration
.lineThrough,
color: Theme.of(
context)
.colorScheme
.onSurfaceVariant,
),
);
}
@@ -204,27 +233,32 @@ class EventAnalysisCard extends StatelessWidget {
},
),
const SizedBox(width: 8),
const Icon(
Icon(
Icons.arrow_forward,
size: 12,
color: AppColors.navyGray,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 8),
FutureBuilder<String>(
future: CurrencyUtil
.formatAmount(
sub.eventPrice ?? sub.originalPrice,
future:
CurrencyUtil.formatAmount(
sub.eventPrice ??
sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
fontWeight:
FontWeight.bold,
color:
Color(0xFF10B981),
Theme.of(context)
.colorScheme
.success,
),
);
}
@@ -242,23 +276,29 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B)
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
borderRadius:
BorderRadius.circular(4),
),
child: Text(
'$discountRate${AppLocalizations.of(context).discountPercent}',
style: const TextStyle(
_formatDiscountPercent(
context, discountRate),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B),
color: Theme.of(context)
.colorScheme
.error,
),
),
),
],
),
);
}).toList(),
}),
],
),
),
@@ -271,4 +311,18 @@ class EventAnalysisCard extends StatelessWidget {
),
);
}
}
}
String _formatDiscountPercent(BuildContext context, int percent) {
final raw = AppLocalizations.of(context).discountPercent;
// 우선 @ 플레이스홀더가 있으면 치환
if (raw.contains('@')) {
return raw.replaceAll('@', percent.toString());
}
// % 마커가 있으면 첫 번째 %를 숫자%로 치환
if (raw.contains('%')) {
return raw.replaceFirst('%', '$percent%');
}
// 폴백: "99% text" 형태
return '$percent% $raw';
}

View File

@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import '../../theme/color_scheme_ext.dart';
import 'package:fl_chart/fl_chart.dart';
import 'dart:math' as math;
import 'package:provider/provider.dart';
import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/reduce_motion.dart';
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
class MonthlyExpenseChartCard extends StatelessWidget {
@@ -23,7 +24,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
/// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰)
double _calculateChartMaxY(double maxValue, String locale) {
final currency = CurrencyUtil.getDefaultCurrency(locale);
if (currency == 'KRW' || currency == 'JPY') {
// 소수점이 없는 통화 (원화, 엔화)
if (maxValue <= 0) return 100000;
@@ -33,9 +34,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
if (maxValue <= 200000) return 200000;
if (maxValue <= 500000) return 500000;
if (maxValue <= 1000000) return 1000000;
// 큰 금액은 자릿수에 맞춰 반올림
final magnitude = math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
final magnitude =
math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
return ((maxValue / magnitude).ceil() * magnitude).toDouble();
} else {
// 소수점이 있는 통화 (달러, 위안)
@@ -47,7 +49,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
if (maxValue <= 250) return 250.0;
if (maxValue <= 500) return 500.0;
if (maxValue <= 1000) return 1000.0;
// 큰 금액은 100 단위로 반올림
return ((maxValue / 100).ceil() * 100).toDouble();
}
@@ -73,11 +75,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
}
// 월간 지출 차트 데이터
List<BarChartGroupData> _getMonthlyBarGroups(String locale) {
List<BarChartGroupData> _getMonthlyBarGroups(
BuildContext context, String locale) {
final List<BarChartGroupData> barGroups = [];
final calculatedMax = monthlyData.fold<double>(
0, (max, data) => math.max(max, data['totalExpense'] as double));
final maxAmount = _calculateChartMaxY(calculatedMax, locale);
final scheme = Theme.of(context).colorScheme;
for (int i = 0; i < monthlyData.length; i++) {
final data = monthlyData[i];
@@ -87,20 +91,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
barRods: [
BarChartRodData(
toY: data['totalExpense'],
gradient: LinearGradient(
colors: [
const Color(0xFF3B82F6).withValues(alpha: 0.7),
const Color(0xFF60A5FA),
],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
),
color: scheme.primary,
width: 18,
borderRadius: BorderRadius.circular(4),
backDrawRodData: BackgroundBarChartRodData(
show: true,
toY: maxAmount,
color: AppColors.navyGray.withValues(alpha: 0.1),
color: scheme.onSurfaceVariant.withValues(alpha: 0.08),
),
),
],
@@ -130,10 +127,17 @@ class MonthlyExpenseChartCard extends StatelessWidget {
parent: animationController,
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -153,108 +157,122 @@ class MonthlyExpenseChartCard extends StatelessWidget {
),
),
const SizedBox(height: 20),
// 바 차트
AspectRatio(
aspectRatio: 1.6,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: _calculateChartMaxY(
monthlyData.fold<double>(
0,
(max, data) => math.max(
max, data['totalExpense'] as double)),
locale
),
barGroups: _getMonthlyBarGroups(locale),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: _calculateGridInterval(
_calculateChartMaxY(
monthlyData.fold<double>(
0,
(max, data) => math.max(max,
data['totalExpense'] as double)),
locale
),
CurrencyUtil.getDefaultCurrency(locale)
// 바 차트 (RepaintBoundary로 페인트 분리)
RepaintBoundary(
child: AspectRatio(
aspectRatio: 1.6,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: _calculateChartMaxY(
monthlyData.fold<double>(
0,
(max, data) => math.max(
max, data['totalExpense'] as double)),
locale),
barGroups: _getMonthlyBarGroups(context, locale),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: _calculateGridInterval(
_calculateChartMaxY(
monthlyData.fold<double>(
0,
(max, data) => math.max(max,
data['totalExpense'] as double)),
locale),
CurrencyUtil.getDefaultCurrency(locale)),
getDrawingHorizontalLine: (value) {
return FlLine(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.1),
strokeWidth: 1,
);
},
),
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppColors.navyGray.withValues(alpha: 0.1),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: ThemedText.caption(
text: monthlyData[value.toInt()]
['monthName'],
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
titlesData: FlTitlesData(
show: true,
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: ThemedText.caption(
text: monthlyData[value.toInt()]
['monthName'],
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBorderRadius: BorderRadius.circular(8),
getTooltipColor: (group) => Theme.of(context)
.colorScheme
.inverseSurface,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
TextStyle(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: CurrencyUtil
.formatTotalAmountWithLocale(
monthlyData[group.x]
['totalExpense'] as double,
locale),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.warning,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: AppColors.darkNavy,
tooltipRoundedRadius: 8,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
const TextStyle(
color: AppColors.pureWhite,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: CurrencyUtil.formatTotalAmountWithLocale(
monthlyData[group.x]['totalExpense']
as double,
locale),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
);
},
),
),
duration: ReduceMotion.isEnabled(context)
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
),
),
const SizedBox(height: 16),
Center(
child: ThemedText.caption(
text: AppLocalizations.of(context).monthlySubscriptionExpense,
text: AppLocalizations.of(context)
.monthlySubscriptionExpense,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@@ -270,4 +288,4 @@ class MonthlyExpenseChartCard extends StatelessWidget {
),
);
}
}
}

View File

@@ -4,12 +4,14 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../services/exchange_rate_service.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
// import '../../theme/app_colors.dart';
import '../../theme/color_scheme_ext.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
import 'analysis_badge.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/locale_provider.dart';
import '../../utils/reduce_motion.dart';
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
class SubscriptionPieChartCard extends StatefulWidget {
@@ -23,23 +25,25 @@ class SubscriptionPieChartCard extends StatefulWidget {
});
@override
State<SubscriptionPieChartCard> createState() => _SubscriptionPieChartCardState();
State<SubscriptionPieChartCard> createState() =>
_SubscriptionPieChartCardState();
}
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
int _touchedIndex = -1;
late Future<List<PieChartSectionData>> _pieSectionsFuture;
// kept for compatibility previously; computation now happens per build
String? _lastLocale;
static const _chartColors = [
Color(0xFF3B82F6),
Color(0xFF10B981),
Color(0xFFF59E0B),
Color(0xFFEF4444),
Color(0xFF8B5CF6),
Color(0xFF0EA5E9),
Color(0xFFEC4899),
];
// 차트 팔레트: ColorScheme + 보조 상수(성공/경고/액센트)
List<Color> _getChartColors(ColorScheme scheme) => [
scheme.primary,
scheme.success,
scheme.warning,
scheme.error,
scheme.tertiary,
scheme.secondary,
const Color(0xFFEC4899), // accent
];
@override
void initState() {
@@ -52,7 +56,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
super.didUpdateWidget(oldWidget);
// subscriptions나 locale이 변경된 경우만 Future 재생성
final currentLocale = context.read<LocaleProvider>().locale.languageCode;
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
_lastLocale != currentLocale) {
_initializeFuture();
}
@@ -60,13 +64,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
void _initializeFuture() {
_lastLocale = context.read<LocaleProvider>().locale.languageCode;
_pieSectionsFuture = _getPieSections();
// no-op: Future computed on demand in build
}
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i].id != b[i].id ||
if (a[i].id != b[i].id ||
a[i].currentPrice != b[i].currentPrice ||
a[i].currency != b[i].currency ||
a[i].serviceName != b[i].serviceName) {
@@ -78,12 +82,14 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
Future<List<PieChartSectionData>> _getPieSections() async {
if (widget.subscriptions.isEmpty) return [];
// 현재 locale 가져오기
final locale = context.read<LocaleProvider>().locale.languageCode;
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
// Chart palette (capture scheme before any awaits)
final scheme = Theme.of(context).colorScheme;
final chartColors = _getChartColors(scheme);
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
List<double> sectionValues = [];
@@ -91,17 +97,19 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 각 구독의 현재 가격을 언어별 기본 통화로 환산
for (var subscription in widget.subscriptions) {
double value = subscription.currentPrice;
if (subscription.currency == defaultCurrency) {
// 이미 기본 통화인 경우 그대로 사용
sectionValues.add(value);
} else if (subscription.currency == 'USD') {
// USD를 기본 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency);
final converted = await ExchangeRateService()
.convertUsdToTarget(value, defaultCurrency);
sectionValues.add(converted ?? value);
} else if (defaultCurrency == 'USD') {
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환
final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency);
final converted = await ExchangeRateService()
.convertTargetToUsd(value, subscription.currency);
sectionValues.add(converted ?? value);
} else {
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
@@ -111,14 +119,14 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 총합 계산
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
// 총합이 0이면 빈 배열 반환
if (sectionsTotal == 0) return [];
// 섹션 데이터 생성 (터치 상태 제외)
final sections = List.generate(widget.subscriptions.length, (i) {
final percentage = (sectionValues[i] / sectionsTotal) * 100;
final index = i % _chartColors.length;
final index = i % chartColors.length;
return PieChartSectionData(
value: sectionValues[i],
@@ -126,57 +134,61 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
titleStyle: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
color: Colors.white,
shadows: [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
color: _chartColors[index],
color: chartColors[index],
radius: 100.0,
titlePositionPercentageOffset: 0.6,
badgeWidget: null,
badgePositionPercentageOffset: .98,
);
});
return sections;
}
// 배지 위젯 생성
Widget _createBadgeWidget(int index) {
if (index >= widget.subscriptions.length) return const SizedBox.shrink();
final subscription = widget.subscriptions[index];
final colorIndex = index % _chartColors.length;
final chartColors = _getChartColors(Theme.of(context).colorScheme);
final colorIndex = index % chartColors.length;
return IgnorePointer(
child: AnalysisBadge(
size: 40,
borderColor: _chartColors[colorIndex],
borderColor: chartColors[colorIndex],
subscription: subscription,
),
);
}
// 터치 상태를 반영한 섹션 데이터 생성
List<PieChartSectionData> _applyTouchedState(List<PieChartSectionData> sections) {
List<PieChartSectionData> _applyTouchedState(
List<PieChartSectionData> sections) {
return List.generate(sections.length, (i) {
final section = sections[i];
final isTouched = _touchedIndex == i;
final fontSize = isTouched ? 16.0 : 12.0;
final radius = isTouched ? 105.0 : 100.0;
return PieChartSectionData(
value: section.value,
title: section.title,
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
shadows: const [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ??
TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: const [
Shadow(
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
color: section.color,
radius: radius,
titlePositionPercentageOffset: section.titlePositionPercentageOffset,
@@ -204,10 +216,17 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
parent: widget.animationController,
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -217,38 +236,47 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: AppLocalizations.of(context).subscriptionServiceRatio,
text: AppLocalizations.of(context)
.subscriptionServiceRatio,
style: const TextStyle(
fontSize: 18,
),
),
FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfoForLocale(
context.watch<LocaleProvider>().locale.languageCode
),
context
.watch<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) {
if (snapshot.hasData &&
snapshot.data!.isNotEmpty) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFE5F2FF),
borderRadius:
BorderRadius.circular(4),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFFBFDBFE),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
width: 1,
),
),
child: Text(
AppLocalizations.of(context).exchangeRateFormat(snapshot.data!),
style: const TextStyle(
AppLocalizations.of(context)
.exchangeRateFormat(snapshot.data!),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
color:
Theme.of(context).colorScheme.primary,
),
),
);
@@ -272,7 +300,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
height: 250,
child: Center(
child: ThemedText(
AppLocalizations.of(context).noSubscriptionServices,
AppLocalizations.of(context)
.noSubscriptionServices,
style: const TextStyle(
fontSize: 16,
),
@@ -282,72 +311,89 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
: SizedBox(
height: 250,
child: FutureBuilder<List<PieChartSectionData>>(
future: _pieSectionsFuture,
future: _getPieSections(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
if (!snapshot.hasData ||
snapshot.data!.isEmpty) {
return Center(
child: ThemedText(
AppLocalizations.of(context).noSubscriptionServices,
AppLocalizations.of(context)
.noSubscriptionServices,
style: const TextStyle(
fontSize: 16,
),
),
);
}
return PieChart(
PieChartData(
borderData: FlBorderData(show: false),
sectionsSpace: 2,
centerSpaceRadius: 60,
sections: _applyTouchedState(snapshot.data!),
pieTouchData: PieTouchData(
enabled: true,
touchCallback: (FlTouchEvent event,
pieTouchResponse) {
// 터치 응답이 없거나 섹션이 없는 경우
if (pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
// 차트 밖으로 나갔을 때만 리셋
if (_touchedIndex != -1) {
setState(() {
_touchedIndex = -1;
});
return RepaintBoundary(
child: PieChart(
PieChartData(
borderData: FlBorderData(show: false),
sectionsSpace: 2,
centerSpaceRadius: 60,
sections:
_applyTouchedState(snapshot.data!),
pieTouchData: PieTouchData(
enabled: true,
touchCallback: (FlTouchEvent event,
pieTouchResponse) {
// 터치 응답이 없거나 섹션이 없는 경우
if (pieTouchResponse == null ||
pieTouchResponse
.touchedSection ==
null) {
// 차트 밖으로 나갔을 때만 리셋
if (_touchedIndex != -1) {
setState(() {
_touchedIndex = -1;
});
}
return;
}
return;
}
final touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
// 탭 이벤트 처리 (토글)
if (event is FlTapUpEvent) {
setState(() {
// 동일 섹션 탭하면 선택 해제, 아니면 선택
_touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex;
});
return;
}
// hover 이벤트 처리 (단순 표시)
if (event is FlPointerHoverEvent ||
event is FlPointerEnterEvent) {
// 현재 인덱스와 다른 경우만 업데이트
if (_touchedIndex != touchedIndex) {
final touchedIndex =
pieTouchResponse.touchedSection!
.touchedSectionIndex;
// 탭 이벤트 처리 (토글)
if (event is FlTapUpEvent) {
setState(() {
_touchedIndex = touchedIndex;
// 동일 섹션 탭하면 선택 해제, 아니면 선택
_touchedIndex =
(_touchedIndex ==
touchedIndex)
? -1
: touchedIndex;
});
return;
}
}
},
// hover 이벤트 처리 (단순 표시)
if (event is FlPointerHoverEvent ||
event is FlPointerEnterEvent) {
// 현재 인덱스와 다른 경우만 업데이트
if (_touchedIndex !=
touchedIndex) {
setState(() {
_touchedIndex = touchedIndex;
});
}
}
},
),
),
duration: ReduceMotion.isEnabled(context)
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
);
},
@@ -364,10 +410,12 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
(index) {
final subscription =
widget.subscriptions[index];
final color = _chartColors[index % _chartColors.length];
final chartColors = _getChartColors(
Theme.of(context).colorScheme);
final color =
chartColors[index % chartColors.length];
return Padding(
padding: const EdgeInsets.only(
bottom: 4.0),
padding: const EdgeInsets.only(bottom: 4.0),
child: Row(
children: [
Container(
@@ -385,31 +433,31 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
style: const TextStyle(
fontSize: 14,
),
overflow:
TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis,
),
),
FutureBuilder<String>(
future: CurrencyUtil
.formatSubscriptionAmountWithLocale(
subscription,
context.read<LocaleProvider>().locale.languageCode),
context
.read<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
fontSize: 14,
fontWeight:
FontWeight.bold,
fontWeight: FontWeight.bold,
),
);
}
return const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(
child: CircularProgressIndicator(
strokeWidth: 2,
),
);
@@ -430,4 +478,4 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
),
);
}
}
}

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