22 Commits

Author SHA1 Message Date
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
JiWoong Sul
186d1bbf66 feat: SMS 스캔 화면 리팩토링 및 MVC 패턴 적용
- SMS 스캔 화면을 컨트롤러/서비스/위젯으로 분리
- 코드 가독성 및 유지보수성 향상
- 새로운 다국어 지원 키 추가
- Git 커밋 가이드라인 문서화
2025-07-17 16:59:19 +09:00
150 changed files with 7486 additions and 7768 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

69
AGENTS.md Normal file
View File

@@ -0,0 +1,69 @@
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`).
- 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

@@ -159,6 +159,53 @@ Before starting any task, you MUST respond in the following format:
- Follow **GivenWhenThen** structure - Follow **GivenWhenThen** structure
- Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures - Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures
## 📝 Git Commit Guidelines
### Commit Message Format
- **Use clear, descriptive commit messages in Korean**
- **Follow conventional commit format**: `type: description`
- **Keep commit messages concise and focused**
- **DO NOT include Claude Code attribution or co-author tags**
### Commit Message Structure
```
type: brief description in Korean
Optional detailed explanation if needed
```
### Commit Types
- `feat`: 새로운 기능 추가
- `fix`: 버그 수정
- `refactor`: 코드 리팩토링
- `style`: 코드 스타일 변경 (formatting, missing semi-colons, etc)
- `docs`: 문서 변경
- `test`: 테스트 코드 추가 또는 수정
- `chore`: 빌드 프로세스 또는 보조 도구 변경
### Examples
**Good Examples:**
- `feat: 월별 차트 다국어 지원 추가`
- `fix: 분석화면 총지출 금액 불일치 문제 해결`
- `refactor: 통화 변환 로직 모듈화`
**Avoid These:**
- Including "🤖 Generated with [Claude Code](https://claude.ai/code)"
- Including "Co-Authored-By: Claude <noreply@anthropic.com>"
- Vague messages like "update code" or "fix stuff"
- English commit messages (use Korean)
### Critical Rules
- **NEVER include AI tool attribution in commit messages**
- **Focus on what was changed and why**
- **Use present tense and imperative mood**
- **Keep the first line under 50 characters when possible**
## 🧠 Error Analysis & Rule Documentation ## 🧠 Error Analysis & Rule Documentation
### Mandatory Process When Errors Occur ### Mandatory Process When Errors Occur

View File

@@ -6,13 +6,14 @@ plugins {
} }
android { android {
namespace = "com.example.submanager" namespace = "com.naturebridgeai.digitalrentmanager"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
@@ -21,7 +22,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // 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. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
@@ -42,3 +43,7 @@ android {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}

View File

@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_SMS" /> <uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:label="구독 관리" android:label="구독 관리"
android:name="${applicationName}" android:name="${applicationName}"
@@ -32,6 +32,10 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> 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" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and 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 코드는 제거되었습니다
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

View File

@@ -215,7 +215,19 @@
"amountRequired": "Please enter amount", "amountRequired": "Please enter amount",
"subscriptionDetail": "Subscription Detail", "subscriptionDetail": "Subscription Detail",
"enterAmount": "Enter amount", "enterAmount": "Enter amount",
"invalidAmount": "Please enter a valid 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": { "ko": {
"appTitle": "디지털 월세 관리자", "appTitle": "디지털 월세 관리자",
@@ -433,7 +445,19 @@
"amountRequired": "금액을 입력해주세요", "amountRequired": "금액을 입력해주세요",
"subscriptionDetail": "구독 상세", "subscriptionDetail": "구독 상세",
"enterAmount": "금액을 입력하세요", "enterAmount": "금액을 입력하세요",
"invalidAmount": "올바른 금액을 입력해주세요" "invalidAmount": "올바른 금액을 입력해주세요",
"featureComingSoon": "이 기능은 곧 출시됩니다"
,
"smsPermissionTitle": "SMS 권한 요청",
"smsPermissionReasonTitle": "이유",
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
"smsPermissionScopeTitle": "수집 범위",
"smsPermissionScopeBody": "결제 관련 문자 메시지의 패턴(서비스명/금액/날짜)만 로컬에서 처리하며, 외부로 전송하지 않습니다.",
"permanentlyDeniedMessage": "권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.",
"openSettings": "설정 열기",
"later": "나중에 하기",
"requesting": "요청 중...",
"smsPermissionLabel": "SMS 권한"
}, },
"ja": { "ja": {
"appTitle": "デジタル月額管理者", "appTitle": "デジタル月額管理者",
@@ -651,7 +675,8 @@
"amountRequired": "金額を入力してください", "amountRequired": "金額を入力してください",
"subscriptionDetail": "サブスクリプション詳細", "subscriptionDetail": "サブスクリプション詳細",
"enterAmount": "金額を入力してください", "enterAmount": "金額を入力してください",
"invalidAmount": "正しい金額を入力してください" "invalidAmount": "正しい金額を入力してください",
"featureComingSoon": "この機能は近日公開予定です"
}, },
"zh": { "zh": {
"appTitle": "数字月租管理器", "appTitle": "数字月租管理器",
@@ -869,6 +894,7 @@
"amountRequired": "请输入金额", "amountRequired": "请输入金额",
"subscriptionDetail": "订阅详情", "subscriptionDetail": "订阅详情",
"enterAmount": "请输入金额", "enterAmount": "请输入金额",
"invalidAmount": "请输入有效的金额" "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
---

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% 및 구식 코드 제거.
---
작성자 메모: 본 계획은 코드 변경 없이 문서만 추가되었습니다. 승인 후 단계별 구현을 진행합니다.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,293 @@
import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../models/subscription.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';
class SmsScanController extends ChangeNotifier {
// 상태 관리
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
List<Subscription> _scannedSubscriptions = [];
List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
int _currentIndex = 0;
int get currentIndex => _currentIndex;
String? _selectedCategoryId;
String? get selectedCategoryId => _selectedCategoryId;
final TextEditingController websiteUrlController = TextEditingController();
// 의존성
final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter();
@override
void dispose() {
websiteUrlController.dispose();
super.dispose();
}
void setSelectedCategoryId(String? categoryId) {
_selectedCategoryId = categoryId;
notifyListeners();
}
void resetWebsiteUrl() {
websiteUrlController.text = '';
}
Future<void> scanSms(BuildContext context) async {
_isLoading = true;
_errorMessage = null;
_scannedSubscriptions = [];
_currentIndex = 0;
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 스캔 실행
Log.i('SMS 스캔 시작');
final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) {
Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
}
if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) {
Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false;
notifyListeners();
return;
}
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
_filter.filterByRepeatCount(scannedSubscriptions, 2);
Log.d('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) {
Log.d(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
}
if (repeatSubscriptions.isEmpty) {
Log.i('반복 결제된 구독이 없음');
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
_isLoading = false;
notifyListeners();
return;
}
// 구독 목록 가져오기
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions;
Log.d('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링
final filteredSubscriptions =
_filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) {
Log.d(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
// 중복 제거 후 신규 구독이 없는 경우
if (filteredSubscriptions.isEmpty) {
Log.i('중복 제거 후 신규 구독이 없음');
_isLoading = false;
notifyListeners();
return;
}
_scannedSubscriptions = filteredSubscriptions;
_isLoading = false;
websiteUrlController.text = ''; // URL 입력 필드 초기화
notifyListeners();
} catch (e) {
Log.e('SMS 스캔 중 오류 발생', e);
if (context.mounted) {
_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);
// websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
? websiteUrlController.text.trim()
: subscription.websiteUrl;
Log.d(
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
// addSubscription 호출
await provider.addSubscription(
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate,
websiteUrl: websiteUrl,
isAutoDetected: true,
repeatCount: subscription.repeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: finalCategoryId,
currency: subscription.currency,
);
Log.i('구독 추가 성공: ${subscription.serviceName}');
if (!context.mounted) return;
moveToNextSubscription(context);
} catch (e) {
Log.e('구독 추가 중 오류 발생', e);
// 오류가 있어도 다음 구독으로 이동
if (!context.mounted) return;
moveToNextSubscription(context);
}
}
void skipCurrentSubscription(BuildContext context) {
final subscription = _scannedSubscriptions[_currentIndex];
Log.i('구독 건너뛰기: ${subscription.serviceName}');
moveToNextSubscription(context);
}
void moveToNextSubscription(BuildContext context) {
_currentIndex++;
websiteUrlController.text = ''; // URL 입력 필드 초기화
_selectedCategoryId = null; // 카테고리 선택 초기화
// 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) {
navigateToHome(context);
}
notifyListeners();
}
void navigateToHome(BuildContext context) {
// NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0);
}
void resetState() {
_scannedSubscriptions = [];
_currentIndex = 0;
_errorMessage = null;
notifyListeners();
}
String getDefaultCategoryId(CategoryProvider categoryProvider) {
final otherCategory = categoryProvider.categories.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first,
);
Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
return otherCategory.id;
}
void initializeWebsiteUrl() {
if (_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!;
}
}
}
}

View File

@@ -14,22 +14,21 @@ class AppLocalizations {
// JSON 파일에서 번역 데이터 로드 // JSON 파일에서 번역 데이터 로드
Future<void> load() async { Future<void> load() async {
String jsonString = String jsonString = await rootBundle.loadString('assets/data/text.json');
await rootBundle.loadString('assets/data/text.json');
Map<String, dynamic> jsonMap = json.decode(jsonString); Map<String, dynamic> jsonMap = json.decode(jsonString);
_localizedStrings = jsonMap[locale.languageCode]; _localizedStrings = jsonMap[locale.languageCode];
} }
String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager'; 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 => String get subscriptionManagement =>
_localizedStrings['subscriptionManagement'] ?? 'Subscription Management'; _localizedStrings['subscriptionManagement'] ?? 'Subscription Management';
String get addSubscription => String get addSubscription =>
_localizedStrings['addSubscription'] ?? 'Add Subscription'; _localizedStrings['addSubscription'] ?? 'Add Subscription';
String get subscriptionName => String get subscriptionName =>
_localizedStrings['subscriptionName'] ?? 'Service Name'; _localizedStrings['subscriptionName'] ?? 'Service Name';
String get monthlyCost => String get monthlyCost => _localizedStrings['monthlyCost'] ?? 'Monthly Cost';
_localizedStrings['monthlyCost'] ?? 'Monthly Cost';
String get billingCycle => String get billingCycle =>
_localizedStrings['billingCycle'] ?? 'Billing Cycle'; _localizedStrings['billingCycle'] ?? 'Billing Cycle';
String get nextBillingDate => String get nextBillingDate =>
@@ -55,29 +54,50 @@ class AppLocalizations {
_localizedStrings['categoryManagement'] ?? 'Category Management'; _localizedStrings['categoryManagement'] ?? 'Category Management';
String get categoryName => String get categoryName =>
_localizedStrings['categoryName'] ?? 'Category Name'; _localizedStrings['categoryName'] ?? 'Category Name';
String get selectColor => String get selectColor => _localizedStrings['selectColor'] ?? 'Select Color';
_localizedStrings['selectColor'] ?? 'Select Color'; String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
String get selectIcon => String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
_localizedStrings['selectIcon'] ?? 'Select Icon';
String get addCategory =>
_localizedStrings['addCategory'] ?? 'Add Category';
String get settings => _localizedStrings['settings'] ?? 'Settings'; String get settings => _localizedStrings['settings'] ?? 'Settings';
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode'; String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
String get language => _localizedStrings['language'] ?? 'Language'; String get language => _localizedStrings['language'] ?? 'Language';
String get notifications => String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications'; _localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock'; 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 => String get notificationPermission =>
_localizedStrings['notificationPermission'] ?? 'Notification Permission'; _localizedStrings['notificationPermission'] ?? 'Notification Permission';
String get notificationPermissionDesc => String get notificationPermissionDesc =>
_localizedStrings['notificationPermissionDesc'] ?? 'Permission is required to receive notifications'; _localizedStrings['notificationPermissionDesc'] ??
'Permission is required to receive notifications';
String get requestPermission => String get requestPermission =>
_localizedStrings['requestPermission'] ?? 'Request Permission'; _localizedStrings['requestPermission'] ?? 'Request Permission';
String get paymentNotification => String get paymentNotification =>
_localizedStrings['paymentNotification'] ?? 'Payment Due Notification'; _localizedStrings['paymentNotification'] ?? 'Payment Due Notification';
String get paymentNotificationDesc => String get paymentNotificationDesc =>
_localizedStrings['paymentNotificationDesc'] ?? 'Receive notification on payment due date'; _localizedStrings['paymentNotificationDesc'] ??
'Receive notification on payment due date';
String get notificationTiming => String get notificationTiming =>
_localizedStrings['notificationTiming'] ?? 'Notification Timing'; _localizedStrings['notificationTiming'] ?? 'Notification Timing';
String get notificationTime => String get notificationTime =>
@@ -85,11 +105,14 @@ class AppLocalizations {
String get dailyReminder => String get dailyReminder =>
_localizedStrings['dailyReminder'] ?? 'Daily Reminder'; _localizedStrings['dailyReminder'] ?? 'Daily Reminder';
String get dailyReminderEnabled => String get dailyReminderEnabled =>
_localizedStrings['dailyReminderEnabled'] ?? 'Receive daily notifications until payment date'; _localizedStrings['dailyReminderEnabled'] ??
'Receive daily notifications until payment date';
String get dailyReminderDisabled => String get dailyReminderDisabled =>
_localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment'; _localizedStrings['dailyReminderDisabled'] ??
'Receive notification @ day(s) before payment';
String get notificationPermissionDenied => String get notificationPermissionDenied =>
_localizedStrings['notificationPermissionDenied'] ?? 'Notification permission denied'; _localizedStrings['notificationPermissionDenied'] ??
'Notification permission denied';
// 앱 정보 // 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info'; String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version'; String get version => _localizedStrings['version'] ?? 'Version';
@@ -102,7 +125,8 @@ class AppLocalizations {
String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light'; String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light';
String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark'; String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark';
String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black'; String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black';
String get systemTheme => _localizedStrings['systemTheme'] ?? 'System Default'; String get systemTheme =>
_localizedStrings['systemTheme'] ?? 'System Default';
// 기타 메시지 // 기타 메시지
String get subscriptionAdded => String get subscriptionAdded =>
_localizedStrings['subscriptionAdded'] ?? 'Subscription added'; _localizedStrings['subscriptionAdded'] ?? 'Subscription added';
@@ -112,190 +136,302 @@ class AppLocalizations {
String get japanese => _localizedStrings['japanese'] ?? '日本語'; String get japanese => _localizedStrings['japanese'] ?? '日本語';
String get chinese => _localizedStrings['chinese'] ?? '中文'; String get chinese => _localizedStrings['chinese'] ?? '中文';
// 날짜 // 날짜
String get oneDayBefore => _localizedStrings['oneDayBefore'] ?? '1 day before'; String get oneDayBefore =>
String get twoDaysBefore => _localizedStrings['twoDaysBefore'] ?? '2 days before'; _localizedStrings['oneDayBefore'] ?? '1 day before';
String get threeDaysBefore => _localizedStrings['threeDaysBefore'] ?? '3 days 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 requiredFieldsError =>
String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated'; _localizedStrings['requiredFieldsError'] ??
String get officialCancelPageNotFound => _localizedStrings['officialCancelPageNotFound'] ?? 'Official cancellation page not found. Redirecting to Google search.'; 'Please fill in all required fields';
String get cannotOpenWebsite => _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website'; String get subscriptionUpdated =>
String get noWebsiteInfo => _localizedStrings['noWebsiteInfo'] ?? 'No website information available. Please cancel through the website.'; _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 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 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 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 eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price';
String get eventPriceHint => _localizedStrings['eventPriceHint'] ?? 'Enter discounted price'; String get eventPriceHint =>
String get eventPriceRequired => _localizedStrings['eventPriceRequired'] ?? 'Please enter event price'; _localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
String get invalidPrice => _localizedStrings['invalidPrice'] ?? 'Please enter a valid 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 smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS';
String get home => _localizedStrings['home'] ?? 'Home'; String get home => _localizedStrings['home'] ?? 'Home';
String get analysis => _localizedStrings['analysis'] ?? 'Analysis'; String get analysis => _localizedStrings['analysis'] ?? 'Analysis';
String get back => _localizedStrings['back'] ?? 'Back'; String get back => _localizedStrings['back'] ?? 'Back';
String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App'; 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 exit => _localizedStrings['exit'] ?? 'Exit';
String get pageNotFound => _localizedStrings['pageNotFound'] ?? 'Page not found'; String get pageNotFound =>
String get serviceNameExample => _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify'; _localizedStrings['pageNotFound'] ?? 'Page not found';
String get urlExample => _localizedStrings['urlExample'] ?? 'https://example.com'; String get serviceNameExample =>
String get appLockDesc => _localizedStrings['appLockDesc'] ?? 'App lock with biometric authentication'; _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
String get unlockWithBiometric => _localizedStrings['unlockWithBiometric'] ?? 'Unlock with biometric authentication'; String get urlExample =>
String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.'; _localizedStrings['urlExample'] ?? 'https://example.com';
String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required'; String get appLockDesc =>
String get noSubscriptionSmsFound => _localizedStrings['noSubscriptionSmsFound'] ?? 'No subscription related SMS found'; _localizedStrings['appLockDesc'] ??
String get smsScanError => _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan'; 'App lock with biometric authentication';
String get saveError => _localizedStrings['saveError'] ?? 'Error occurred while saving'; String get unlockWithBiometric =>
String get newSubscriptionSmsNotFound => _localizedStrings['newSubscriptionSmsNotFound'] ?? 'No new subscription SMS found'; _localizedStrings['unlockWithBiometric'] ??
String get subscriptionAddError => _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription'; 'Unlock with biometric authentication';
String get allSubscriptionsProcessed => _localizedStrings['allSubscriptionsProcessed'] ?? 'All subscriptions have been processed.'; String get authenticationFailed =>
String get websiteUrlExtracted => _localizedStrings['websiteUrlExtracted'] ?? 'Website URL (Auto-extracted)'; _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 startDate => _localizedStrings['startDate'] ?? 'Start Date';
String get endDate => _localizedStrings['endDate'] ?? 'End Date'; String get endDate => _localizedStrings['endDate'] ?? 'End Date';
// 새로 추가된 항목들 // 새로 추가된 항목들
String get monthlyTotalSubscriptionCost => _localizedStrings['monthlyTotalSubscriptionCost'] ?? 'Total Monthly Subscription Cost'; String get monthlyTotalSubscriptionCost =>
String get todaysExchangeRate => _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate'; _localizedStrings['monthlyTotalSubscriptionCost'] ??
'Total Monthly Subscription Cost';
String get todaysExchangeRate =>
_localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
String get won => _localizedStrings['won'] ?? 'KRW'; String get won => _localizedStrings['won'] ?? 'KRW';
String get estimatedAnnualCost => _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost'; String get estimatedAnnualCost =>
String get totalSubscriptionServices => _localizedStrings['totalSubscriptionServices'] ?? 'Total Subscription Services'; _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
String get totalSubscriptionServices =>
_localizedStrings['totalSubscriptionServices'] ??
'Total Subscription Services';
String get services => _localizedStrings['services'] ?? '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 saving => _localizedStrings['saving'] ?? 'Saving';
String get paymentDueToday => _localizedStrings['paymentDueToday'] ?? 'Payment Due Today'; String get paymentDueToday =>
String get paymentInfoNeeded => _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed'; _localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
String get paymentInfoNeeded =>
_localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
String get event => _localizedStrings['event'] ?? 'Event'; String get event => _localizedStrings['event'] ?? 'Event';
// 카테고리 getter들 // 카테고리 getter들
String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music'; String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music';
String get categoryOttVideo => _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)'; String get categoryOttVideo =>
String get categoryStorageCloud => _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud'; _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
String get categoryTelecomInternetTv => _localizedStrings['categoryTelecomInternetTv'] ?? 'Telecom · Internet · TV'; String get categoryStorageCloud =>
String get categoryLifestyle => _localizedStrings['categoryLifestyle'] ?? 'Lifestyle'; _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
String get categoryShoppingEcommerce => _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce'; String get categoryTelecomInternetTv =>
String get categoryProgramming => _localizedStrings['categoryProgramming'] ?? 'Programming'; _localizedStrings['categoryTelecomInternetTv'] ??
String get categoryCollaborationOffice => _localizedStrings['categoryCollaborationOffice'] ?? 'Collaboration/Office'; 'Telecom · Internet · TV';
String get categoryAiService => _localizedStrings['categoryAiService'] ?? 'AI Service'; 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 get categoryOther => _localizedStrings['categoryOther'] ?? 'Other';
// 동적 메시지 생성 메서드 // 동적 메시지 생성 메서드
String daysBefore(int days) { String daysBefore(int days) {
return '$days${_localizedStrings['daysBefore'] ?? 'day(s) before'}'; return '$days${_localizedStrings['daysBefore'] ?? 'day(s) before'}';
} }
String dailyReminderDisabledWithDays(int days) { 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()); return template.replaceAll('@', days.toString());
} }
String subscriptionAddedWithName(String serviceName) { String subscriptionAddedWithName(String serviceName) {
final template = _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.'; final template =
_localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
return template.replaceAll('@', serviceName); return template.replaceAll('@', serviceName);
} }
String subscriptionDeleted(String 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); return template.replaceAll('@', serviceName);
} }
String totalExpenseCopied(String amount) { String totalExpenseCopied(String amount) {
final template = _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @'; final template =
_localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
return template.replaceAll('@', amount); return template.replaceAll('@', amount);
} }
String serviceRecognized(String serviceName) { 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); return template.replaceAll('@', serviceName);
} }
String smsScanErrorWithMessage(String error) { 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); return template.replaceAll('@', error);
} }
String saveErrorWithMessage(String 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); return template.replaceAll('@', error);
} }
String subscriptionAddErrorWithMessage(String error) { String subscriptionAddErrorWithMessage(String error) {
final template = _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription: @'; final template = _localizedStrings['subscriptionAddError'] ??
'Error adding subscription: @';
return template.replaceAll('@', error); return template.replaceAll('@', error);
} }
String subscriptionSkipped(String serviceName) { String subscriptionSkipped(String serviceName) {
final template = _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.'; final template =
_localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
return template.replaceAll('@', serviceName); return template.replaceAll('@', serviceName);
} }
// 홈화면 관련 // 홈화면 관련
String get mySubscriptions => _localizedStrings['mySubscriptions'] ?? 'My Subscriptions'; String get mySubscriptions =>
_localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
String subscriptionCount(int count) { String subscriptionCount(int count) {
if (locale.languageCode == 'ko') { if (locale.languageCode == 'ko') {
return '${count}'; return '$count개';
} else if (locale.languageCode == 'ja') { } else if (locale.languageCode == 'ja') {
return '${count}'; return '$count個';
} else if (locale.languageCode == 'zh') { } else if (locale.languageCode == 'zh') {
return '${count}'; return '$count个';
} else { } else {
return count.toString(); return count.toString();
} }
} }
// 분석화면 관련 // 분석화면 관련
String get monthlyExpenseTitle => _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status'; String get monthlyExpenseTitle =>
String get recentSixMonthsTrend => _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend'; _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
String get monthlySubscriptionExpense => _localizedStrings['monthlySubscriptionExpense'] ?? 'Monthly subscription expense'; String get recentSixMonthsTrend =>
String get subscriptionServiceRatio => _localizedStrings['subscriptionServiceRatio'] ?? 'Subscription Service Ratio'; _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
String get monthlyExpenseBasis => _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense'; String get monthlySubscriptionExpense =>
String get noSubscriptionServices => _localizedStrings['noSubscriptionServices'] ?? 'No subscription services'; _localizedStrings['monthlySubscriptionExpense'] ??
String get totalExpenseSummary => _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary'; 'Monthly subscription expense';
String get monthlyTotalAmount => _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount'; String get subscriptionServiceRatio =>
String get totalExpense => _localizedStrings['totalExpense'] ?? 'Total Expense'; _localizedStrings['subscriptionServiceRatio'] ??
String get totalServices => _localizedStrings['totalServices'] ?? 'Total Services'; '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 servicesUnit => _localizedStrings['servicesUnit'] ?? 'services';
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost'; String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status'; String get eventDiscountStatus =>
String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress'; _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get monthlySavingAmount => _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount'; String get inProgressUnit =>
String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress'; _localizedStrings['inProgressUnit'] ?? 'in progress';
String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount'; 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'; String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW';
// SMS 스캔 관련 // SMS 스캔 관련
String get scanningMessages => _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...'; String get scanningMessages =>
String get findingSubscriptions => _localizedStrings['findingSubscriptions'] ?? 'Finding subscription services'; _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
String get subscriptionNotFound => _localizedStrings['subscriptionNotFound'] ?? 'Subscription information not found.'; String get findingSubscriptions =>
String get repeatSubscriptionNotFound => _localizedStrings['repeatSubscriptionNotFound'] ?? 'No repeated subscription information found.'; _localizedStrings['findingSubscriptions'] ??
String get newSubscriptionNotFound => _localizedStrings['newSubscriptionNotFound'] ?? 'No new subscription SMS found'; 'Finding subscription services';
String get findRepeatSubscriptions => _localizedStrings['findRepeatSubscriptions'] ?? 'Find subscriptions paid 2+ times'; String get subscriptionNotFound =>
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.'; _localizedStrings['subscriptionNotFound'] ??
String get startScanning => _localizedStrings['startScanning'] ?? 'Start Scanning'; 'Subscription information not found.';
String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription'; 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 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 category => _localizedStrings['category'] ?? 'Category';
String get websiteUrlAuto => _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)'; String get websiteUrlAuto =>
String get websiteUrlHint => _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty'; _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 skip => _localizedStrings['skip'] ?? 'Skip';
String get add => _localizedStrings['add'] ?? 'Add'; 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) { 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()); return template.replaceAll('@', date).replaceAll('#', days.toString());
} }
String nextBillingDateInfo(String date, int days) { 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()); 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) { String formatDate(DateTime date) {
if (locale.languageCode == 'ko') { if (locale.languageCode == 'ko') {
return '${date.year}${date.month}${date.day}'; return '${date.year}${date.month}${date.day}';
@@ -304,112 +440,165 @@ class AppLocalizations {
} else if (locale.languageCode == 'zh') { } else if (locale.languageCode == 'zh') {
return '${date.year}${date.month}${date.day}'; return '${date.year}${date.month}${date.day}';
} else { } 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}'; return '${months[date.month - 1]} ${date.day}, ${date.year}';
} }
} }
String repeatCountDetected(int count) { String repeatCountDetected(int count) {
final template = _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected'; final template =
_localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected';
return template.replaceAll('@', count.toString()); return template.replaceAll('@', count.toString());
} }
String servicesInProgress(int count) { String servicesInProgress(int count) {
if (locale.languageCode == 'ko') { if (locale.languageCode == 'ko') {
return '${count} 진행중'; return '$count 진행중';
} else if (locale.languageCode == 'ja') { } else if (locale.languageCode == 'ja') {
return '${count}個進行中'; return '$count個進行中';
} else if (locale.languageCode == 'zh') { } else if (locale.languageCode == 'zh') {
return '${count}个进行中'; return '$count个进行中';
} else { } else {
return '$count in progress'; return '$count in progress';
} }
} }
// 새로 추가된 동적 메서드들 // 새로 추가된 동적 메서드들
String paymentDueInDays(int days) { 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()); return template.replaceAll('@', days.toString());
} }
String daysRemaining(int days) { String daysRemaining(int days) {
final template = _localizedStrings['daysRemaining'] ?? '@ days remaining'; final template = _localizedStrings['daysRemaining'] ?? '@ days remaining';
return template.replaceAll('@', days.toString()); return template.replaceAll('@', days.toString());
} }
String exchangeRateFormat(String rate) { String exchangeRateFormat(String rate) {
final template = _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @'; final template =
_localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
return template.replaceAll('@', rate); return template.replaceAll('@', rate);
} }
// 결제 주기 결제 메시지 // 결제 주기 결제 메시지
String get billingCyclePayment => _localizedStrings['billingCyclePayment'] ?? '@ Payment'; String get billingCyclePayment =>
_localizedStrings['billingCyclePayment'] ?? '@ Payment';
// 할인 금액 표시 getter들 // 할인 금액 표시 getter들
String get discountAmountWon => _localizedStrings['discountAmountWon'] ?? 'Save ₩@'; String get discountAmountWon =>
String get discountAmountDollar => _localizedStrings['discountAmountDollar'] ?? 'Save \$@'; _localizedStrings['discountAmountWon'] ?? 'Save @';
String get discountAmountYen => _localizedStrings['discountAmountYen'] ?? 'Save ¥@'; String get discountAmountDollar =>
String get discountAmountYuan => _localizedStrings['discountAmountYuan'] ?? 'Save ¥@'; _localizedStrings['discountAmountDollar'] ?? 'Save \$@';
String get discountAmountYen =>
_localizedStrings['discountAmountYen'] ?? 'Save ¥@';
String get discountAmountYuan =>
_localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
// 결제 주기 관련 getter // 결제 주기 관련 getter
String get monthly => _localizedStrings['monthly'] ?? 'Monthly'; String get monthly => _localizedStrings['monthly'] ?? 'Monthly';
String get weekly => _localizedStrings['weekly'] ?? 'Weekly'; String get weekly => _localizedStrings['weekly'] ?? 'Weekly';
String get yearly => _localizedStrings['yearly'] ?? 'Yearly'; String get yearly => _localizedStrings['yearly'] ?? 'Yearly';
String get billingCycleMonthly => _localizedStrings['billingCycleMonthly'] ?? 'Monthly'; String get billingCycleMonthly =>
String get billingCycleQuarterly => _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly'; _localizedStrings['billingCycleMonthly'] ?? 'Monthly';
String get billingCycleHalfYearly => _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly'; String get billingCycleQuarterly =>
String get billingCycleYearly => _localizedStrings['billingCycleYearly'] ?? 'Yearly'; _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
String get billingCycleHalfYearly =>
_localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
String get billingCycleYearly =>
_localizedStrings['billingCycleYearly'] ?? 'Yearly';
// 색상 관련 getter // 색상 관련 getter
String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue'; String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue';
String get colorGreen => _localizedStrings['colorGreen'] ?? 'Green'; String get colorGreen => _localizedStrings['colorGreen'] ?? 'Green';
String get colorOrange => _localizedStrings['colorOrange'] ?? 'Orange'; String get colorOrange => _localizedStrings['colorOrange'] ?? 'Orange';
String get colorRed => _localizedStrings['colorRed'] ?? 'Red'; String get colorRed => _localizedStrings['colorRed'] ?? 'Red';
String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple'; String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple';
// 날짜 형식 관련 getter // 날짜 형식 관련 getter
String get dateFormatFull => _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy'; String get dateFormatFull =>
_localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd'; String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd';
// USD 환율 표시 형식 // USD 환율 표시 형식
String get exchangeRateDisplay => _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @'; String get exchangeRateDisplay =>
_localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
// 라벨 및 힌트 텍스트 // 라벨 및 힌트 텍스트
String get labelServiceName => _localizedStrings['labelServiceName'] ?? 'Service Name'; String get labelServiceName =>
String get hintServiceName => _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify'; _localizedStrings['labelServiceName'] ?? 'Service Name';
String get labelMonthlyExpense => _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense'; String get hintServiceName =>
String get labelNextBillingDate => _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date'; _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
String get labelWebsiteUrl => _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)'; String get labelMonthlyExpense =>
String get hintWebsiteUrl => _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com'; _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
String get labelEventPrice => _localizedStrings['labelEventPrice'] ?? 'Event Price'; String get labelNextBillingDate =>
String get hintEventPrice => _localizedStrings['hintEventPrice'] ?? 'Enter discounted price'; _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 labelCategory => _localizedStrings['labelCategory'] ?? 'Category';
// 기타 번역 // 기타 번역
String get subscription => _localizedStrings['subscription'] ?? 'Subscription'; String get subscription =>
_localizedStrings['subscription'] ?? 'Subscription';
String get movie => _localizedStrings['movie'] ?? 'Movie'; 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 exercise => _localizedStrings['exercise'] ?? 'Exercise';
String get shopping => _localizedStrings['shopping'] ?? 'Shopping'; String get shopping => _localizedStrings['shopping'] ?? 'Shopping';
String get currency => _localizedStrings['currency'] ?? 'Currency'; String get currency => _localizedStrings['currency'] ?? 'Currency';
String get websiteInfo => _localizedStrings['websiteInfo'] ?? 'Website Information'; String get websiteInfo =>
String get cancelGuide => _localizedStrings['cancelGuide'] ?? 'Cancellation Guide'; _localizedStrings['websiteInfo'] ?? 'Website Information';
String get cancelServiceGuide => _localizedStrings['cancelServiceGuide'] ?? 'To cancel this service, please go to the cancellation page through the link below.'; String get cancelGuide =>
String get goToCancelPage => _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page'; _localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
String get urlAutoMatchInfo => _localizedStrings['urlAutoMatchInfo'] ?? 'If URL is empty, it will be automatically matched based on the service name'; 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'; String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select';
// 새로 추가된 getter들 // 새로 추가된 getter들
String get serviceInfo => _localizedStrings['serviceInfo'] ?? 'Service Information'; String get serviceInfo =>
String get newSubscriptionAdd => _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription'; _localizedStrings['serviceInfo'] ?? 'Service Information';
String get enterServiceInfo => _localizedStrings['enterServiceInfo'] ?? 'Enter service information'; String get newSubscriptionAdd =>
String get addSubscriptionButton => _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription'; _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
String get serviceNameRequired => _localizedStrings['serviceNameRequired'] ?? 'Please enter service name'; String get enterServiceInfo =>
String get amountRequired => _localizedStrings['amountRequired'] ?? 'Please enter amount'; _localizedStrings['enterServiceInfo'] ?? 'Enter service information';
String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail'; 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 enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; String get invalidAmount =>
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon =>
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
// 결제 주기를 키값으로 변환하여 번역된 이름 반환 // 결제 주기를 키값으로 변환하여 번역된 이름 반환
String getBillingCycleName(String billingCycleKey) { String getBillingCycleName(String billingCycleKey) {
switch (billingCycleKey) { switch (billingCycleKey) {
@@ -432,7 +621,7 @@ class AppLocalizations {
return billingCycleKey; // 매칭되지 않으면 원본 반환 return billingCycleKey; // 매칭되지 않으면 원본 반환
} }
} }
// 카테고리 이름을 키로 변환하여 번역된 이름 반환 // 카테고리 이름을 키로 변환하여 번역된 이름 반환
String getCategoryName(String categoryKey) { String getCategoryName(String categoryKey) {
switch (categoryKey) { switch (categoryKey) {
@@ -466,7 +655,8 @@ class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const AppLocalizationsDelegate(); const AppLocalizationsDelegate();
@override @override
bool isSupported(Locale locale) => ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode); bool isSupported(Locale locale) =>
['en', 'ko', 'ja', 'zh'].contains(locale.languageCode);
@override @override
Future<AppLocalizations> load(Locale locale) async { 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:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:async' show unawaited;
import 'utils/memory_manager.dart'; import 'utils/memory_manager.dart';
import 'utils/logger.dart';
import 'utils/performance_optimizer.dart'; import 'utils/performance_optimizer.dart';
import 'navigator_key.dart'; import 'navigator_key.dart';
// AdMob 활성화 플래그 (개발 중 false, 프로덕션 시 true로 변경)
const bool enableAdMob = true;
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만) // 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만)
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && enableAdMob) {
await MobileAds.instance.initialize(); unawaited(MobileAds.instance.initialize());
} }
// 성능 최적화 설정 // 성능 최적화 설정
@@ -40,16 +45,23 @@ Future<void> main() async {
try { try {
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비 // 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지) // 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화
await DefaultCacheManager().emptyCache(); // 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요.
const bool clearCacheOnStartup = bool.fromEnvironment(
'CLEAR_CACHE_ON_STARTUP',
defaultValue: false,
);
if (clearCacheOnStartup) {
await DefaultCacheManager().emptyCache();
}
if (kDebugMode) { if (kDebugMode) {
print('이미지 캐시 관리 초기화 완료'); Log.d('이미지 캐시 관리 초기화 완료');
PerformanceOptimizer.checkConstOptimization(); PerformanceOptimizer.checkConstOptimization();
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('캐시 초기화 오류: $e'); Log.e('캐시 초기화 오류', e);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,12 +86,12 @@ class NotificationProvider extends ChangeNotifier {
try { try {
_isEnabled = value; _isEnabled = value;
await NotificationService.setNotificationEnabled(value); await NotificationService.setNotificationEnabled(value);
// 첫 권한 부여 시 기본 설정 적용 // 첫 권한 부여 시 기본 설정 적용
if (value) { if (value) {
await initializeDefaultSettingsOnFirstPermission(); await initializeDefaultSettingsOnFirstPermission();
} }
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('알림 활성화 설정 중 오류 발생: $e'); debugPrint('알림 활성화 설정 중 오류 발생: $e');
@@ -270,15 +270,17 @@ class NotificationProvider extends ChangeNotifier {
// 첫 권한 부여 시 기본 설정 초기화 // 첫 권한 부여 시 기본 설정 초기화
Future<void> initializeDefaultSettingsOnFirstPermission() async { Future<void> initializeDefaultSettingsOnFirstPermission() async {
try { try {
final firstGranted = await _secureStorage.read(key: _firstPermissionGrantedKey); final firstGranted =
await _secureStorage.read(key: _firstPermissionGrantedKey);
if (firstGranted != 'true') { if (firstGranted != 'true') {
// 첫 권한 부여 시 기본값 설정 // 첫 권한 부여 시 기본값 설정
await setReminderDays(2); // 2일 전 알림 await setReminderDays(2); // 2일 전 알림
await setDailyReminderEnabled(true); // 반복 알림 활성화 await setDailyReminderEnabled(true); // 반복 알림 활성화
await setPaymentEnabled(true); // 결제 예정 알림 활성화 await setPaymentEnabled(true); // 결제 예정 알림 활성화
// 첫 권한 부여 플래그 저장 // 첫 권한 부여 플래그 저장
await _secureStorage.write(key: _firstPermissionGrantedKey, value: 'true'); await _secureStorage.write(
key: _firstPermissionGrantedKey, value: 'true');
} }
} catch (e) { } catch (e) {
debugPrint('기본 설정 초기화 중 오류 발생: $e'); debugPrint('기본 설정 초기화 중 오류 발생: $e');

View File

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

View File

@@ -7,24 +7,24 @@ import '../theme/adaptive_theme.dart';
class ThemeProvider extends ChangeNotifier { class ThemeProvider extends ChangeNotifier {
static const String _themeBoxName = 'theme_settings'; static const String _themeBoxName = 'theme_settings';
static const String _themeKey = 'theme_settings'; static const String _themeKey = 'theme_settings';
late Box<Map> _themeBox; late Box<Map> _themeBox;
ThemeSettings _themeSettings = const ThemeSettings(); ThemeSettings _themeSettings = const ThemeSettings();
ThemeSettings get themeSettings => _themeSettings; ThemeSettings get themeSettings => _themeSettings;
AppThemeMode get themeMode => _themeSettings.mode; AppThemeMode get themeMode => _themeSettings.mode;
bool get useSystemColors => _themeSettings.useSystemColors; bool get useSystemColors => _themeSettings.useSystemColors;
bool get largeText => _themeSettings.largeText; bool get largeText => _themeSettings.largeText;
bool get reduceMotion => _themeSettings.reduceMotion; bool get reduceMotion => _themeSettings.reduceMotion;
bool get highContrast => _themeSettings.highContrast; bool get highContrast => _themeSettings.highContrast;
/// Provider 초기화 /// Provider 초기화
Future<void> initialize() async { Future<void> initialize() async {
_themeBox = await Hive.openBox<Map>(_themeBoxName); _themeBox = await Hive.openBox<Map>(_themeBoxName);
await _loadThemeSettings(); await _loadThemeSettings();
} }
/// 저장된 테마 설정 로드 /// 저장된 테마 설정 로드
Future<void> _loadThemeSettings() async { Future<void> _loadThemeSettings() async {
final savedSettings = _themeBox.get(_themeKey); final savedSettings = _themeBox.get(_themeKey);
@@ -35,53 +35,53 @@ class ThemeProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
/// 테마 설정 저장 /// 테마 설정 저장
Future<void> _saveThemeSettings() async { Future<void> _saveThemeSettings() async {
await _themeBox.put(_themeKey, _themeSettings.toJson()); await _themeBox.put(_themeKey, _themeSettings.toJson());
} }
/// 테마 모드 변경 /// 테마 모드 변경
Future<void> setThemeMode(AppThemeMode mode) async { Future<void> setThemeMode(AppThemeMode mode) async {
_themeSettings = _themeSettings.copyWith(mode: mode); _themeSettings = _themeSettings.copyWith(mode: mode);
await _saveThemeSettings(); await _saveThemeSettings();
notifyListeners(); notifyListeners();
} }
/// 시스템 색상 사용 설정 /// 시스템 색상 사용 설정
Future<void> setUseSystemColors(bool value) async { Future<void> setUseSystemColors(bool value) async {
_themeSettings = _themeSettings.copyWith(useSystemColors: value); _themeSettings = _themeSettings.copyWith(useSystemColors: value);
await _saveThemeSettings(); await _saveThemeSettings();
notifyListeners(); notifyListeners();
} }
/// 큰 텍스트 설정 /// 큰 텍스트 설정
Future<void> setLargeText(bool value) async { Future<void> setLargeText(bool value) async {
_themeSettings = _themeSettings.copyWith(largeText: value); _themeSettings = _themeSettings.copyWith(largeText: value);
await _saveThemeSettings(); await _saveThemeSettings();
notifyListeners(); notifyListeners();
} }
/// 모션 감소 설정 /// 모션 감소 설정
Future<void> setReduceMotion(bool value) async { Future<void> setReduceMotion(bool value) async {
_themeSettings = _themeSettings.copyWith(reduceMotion: value); _themeSettings = _themeSettings.copyWith(reduceMotion: value);
await _saveThemeSettings(); await _saveThemeSettings();
notifyListeners(); notifyListeners();
} }
/// 고대비 설정 /// 고대비 설정
Future<void> setHighContrast(bool value) async { Future<void> setHighContrast(bool value) async {
_themeSettings = _themeSettings.copyWith(highContrast: value); _themeSettings = _themeSettings.copyWith(highContrast: value);
await _saveThemeSettings(); await _saveThemeSettings();
notifyListeners(); notifyListeners();
} }
/// 현재 설정에 따른 테마 가져오기 /// 현재 설정에 따른 테마 가져오기
ThemeData getTheme(BuildContext context) { ThemeData getTheme(BuildContext context) {
final platformBrightness = MediaQuery.of(context).platformBrightness; final platformBrightness = MediaQuery.of(context).platformBrightness;
ThemeData baseTheme; ThemeData baseTheme;
switch (_themeSettings.mode) { switch (_themeSettings.mode) {
case AppThemeMode.light: case AppThemeMode.light:
baseTheme = AdaptiveTheme.lightTheme; baseTheme = AdaptiveTheme.lightTheme;
@@ -98,7 +98,7 @@ class ThemeProvider extends ChangeNotifier {
: AdaptiveTheme.lightTheme; : AdaptiveTheme.lightTheme;
break; break;
} }
// 접근성 설정 적용 // 접근성 설정 적용
return AdaptiveTheme.getAccessibleTheme( return AdaptiveTheme.getAccessibleTheme(
baseTheme, baseTheme,
@@ -107,11 +107,11 @@ class ThemeProvider extends ChangeNotifier {
highContrast: _themeSettings.highContrast, highContrast: _themeSettings.highContrast,
); );
} }
/// 현재 테마가 다크 모드인지 확인 /// 현재 테마가 다크 모드인지 확인
bool isDarkMode(BuildContext context) { bool isDarkMode(BuildContext context) {
final platformBrightness = MediaQuery.of(context).platformBrightness; final platformBrightness = MediaQuery.of(context).platformBrightness;
switch (_themeSettings.mode) { switch (_themeSettings.mode) {
case AppThemeMode.light: case AppThemeMode.light:
return false; return false;
@@ -122,7 +122,7 @@ class ThemeProvider extends ChangeNotifier {
return platformBrightness == Brightness.dark; return platformBrightness == Brightness.dark;
} }
} }
/// 테마 토글 (라이트/다크) /// 테마 토글 (라이트/다크)
Future<void> toggleTheme() async { Future<void> toggleTheme() async {
if (_themeSettings.mode == AppThemeMode.light) { if (_themeSettings.mode == AppThemeMode.light) {
@@ -137,7 +137,7 @@ class ThemeProvider extends ChangeNotifier {
class AnimatedThemeBuilder extends StatelessWidget { class AnimatedThemeBuilder extends StatelessWidget {
final Widget Function(BuildContext, ThemeData) builder; final Widget Function(BuildContext, ThemeData) builder;
final Duration duration; final Duration duration;
const AnimatedThemeBuilder({ const AnimatedThemeBuilder({
super.key, super.key,
required this.builder, required this.builder,
@@ -148,7 +148,7 @@ class AnimatedThemeBuilder extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>(); final themeProvider = context.watch<ThemeProvider>();
final theme = themeProvider.getTheme(context); final theme = themeProvider.getTheme(context);
return AnimatedTheme( return AnimatedTheme(
data: theme, data: theme,
duration: duration, duration: duration,
@@ -164,7 +164,7 @@ class ThemedColor extends StatelessWidget {
final Color lightColor; final Color lightColor;
final Color darkColor; final Color darkColor;
final Widget child; final Widget child;
const ThemedColor({ const ThemedColor({
super.key, super.key,
required this.lightColor, required this.lightColor,
@@ -175,7 +175,7 @@ class ThemedColor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = context.read<ThemeProvider>().isDarkMode(context); final isDark = context.read<ThemeProvider>().isDarkMode(context);
return Theme( return Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
primaryColor: isDark ? darkColor : lightColor, primaryColor: isDark ? darkColor : lightColor,
@@ -183,4 +183,4 @@ class ThemedColor extends StatelessWidget {
child: child, 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/analysis_screen.dart';
import 'package:submanager/screens/settings_screen.dart'; import 'package:submanager/screens/settings_screen.dart';
import 'package:submanager/screens/splash_screen.dart'; import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/screens/sms_permission_screen.dart';
import 'package:submanager/models/subscription_model.dart'; import 'package:submanager/models/subscription_model.dart';
class AppRoutes { class AppRoutes {
@@ -16,6 +17,7 @@ class AppRoutes {
static const String smsScanner = '/sms-scanner'; static const String smsScanner = '/sms-scanner';
static const String analysis = '/analysis'; static const String analysis = '/analysis';
static const String settings = '/settings'; static const String settings = '/settings';
static const String smsPermission = '/sms-permission';
static Map<String, WidgetBuilder> getRoutes() { static Map<String, WidgetBuilder> getRoutes() {
return { return {
@@ -25,6 +27,7 @@ class AppRoutes {
smsScanner: (context) => const SmsScanScreen(), smsScanner: (context) => const SmsScanScreen(),
analysis: (context) => const AnalysisScreen(), analysis: (context) => const AnalysisScreen(),
settings: (context) => const SettingsScreen(), settings: (context) => const SettingsScreen(),
smsPermission: (context) => const SmsPermissionScreen(),
}; };
} }
@@ -32,29 +35,33 @@ class AppRoutes {
switch (routeSettings.name) { switch (routeSettings.name) {
case splash: case splash:
return _buildRoute(const SplashScreen(), routeSettings); return _buildRoute(const SplashScreen(), routeSettings);
case main: case main:
return _buildRoute(const MainScreen(), routeSettings); return _buildRoute(const MainScreen(), routeSettings);
case addSubscription: case addSubscription:
return _buildRoute(const AddSubscriptionScreen(), routeSettings); return _buildRoute(const AddSubscriptionScreen(), routeSettings);
case subscriptionDetail: case subscriptionDetail:
final subscription = routeSettings.arguments as SubscriptionModel?; final subscription = routeSettings.arguments as SubscriptionModel?;
if (subscription != null) { if (subscription != null) {
return _buildRoute(DetailScreen(subscription: subscription), routeSettings); return _buildRoute(
DetailScreen(subscription: subscription), routeSettings);
} }
return _errorRoute(); return _errorRoute();
case smsScanner: case smsScanner:
return _buildRoute(const SmsScanScreen(), routeSettings); return _buildRoute(const SmsScanScreen(), routeSettings);
case analysis: case analysis:
return _buildRoute(const AnalysisScreen(), routeSettings); return _buildRoute(const AnalysisScreen(), routeSettings);
case settings: case settings:
return _buildRoute(const SettingsScreen(), routeSettings); return _buildRoute(const SettingsScreen(), routeSettings);
case smsPermission:
return _buildRoute(const SmsPermissionScreen(), routeSettings);
default: default:
return _errorRoute(); 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); 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); 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( Navigator.pushNamedAndRemoveUntil(
context, context,
routeName, routeName,
@@ -103,4 +113,4 @@ class AppRoutes {
static bool canPop(BuildContext context) { static bool canPop(BuildContext context) {
return Navigator.canPop(context); return Navigator.canPop(context);
} }
} }

View File

@@ -62,14 +62,14 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox(height: MediaQuery.of(context).padding.top + 60), SizedBox(height: MediaQuery.of(context).padding.top + 60),
// 헤더 섹션 // 헤더 섹션
AddSubscriptionHeader( AddSubscriptionHeader(
controller: _controller, controller: _controller,
fadeAnimation: _controller.fadeAnimation!, fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!, slideAnimation: _controller.slideAnimation!,
), ),
// 서비스 정보 폼 // 서비스 정보 폼
AddSubscriptionForm( AddSubscriptionForm(
controller: _controller, controller: _controller,
@@ -78,7 +78,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
setState: setState, setState: setState,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 이벤트/할인 섹션 // 이벤트/할인 섹션
AddSubscriptionEventSection( AddSubscriptionEventSection(
controller: _controller, controller: _controller,
@@ -87,7 +87,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
setState: setState, setState: setState,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
// 저장 버튼 // 저장 버튼
AddSubscriptionSaveButton( AddSubscriptionSaveButton(
controller: _controller, controller: _controller,
@@ -101,4 +101,4 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
), ),
); );
} }
} }

View File

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

View File

@@ -13,13 +13,13 @@ class AppLockScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.lock_outline, Icons.lock_outline,
size: 80, size: 80,
color: AppColors.navyGray, color: AppColors.navyGray,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( const Text(
'앱이 잠겨 있습니다', '앱이 잠겨 있습니다',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
@@ -28,7 +28,7 @@ class AppLockScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( const Text(
'생체 인증으로 잠금을 해제하세요', '생체 인증으로 잠금을 해제하세요',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
@@ -42,7 +42,7 @@ class AppLockScreen extends StatelessWidget {
final success = await appLock.authenticate(); final success = await appLock.authenticate();
if (!success && context.mounted) { if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text( content: Text(
'인증에 실패했습니다. 다시 시도해주세요.', '인증에 실패했습니다. 다시 시도해주세요.',
style: TextStyle( style: TextStyle(

View File

@@ -43,7 +43,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: const Text(
'카테고리 관리', '카테고리 관리',
style: TextStyle( style: TextStyle(
color: AppColors.pureWhite, color: AppColors.pureWhite,
@@ -66,7 +66,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '카테고리 이름', labelText: '카테고리 이름',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
@@ -82,7 +82,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedColor, value: _selectedColor,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '색상 선택', labelText: '색상 선택',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
@@ -90,15 +90,35 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
), ),
items: [ items: [
DropdownMenuItem( DropdownMenuItem(
value: '#1976D2', child: Text(AppLocalizations.of(context).colorBlue, style: TextStyle(color: AppColors.darkNavy))), value: '#1976D2',
child: Text(
AppLocalizations.of(context).colorBlue,
style: const TextStyle(
color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#4CAF50', child: Text(AppLocalizations.of(context).colorGreen, style: TextStyle(color: AppColors.darkNavy))), value: '#4CAF50',
child: Text(
AppLocalizations.of(context).colorGreen,
style: const TextStyle(
color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#FF9800', child: Text(AppLocalizations.of(context).colorOrange, style: TextStyle(color: AppColors.darkNavy))), value: '#FF9800',
child: Text(
AppLocalizations.of(context).colorOrange,
style: const TextStyle(
color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#F44336', child: Text(AppLocalizations.of(context).colorRed, style: TextStyle(color: AppColors.darkNavy))), value: '#F44336',
child: Text(
AppLocalizations.of(context).colorRed,
style: const TextStyle(
color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#9C27B0', child: Text(AppLocalizations.of(context).colorPurple, style: TextStyle(color: AppColors.darkNavy))), value: '#9C27B0',
child: Text(
AppLocalizations.of(context).colorPurple,
style: const TextStyle(
color: AppColors.darkNavy))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -109,22 +129,38 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedIcon, value: _selectedIcon,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '아이콘 선택', labelText: '아이콘 선택',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
), ),
), ),
items: [ items: const [
DropdownMenuItem( DropdownMenuItem(
value: 'subscriptions', child: Text('구독', style: TextStyle(color: AppColors.darkNavy))), value: 'subscriptions',
DropdownMenuItem(value: 'movie', child: Text('영화', style: TextStyle(color: AppColors.darkNavy))), child: Text('구독',
style:
TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: 'music_note', child: Text('음악', style: TextStyle(color: AppColors.darkNavy))), value: 'movie',
child: Text('영화',
style:
TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: 'fitness_center', child: Text('운동', style: TextStyle(color: AppColors.darkNavy))), value: 'music_note',
child: Text('음악',
style:
TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: 'shopping_cart', child: Text('쇼핑', style: TextStyle(color: AppColors.darkNavy))), value: 'fitness_center',
child: Text('운동',
style:
TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem(
value: 'shopping_cart',
child: Text('쇼핑',
style:
TextStyle(color: AppColors.darkNavy))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -135,7 +171,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _addCategory, onPressed: _addCategory,
child: Text( child: const Text(
'카테고리 추가', '카테고리 추가',
style: TextStyle( style: TextStyle(
color: AppColors.pureWhite, color: AppColors.pureWhite,
@@ -163,8 +199,9 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
int.parse(category.color.replaceAll('#', '0xFF'))), int.parse(category.color.replaceAll('#', '0xFF'))),
), ),
title: Text( title: Text(
provider.getLocalizedCategoryName(context, category.name), provider.getLocalizedCategoryName(
style: TextStyle( context, category.name),
style: const TextStyle(
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),
), ),

View File

@@ -43,7 +43,6 @@ class _DetailScreenState extends State<DetailScreen>
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final baseColor = _controller.getCardColor(); final baseColor = _controller.getCardColor();
@@ -53,111 +52,112 @@ class _DetailScreenState extends State<DetailScreen>
child: Scaffold( child: Scaffold(
backgroundColor: AppColors.backgroundColor, backgroundColor: AppColors.backgroundColor,
body: CustomScrollView( body: CustomScrollView(
controller: _controller.scrollController, controller: _controller.scrollController,
slivers: [ slivers: [
// 상단 헤더 섹션 // 상단 헤더 섹션
SliverToBoxAdapter( SliverToBoxAdapter(
child: DetailHeaderSection( child: DetailHeaderSection(
subscription: widget.subscription, subscription: widget.subscription,
controller: _controller, controller: _controller,
fadeAnimation: _controller.fadeAnimation!, fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!, slideAnimation: _controller.slideAnimation!,
rotateAnimation: _controller.rotateAnimation!, 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!,
),
],
), ),
), ),
), // 본문 콘텐츠
], 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: const 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!,
),
],
),
),
),
],
), ),
), ),
); );
} }
} }

View File

@@ -14,6 +14,7 @@ import '../widgets/floating_navigation_bar.dart';
import '../widgets/glassmorphic_scaffold.dart'; import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/home_content.dart'; import '../widgets/home_content.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/platform_helper.dart';
class MainScreen extends StatefulWidget { class MainScreen extends StatefulWidget {
const MainScreen({super.key}); const MainScreen({super.key});
@@ -32,7 +33,7 @@ class _MainScreenState extends State<MainScreen>
late AnimationController _waveController; late AnimationController _waveController;
late ScrollController _scrollController; late ScrollController _scrollController;
late FloatingNavBarScrollController _navBarScrollController; late FloatingNavBarScrollController _navBarScrollController;
// 화면 목록 // 화면 목록
late final List<Widget> _screens; late final List<Widget> _screens;
@@ -62,14 +63,14 @@ class _MainScreenState extends State<MainScreen>
); );
_scrollController = ScrollController(); _scrollController = ScrollController();
_navBarScrollController = FloatingNavBarScrollController( _navBarScrollController = FloatingNavBarScrollController(
scrollController: _scrollController, scrollController: _scrollController,
onHide: () {}, onHide: () {},
onShow: () {}, onShow: () {},
); );
// 화면 목록 초기화 // 화면 목록 초기화 (iOS에서는 SMS 스캔 제외)
_screens = [ _screens = [
HomeContent( HomeContent(
fadeController: _fadeController, fadeController: _fadeController,
@@ -82,7 +83,7 @@ class _MainScreenState extends State<MainScreen>
), ),
const AnalysisScreen(), const AnalysisScreen(),
Container(), // 추가 버튼은 별도 처리 Container(), // 추가 버튼은 별도 처리
const SmsScanScreen(), if (!PlatformHelper.isIOS) const SmsScanScreen(),
const SettingsScreen(), const SettingsScreen(),
]; ];
} }
@@ -156,7 +157,7 @@ class _MainScreenState extends State<MainScreen>
AppRoutes.addSubscription, AppRoutes.addSubscription,
).then((result) { ).then((result) {
_resetAnimations(); _resetAnimations();
// 구독이 성공적으로 추가된 경우 // 구독이 성공적으로 추가된 경우
if (result == true) { if (result == true) {
// 상단에 스낵바 표시 // 상단에 스낵바 표시
@@ -202,18 +203,18 @@ class _MainScreenState extends State<MainScreen>
void _handleNavigation(int index, BuildContext context) { void _handleNavigation(int index, BuildContext context) {
final navigationProvider = context.read<NavigationProvider>(); final navigationProvider = context.read<NavigationProvider>();
// 이미 같은 인덱스면 무시 // 이미 같은 인덱스면 무시
if (navigationProvider.currentIndex == index) { if (navigationProvider.currentIndex == index) {
return; return;
} }
// 추가 버튼은 별도 처리 // 추가 버튼은 별도 처리
if (index == 2) { if (index == 2) {
_navigateToAddSubscription(context); _navigateToAddSubscription(context);
return; return;
} }
// 인덱스 업데이트 // 인덱스 업데이트
navigationProvider.updateCurrentIndex(index); navigationProvider.updateCurrentIndex(index);
} }
@@ -221,7 +222,7 @@ class _MainScreenState extends State<MainScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigationProvider = context.watch<NavigationProvider>(); final navigationProvider = context.watch<NavigationProvider>();
// 메인 그라데이션 사용 // 메인 그라데이션 사용
List<Color> backgroundGradient = AppColors.mainGradient; List<Color> backgroundGradient = AppColors.mainGradient;
@@ -233,7 +234,13 @@ class _MainScreenState extends State<MainScreen>
return GlassmorphicScaffold( return GlassmorphicScaffold(
body: IndexedStack( body: IndexedStack(
index: currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex, index: PlatformHelper.isIOS
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
: (currentIndex == 3
? 3
: currentIndex == 4
? 4
: currentIndex), // Android: 기존 로직
children: _screens, children: _screens,
), ),
backgroundGradient: backgroundGradient, backgroundGradient: backgroundGradient,
@@ -246,4 +253,4 @@ class _MainScreenState extends State<MainScreen>
enableWaveAnimation: false, enableWaveAnimation: false,
); );
} }
} }

View File

@@ -5,13 +5,14 @@ import '../providers/notification_provider.dart';
import 'dart:io'; import 'dart:io';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../theme/adaptive_theme.dart';
import '../widgets/glassmorphism_card.dart'; import '../widgets/glassmorphism_card.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../widgets/native_ad_widget.dart'; import '../widgets/native_ad_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import '../services/sms_service.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -228,6 +229,7 @@ class SettingsScreen extends StatelessWidget {
if (granted) { if (granted) {
await provider.setEnabled(true); await provider.setEnabled(true);
} else { } else {
if (!context.mounted) return;
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: AppLocalizations.of(context) message: AppLocalizations.of(context)
@@ -271,7 +273,7 @@ class SettingsScreen extends StatelessWidget {
elevation: 0, elevation: 0,
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceVariant .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -412,7 +414,7 @@ class SettingsScreen extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceVariant .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
borderRadius: borderRadius:
BorderRadius.circular(8), BorderRadius.circular(8),
@@ -476,6 +478,89 @@ class SettingsScreen extends StatelessWidget {
), ),
), ),
// SMS 권한 설정
if (!kIsWeb && Platform.isAndroid)
GlassmorphismCard(
margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8),
child: FutureBuilder<permission.PermissionStatus>(
future: permission.Permission.sms.status,
builder: (context, snapshot) {
final isLoading =
snapshot.connectionState == ConnectionState.waiting;
final status = snapshot.data;
final hasPermission = status?.isGranted ?? false;
final isPermanentlyDenied =
status?.isPermanentlyDenied ?? false;
return ListTile(
leading: const Icon(
Icons.sms,
color: AppColors.textSecondary,
),
title: Text(
AppLocalizations.of(context).smsPermissionLabel,
style: const TextStyle(color: AppColors.textPrimary),
),
subtitle: !hasPermission
? Text(
isPermanentlyDenied
? AppLocalizations.of(context)
.permanentlyDeniedMessage
: AppLocalizations.of(context)
.smsPermissionRequired,
style: const TextStyle(
color: AppColors.textSecondary),
)
: null,
trailing: isLoading
? const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: hasPermission
? const Padding(
padding:
EdgeInsets.symmetric(horizontal: 8.0),
child: Icon(Icons.check_circle,
color: Colors.green),
)
: isPermanentlyDenied
? TextButton(
onPressed: () async {
await permission.openAppSettings();
},
child: Text(AppLocalizations.of(context)
.openSettings),
)
: ElevatedButton(
onPressed: () async {
final granted = await SMSService
.requestSMSPermission();
if (!granted) {
final newStatus = await permission
.Permission.sms.status;
if (newStatus.isPermanentlyDenied) {
await permission
.openAppSettings();
}
}
if (context.mounted) {
(context as Element)
.markNeedsBuild();
}
},
child: Text(AppLocalizations.of(context)
.requestPermission),
),
);
},
),
),
// 앱 정보 // 앱 정보
GlassmorphismCard( GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import '../theme/app_colors.dart';
import '../widgets/glassmorphism_card.dart';
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: [
const Icon(Icons.sms, size: 64, color: AppColors.primaryColor),
const SizedBox(height: 16),
Text(
loc.smsPermissionTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
loc.smsPermissionRequired,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 16),
GlassmorphismCard(
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,25 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/sms_scanner.dart'; import '../controllers/sms_scan_controller.dart';
import '../providers/subscription_provider.dart'; import '../widgets/sms_scan/scan_loading_widget.dart';
import '../providers/navigation_provider.dart'; import '../widgets/sms_scan/scan_initial_widget.dart';
import '../providers/locale_provider.dart'; import '../widgets/sms_scan/scan_progress_widget.dart';
import 'package:provider/provider.dart'; import '../widgets/sms_scan/subscription_card_widget.dart';
import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart';
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
import '../widgets/glassmorphism_card.dart';
import '../widgets/themed_text.dart';
import '../theme/app_colors.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../widgets/common/buttons/primary_button.dart';
import '../widgets/common/buttons/secondary_button.dart';
import '../widgets/common/form_fields/base_text_field.dart';
import '../providers/category_provider.dart';
import '../models/category_model.dart';
import '../widgets/common/form_fields/category_selector.dart';
import '../widgets/native_ad_widget.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
class SmsScanScreen extends StatefulWidget { class SmsScanScreen extends StatefulWidget {
@@ -30,581 +15,91 @@ class SmsScanScreen extends StatefulWidget {
} }
class _SmsScanScreenState extends State<SmsScanScreen> { class _SmsScanScreenState extends State<SmsScanScreen> {
bool _isLoading = false; late SmsScanController _controller;
String? _errorMessage;
final SmsScanner _smsScanner = SmsScanner();
// 스캔한 구독 목록 @override
List<Subscription> _scannedSubscriptions = []; void initState() {
super.initState();
// 현재 표시 중인 구독 인덱스 _controller = SmsScanController();
int _currentIndex = 0; _controller.addListener(_handleControllerUpdate);
}
// 웹사이트 URL 컨트롤러
final TextEditingController _websiteUrlController = TextEditingController();
// 선택된 카테고리 ID 저장
String? _selectedCategoryId;
@override @override
void dispose() { void dispose() {
_websiteUrlController.dispose(); _controller.removeListener(_handleControllerUpdate);
_controller.dispose();
super.dispose(); super.dispose();
} }
// SMS 스캔 실행 void _handleControllerUpdate() {
Future<void> _scanSms() async {
setState(() {
_isLoading = true;
_errorMessage = null;
_scannedSubscriptions = [];
_currentIndex = 0;
});
try {
// SMS 스캔 실행
print('SMS 스캔 시작');
final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions();
print('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) {
print(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
}
if (!mounted) return;
if (scannedSubscriptionModels.isEmpty) {
print('스캔된 구독이 없음');
setState(() {
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false;
});
return;
}
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
scannedSubscriptions.where((sub) => sub.repeatCount >= 2).toList();
print('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) {
print(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
}
if (repeatSubscriptions.isEmpty) {
print('반복 결제된 구독이 없음');
setState(() {
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
_isLoading = false;
});
return;
}
// 구독 목록 가져오기
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions;
print('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링
final filteredSubscriptions =
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) {
print(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
// 중복 제거 후 신규 구독이 없는 경우
if (filteredSubscriptions.isEmpty) {
print('중복 제거 후 신규 구독이 없음');
setState(() {
_isLoading = false;
});
// 스낵바로 안내 메시지 표시
if (mounted) {
AppSnackBar.showInfo(
context: context,
message: AppLocalizations.of(context).newSubscriptionNotFound,
icon: Icons.search_off_rounded,
);
}
return;
}
setState(() {
_scannedSubscriptions = filteredSubscriptions;
_isLoading = false;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
});
} catch (e) {
print('SMS 스캔 중 오류 발생: $e');
if (mounted) {
setState(() {
_errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
_isLoading = false;
});
}
}
}
// SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> _convertModelsToSubscriptions(
List<SubscriptionModel> models) {
final result = <Subscription>[];
for (var model in models) {
try {
// 모델의 필드가 null인 경우 기본값 사용
result.add(Subscription(
id: model.id,
serviceName: model.serviceName,
monthlyCost: model.monthlyCost,
billingCycle: model.billingCycle,
nextBillingDate: model.nextBillingDate,
category: model.categoryId, // categoryId를 category로 올바르게 매핑
repeatCount: model.repeatCount > 0
? model.repeatCount
: 1, // 반복 횟수가 0 이하인 경우 기본값 1 사용
lastPaymentDate: model.lastPaymentDate,
websiteUrl: model.websiteUrl,
currency: model.currency, // 통화 단위 정보 추가
));
print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
} catch (e) {
print('모델 변환 중 오류 발생: $e');
}
}
return result;
}
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
List<Subscription> _filterDuplicates(
List<Subscription> scanned, List<SubscriptionModel> existing) {
print(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링
final nonDuplicates = scanned.where((scannedSub) {
// 서비스명과 금액이 동일한 기존 구독 찾기
final hasDuplicate = existing.any((existingSub) =>
existingSub.serviceName.toLowerCase() ==
scannedSub.serviceName.toLowerCase() &&
existingSub.monthlyCost == scannedSub.monthlyCost);
if (hasDuplicate) {
print('_filterDuplicates: 중복 발견 - ${scannedSub.serviceName}');
}
// 중복이 없으면 true 반환
return !hasDuplicate;
}).toList();
print('_filterDuplicates: 중복 제거 후 ${nonDuplicates.length}');
// 각 구독에 웹사이트 URL 자동 매칭 시도
final result = <Subscription>[];
for (int i = 0; i < nonDuplicates.length; i++) {
final subscription = nonDuplicates[i];
String? websiteUrl = subscription.websiteUrl;
if (websiteUrl == null || websiteUrl.isEmpty) {
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
print(
'_filterDuplicates: URL 자동 매칭 시도 - ${subscription.serviceName}, 결과: ${websiteUrl ?? "매칭 실패"}');
}
try {
// 유효성 검사
if (subscription.serviceName.isEmpty) {
print('_filterDuplicates: 서비스명이 비어 있습니다. 건너뜁니다.');
continue;
}
if (subscription.monthlyCost <= 0) {
print('_filterDuplicates: 월 비용이 0 이하입니다. 건너뜁니다.');
continue;
}
// Subscription 객체에 URL 설정 (새 객체 생성)
result.add(Subscription(
id: subscription.id,
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate,
category: subscription.category,
notes: subscription.notes,
repeatCount:
subscription.repeatCount > 0 ? subscription.repeatCount : 1,
lastPaymentDate: subscription.lastPaymentDate,
websiteUrl: websiteUrl,
currency: subscription.currency, // 통화 단위 정보 추가
));
print(
'_filterDuplicates: URL 설정 - ${subscription.serviceName}, URL: ${websiteUrl ?? "없음"}, 카테고리: ${subscription.category ?? "없음"}, 통화: ${subscription.currency}');
} catch (e) {
print('_filterDuplicates: 구독 객체 생성 중 오류 발생: $e');
}
}
print('_filterDuplicates: URL 설정 완료, 최종 ${result.length}개 구독');
return result;
}
// 현재 구독 추가
Future<void> _addCurrentSubscription() async {
if (_scannedSubscriptions.isEmpty ||
_currentIndex >= _scannedSubscriptions.length) {
print(
'오류: 인덱스가 범위를 벗어났습니다. (index: $_currentIndex, size: ${_scannedSubscriptions.length})');
return;
}
final subscription = _scannedSubscriptions[_currentIndex];
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
// 날짜가 과거면 다음 결제일을 조정
final now = DateTime.now();
DateTime nextBillingDate = subscription.nextBillingDate;
if (nextBillingDate.isBefore(now)) {
// 주기에 따라 다음 결제일 조정
if (subscription.billingCycle == '월간') {
// 현재 달의 결제일
int day = nextBillingDate.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjustedDate = DateTime(now.year, now.month, day);
// 현재 날짜보다 이전이라면 다음 달로 설정
if (adjustedDate.isBefore(now)) {
// 다음 달의 마지막 날을 초과하는 경우 조정
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
if (day > nextMonthLastDay) {
day = nextMonthLastDay;
}
adjustedDate = DateTime(now.year, now.month + 1, day);
}
nextBillingDate = adjustedDate;
} else if (subscription.billingCycle == '연간') {
// 현재 년도의 결제일
int day = nextBillingDate.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, nextBillingDate.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjustedDate = DateTime(now.year, nextBillingDate.month, day);
// 현재 날짜보다 이전이라면 다음 해로 설정
if (adjustedDate.isBefore(now)) {
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
final nextYearLastDay =
DateTime(now.year + 1, nextBillingDate.month + 1, 0).day;
if (day > nextYearLastDay) {
day = nextYearLastDay;
}
adjustedDate = DateTime(now.year + 1, nextBillingDate.month, day);
}
nextBillingDate = adjustedDate;
} else if (subscription.billingCycle == '주간') {
// 현재 날짜에서 가장 가까운 다음 주 같은 요일
final daysUntilNext = 7 - (now.weekday - nextBillingDate.weekday) % 7;
nextBillingDate =
now.add(Duration(days: daysUntilNext == 0 ? 7 : daysUntilNext));
}
}
// 웹사이트 URL이 비어있으면 자동 매칭 시도
String? websiteUrl = _websiteUrlController.text.trim();
if (websiteUrl.isEmpty && subscription.websiteUrl != null) {
websiteUrl = subscription.websiteUrl;
print('구독 추가: 기존 URL 사용 - ${websiteUrl ?? "없음"}');
} else if (websiteUrl.isEmpty) {
try {
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
print(
'구독 추가: URL 자동 매칭 - ${subscription.serviceName} -> ${websiteUrl ?? "매칭 실패"}');
} catch (e) {
print('구독 추가: URL 자동 매칭 실패 - $e');
websiteUrl = null;
}
} else {
print('구독 추가: 사용자 입력 URL 사용 - $websiteUrl');
}
try {
print(
'구독 추가 시도 - 서비스명: ${subscription.serviceName}, 비용: ${subscription.monthlyCost}, 반복 횟수: ${subscription.repeatCount}');
// 반복 횟수가 0 이하인 경우 기본값 1 사용
final int safeRepeatCount =
subscription.repeatCount > 0 ? subscription.repeatCount : 1;
// 카테고리 설정 로직
final categoryId = _selectedCategoryId ?? subscription.category ?? _getDefaultCategoryId();
print('카테고리 설정 - 선택된: $_selectedCategoryId, 자동매칭: ${subscription.category}, 최종: $categoryId');
await provider.addSubscription(
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl,
isAutoDetected: true,
repeatCount: safeRepeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: categoryId,
currency: subscription.currency, // 통화 단위 정보 추가
);
print('구독 추가 성공');
// 성공 메시지 표시
if (mounted) {
AppSnackBar.showSuccess(
context: context,
message: AppLocalizations.of(context).subscriptionAddedWithName(subscription.serviceName),
);
}
// 다음 구독으로 이동
_moveToNextSubscription();
} catch (e) {
print('구독 추가 중 오류 발생: $e');
if (mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).subscriptionAddErrorWithMessage(e.toString()),
);
// 오류가 있어도 다음 구독으로 이동
_moveToNextSubscription();
}
}
}
// 현재 구독 건너뛰기
void _skipCurrentSubscription() {
final subscription = _scannedSubscriptions[_currentIndex];
if (mounted) { if (mounted) {
AppSnackBar.showInfo( setState(() {});
context: context, }
message: AppLocalizations.of(context).subscriptionSkipped(subscription.serviceName), }
icon: Icons.skip_next_rounded,
@override
void didChangeDependencies() {
super.didChangeDependencies();
_controller.initializeWebsiteUrl();
}
Widget _buildContent() {
if (_controller.isLoading) {
return const ScanLoadingWidget();
}
if (_controller.scannedSubscriptions.isEmpty) {
return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context),
errorMessage: _controller.errorMessage,
); );
} }
_moveToNextSubscription();
}
// 다음 구독으로 이동 // 모든 구독 처리 완료 확인
void _moveToNextSubscription() { if (_controller.currentIndex >= _controller.scannedSubscriptions.length) {
setState(() { // 중복 스낵바 방지를 위해 바로 초기 화면으로
_currentIndex++; WidgetsBinding.instance.addPostFrameCallback((_) {
_websiteUrlController.text = ''; // URL 입력 필드 초기화 if (mounted && _controller.scannedSubscriptions.isNotEmpty) {
_selectedCategoryId = null; // 카테고리 선택 초기화 AppSnackBar.showSuccess(
context: context,
// 모든 구독을 처리했으면 홈 화면으로 이동 message: AppLocalizations.of(context).allSubscriptionsProcessed,
if (_currentIndex >= _scannedSubscriptions.length) { );
_navigateToHome(); // 상태 초기화
} _controller.resetState();
});
}
// 홈 화면으로 이동
void _navigateToHome() {
// NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0);
// 완료 메시지 표시
AppSnackBar.showSuccess(
context: context,
message: AppLocalizations.of(context).allSubscriptionsProcessed,
);
}
// 날짜 상태 텍스트 가져오기
String _getNextBillingText(DateTime date) {
final now = DateTime.now();
if (date.isBefore(now)) {
// 주기에 따라 다음 결제일 예측
if (_currentIndex >= _scannedSubscriptions.length) {
return '다음 결제일 확인 필요';
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription.billingCycle == '월간') {
// 이번 달 또는 다음 달 같은 날짜
int day = date.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
} }
});
DateTime adjusted = DateTime(now.year, now.month, day); return ScanInitialWidget(
if (adjusted.isBefore(now)) { onScanPressed: () => _controller.scanSms(context),
// 다음 달의 마지막 날을 초과하는 경우 조정 errorMessage: _controller.errorMessage,
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day; );
if (day > nextMonthLastDay) {
day = nextMonthLastDay;
}
adjusted = DateTime(now.year, now.month + 1, day);
}
final daysUntil = adjusted.difference(now).inDays;
return AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil);
} else if (subscription.billingCycle == '연간') {
// 올해 또는 내년 같은 날짜
int day = date.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, date.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjusted = DateTime(now.year, date.month, day);
if (adjusted.isBefore(now)) {
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
final nextYearLastDay = DateTime(now.year + 1, date.month + 1, 0).day;
if (day > nextYearLastDay) {
day = nextYearLastDay;
}
adjusted = DateTime(now.year + 1, date.month, day);
}
final daysUntil = adjusted.difference(now).inDays;
return AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil);
} else {
return '다음 결제일 확인 필요 (과거 날짜)';
}
} else {
// 미래 날짜인 경우
final daysUntil = date.difference(now).inDays;
return AppLocalizations.of(context).nextBillingDateInfo(AppLocalizations.of(context).formatDate(date), daysUntil);
} }
}
// 날짜 포맷 함수 final currentSubscription =
String _formatDate(DateTime date) { _controller.scannedSubscriptions[_controller.currentIndex];
return '${date.year}${date.month}${date.day}';
}
// 결제 반복 횟수 텍스트 return Column(
String _getRepeatCountText(int count) { crossAxisAlignment: CrossAxisAlignment.stretch,
return AppLocalizations.of(context).repeatCountDetected(count); children: [
} Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
// 카테고리 칩 빌드 child: ScanProgressWidget(
Widget _buildCategoryChip(String? categoryId, CategoryProvider categoryProvider) { currentIndex: _controller.currentIndex,
final category = categoryId != null totalCount: _controller.scannedSubscriptions.length,
? categoryProvider.getCategoryById(categoryId)
: null;
// 카테고리가 없으면 기타 카테고리 찾기
final defaultCategory = category ?? categoryProvider.categories.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.navyGray.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 카테고리 아이콘 표시
Icon(
_getCategoryIcon(defaultCategory),
size: 16,
color: AppColors.darkNavy,
), ),
const SizedBox(width: 6), ),
ThemedText( const SizedBox(height: 24),
categoryProvider.getLocalizedCategoryName(context, defaultCategory.name), SubscriptionCardWidget(
fontSize: 14, subscription: currentSubscription,
fontWeight: FontWeight.w500, websiteUrlController: _controller.websiteUrlController,
forceDark: true, selectedCategoryId: _controller.selectedCategoryId,
), onCategoryChanged: _controller.setSelectedCategoryId,
], onAdd: () => _controller.addCurrentSubscription(context),
), onSkip: () => _controller.skipCurrentSubscription(context),
),
],
); );
} }
// 카테고리 아이콘 반환
IconData _getCategoryIcon(CategoryModel category) {
switch (category.name) {
case 'music':
return Icons.music_note_rounded;
case 'ottVideo':
return Icons.movie_filter_rounded;
case 'storageCloud':
return Icons.cloud_outlined;
case 'telecomInternetTv':
return Icons.wifi_rounded;
case 'lifestyle':
return Icons.home_outlined;
case 'shoppingEcommerce':
return Icons.shopping_cart_outlined;
case 'programming':
return Icons.code_rounded;
case 'collaborationOffice':
return Icons.business_center_outlined;
case 'aiService':
return Icons.smart_toy_outlined;
case 'other':
default:
return Icons.category_outlined;
}
}
// 기본 카테고리 ID (기타) 반환
String _getDefaultCategoryId() {
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final otherCategory = categoryProvider.categories.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first, // 만약 "기타"가 없으면 첫 번째 카테고리
);
print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
return otherCategory.id;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return SingleChildScrollView(
@@ -615,11 +110,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
SizedBox( SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top, height: kToolbarHeight + MediaQuery.of(context).padding.top,
), ),
_isLoading _buildContent(),
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState()),
// FloatingNavigationBar를 위한 충분한 하단 여백 // FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox( SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom, height: 120 + MediaQuery.of(context).padding.bottom,
@@ -628,310 +119,4 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
), ),
); );
} }
// 로딩 상태 UI
Widget _buildLoadingState() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
),
const SizedBox(height: 16),
ThemedText(AppLocalizations.of(context).scanningMessages, forceDark: true),
const SizedBox(height: 8),
ThemedText(AppLocalizations.of(context).findingSubscriptions, opacity: 0.7, forceDark: true),
],
),
),
);
}
// 초기 상태 UI
Widget _buildInitialState() {
return Column(
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
const SizedBox(height: 48),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: ThemedText(
_errorMessage!,
color: Colors.red,
textAlign: TextAlign.center,
),
),
ThemedText(
AppLocalizations.of(context).findRepeatSubscriptions,
fontSize: 20,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ThemedText(
AppLocalizations.of(context).scanTextMessages,
textAlign: TextAlign.center,
opacity: 0.7,
forceDark: true,
),
),
const SizedBox(height: 32),
PrimaryButton(
text: AppLocalizations.of(context).startScanning,
icon: Icons.search_rounded,
onPressed: _scanSms,
width: 200,
height: 56,
backgroundColor: AppColors.primaryColor,
),
],
),
),
],
);
}
// 구독 표시 상태 UI
Widget _buildSubscriptionState() {
if (_currentIndex >= _scannedSubscriptions.length) {
// 처리 완료 후 초기 상태로 복귀
_scannedSubscriptions = [];
_currentIndex = 0;
return _buildInitialState(); // 스캔 버튼이 있는 초기 화면으로 돌아감
}
final subscription = _scannedSubscriptions[_currentIndex];
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
_websiteUrlController.text = subscription.websiteUrl!;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 진행 상태 표시
LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length,
backgroundColor: AppColors.navyGray.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 8),
ThemedText(
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 24),
// 구독 정보 카드
GlassmorphismCard(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context).foundSubscription,
fontSize: 18,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 24),
// 서비스명
ThemedText(
AppLocalizations.of(context).serviceName,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
subscription.serviceName,
fontSize: 22,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 16),
// 금액 및 결제 주기
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context).monthlyCost,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
// 언어별 통화 표시
FutureBuilder<String>(
future: CurrencyUtil.formatAmountWithLocale(
subscription.monthlyCost,
subscription.currency,
context.read<LocaleProvider>().locale.languageCode,
),
builder: (context, snapshot) {
return ThemedText(
snapshot.data ?? '-',
fontSize: 18,
fontWeight: FontWeight.bold,
forceDark: true,
);
},
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context).billingCycle,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
subscription.billingCycle,
fontSize: 16,
fontWeight: FontWeight.w500,
forceDark: true,
),
],
),
),
],
),
const SizedBox(height: 16),
// 다음 결제일
ThemedText(
AppLocalizations.of(context).nextBillingDateLabel,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
_getNextBillingText(subscription.nextBillingDate),
fontSize: 16,
fontWeight: FontWeight.w500,
forceDark: true,
),
const SizedBox(height: 16),
// 카테고리 선택
ThemedText(
AppLocalizations.of(context).category,
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 8),
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: _selectedCategoryId ?? subscription.category,
onChanged: (categoryId) {
setState(() {
_selectedCategoryId = categoryId;
});
},
baseColor: (() {
final categoryId = _selectedCategoryId ?? subscription.category;
if (categoryId == null) return null;
final category = categoryProvider.getCategoryById(categoryId);
if (category == null) return null;
return Color(int.parse(category.color.replaceFirst('#', '0xFF')));
})(),
isGlassmorphism: true,
),
const SizedBox(height: 24),
// 웹사이트 URL 입력 필드 추가/수정
BaseTextField(
controller: _websiteUrlController,
label: AppLocalizations.of(context).websiteUrlAuto,
hintText: AppLocalizations.of(context).websiteUrlHint,
prefixIcon: Icon(
Icons.language,
color: AppColors.navyGray,
),
style: TextStyle(
color: AppColors.darkNavy,
),
fillColor: AppColors.pureWhite.withValues(alpha: 0.8),
),
const SizedBox(height: 32),
// 작업 버튼
Row(
children: [
Expanded(
child: SecondaryButton(
text: AppLocalizations.of(context).skip,
onPressed: _skipCurrentSubscription,
height: 48,
),
),
const SizedBox(width: 16),
Expanded(
child: PrimaryButton(
text: AppLocalizations.of(context).add,
onPressed: _addCurrentSubscription,
height: 48,
),
),
],
),
],
),
),
],
),
),
],
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_scannedSubscriptions.isNotEmpty &&
_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
_websiteUrlController.text = currentSub.websiteUrl!;
}
}
}
} }

View File

@@ -2,8 +2,11 @@ import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; 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 '../routes/app_routes.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/reduce_motion.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@@ -63,7 +66,8 @@ class _SplashScreenState extends State<SplashScreen>
)); ));
// 랜덤 파티클 생성 // 랜덤 파티클 생성
_generateParticles(); // 접근성(모션 축소) 고려한 파티클 생성
_generateParticles(reduced: ReduceMotion.platform());
_animationController.forward(); _animationController.forward();
@@ -73,15 +77,17 @@ class _SplashScreenState extends State<SplashScreen>
}); });
} }
void _generateParticles() { void _generateParticles({bool reduced = false}) {
final random = DateTime.now().millisecondsSinceEpoch; 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 size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
final x = (random % 100) / 100 * 300; // 랜덤 X 위치 final x = (random % 100) / 100 * 300; // 랜덤 X 위치
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치 final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도 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초 사이의 지연시간 final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
int colorIndex = (random + i) % AppColors.blueGradient.length; int colorIndex = (random + i) % AppColors.blueGradient.length;
@@ -98,9 +104,20 @@ class _SplashScreenState extends State<SplashScreen>
} }
} }
void navigateToNextScreen() { Future<void> navigateToNextScreen() async {
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동 // 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( Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.main, AppRoutes.main,
(route) => false, (route) => false,
@@ -244,7 +261,14 @@ class _SplashScreenState extends State<SplashScreen>
BorderRadius.circular(30), BorderRadius.circular(30),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur( 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( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -269,7 +293,11 @@ class _SplashScreenState extends State<SplashScreen>
color: color:
AppColors.shadowBlack, AppColors.shadowBlack,
spreadRadius: 0, spreadRadius: 0,
blurRadius: 30, blurRadius:
ReduceMotion.scale(
context,
normal: 30,
reduced: 12),
offset: const Offset(0, 10), offset: const Offset(0, 10),
), ),
], ],
@@ -385,7 +413,7 @@ class _SplashScreenState extends State<SplashScreen>
width: 1, width: 1,
), ),
), ),
child: CircularProgressIndicator( child: const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
AppColors.pureWhite), AppColors.pureWhite),
strokeWidth: 3, strokeWidth: 3,

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 'package:intl/intl.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import 'exchange_rate_service.dart'; import 'exchange_rate_service.dart';
import 'cache_manager.dart';
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스 /// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
class CurrencyUtil { class CurrencyUtil {
static final ExchangeRateService _exchangeRateService = ExchangeRateService(); 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) { static String getDefaultCurrency(String locale) {
@@ -66,7 +73,7 @@ class CurrencyUtil {
final locale = _getLocaleForCurrency(currency); final locale = _getLocaleForCurrency(currency);
final symbol = getCurrencySymbol(currency); final symbol = getCurrencySymbol(currency);
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2; final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
return NumberFormat.currency( return NumberFormat.currency(
locale: locale, locale: locale,
symbol: symbol, symbol: symbol,
@@ -80,30 +87,46 @@ class CurrencyUtil {
String currency, String currency,
String locale, String locale,
) async { ) 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); final defaultCurrency = getDefaultCurrency(locale);
// 입력 통화가 기본 통화인 경우 // 입력 통화가 기본 통화인 경우
if (currency == defaultCurrency) { if (currency == defaultCurrency) {
return _formatSingleCurrency(amount, currency); final result = _formatSingleCurrency(amount, currency);
_fmtCache.set(key, result, size: result.length);
return result;
} }
// USD 입력인 경우 - 기본 통화로 변환하여 표시 // USD 입력인 경우 - 기본 통화로 변환하여 표시
if (currency == 'USD' && defaultCurrency != 'USD') { if (currency == 'USD' && defaultCurrency != 'USD') {
final convertedAmount = await _exchangeRateService.convertUsdToTarget(amount, defaultCurrency); final convertedAmount = await _exchangeRateService.convertUsdToTarget(
amount, defaultCurrency);
if (convertedAmount != null) { if (convertedAmount != null) {
final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency); final primaryFormatted =
_formatSingleCurrency(convertedAmount, defaultCurrency);
final usdFormatted = _formatSingleCurrency(amount, 'USD'); final usdFormatted = _formatSingleCurrency(amount, 'USD');
return '$primaryFormatted ($usdFormatted)'; final result = '$primaryFormatted ($usdFormatted)';
_fmtCache.set(key, result, size: result.length);
return result;
} }
} }
// 영어 사용자가 KRW 선택한 경우 // 영어 사용자가 KRW 선택한 경우
if (locale == 'en' && currency == '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) { for (var subscription in subscriptions) {
final price = subscription.currentPrice; final price = subscription.currentPrice;
final converted = await _exchangeRateService.convertBetweenCurrencies( final converted = await _exchangeRateService.convertBetweenCurrencies(
price, price,
subscription.currency, subscription.currency,
defaultCurrency, defaultCurrency,
); );
total += converted ?? price; total += converted ?? price;
} }
@@ -139,7 +162,20 @@ class CurrencyUtil {
static Future<String> formatSubscriptionAmountWithLocale( static Future<String> formatSubscriptionAmountWithLocale(
SubscriptionModel subscription, String locale) async { SubscriptionModel subscription, String locale) async {
final price = subscription.currentPrice; 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) { for (var subscription in subscriptions) {
if (subscription.isCurrentlyInEvent) { if (subscription.isCurrentlyInEvent) {
final savings = subscription.eventSavings; final savings = subscription.eventSavings;
final converted = await _exchangeRateService.convertBetweenCurrencies( final converted = await _exchangeRateService.convertBetweenCurrencies(
savings, savings,
subscription.currency, subscription.currency,
defaultCurrency, defaultCurrency,
); );
total += converted ?? savings; total += converted ?? savings;
} }
} }
@@ -204,7 +240,7 @@ class CurrencyUtil {
if (!subscription.isCurrentlyInEvent) { if (!subscription.isCurrentlyInEvent) {
return ''; return '';
} }
final savings = subscription.eventSavings; final savings = subscription.eventSavings;
return formatAmountWithLocale(savings, subscription.currency, locale); return formatAmountWithLocale(savings, subscription.currency, locale);
} }
@@ -225,4 +261,4 @@ class CurrencyUtil {
static Future<String> formatAmount(double amount, String currency) async { static Future<String> formatAmount(double amount, String currency) async {
return formatAmountWithCurrencyAndLocale(amount, currency, 'ko'); return formatAmountWithCurrencyAndLocale(amount, currency, 'ko');
} }
} }

View File

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

View File

@@ -1,15 +1,15 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:io' show Platform;
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class NotificationService { class NotificationService {
static final FlutterLocalNotificationsPlugin _notifications = static final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
static final _secureStorage = const FlutterSecureStorage(); static const _secureStorage = FlutterSecureStorage();
static const _notificationEnabledKey = 'notification_enabled'; static const _notificationEnabledKey = 'notification_enabled';
static const _paymentNotificationEnabledKey = 'payment_notification_enabled'; static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
@@ -149,13 +149,73 @@ class NotificationService {
} }
} }
static Future<bool> requestPermission() async { static Future<bool> requestPermission() async {
final result = await _notifications // 웹 플랫폼인 경우 false 반환
.resolvePlatformSpecificImplementation< if (_isWeb) return false;
AndroidFlutterLocalNotificationsPlugin>()
?.requestPermission(); // iOS 처리
return result ?? false; 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; // 기본값
} }
// 알림 스케줄 설정 // 알림 스케줄 설정
@@ -170,7 +230,7 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return; return;
} }
try { try {
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
'subscription_channel', 'subscription_channel',
@@ -180,8 +240,8 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
); );
final iosDetails = const DarwinNotificationDetails(); const iosDetails = DarwinNotificationDetails();
// tz.local 초기화 확인 및 재시도 // tz.local 초기화 확인 및 재시도
tz.Location location; tz.Location location;
try { try {
@@ -205,10 +265,10 @@ class NotificationService {
title, title,
body, body,
tz.TZDateTime.from(scheduledDate, location), tz.TZDateTime.from(scheduledDate, location),
NotificationDetails(android: androidDetails, iOS: iosDetails), const NotificationDetails(android: androidDetails, iOS: iosDetails),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('알림 예약 중 오류 발생: $e'); debugPrint('알림 예약 중 오류 발생: $e');
@@ -243,7 +303,7 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return; return;
} }
try { try {
final notificationId = subscription.id.hashCode; final notificationId = subscription.id.hashCode;
@@ -265,7 +325,7 @@ class NotificationService {
android: androidDetails, android: androidDetails,
iOS: iosDetails, iOS: iosDetails,
); );
// tz.local 초기화 확인 및 재시도 // tz.local 초기화 확인 및 재시도
tz.Location location; tz.Location location;
try { try {
@@ -290,9 +350,9 @@ class NotificationService {
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.', '${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
tz.TZDateTime.from(subscription.nextBillingDate, location), tz.TZDateTime.from(subscription.nextBillingDate, location),
notificationDetails, notificationDetails,
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('구독 알림 예약 중 오류 발생: $e'); debugPrint('구독 알림 예약 중 오류 발생: $e');
@@ -318,11 +378,11 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return; return;
} }
try { try {
final paymentDate = subscription.nextBillingDate; final paymentDate = subscription.nextBillingDate;
final reminderDate = paymentDate.subtract(const Duration(days: 3)); final reminderDate = paymentDate.subtract(const Duration(days: 3));
// tz.local 초기화 확인 및 재시도 // tz.local 초기화 확인 및 재시도
tz.Location location; tz.Location location;
try { try {
@@ -355,9 +415,9 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
), ),
), ),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('결제 알림 예약 중 오류 발생: $e'); debugPrint('결제 알림 예약 중 오류 발생: $e');
@@ -371,11 +431,11 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return; return;
} }
try { try {
final expirationDate = subscription.nextBillingDate; final expirationDate = subscription.nextBillingDate;
final reminderDate = expirationDate.subtract(const Duration(days: 7)); final reminderDate = expirationDate.subtract(const Duration(days: 7));
// tz.local 초기화 확인 및 재시도 // tz.local 초기화 확인 및 재시도
tz.Location location; tz.Location location;
try { try {
@@ -395,7 +455,7 @@ class NotificationService {
} }
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
(subscription.id + '_expiration').hashCode, ('${subscription.id}_expiration').hashCode,
'구독 만료 예정 알림', '구독 만료 예정 알림',
'${subscription.serviceName} 구독이 7일 후 만료됩니다.', '${subscription.serviceName} 구독이 7일 후 만료됩니다.',
tz.TZDateTime.from(reminderDate, location), tz.TZDateTime.from(reminderDate, location),
@@ -408,9 +468,9 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
), ),
), ),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('만료 알림 예약 중 오류 발생: $e'); debugPrint('만료 알림 예약 중 오류 발생: $e');
@@ -448,16 +508,17 @@ class NotificationService {
location = tz.UTC; location = tz.UTC;
} }
} }
// 기본 알림 예약 (지정된 일수 전) // 기본 알림 예약 (지정된 일수 전)
final scheduledDate = final scheduledDate = subscription.nextBillingDate
subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith( .subtract(Duration(days: reminderDays))
hour: reminderHour, .copyWith(
minute: reminderMinute, hour: reminderHour,
second: 0, minute: reminderMinute,
millisecond: 0, second: 0,
microsecond: 0, millisecond: 0,
); microsecond: 0,
);
// 남은 일수에 따른 메시지 생성 // 남은 일수에 따른 메시지 생성
String daysText = '$reminderDays일'; String daysText = '$reminderDays일';
@@ -467,19 +528,21 @@ class NotificationService {
// 이벤트 종료로 인한 가격 변동 확인 // 이벤트 종료로 인한 가격 변동 확인
String notificationBody; String notificationBody;
if (subscription.isEventActive && if (subscription.isEventActive &&
subscription.eventEndDate != null && subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) { subscription.eventEndDate!.isAfter(DateTime.now())) {
// 이벤트가 결제일 전에 종료되는 경우 // 이벤트가 결제일 전에 종료되는 경우
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost; final normalPrice = subscription.monthlyCost;
notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n' notificationBody =
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}으로 변경됩니다.'; '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}$daysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else { } else {
// 일반 알림 // 일반 알림
final currentPrice = subscription.currentPrice; final currentPrice = subscription.currentPrice;
notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.'; notificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
} }
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
@@ -506,13 +569,14 @@ class NotificationService {
if (isDailyReminder && reminderDays >= 2) { if (isDailyReminder && reminderDays >= 2) {
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약 // 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
for (int i = reminderDays - 1; i >= 1; i--) { for (int i = reminderDays - 1; i >= 1; i--) {
final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( final dailyDate =
hour: reminderHour, subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
minute: reminderMinute, hour: reminderHour,
second: 0, minute: reminderMinute,
millisecond: 0, second: 0,
microsecond: 0, millisecond: 0,
); microsecond: 0,
);
// 남은 일수에 따른 메시지 생성 // 남은 일수에 따른 메시지 생성
String remainingDaysText = '$i일'; String remainingDaysText = '$i일';
@@ -522,17 +586,21 @@ class NotificationService {
// 각 날짜에 대한 이벤트 종료 확인 // 각 날짜에 대한 이벤트 종료 확인
String dailyNotificationBody; String dailyNotificationBody;
if (subscription.isEventActive && if (subscription.isEventActive &&
subscription.eventEndDate != null && subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && subscription.eventEndDate!
.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) { subscription.eventEndDate!.isAfter(DateTime.now())) {
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; final eventPrice =
subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost; final normalPrice = subscription.monthlyCost;
dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n' dailyNotificationBody =
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}으로 변경됩니다.'; '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}$remainingDaysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else { } else {
final currentPrice = subscription.currentPrice; final currentPrice = subscription.currentPrice;
dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.'; dailyNotificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
} }
await _notifications.zonedSchedule( await _notifications.zonedSchedule(

View File

@@ -0,0 +1,84 @@
import '../../models/subscription.dart';
import '../../models/subscription_model.dart';
class SubscriptionConverter {
// SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> convertModelsToSubscriptions(
List<SubscriptionModel> models) {
final result = <Subscription>[];
for (var model in models) {
try {
final subscription = _convertSingle(model);
result.add(subscription);
// 개발 편의를 위한 디버그 로그
// ignore: avoid_print
print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
} catch (e) {
// ignore: avoid_print
print('모델 변환 중 오류 발생: $e');
}
}
return result;
}
// 단일 모델 변환
Subscription _convertSingle(SubscriptionModel model) {
return Subscription(
id: model.id,
serviceName: model.serviceName,
monthlyCost: model.monthlyCost,
billingCycle: _denormalizeBillingCycle(model.billingCycle), // 영어 -> 한국어
nextBillingDate: model.nextBillingDate,
category: model.categoryId, // categoryId를 category로 매핑
repeatCount: model.repeatCount > 0 ? model.repeatCount : 1,
lastPaymentDate: model.lastPaymentDate,
websiteUrl: model.websiteUrl,
currency: model.currency,
);
}
// billingCycle 역정규화 (영어 -> 한국어)
String _denormalizeBillingCycle(String cycle) {
switch (cycle.toLowerCase()) {
case 'monthly':
return '월간';
case 'yearly':
case 'annually':
return '연간';
case 'weekly':
return '주간';
case 'daily':
return '일간';
case 'quarterly':
return '분기별';
case 'semi-annually':
return '반기별';
default:
return cycle; // 알 수 없는 형식은 그대로 반환
}
}
// billingCycle 정규화 (한국어 -> 영어)
String normalizeBillingCycle(String cycle) {
switch (cycle) {
case '월간':
return 'monthly';
case '연간':
return 'yearly';
case '주간':
return 'weekly';
case '일간':
return 'daily';
case '분기별':
return 'quarterly';
case '반기별':
return 'semi-annually';
default:
return 'monthly'; // 기본값
}
}
}

View File

@@ -0,0 +1,65 @@
import '../../models/subscription.dart';
import '../../models/subscription_model.dart';
import '../../utils/logger.dart';
class SubscriptionFilter {
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
List<Subscription> filterDuplicates(
List<Subscription> scanned, List<SubscriptionModel> existing) {
Log.d(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링
return scanned.where((scannedSub) {
// 기존 구독 중에 같은 서비스명과 월 비용을 가진 것이 있는지 확인
final isDuplicate = existing.any((existingSub) {
final isSameName = existingSub.serviceName.toLowerCase() ==
scannedSub.serviceName.toLowerCase();
final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost;
if (isSameName && isSameCost) {
Log.d(
'중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
return true;
}
return false;
});
return !isDuplicate;
}).toList();
}
// 반복 횟수 기반 필터링
List<Subscription> filterByRepeatCount(
List<Subscription> subscriptions, int minCount) {
return subscriptions.where((sub) => sub.repeatCount >= minCount).toList();
}
// 날짜 기반 필터링 (선택적)
List<Subscription> filterByDateRange(
List<Subscription> subscriptions, DateTime startDate, DateTime endDate) {
return subscriptions.where((sub) {
return sub.nextBillingDate.isAfter(startDate) &&
sub.nextBillingDate.isBefore(endDate);
}).toList();
}
// 금액 기반 필터링 (선택적)
List<Subscription> filterByPriceRange(
List<Subscription> subscriptions, double minPrice, double maxPrice) {
return subscriptions
.where(
(sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
.toList();
}
// 카테고리 기반 필터링 (선택적)
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,38 @@
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 '../models/subscription_model.dart';
import '../utils/logger.dart';
import '../temp/test_sms_data.dart'; import '../temp/test_sms_data.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart';
class SmsScanner { class SmsScanner {
final SmsQuery _query = SmsQuery();
Future<List<SubscriptionModel>> scanForSubscriptions() async { Future<List<SubscriptionModel>> scanForSubscriptions() async {
try { try {
List<dynamic> smsList; List<dynamic> smsList;
print('SmsScanner: 스캔 시작'); Log.d('SmsScanner: 스캔 시작');
// 디버그 모드에서는 테스트 데이터 사용 // 플랫폼별 분기 처리
if (kDebugMode) { if (kIsWeb) {
print('SmsScanner: 디버그 모드에서 테스트 데이터 사용'); // 웹 환경: 테스트 데이터 사용
Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용');
smsList = TestSmsData.getTestData(); 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 { } else {
print('SmsScanner: 실제 SMS 데이터 스캔'); // 기타 플랫폼
// 실제 환경에서는 네이티브 코드 호출 Log.w('SmsScanner: 지원하지 않는 플랫폼');
const platform = MethodChannel('com.submanager/sms'); return [];
try {
smsList = await platform.invokeMethod('scanSubscriptions');
print('SmsScanner: 네이티브 호출 성공, SMS 데이터 개수: ${smsList.length}');
} catch (e) {
print('SmsScanner: 네이티브 호출 실패: $e');
// 오류 발생 시 빈 목록 반환
return [];
}
} }
// SMS 데이터를 분석하여 반복 결제되는 구독 식별 // SMS 데이터를 분석하여 반복 결제되는 구독 식별
@@ -42,41 +48,70 @@ class SmsScanner {
serviceGroups[serviceName]!.add(sms); serviceGroups[serviceName]!.add(sms);
} }
print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
// 그룹화된 데이터로 구독 분석 // 그룹화된 데이터로 구독 분석
for (final entry in serviceGroups.entries) { for (final entry in serviceGroups.entries) {
print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
// 2회 이상 반복된 서비스만 구독으로 간주 // 2회 이상 반복된 서비스만 구독으로 간주
if (entry.value.length >= 2) { if (entry.value.length >= 2) {
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용 final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
final subscription = _parseSms(serviceSms, entry.value.length); final subscription = _parseSms(serviceSms, entry.value.length);
if (subscription != null) { if (subscription != null) {
print( Log.i(
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); 'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
subscriptions.add(subscription); subscriptions.add(subscription);
} else { } else {
print('SmsScanner: 구독 파싱 실패: ${entry.key}'); Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
} }
} else { } else {
print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
} }
} }
print('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
return subscriptions; return subscriptions;
} catch (e) { } catch (e) {
print('SmsScanner: 예외 발생: $e'); Log.e('SmsScanner: 예외 발생', e);
throw Exception('SMS 스캔 중 오류 발생: $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) { SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try { try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; 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 nextBillingDateStr = sms['nextBillingDate'] as String?;
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨) // 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
@@ -94,7 +129,7 @@ class SmsScanner {
'Spotify Premium' 'Spotify Premium'
]; ];
if (dollarServices.any((service) => serviceName.contains(service))) { if (dollarServices.any((service) => serviceName.contains(service))) {
print('서비스명 $serviceName으로 USD 통화 단위 확정'); Log.d('서비스명 $serviceName으로 USD 통화 단위 확정');
currency = 'USD'; currency = 'USD';
} }
@@ -204,8 +239,6 @@ class SmsScanner {
return serviceUrls[serviceName]; return serviceUrls[serviceName];
} }
// 메시지에서 통화 단위를 감지하는 함수 // 메시지에서 통화 단위를 감지하는 함수
String _detectCurrency(String message) { String _detectCurrency(String message) {
final dollarKeywords = [ final dollarKeywords = [
@@ -226,7 +259,7 @@ class SmsScanner {
// 서비스명 기반 통화 단위 확인 // 서비스명 기반 통화 단위 확인
for (final service in serviceCurrencyMap.keys) { for (final service in serviceCurrencyMap.keys) {
if (message.contains(service)) { if (message.contains(service)) {
print('_detectCurrency: ${service} USD 서비스로 판별됨'); Log.d('_detectCurrency: $service USD 서비스로 판별됨');
return 'USD'; return 'USD';
} }
} }
@@ -234,7 +267,7 @@ class SmsScanner {
// 메시지에 달러 관련 키워드가 있는지 확인 // 메시지에 달러 관련 키워드가 있는지 확인
for (final keyword in dollarKeywords) { for (final keyword in dollarKeywords) {
if (message.toLowerCase().contains(keyword.toLowerCase())) { if (message.toLowerCase().contains(keyword.toLowerCase())) {
print('_detectCurrency: USD 키워드 발견: $keyword'); Log.d('_detectCurrency: USD 키워드 발견: $keyword');
return 'USD'; return 'USD';
} }
} }
@@ -243,3 +276,148 @@ class SmsScanner {
return 'KRW'; 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/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart' as permission; import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/platform_helper.dart';
class SMSService { class SMSService {
static const platform = MethodChannel('com.submanager/sms'); static const platform = MethodChannel('com.submanager/sms');
static Future<bool> requestSMSPermission() async { static Future<bool> requestSMSPermission() async {
if (kIsWeb) return false; // 웹이나 iOS에서는 SMS 권한 불필요
final status = await permission.Permission.sms.request(); if (kIsWeb || PlatformHelper.isIOS) return true;
return status.isGranted;
// Android에서만 권한 요청
if (PlatformHelper.isAndroid) {
final status = await permission.Permission.sms.request();
return status.isGranted;
}
return false;
} }
static Future<bool> hasSMSPermission() async { static Future<bool> hasSMSPermission() async {
if (kIsWeb) return false; // 웹이나 iOS에서는 항상 true 반환 (권한 불필요)
final status = await permission.Permission.sms.status; if (kIsWeb || PlatformHelper.isIOS) return true;
return status.isGranted;
// Android에서만 실제 권한 확인
if (PlatformHelper.isAndroid) {
final status = await permission.Permission.sms.status;
return status.isGranted;
}
return false;
} }
static Future<List<Map<String, dynamic>>> scanSubscriptions() async { static Future<List<Map<String, dynamic>>> scanSubscriptions() async {

View File

@@ -1,576 +1,110 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'url_matcher/models/service_info.dart';
import 'url_matcher/data/legacy_service_data.dart';
// ServiceInfo를 외부에서 접근 가능하도록 export // ServiceInfo를 외부에서 접근 가능하도록 export
export 'url_matcher/models/service_info.dart'; export 'url_matcher/models/service_info.dart';
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스 import 'url_matcher/models/service_info.dart';
import 'url_matcher/data/service_data_repository.dart';
import 'url_matcher/services/url_matcher_service.dart';
import 'url_matcher/services/category_mapper_service.dart';
import 'url_matcher/services/cancellation_url_service.dart';
import 'url_matcher/services/service_name_resolver.dart';
import 'url_matcher/services/sms_extractor_service.dart';
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스 (Facade 패턴)
class SubscriptionUrlMatcher { class SubscriptionUrlMatcher {
static Map<String, dynamic>? _servicesData; static ServiceDataRepository? _dataRepository;
static bool _isInitialized = false; static UrlMatcherService? _urlMatcher;
static CategoryMapperService? _categoryMapper;
/// JSON 데이터 초기화 static CancellationUrlService? _cancellationService;
static ServiceNameResolver? _nameResolver;
static SmsExtractorService? _smsExtractor;
/// 서비스 초기화
static Future<void> initialize() async { static Future<void> initialize() async {
if (_isInitialized) return; if (_dataRepository != null && _dataRepository!.isInitialized) return;
try { // 1. 데이터 저장소 초기화
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json'); _dataRepository = ServiceDataRepository();
_servicesData = json.decode(jsonString); await _dataRepository!.initialize();
_isInitialized = true;
print('SubscriptionUrlMatcher: JSON 데이터 로드 완료'); // 2. 서비스 초기화
} catch (e) { _categoryMapper = CategoryMapperService(_dataRepository!);
print('SubscriptionUrlMatcher: JSON 로드 실패 - $e'); _urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!);
// 로드 실패시 기존 하드코딩 데이터 사용 _cancellationService =
_isInitialized = true; CancellationUrlService(_dataRepository!, _urlMatcher!);
} _nameResolver = ServiceNameResolver(_dataRepository!);
_smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!);
} }
/// 도메인 추출 (www와 TLD 제외) /// 도메인 추출 (www와 TLD 제외)
static String? extractDomain(String url) { static String? extractDomain(String url) {
try { return _urlMatcher?.extractDomain(url);
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로 서비스 찾기 /// URL로 서비스 찾기
static Future<ServiceInfo?> findServiceByUrl(String url) async { static Future<ServiceInfo?> findServiceByUrl(String url) async {
await initialize(); await initialize();
return _urlMatcher?.findServiceByUrl(url);
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 LegacyServiceData.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 메서드 유지) /// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
static String? suggestUrl(String serviceName) { static String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) { return _urlMatcher?.suggestUrl(serviceName);
print('SubscriptionUrlMatcher: 빈 serviceName');
return null;
}
// 소문자로 변환하여 비교
final lowerName = serviceName.toLowerCase().trim();
try {
// 정확한 매칭을 먼저 시도
for (final entry in LegacyServiceData.allServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print('SubscriptionUrlMatcher: 정확한 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// OTT 서비스 검사
for (final entry in LegacyServiceData.ottServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 음악 서비스 검사
for (final entry in LegacyServiceData.musicServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// AI 서비스 검사
for (final entry in LegacyServiceData.aiServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: AI 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 개발 서비스 검사
for (final entry in LegacyServiceData.programmingServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: 개발 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 오피스 툴 검사
for (final entry in LegacyServiceData.officeTools.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 기타 서비스 검사
for (final entry in LegacyServiceData.otherServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 유사한 이름 검사 (퍼지 매칭) - 단어 기반으로 검색
for (final entry in LegacyServiceData.allServices.entries) {
final serviceWords = lowerName.split(' ');
final keyWords = entry.key.toLowerCase().split(' ');
// 단어 단위로 일치하는지 확인
for (final word in serviceWords) {
if (word.length > 2 &&
keyWords.any((keyWord) => keyWord.contains(word))) {
print(
'SubscriptionUrlMatcher: 단어 기반 매칭 - $word (in $lowerName) -> ${entry.key}');
return entry.value;
}
}
}
// 추출 가능한 도메인이 있는지 확인
final domainMatch = RegExp(r'(\w+)').firstMatch(lowerName);
if (domainMatch != null && domainMatch.group(1)!.length > 2) {
final domain = domainMatch.group(1)!.trim();
if (domain.length > 2 &&
!['the', 'and', 'for', 'www'].contains(domain)) {
final url = 'https://www.$domain.com';
print('SubscriptionUrlMatcher: 도메인 추출 - $lowerName -> $url');
return url;
}
}
print('SubscriptionUrlMatcher: 매칭 실패 - $lowerName');
return null;
} catch (e) {
print('SubscriptionUrlMatcher: URL 매칭 중 오류 발생: $e');
return null;
}
} }
/// 해지 안내 URL 찾기 (개선된 버전) /// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
static Future<String?> findCancellationUrl({ static Future<String?> findCancellationUrl({
String? serviceName, String? serviceName,
String? websiteUrl, String? websiteUrl,
String locale = 'kr', String locale = 'kr',
}) async { }) async {
await initialize(); await initialize();
return _cancellationService?.findCancellationUrl(
// JSON 데이터가 있으면 JSON에서 찾기 serviceName: serviceName,
if (_servicesData != null) { websiteUrl: websiteUrl,
final categories = _servicesData!['categories'] as Map<String, dynamic>; locale: locale,
);
// 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 (LegacyServiceData.cancellationUrls.containsKey(lowerText)) {
return LegacyServiceData.cancellationUrls[lowerText];
}
// 서비스명에 부분 포함으로 찾기
for (var entry in LegacyServiceData.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 LegacyServiceData.cancellationUrls.entries) {
if (entry.key.toLowerCase().contains(domain)) {
return entry.value;
}
}
}
}
// 해지 안내 페이지를 찾지 못함
return null;
} }
/// 서비스에 공식 해지 안내 페이지가 있는지 확인 /// 서비스에 공식 해지 안내 페이지가 있는지 확인
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async { static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
// 새로운 JSON 기반 방식으로 확인 await initialize();
final cancellationUrl = await findCancellationUrl( return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ??
serviceName: serviceNameOrUrl, false;
websiteUrl: serviceNameOrUrl,
);
return cancellationUrl != null;
} }
/// 서비스명으로 카테고리 찾기 /// 서비스명으로 카테고리 찾기
static Future<String?> findCategoryByServiceName(String serviceName) async { static Future<String?> findCategoryByServiceName(String serviceName) async {
await initialize(); await initialize();
if (serviceName.isEmpty) return null; return _categoryMapper?.findCategoryByServiceName(serviceName);
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({ static Future<String> getServiceDisplayName({
required String serviceName, required String serviceName,
required String locale, required String locale,
}) async { }) async {
await initialize(); await initialize();
return await _nameResolver?.getServiceDisplayName(
if (_servicesData == null) { serviceName: serviceName,
return serviceName; locale: locale,
} ) ??
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 (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';
}
/// SMS에서 URL과 서비스 정보 추출 /// SMS에서 URL과 서비스 정보 추출
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async { static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
await initialize(); await initialize();
return _smsExtractor?.extractServiceFromSms(smsText);
// 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 LegacyServiceData.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인지 확인 /// URL이 알려진 서비스 URL인지 확인
static Future<bool> isKnownServiceUrl(String url) async { static Future<bool> isKnownServiceUrl(String url) async {
final serviceInfo = await findServiceByUrl(url); await initialize();
return serviceInfo != null; return await _urlMatcher?.isKnownServiceUrl(url) ?? false;
} }
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성) /// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성)
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) { static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
// 입력 텍스트가 비어있거나 null인 경우 return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch);
if (text.isEmpty) {
return null;
}
// 소문자로 변환하여 처리
final String lowerText = text.toLowerCase().trim();
// 정확히 일치하는 경우
if (LegacyServiceData.allServices.containsKey(lowerText)) {
return LegacyServiceData.allServices[lowerText];
}
// 부분 일치 검색이 활성화된 경우
if (usePartialMatch) {
// 가장 긴 부분 매칭 찾기
String? bestMatch;
int maxLength = 0;
for (var entry in LegacyServiceData.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

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

View File

@@ -0,0 +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');
_servicesData = json.decode(jsonString);
_isInitialized = true;
Log.i('ServiceDataRepository: JSON 데이터 로드 완료');
} catch (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 categoryId;
final String categoryNameKr; final String categoryNameKr;
final String categoryNameEn; final String categoryNameEn;
ServiceInfo({ ServiceInfo({
required this.serviceId, required this.serviceId,
required this.serviceName, required this.serviceName,
@@ -17,4 +17,4 @@ class ServiceInfo {
required this.categoryNameKr, required this.categoryNameKr,
required this.categoryNameEn, required this.categoryNameEn,
}); });
} }

View File

@@ -0,0 +1,137 @@
import '../data/service_data_repository.dart';
import '../data/legacy_service_data.dart';
import 'url_matcher_service.dart';
/// 해지 URL 관련 기능을 제공하는 서비스 클래스
class CancellationUrlService {
final ServiceDataRepository _dataRepository;
final UrlMatcherService _urlMatcher;
CancellationUrlService(this._dataRepository, this._urlMatcher);
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
Future<String?> findCancellationUrl({
String? serviceName,
String? websiteUrl,
String locale = 'kr',
}) async {
// JSON 데이터가 있으면 JSON에서 찾기
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>;
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 = _urlMatcher.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 찾기 (레거시)
String? _findCancellationUrlLegacy(String serviceNameOrUrl) {
if (serviceNameOrUrl.isEmpty) {
return null;
}
// 소문자로 변환하여 처리
final String lowerText = serviceNameOrUrl.toLowerCase().trim();
// 직접 서비스명으로 찾기
if (LegacyServiceData.cancellationUrls.containsKey(lowerText)) {
return LegacyServiceData.cancellationUrls[lowerText];
}
// 서비스명에 부분 포함으로 찾기
for (var entry in LegacyServiceData.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 LegacyServiceData.cancellationUrls.entries) {
if (entry.key.toLowerCase().contains(domain)) {
return entry.value;
}
}
}
}
// 해지 안내 페이지를 찾지 못함
return null;
}
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
// 새로운 JSON 기반 방식으로 확인
final cancellationUrl = await findCancellationUrl(
serviceName: serviceNameOrUrl,
websiteUrl: serviceNameOrUrl,
);
return cancellationUrl != null;
}
}

View File

@@ -0,0 +1,108 @@
import '../data/service_data_repository.dart';
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'] ?? []);
for (final name in names) {
if (lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
return getCategoryIdByKey(categoryId);
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
return getCategoryForLegacyService(serviceName);
}
/// 카테고리 키를 실제 카테고리 ID로 매핑
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';
}
}
/// 레거시 서비스명으로 카테고리 추측
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';
}
return 'other';
}
}

View File

@@ -0,0 +1,62 @@
import '../data/service_data_repository.dart';
/// 서비스명 관련 기능을 제공하는 서비스 클래스
class ServiceNameResolver {
final ServiceDataRepository _dataRepository;
ServiceNameResolver(this._dataRepository);
/// 현재 로케일에 따라 서비스 표시명 가져오기
Future<String> getServiceDisplayName({
required String serviceName,
required String locale,
}) async {
final servicesData = _dataRepository.getServicesData();
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;
}
}

View File

@@ -0,0 +1,57 @@
import '../models/service_info.dart';
import '../data/legacy_service_data.dart';
import 'url_matcher_service.dart';
import 'category_mapper_service.dart';
/// SMS에서 서비스 정보를 추출하는 서비스 클래스
class SmsExtractorService {
final UrlMatcherService _urlMatcher;
final CategoryMapperService _categoryMapper;
SmsExtractorService(this._urlMatcher, this._categoryMapper);
/// SMS에서 URL과 서비스 정보 추출
Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
// 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 _urlMatcher.findServiceByUrl(url);
if (serviceInfo != null) {
return serviceInfo;
}
}
}
// 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';
return ServiceInfo(
serviceId: entry.key,
serviceName: entry.key,
serviceUrl: entry.value,
cancellationUrl: null,
categoryId: categoryId,
categoryNameKr: '',
categoryNameEn: '',
);
}
}
return null;
}
}

View File

@@ -0,0 +1,237 @@
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) {
// 서브도메인 포함 전체 도메인 반환
return parts.sublist(0, parts.length - 1).join('.');
} else if (parts.length >= 2) {
// 메인 도메인만 반환
return parts[0];
}
return null;
} catch (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)) {
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: _categoryMapper.getCategoryIdByKey(categoryId),
categoryNameKr: categoryData['nameKr'] ?? '',
categoryNameEn: categoryData['nameEn'] ?? '',
);
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
for (final entry in LegacyServiceData.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: _categoryMapper.getCategoryForLegacyService(entry.key),
categoryNameKr: '',
categoryNameEn: '',
);
}
}
return null;
}
/// 서비스명으로 URL 찾기
String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) {
Log.w('UrlMatcherService: 빈 serviceName');
return null;
}
// 소문자로 변환하여 비교
final lowerName = serviceName.toLowerCase().trim();
try {
// 정확한 매칭을 먼저 시도
for (final entry in LegacyServiceData.allServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
Log.d('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// OTT 서비스 검사
for (final entry in LegacyServiceData.ottServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
Log.d('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 음악 서비스 검사
for (final entry in LegacyServiceData.musicServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
Log.d('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// AI 서비스 검사
for (final entry in LegacyServiceData.aiServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
Log.d('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 프로그래밍 서비스 검사
for (final entry in LegacyServiceData.programmingServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
Log.d('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 오피스 툴 검사
for (final entry in LegacyServiceData.officeTools.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
Log.d('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 기타 서비스 검사
for (final entry in LegacyServiceData.otherServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
Log.d('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 전체 서비스에서 부분 매칭 재시도
for (final entry in LegacyServiceData.allServices.entries) {
final key = entry.key.toLowerCase();
if (key.contains(lowerName) || lowerName.contains(key)) {
Log.d('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
Log.d('UrlMatcherService: 매칭 실패 - $lowerName');
return null;
} catch (e) {
Log.e('UrlMatcherService: suggestUrl 에러', e);
return null;
}
}
/// URL이 알려진 서비스 URL인지 확인
Future<bool> isKnownServiceUrl(String url) async {
final serviceInfo = await findServiceByUrl(url);
return serviceInfo != null;
}
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환
String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
// 입력 텍스트가 비어있거나 null인 경우
if (text.isEmpty) {
return null;
}
// 소문자로 변환하여 처리
final String lowerText = text.toLowerCase().trim();
// 정확히 일치하는 경우
if (LegacyServiceData.allServices.containsKey(lowerText)) {
return LegacyServiceData.allServices[lowerText];
}
// 부분 일치 검색이 활성화된 경우
if (usePartialMatch) {
// 가장 긴 부분 매칭 찾기
String? bestMatch;
int maxLength = 0;
for (var entry in LegacyServiceData.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

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

View File

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

View File

@@ -7,7 +7,7 @@ import 'app_theme.dart';
class AdaptiveTheme { class AdaptiveTheme {
/// 라이트 테마 /// 라이트 테마
static ThemeData get lightTheme => AppTheme.lightTheme; static ThemeData get lightTheme => AppTheme.lightTheme;
/// 다크 테마 /// 다크 테마
static ThemeData get darkTheme { static ThemeData get darkTheme {
return ThemeData( return ThemeData(
@@ -19,24 +19,21 @@ class AdaptiveTheme {
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor, tertiary: AppColors.infoColor,
error: AppColors.dangerColor, error: AppColors.dangerColor,
background: const Color(0xFF121212), surface: Color(0xFF1E1E1E),
surface: const Color(0xFF1E1E1E),
), ),
scaffoldBackgroundColor: const Color(0xFF121212), scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: CardThemeData(
cardTheme: CardTheme(
color: const Color(0xFF1E1E1E), color: const Color(0xFF1E1E1E),
elevation: 2, elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.3), shadowColor: Colors.black.withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5), side: BorderSide(
color: Colors.white.withValues(alpha: 0.1), width: 0.5),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: const Color(0xFF1E1E1E),
foregroundColor: Colors.white, foregroundColor: Colors.white,
@@ -53,7 +50,6 @@ class AdaptiveTheme {
size: 24, size: 24,
), ),
), ),
textTheme: TextTheme( textTheme: TextTheme(
headlineLarge: const TextStyle( headlineLarge: const TextStyle(
color: Colors.white, color: Colors.white,
@@ -119,22 +115,24 @@ class AdaptiveTheme {
height: 1.5, height: 1.5,
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: const Color(0xFF2A2A2A), fillColor: const Color(0xFF2A2A2A),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1), borderSide:
BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), borderSide:
const BorderSide(color: AppColors.primaryColor, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -151,7 +149,6 @@ class AdaptiveTheme {
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor, backgroundColor: AppColors.primaryColor,
@@ -164,7 +161,6 @@ class AdaptiveTheme {
elevation: 0, elevation: 0,
), ),
), ),
dividerTheme: DividerThemeData( dividerTheme: DividerThemeData(
color: Colors.white.withValues(alpha: 0.1), color: Colors.white.withValues(alpha: 0.1),
thickness: 1, thickness: 1,
@@ -172,13 +168,12 @@ class AdaptiveTheme {
), ),
); );
} }
/// OLED 최적화 다크 테마 /// OLED 최적화 다크 테마
static ThemeData get oledTheme { static ThemeData get oledTheme {
return darkTheme.copyWith( return darkTheme.copyWith(
scaffoldBackgroundColor: Colors.black, scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith( colorScheme: darkTheme.colorScheme.copyWith(
background: Colors.black,
surface: const Color(0xFF0A0A0A), surface: const Color(0xFF0A0A0A),
), ),
cardTheme: darkTheme.cardTheme.copyWith( cardTheme: darkTheme.cardTheme.copyWith(
@@ -192,7 +187,7 @@ class AdaptiveTheme {
), ),
); );
} }
/// 고대비 테마 /// 고대비 테마
static ThemeData get highContrastTheme { static ThemeData get highContrastTheme {
return ThemeData( return ThemeData(
@@ -203,10 +198,8 @@ class AdaptiveTheme {
secondary: Colors.black87, secondary: Colors.black87,
tertiary: Colors.black54, tertiary: Colors.black54,
error: Colors.red, error: Colors.red,
background: Colors.white,
surface: Colors.white, surface: Colors.white,
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
headlineLarge: TextStyle( headlineLarge: TextStyle(
color: Colors.black, color: Colors.black,
@@ -234,15 +227,13 @@ class AdaptiveTheme {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
cardTheme: CardThemeData(
cardTheme: CardTheme(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
side: const BorderSide(color: Colors.black, width: 2), side: const BorderSide(color: Colors.black, width: 2),
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -255,31 +246,28 @@ class AdaptiveTheme {
), ),
); );
} }
/// 시스템 테마에 따른 상태바 스타일 적용 /// 시스템 테마에 따른 상태바 스타일 적용
static void applySystemUIOverlay(BuildContext context) { static void applySystemUIOverlay(BuildContext context) {
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black; final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent, statusBarColor: Colors.transparent,
statusBarIconBrightness: brightness == Brightness.dark statusBarIconBrightness:
? Brightness.light brightness == Brightness.dark ? Brightness.light : Brightness.dark,
: Brightness.dark, statusBarBrightness:
statusBarBrightness: brightness == Brightness.dark brightness == Brightness.dark ? Brightness.light : Brightness.dark,
? Brightness.light systemNavigationBarColor: isOled
: Brightness.dark, ? Colors.black
systemNavigationBarColor: isOled : (brightness == Brightness.dark
? Colors.black ? const Color(0xFF121212)
: (brightness == Brightness.dark
? const Color(0xFF121212)
: Colors.white), : Colors.white),
systemNavigationBarIconBrightness: brightness == Brightness.dark systemNavigationBarIconBrightness:
? Brightness.light brightness == Brightness.dark ? Brightness.light : Brightness.dark,
: Brightness.dark,
)); ));
} }
/// 접근성 설정에 따른 테마 조정 /// 접근성 설정에 따른 테마 조정
static ThemeData getAccessibleTheme( static ThemeData getAccessibleTheme(
ThemeData baseTheme, { ThemeData baseTheme, {
@@ -290,9 +278,9 @@ class AdaptiveTheme {
if (highContrast) { if (highContrast) {
return highContrastTheme; return highContrastTheme;
} }
ThemeData theme = baseTheme; ThemeData theme = baseTheme;
if (largeText) { if (largeText) {
theme = theme.copyWith( theme = theme.copyWith(
textTheme: theme.textTheme.apply( textTheme: theme.textTheme.apply(
@@ -300,7 +288,7 @@ class AdaptiveTheme {
), ),
); );
} }
if (reduceMotion) { if (reduceMotion) {
theme = theme.copyWith( theme = theme.copyWith(
pageTransitionsTheme: const PageTransitionsTheme( pageTransitionsTheme: const PageTransitionsTheme(
@@ -311,7 +299,7 @@ class AdaptiveTheme {
), ),
); );
} }
return theme; return theme;
} }
} }
@@ -331,7 +319,7 @@ class ThemeSettings {
final bool largeText; final bool largeText;
final bool reduceMotion; final bool reduceMotion;
final bool highContrast; final bool highContrast;
const ThemeSettings({ const ThemeSettings({
this.mode = AppThemeMode.system, this.mode = AppThemeMode.system,
this.useSystemColors = false, this.useSystemColors = false,
@@ -339,7 +327,7 @@ class ThemeSettings {
this.reduceMotion = false, this.reduceMotion = false,
this.highContrast = false, this.highContrast = false,
}); });
ThemeSettings copyWith({ ThemeSettings copyWith({
AppThemeMode? mode, AppThemeMode? mode,
bool? useSystemColors, bool? useSystemColors,
@@ -355,15 +343,15 @@ class ThemeSettings {
highContrast: highContrast ?? this.highContrast, highContrast: highContrast ?? this.highContrast,
); );
} }
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'mode': mode.name, 'mode': mode.name,
'useSystemColors': useSystemColors, 'useSystemColors': useSystemColors,
'largeText': largeText, 'largeText': largeText,
'reduceMotion': reduceMotion, 'reduceMotion': reduceMotion,
'highContrast': highContrast, 'highContrast': highContrast,
}; };
factory ThemeSettings.fromJson(Map<String, dynamic> json) { factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings( return ThemeSettings(
mode: AppThemeMode.values.firstWhere( mode: AppThemeMode.values.firstWhere(
@@ -376,4 +364,4 @@ class ThemeSettings {
highContrast: json['highContrast'] ?? false, highContrast: json['highContrast'] ?? false,
); );
} }
} }

View File

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

View File

@@ -10,7 +10,6 @@ class AppTheme {
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor, tertiary: AppColors.infoColor,
error: AppColors.dangerColor, error: AppColors.dangerColor,
background: AppColors.backgroundColor,
surface: AppColors.surfaceColor, surface: AppColors.surfaceColor,
), ),
@@ -18,7 +17,7 @@ class AppTheme {
scaffoldBackgroundColor: AppColors.backgroundColor, scaffoldBackgroundColor: AppColors.backgroundColor,
// 카드 스타일 - 글래스모피즘 효과 // 카드 스타일 - 글래스모피즘 효과
cardTheme: CardTheme( cardTheme: CardThemeData(
color: AppColors.glassCard, color: AppColors.glassCard,
elevation: 0, elevation: 0,
shadowColor: AppColors.shadowBlack, shadowColor: AppColors.shadowBlack,
@@ -36,13 +35,13 @@ class AppTheme {
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.textPrimary,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: const TextStyle( titleTextStyle: TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.2, letterSpacing: -0.2,
), ),
iconTheme: const IconThemeData( iconTheme: IconThemeData(
color: AppColors.primaryColor, color: AppColors.primaryColor,
size: 24, size: 24,
), ),
@@ -51,22 +50,22 @@ class AppTheme {
// 타이포그래피 - Metronic Tailwind 스타일 // 타이포그래피 - Metronic Tailwind 스타일
textTheme: const TextTheme( textTheme: const TextTheme(
// 헤드라인 - 페이지 제목 // 헤드라인 - 페이지 제목
headlineLarge: const TextStyle( headlineLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineMedium: const TextStyle( headlineMedium: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineSmall: const TextStyle( headlineSmall: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.25, letterSpacing: -0.25,
@@ -74,22 +73,22 @@ class AppTheme {
), ),
// 타이틀 - 카드, 섹션 제목 // 타이틀 - 카드, 섹션 제목
titleLarge: const TextStyle( titleLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.2, letterSpacing: -0.2,
height: 1.4, height: 1.4,
), ),
titleMedium: TextStyle( titleMedium: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.1, letterSpacing: -0.1,
height: 1.4, height: 1.4,
), ),
titleSmall: TextStyle( titleSmall: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0, letterSpacing: 0,
@@ -98,21 +97,21 @@ class AppTheme {
// 본문 텍스트 // 본문 텍스트
bodyLarge: TextStyle( bodyLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0.1, letterSpacing: 0.1,
height: 1.5, height: 1.5,
), ),
bodyMedium: TextStyle( bodyMedium: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0.1, letterSpacing: 0.1,
height: 1.5, height: 1.5,
), ),
bodySmall: TextStyle( bodySmall: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0.2, letterSpacing: 0.2,
@@ -121,21 +120,21 @@ class AppTheme {
// 라벨 텍스트 // 라벨 텍스트
labelLarge: TextStyle( labelLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.1, letterSpacing: 0.1,
height: 1.4, height: 1.4,
), ),
labelMedium: TextStyle( labelMedium: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.2, letterSpacing: 0.2,
height: 1.4, height: 1.4,
), ),
labelSmall: TextStyle( labelSmall: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.2, letterSpacing: 0.2,
@@ -257,14 +256,14 @@ class AppTheme {
// 스위치 스타일 // 스위치 스타일
switchTheme: SwitchThemeData( switchTheme: SwitchThemeData(
thumbColor: MaterialStateProperty.resolveWith<Color>((states) { thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return Colors.white; return Colors.white;
}), }),
trackColor: MaterialStateProperty.resolveWith<Color>((states) { trackColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.secondaryColor.withValues(alpha: 0.5); return AppColors.secondaryColor.withValues(alpha: 0.5);
} }
return AppColors.borderColor; return AppColors.borderColor;
@@ -273,8 +272,8 @@ class AppTheme {
// 체크박스 스타일 // 체크박스 스타일
checkboxTheme: CheckboxThemeData( checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>((states) { fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return Colors.transparent; return Colors.transparent;
@@ -287,8 +286,8 @@ class AppTheme {
// 라디오 버튼 스타일 // 라디오 버튼 스타일
radioTheme: RadioThemeData( radioTheme: RadioThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>((states) { fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return AppColors.textSecondary; return AppColors.textSecondary;
@@ -307,16 +306,16 @@ class AppTheme {
), ),
// 탭바 스타일 // 탭바 스타일
tabBarTheme: const TabBarTheme( tabBarTheme: const TabBarThemeData(
labelColor: AppColors.primaryColor, labelColor: AppColors.primaryColor,
unselectedLabelColor: AppColors.textSecondary, unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.primaryColor, indicatorColor: AppColors.primaryColor,
labelStyle: const TextStyle( labelStyle: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
unselectedLabelStyle: const TextStyle( unselectedLabelStyle: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.1, letterSpacing: 0.1,

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

View File

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

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import '../../models/category_model.dart';
class CategoryIconMapper {
// 카테고리 아이콘 반환
static IconData getCategoryIcon(CategoryModel category) {
switch (category.name) {
case 'music':
return Icons.music_note_rounded;
case 'ottVideo':
return Icons.movie_filter_rounded;
case 'storageCloud':
return Icons.cloud_outlined;
case 'telecomInternetTv':
return Icons.wifi_rounded;
case 'lifestyle':
return Icons.home_outlined;
case 'shoppingEcommerce':
return Icons.shopping_cart_outlined;
case 'programming':
return Icons.code_rounded;
case 'collaborationOffice':
return Icons.business_center_outlined;
case 'aiService':
return Icons.smart_toy_outlined;
case 'other':
default:
return Icons.category_outlined;
}
}
// 카테고리별 배경색 반환
static Color getCategoryColor(CategoryModel category) {
final colorString = category.color;
try {
return Color(int.parse(colorString.replaceFirst('#', '0xFF')));
} catch (e) {
// 파싱 실패 시 기본 색상 반환
return const Color(0xFF6B7280); // 기본 회색
}
}
// 카테고리별 아이콘 크기 반환
static double getCategoryIconSize(CategoryModel category) {
switch (category.name) {
case 'music':
case 'ottVideo':
return 18.0;
default:
return 16.0;
}
}
}

View File

@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
class SmsDateFormatter {
// 날짜 상태 텍스트 가져오기
static String getNextBillingText(
BuildContext context,
DateTime date,
String billingCycle,
) {
final now = DateTime.now();
if (date.isBefore(now)) {
return _getPastDateText(context, date, billingCycle, now);
} else {
return _getFutureDateText(context, date, now);
}
}
// 과거 날짜 처리
static String _getPastDateText(
BuildContext context,
DateTime date,
String billingCycle,
DateTime now,
) {
// 주기에 따라 다음 결제일 예측
DateTime? predictedDate = _predictNextBillingDate(date, billingCycle, now);
if (predictedDate != null) {
final daysUntil = predictedDate.difference(now).inDays;
return AppLocalizations.of(context).nextBillingDateEstimated(
AppLocalizations.of(context).formatDate(predictedDate),
daysUntil,
);
}
return '다음 결제일 확인 필요 (과거 날짜)';
}
// 미래 날짜 처리
static String _getFutureDateText(
BuildContext context,
DateTime date,
DateTime now,
) {
final daysUntil = date.difference(now).inDays;
return AppLocalizations.of(context).nextBillingDateInfo(
AppLocalizations.of(context).formatDate(date),
daysUntil,
);
}
// 다음 결제일 예측
static DateTime? _predictNextBillingDate(
DateTime lastDate,
String billingCycle,
DateTime now,
) {
switch (billingCycle) {
case '월간':
return _getNextMonthlyDate(lastDate, now);
case '연간':
return _getNextYearlyDate(lastDate, now);
case '주간':
return _getNextWeeklyDate(lastDate, now);
case '일간':
return _getNextDailyDate(lastDate, now);
case '분기별':
return _getNextQuarterlyDate(lastDate, now);
case '반기별':
return _getNextSemiAnnuallyDate(lastDate, now);
default:
return null;
}
}
// 다음 월간 결제일 계산
static DateTime _getNextMonthlyDate(DateTime lastDate, DateTime now) {
int day = lastDate.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjusted = DateTime(now.year, now.month, day);
if (adjusted.isBefore(now)) {
// 다음 달의 마지막 날을 초과하는 경우 조정
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
if (day > nextMonthLastDay) {
day = nextMonthLastDay;
}
adjusted = DateTime(now.year, now.month + 1, day);
}
return adjusted;
}
// 다음 연간 결제일 계산
static DateTime _getNextYearlyDate(DateTime lastDate, DateTime now) {
int day = lastDate.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, lastDate.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjusted = DateTime(now.year, lastDate.month, day);
if (adjusted.isBefore(now)) {
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
final nextYearLastDay = DateTime(now.year + 1, lastDate.month + 1, 0).day;
if (day > nextYearLastDay) {
day = nextYearLastDay;
}
adjusted = DateTime(now.year + 1, lastDate.month, day);
}
return adjusted;
}
// 다음 주간 결제일 계산
static DateTime _getNextWeeklyDate(DateTime lastDate, DateTime now) {
DateTime next = lastDate;
while (next.isBefore(now)) {
next = next.add(const Duration(days: 7));
}
return next;
}
// 다음 일간 결제일 계산
static DateTime _getNextDailyDate(DateTime lastDate, DateTime now) {
return now.add(const Duration(days: 1));
}
// 다음 분기별 결제일 계산
static DateTime _getNextQuarterlyDate(DateTime lastDate, DateTime now) {
DateTime next = lastDate;
while (next.isBefore(now)) {
next = DateTime(next.year, next.month + 3, next.day);
}
return next;
}
// 다음 반기별 결제일 계산
static DateTime _getNextSemiAnnuallyDate(DateTime lastDate, DateTime now) {
DateTime next = lastDate;
while (next.isBefore(now)) {
next = DateTime(next.year, next.month + 6, next.day);
}
return next;
}
// 날짜 포맷 함수
static String formatDate(DateTime date) {
return '${date.year}${date.month}${date.day}';
}
// 결제 반복 횟수 텍스트
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 'package:flutter/material.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/url_matcher/data/legacy_service_data.dart'; import '../services/url_matcher/data/legacy_service_data.dart';
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스 /// 구독 서비스를 카테고리별로 구분하는 도우미 클래스
@@ -86,8 +85,8 @@ class SubscriptionCategoryHelper {
categorizedSubscriptions['shoppingEcommerce']!.add(subscription); categorizedSubscriptions['shoppingEcommerce']!.add(subscription);
} }
// 프로그래밍 // 프로그래밍
else if (_isInCategory(subscription.serviceName, else if (_isInCategory(
LegacyServiceData.programmingServices)) { subscription.serviceName, LegacyServiceData.programmingServices)) {
if (!categorizedSubscriptions.containsKey('programming')) { if (!categorizedSubscriptions.containsKey('programming')) {
categorizedSubscriptions['programming'] = []; categorizedSubscriptions['programming'] = [];
} }

View File

@@ -6,7 +6,8 @@ import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 App Bar /// 구독 추가 화면의 App Bar
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget { class AddSubscriptionAppBar extends StatelessWidget
implements PreferredSizeWidget {
final AddSubscriptionController controller; final AddSubscriptionController controller;
final double scrollOffset; final double scrollOffset;
final VoidCallback onScanSMS; final VoidCallback onScanSMS;
@@ -101,4 +102,4 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
), ),
); );
} }
} }

View File

@@ -3,7 +3,6 @@ import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/date_picker_field.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 이벤트/할인 섹션 /// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget { class AddSubscriptionEventSection extends StatelessWidget {
@@ -47,11 +46,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@@ -66,7 +65,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: controller.gradientColors[0].withValues(alpha: 0.1), color: controller.gradientColors[0]
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon( child: Icon(
@@ -122,7 +122,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
), ),
], ],
), ),
// 이벤트 활성화 시 추가 필드 표시 // 이벤트 활성화 시 추가 필드 표시
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -146,7 +146,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.infoColor, color: AppColors.infoColor,
size: 20, size: 20,
@@ -155,7 +155,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Expanded( Expanded(
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final locale = Localizations.localeOf(context); final locale =
Localizations.localeOf(context);
String infoText; String infoText;
switch (locale.languageCode) { switch (locale.languageCode) {
case 'ko': case 'ko':
@@ -168,11 +169,12 @@ class AddSubscriptionEventSection extends StatelessWidget {
infoText = '设置折扣或促销价格'; infoText = '设置折扣或促销价格';
break; break;
default: default:
infoText = 'Set up discount or promotion price'; infoText =
'Set up discount or promotion price';
} }
return Text( return Text(
infoText, infoText,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -185,7 +187,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 이벤트 기간 // 이벤트 기간
Builder( Builder(
builder: (context) { builder: (context) {
@@ -216,8 +218,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
setState(() { setState(() {
controller.eventStartDate = date; controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) { if (date != null &&
controller.eventEndDate = date.add(const Duration(days: 30)); controller.eventEndDate == null) {
controller.eventEndDate =
date.add(const Duration(days: 30));
} }
}); });
}, },
@@ -233,17 +237,18 @@ class AddSubscriptionEventSection extends StatelessWidget {
}, },
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 이벤트 가격 // 이벤트 가격
Builder( Builder(
builder: (BuildContext innerContext) { builder: (BuildContext innerContext) {
// 현재 로케일 확인 // 현재 로케일 확인
final currentLocale = Localizations.localeOf(innerContext); final currentLocale =
Localizations.localeOf(innerContext);
// 로케일에 따라 직접 텍스트 설정 // 로케일에 따라 직접 텍스트 설정
String eventPriceLabel; String eventPriceLabel;
String eventPriceHint; String eventPriceHint;
switch (currentLocale.languageCode) { switch (currentLocale.languageCode) {
case 'ko': case 'ko':
eventPriceLabel = '이벤트 가격'; eventPriceLabel = '이벤트 가격';
@@ -261,7 +266,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
eventPriceLabel = 'Event Price'; eventPriceLabel = 'Event Price';
eventPriceHint = 'Enter discounted price'; eventPriceHint = 'Enter discounted price';
} }
return CurrencyInputField( return CurrencyInputField(
controller: controller.eventPriceController, controller: controller.eventPriceController,
currency: controller.currency, currency: controller.currency,
@@ -280,4 +285,4 @@ class AddSubscriptionEventSection extends StatelessWidget {
), ),
); );
} }
} }

View File

@@ -86,4 +86,4 @@ class AddSubscriptionHeader extends StatelessWidget {
), ),
); );
} }
} }

View File

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

View File

@@ -5,7 +5,6 @@ import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget { class AnalysisBadge extends StatelessWidget {
@@ -33,7 +32,7 @@ class AnalysisBadge extends StatelessWidget {
color: borderColor, color: borderColor,
width: 2, width: 2,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
@@ -69,13 +68,17 @@ class AnalysisBadge extends StatelessWidget {
String displayText = amountText; String displayText = amountText;
if (amountText.length > 12) { if (amountText.length > 12) {
// 괄호 안의 내용 제거 // 괄호 안의 내용 제거
displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim(); displayText =
amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
} }
if (displayText.length > 10) { if (displayText.length > 10) {
// 통화 기호만 남기고 숫자만 표시 // 통화 기호만 남기고 숫자만 표시
final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency); final currencySymbol =
displayText = displayText.replaceAll(currencySymbol, '').trim(); CurrencyUtil.getCurrencySymbol(subscription.currency);
displayText = '$currencySymbol${displayText.substring(0, 6)}...'; displayText =
displayText.replaceAll(currencySymbol, '').trim();
displayText =
'$currencySymbol${displayText.substring(0, 6)}...';
} }
return Text( return Text(
displayText, displayText,
@@ -93,4 +96,4 @@ class AnalysisBadge extends StatelessWidget {
), ),
); );
} }
} }

View File

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

View File

@@ -48,10 +48,12 @@ class EventAnalysisCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: AppLocalizations.of(context).eventDiscountStatus, text: AppLocalizations.of(context)
.eventDiscountStatus,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
@@ -79,7 +81,10 @@ class EventAnalysisCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length), AppLocalizations.of(context)
.servicesInProgress(provider
.activeEventSubscriptions
.length),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -97,15 +102,18 @@ class EventAnalysisCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
const Color(0xFFFF6B6B).withValues(alpha: 0.1), const Color(0xFFFF6B6B)
const Color(0xFFFF8787).withValues(alpha: 0.1), .withValues(alpha: 0.1),
const Color(0xFFFF8787)
.withValues(alpha: 0.1),
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: const Color(0xFFFF6B6B).withValues(alpha: 0.3), color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.3),
), ),
), ),
child: Row( child: Row(
@@ -118,10 +126,12 @@ class EventAnalysisCard extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
ThemedText( ThemedText(
AppLocalizations.of(context).monthlySavingAmount, AppLocalizations.of(context)
.monthlySavingAmount,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -154,24 +164,29 @@ class EventAnalysisCard extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
...provider.activeEventSubscriptions.map((sub) { ...provider.activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice); final savings = sub.originalPrice -
final discountRate = (sub.eventPrice ?? sub.originalPrice);
((savings / sub.originalPrice) * 100).round(); final discountRate =
((savings / sub.originalPrice) * 100)
.round();
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.darkNavy.withValues(alpha: 0.05), color: AppColors.darkNavy
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppColors.darkNavy.withValues(alpha: 0.1), color: AppColors.darkNavy
.withValues(alpha: 0.1),
), ),
), ),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
ThemedText( ThemedText(
sub.serviceName, sub.serviceName,
@@ -184,8 +199,8 @@ class EventAnalysisCard extends StatelessWidget {
Row( Row(
children: [ children: [
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil future:
.formatAmount( CurrencyUtil.formatAmount(
sub.originalPrice, sub.originalPrice,
sub.currency), sub.currency),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -194,9 +209,11 @@ class EventAnalysisCard extends StatelessWidget {
snapshot.data!, snapshot.data!,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
decoration: TextDecoration decoration:
.lineThrough, TextDecoration
color: AppColors.navyGray, .lineThrough,
color: AppColors
.navyGray,
), ),
); );
} }
@@ -211,9 +228,10 @@ class EventAnalysisCard extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil future:
.formatAmount( CurrencyUtil.formatAmount(
sub.eventPrice ?? sub.originalPrice, sub.eventPrice ??
sub.originalPrice,
sub.currency), sub.currency),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
@@ -244,7 +262,8 @@ class EventAnalysisCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFF6B6B) color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.2), .withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius:
BorderRadius.circular(4),
), ),
child: Text( child: Text(
'$discountRate${AppLocalizations.of(context).discountPercent}', '$discountRate${AppLocalizations.of(context).discountPercent}',
@@ -271,4 +290,4 @@ class EventAnalysisCard extends StatelessWidget {
), ),
); );
} }
} }

View File

@@ -8,6 +8,7 @@ import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/reduce_motion.dart';
/// 월별 지출 현황을 차트로 보여주는 카드 위젯 /// 월별 지출 현황을 차트로 보여주는 카드 위젯
class MonthlyExpenseChartCard extends StatelessWidget { class MonthlyExpenseChartCard extends StatelessWidget {
@@ -23,7 +24,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
/// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰) /// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰)
double _calculateChartMaxY(double maxValue, String locale) { double _calculateChartMaxY(double maxValue, String locale) {
final currency = CurrencyUtil.getDefaultCurrency(locale); final currency = CurrencyUtil.getDefaultCurrency(locale);
if (currency == 'KRW' || currency == 'JPY') { if (currency == 'KRW' || currency == 'JPY') {
// 소수점이 없는 통화 (원화, 엔화) // 소수점이 없는 통화 (원화, 엔화)
if (maxValue <= 0) return 100000; if (maxValue <= 0) return 100000;
@@ -33,9 +34,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
if (maxValue <= 200000) return 200000; if (maxValue <= 200000) return 200000;
if (maxValue <= 500000) return 500000; if (maxValue <= 500000) return 500000;
if (maxValue <= 1000000) return 1000000; 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(); return ((maxValue / magnitude).ceil() * magnitude).toDouble();
} else { } else {
// 소수점이 있는 통화 (달러, 위안) // 소수점이 있는 통화 (달러, 위안)
@@ -47,7 +49,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
if (maxValue <= 250) return 250.0; if (maxValue <= 250) return 250.0;
if (maxValue <= 500) return 500.0; if (maxValue <= 500) return 500.0;
if (maxValue <= 1000) return 1000.0; if (maxValue <= 1000) return 1000.0;
// 큰 금액은 100 단위로 반올림 // 큰 금액은 100 단위로 반올림
return ((maxValue / 100).ceil() * 100).toDouble(); return ((maxValue / 100).ceil() * 100).toDouble();
} }
@@ -153,108 +155,114 @@ class MonthlyExpenseChartCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 바 차트 // 바 차트 (RepaintBoundary로 페인트 분리)
AspectRatio( RepaintBoundary(
aspectRatio: 1.6, child: AspectRatio(
child: BarChart( aspectRatio: 1.6,
BarChartData( child: BarChart(
alignment: BarChartAlignment.spaceAround, BarChartData(
maxY: _calculateChartMaxY( alignment: BarChartAlignment.spaceAround,
monthlyData.fold<double>( maxY: _calculateChartMaxY(
0, monthlyData.fold<double>(
(max, data) => math.max( 0,
max, data['totalExpense'] as double)), (max, data) => math.max(
locale max, data['totalExpense'] as double)),
), locale),
barGroups: _getMonthlyBarGroups(locale), barGroups: _getMonthlyBarGroups(locale),
gridData: FlGridData( gridData: FlGridData(
show: true, show: true,
drawVerticalLine: false, drawVerticalLine: false,
horizontalInterval: _calculateGridInterval( horizontalInterval: _calculateGridInterval(
_calculateChartMaxY( _calculateChartMaxY(
monthlyData.fold<double>( monthlyData.fold<double>(
0, 0,
(max, data) => math.max(max, (max, data) => math.max(max,
data['totalExpense'] as double)), data['totalExpense'] as double)),
locale locale),
), CurrencyUtil.getDefaultCurrency(locale)),
CurrencyUtil.getDefaultCurrency(locale) getDrawingHorizontalLine: (value) {
return FlLine(
color:
AppColors.navyGray.withValues(alpha: 0.1),
strokeWidth: 1,
);
},
), ),
getDrawingHorizontalLine: (value) { titlesData: FlTitlesData(
return FlLine( show: true,
color: AppColors.navyGray.withValues(alpha: 0.1), topTitles: const AxisTitles(
strokeWidth: 1, sideTitles: SideTitles(showTitles: false),
); ),
}, bottomTitles: AxisTitles(
), sideTitles: SideTitles(
titlesData: FlTitlesData( showTitles: true,
show: true, getTitlesWidget: (value, meta) {
topTitles: const AxisTitles( return Padding(
sideTitles: SideTitles(showTitles: false), padding: const EdgeInsets.only(top: 8),
), child: ThemedText.caption(
bottomTitles: AxisTitles( text: monthlyData[value.toInt()]
sideTitles: SideTitles( ['monthName'],
showTitles: true, style: const TextStyle(
getTitlesWidget: (value, meta) { fontSize: 12,
return Padding( fontWeight: FontWeight.bold,
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(
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,
),
),
],
); );
}, },
), ),
), ),
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,
),
),
],
);
},
),
), ),
swapAnimationDuration: ReduceMotion.isEnabled(context)
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
swapAnimationCurve: Curves.easeOut,
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Center( Center(
child: ThemedText.caption( child: ThemedText.caption(
text: AppLocalizations.of(context).monthlySubscriptionExpense, text: AppLocalizations.of(context)
.monthlySubscriptionExpense,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -270,4 +278,4 @@ class MonthlyExpenseChartCard extends StatelessWidget {
), ),
); );
} }
} }

View File

@@ -10,6 +10,7 @@ import '../themed_text.dart';
import 'analysis_badge.dart'; import 'analysis_badge.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../utils/reduce_motion.dart';
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯 /// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
class SubscriptionPieChartCard extends StatefulWidget { class SubscriptionPieChartCard extends StatefulWidget {
@@ -23,14 +24,15 @@ class SubscriptionPieChartCard extends StatefulWidget {
}); });
@override @override
State<SubscriptionPieChartCard> createState() => _SubscriptionPieChartCardState(); State<SubscriptionPieChartCard> createState() =>
_SubscriptionPieChartCardState();
} }
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> { class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
int _touchedIndex = -1; int _touchedIndex = -1;
late Future<List<PieChartSectionData>> _pieSectionsFuture; late Future<List<PieChartSectionData>> _pieSectionsFuture;
String? _lastLocale; String? _lastLocale;
static const _chartColors = [ static const _chartColors = [
Color(0xFF3B82F6), Color(0xFF3B82F6),
Color(0xFF10B981), Color(0xFF10B981),
@@ -52,7 +54,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// subscriptions나 locale이 변경된 경우만 Future 재생성 // subscriptions나 locale이 변경된 경우만 Future 재생성
final currentLocale = context.read<LocaleProvider>().locale.languageCode; final currentLocale = context.read<LocaleProvider>().locale.languageCode;
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) || if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
_lastLocale != currentLocale) { _lastLocale != currentLocale) {
_initializeFuture(); _initializeFuture();
} }
@@ -66,7 +68,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) { bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
if (a.length != b.length) return false; if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) { 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].currentPrice != b[i].currentPrice ||
a[i].currency != b[i].currency || a[i].currency != b[i].currency ||
a[i].serviceName != b[i].serviceName) { a[i].serviceName != b[i].serviceName) {
@@ -78,7 +80,6 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산) // 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
Future<List<PieChartSectionData>> _getPieSections() async { Future<List<PieChartSectionData>> _getPieSections() async {
if (widget.subscriptions.isEmpty) return []; if (widget.subscriptions.isEmpty) return [];
// 현재 locale 가져오기 // 현재 locale 가져오기
@@ -91,17 +92,19 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 각 구독의 현재 가격을 언어별 기본 통화로 환산 // 각 구독의 현재 가격을 언어별 기본 통화로 환산
for (var subscription in widget.subscriptions) { for (var subscription in widget.subscriptions) {
double value = subscription.currentPrice; double value = subscription.currentPrice;
if (subscription.currency == defaultCurrency) { if (subscription.currency == defaultCurrency) {
// 이미 기본 통화인 경우 그대로 사용 // 이미 기본 통화인 경우 그대로 사용
sectionValues.add(value); sectionValues.add(value);
} else if (subscription.currency == 'USD') { } else if (subscription.currency == 'USD') {
// USD를 기본 통화로 변환 // USD를 기본 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency); final converted = await ExchangeRateService()
.convertUsdToTarget(value, defaultCurrency);
sectionValues.add(converted ?? value); sectionValues.add(converted ?? value);
} else if (defaultCurrency == 'USD') { } else if (defaultCurrency == 'USD') {
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환 // 기본 통화가 USD인 경우 다른 통화를 USD로 변환
final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency); final converted = await ExchangeRateService()
.convertTargetToUsd(value, subscription.currency);
sectionValues.add(converted ?? value); sectionValues.add(converted ?? value);
} else { } else {
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우) // 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
@@ -111,7 +114,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 총합 계산 // 총합 계산
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value); double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
// 총합이 0이면 빈 배열 반환 // 총합이 0이면 빈 배열 반환
if (sectionsTotal == 0) return []; if (sectionsTotal == 0) return [];
@@ -138,17 +141,17 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
badgePositionPercentageOffset: .98, badgePositionPercentageOffset: .98,
); );
}); });
return sections; return sections;
} }
// 배지 위젯 생성 // 배지 위젯 생성
Widget _createBadgeWidget(int index) { Widget _createBadgeWidget(int index) {
if (index >= widget.subscriptions.length) return const SizedBox.shrink(); if (index >= widget.subscriptions.length) return const SizedBox.shrink();
final subscription = widget.subscriptions[index]; final subscription = widget.subscriptions[index];
final colorIndex = index % _chartColors.length; final colorIndex = index % _chartColors.length;
return IgnorePointer( return IgnorePointer(
child: AnalysisBadge( child: AnalysisBadge(
size: 40, size: 40,
@@ -159,24 +162,27 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
} }
// 터치 상태를 반영한 섹션 데이터 생성 // 터치 상태를 반영한 섹션 데이터 생성
List<PieChartSectionData> _applyTouchedState(List<PieChartSectionData> sections) { List<PieChartSectionData> _applyTouchedState(
List<PieChartSectionData> sections) {
return List.generate(sections.length, (i) { return List.generate(sections.length, (i) {
final section = sections[i]; final section = sections[i];
final isTouched = _touchedIndex == i; final isTouched = _touchedIndex == i;
final fontSize = isTouched ? 16.0 : 12.0; final fontSize = isTouched ? 16.0 : 12.0;
final radius = isTouched ? 105.0 : 100.0; final radius = isTouched ? 105.0 : 100.0;
return PieChartSectionData( return PieChartSectionData(
value: section.value, value: section.value,
title: section.title, title: section.title,
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle( titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ??
fontSize: fontSize, TextStyle(
fontWeight: FontWeight.bold, fontSize: fontSize,
color: AppColors.pureWhite, fontWeight: FontWeight.bold,
shadows: const [ color: AppColors.pureWhite,
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) shadows: const [
], Shadow(
), color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
color: section.color, color: section.color,
radius: radius, radius: radius,
titlePositionPercentageOffset: section.titlePositionPercentageOffset, titlePositionPercentageOffset: section.titlePositionPercentageOffset,
@@ -217,18 +223,20 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: AppLocalizations.of(context).subscriptionServiceRatio, text: AppLocalizations.of(context)
.subscriptionServiceRatio,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfoForLocale( future: CurrencyUtil.getExchangeRateInfoForLocale(
context.watch<LocaleProvider>().locale.languageCode context
), .watch<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData && if (snapshot.hasData && snapshot.data!.isNotEmpty) {
snapshot.data!.isNotEmpty) {
return Container( return Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
@@ -236,15 +244,15 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFE5F2FF), color: const Color(0xFFE5F2FF),
borderRadius: borderRadius: BorderRadius.circular(4),
BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: const Color(0xFFBFDBFE), color: const Color(0xFFBFDBFE),
width: 1, width: 1,
), ),
), ),
child: Text( child: Text(
AppLocalizations.of(context).exchangeRateFormat(snapshot.data!), AppLocalizations.of(context)
.exchangeRateFormat(snapshot.data!),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -272,7 +280,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
height: 250, height: 250,
child: Center( child: Center(
child: ThemedText( child: ThemedText(
AppLocalizations.of(context).noSubscriptionServices, AppLocalizations.of(context)
.noSubscriptionServices,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
), ),
@@ -284,70 +293,89 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
child: FutureBuilder<List<PieChartSectionData>>( child: FutureBuilder<List<PieChartSectionData>>(
future: _pieSectionsFuture, future: _pieSectionsFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Center( return const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
); );
} }
if (!snapshot.hasData || snapshot.data!.isEmpty) { if (!snapshot.hasData ||
snapshot.data!.isEmpty) {
return Center( return Center(
child: ThemedText( child: ThemedText(
AppLocalizations.of(context).noSubscriptionServices, AppLocalizations.of(context)
.noSubscriptionServices,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
), ),
), ),
); );
} }
return PieChart( return RepaintBoundary(
PieChartData( child: PieChart(
borderData: FlBorderData(show: false), PieChartData(
sectionsSpace: 2, borderData: FlBorderData(show: false),
centerSpaceRadius: 60, sectionsSpace: 2,
sections: _applyTouchedState(snapshot.data!), centerSpaceRadius: 60,
pieTouchData: PieTouchData( sections:
enabled: true, _applyTouchedState(snapshot.data!),
touchCallback: (FlTouchEvent event, pieTouchData: PieTouchData(
pieTouchResponse) { enabled: true,
// 터치 응답이 없거나 섹션이 없는 경우 touchCallback: (FlTouchEvent event,
if (pieTouchResponse == null || pieTouchResponse) {
pieTouchResponse.touchedSection == null) { // 터치 응답이 없거나 섹션이 없는 경우
// 차트 밖으로 나갔을 때만 리셋 if (pieTouchResponse == null ||
if (_touchedIndex != -1) { pieTouchResponse
setState(() { .touchedSection ==
_touchedIndex = -1; null) {
}); // 차트 밖으로 나갔을 때만 리셋
if (_touchedIndex != -1) {
setState(() {
_touchedIndex = -1;
});
}
return;
} }
return;
} final touchedIndex =
pieTouchResponse.touchedSection!
final touchedIndex = pieTouchResponse .touchedSectionIndex;
.touchedSection!
.touchedSectionIndex; // 탭 이벤트 처리 (토글)
if (event is FlTapUpEvent) {
// 탭 이벤트 처리 (토글)
if (event is FlTapUpEvent) {
setState(() {
// 동일 섹션 탭하면 선택 해제, 아니면 선택
_touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex;
});
return;
}
// hover 이벤트 처리 (단순 표시)
if (event is FlPointerHoverEvent ||
event is FlPointerEnterEvent) {
// 현재 인덱스와 다른 경우만 업데이트
if (_touchedIndex != touchedIndex) {
setState(() { setState(() {
_touchedIndex = touchedIndex; // 동일 섹션 탭하면 선택 해제, 아니면 선택
_touchedIndex =
(_touchedIndex ==
touchedIndex)
? -1
: touchedIndex;
}); });
return;
} }
}
}, // hover 이벤트 처리 (단순 표시)
if (event is FlPointerHoverEvent ||
event is FlPointerEnterEvent) {
// 현재 인덱스와 다른 경우만 업데이트
if (_touchedIndex !=
touchedIndex) {
setState(() {
_touchedIndex = touchedIndex;
});
}
}
},
),
), ),
swapAnimationDuration:
ReduceMotion.isEnabled(context)
? const Duration(milliseconds: 0)
: const Duration(
milliseconds: 300),
swapAnimationCurve: Curves.easeOut,
), ),
); );
}, },
@@ -364,10 +392,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
(index) { (index) {
final subscription = final subscription =
widget.subscriptions[index]; widget.subscriptions[index];
final color = _chartColors[index % _chartColors.length]; final color =
_chartColors[index % _chartColors.length];
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(bottom: 4.0),
bottom: 4.0),
child: Row( child: Row(
children: [ children: [
Container( Container(
@@ -385,31 +413,31 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
), ),
overflow: overflow: TextOverflow.ellipsis,
TextOverflow.ellipsis,
), ),
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil future: CurrencyUtil
.formatSubscriptionAmountWithLocale( .formatSubscriptionAmountWithLocale(
subscription, subscription,
context.read<LocaleProvider>().locale.languageCode), context
.read<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return ThemedText( return ThemedText(
snapshot.data!, snapshot.data!,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: fontWeight: FontWeight.bold,
FontWeight.bold,
), ),
); );
} }
return const SizedBox( return const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: child: CircularProgressIndicator(
CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
), ),
); );
@@ -430,4 +458,4 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
), ),
); );
} }
} }

View File

@@ -43,185 +43,204 @@ class TotalExpenseSummaryCard extends StatelessWidget {
parent: animationController, parent: animationController,
curve: const Interval(0.2, 0.8, curve: Curves.easeOut), curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
)), )),
child: GlassmorphismCard( child: RepaintBoundary(
blur: 10, child: GlassmorphismCard(
opacity: 0.1, blur: 10,
borderRadius: 16, opacity: 0.1,
child: Padding( borderRadius: 16,
padding: const EdgeInsets.all(16), child: Padding(
child: Column( padding: const EdgeInsets.all(16),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Row( children: [
mainAxisAlignment: MainAxisAlignment.spaceBetween, Row(
children: [ mainAxisAlignment: MainAxisAlignment.spaceBetween,
ThemedText.headline( children: [
text: AppLocalizations.of(context).totalExpenseSummary, ThemedText.headline(
style: const TextStyle( text: AppLocalizations.of(context)
fontSize: 18, .totalExpenseSummary,
style: const TextStyle(
fontSize: 18,
),
), ),
), IconButton(
IconButton( icon: const Icon(Icons.content_copy),
icon: const Icon(Icons.content_copy), iconSize: 20,
iconSize: 20, padding: EdgeInsets.zero,
padding: EdgeInsets.zero, constraints: const BoxConstraints(),
constraints: const BoxConstraints(), onPressed: () async {
onPressed: () async { final totalExpenseText =
final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale); CurrencyUtil.formatTotalAmountWithLocale(
await Clipboard.setData( totalExpense, locale);
ClipboardData(text: totalExpenseText)); await Clipboard.setData(
HapticFeedbackHelper.lightImpact(); ClipboardData(text: totalExpenseText));
if (!context.mounted) return; HapticFeedbackHelper.lightImpact();
ScaffoldMessenger.of(context).showSnackBar( if (!context.mounted) return;
SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)), SnackBar(
duration: const Duration(seconds: 2), content: Text(AppLocalizations.of(context)
behavior: SnackBarBehavior.floating, .totalExpenseCopied(totalExpenseText)),
shape: RoundedRectangleBorder( duration: const Duration(seconds: 2),
borderRadius: BorderRadius.circular(8), behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
backgroundColor: AppColors.glassBackground
.withValues(alpha: 0.3),
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
), ),
backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3), );
margin: const EdgeInsets.symmetric( },
horizontal: 16, ),
vertical: 8, ],
),
),
);
},
),
],
),
const SizedBox(height: 8),
ThemedText.subtitle(
text: AppLocalizations.of(context).monthlyTotalAmount,
style: const TextStyle(
fontSize: 14,
), ),
), const SizedBox(height: 8),
const SizedBox(height: 16), ThemedText.subtitle(
Row( text: AppLocalizations.of(context).monthlyTotalAmount,
crossAxisAlignment: CrossAxisAlignment.start, style: const TextStyle(
children: [ fontSize: 14,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText.caption(
text: AppLocalizations.of(context).totalExpense,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
ThemedText(
CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
],
),
), ),
const SizedBox(width: 16), ),
Expanded( const SizedBox(height: 16),
child: Column( Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Row( children: [
children: [ Expanded(
Container( child: Column(
padding: const EdgeInsets.all(8), crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration( children: [
color: AppColors.glassBackground.withValues(alpha: 0.3), ThemedText.caption(
borderRadius: BorderRadius.circular(8), text:
border: Border.all( AppLocalizations.of(context).totalExpense,
color: AppColors.glassBorder.withValues(alpha: 0.2), style: const TextStyle(
), fontSize: 12,
), fontWeight: FontWeight.bold,
child: const FaIcon(
FontAwesomeIcons.listCheck,
size: 16,
color: AppColors.primaryColor,
),
), ),
const SizedBox(width: 12), ),
Column( const SizedBox(height: 4),
crossAxisAlignment: ThemedText(
CrossAxisAlignment.start, CurrencyUtil.formatTotalAmountWithLocale(
children: [ totalExpense, locale),
ThemedText.caption( style: const TextStyle(
text: AppLocalizations.of(context).totalServices, fontSize: 26,
style: const TextStyle( fontWeight: FontWeight.bold,
fontSize: 12, letterSpacing: -0.5,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
ThemedText(
AppLocalizations.of(context).subscriptionCount(subscriptions.length),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
), ),
], ),
), ],
const SizedBox(height: 12), ),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.glassBackground.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.2),
),
),
child: const FaIcon(
FontAwesomeIcons.chartLine,
size: 16,
color: AppColors.successColor,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText.caption(
text: AppLocalizations.of(context).averageCost,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
ThemedText(
CurrencyUtil.formatTotalAmountWithLocale(
subscriptions.isEmpty
? 0
: totalExpense / subscriptions.length,
locale),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
],
), ),
), const SizedBox(width: 16),
], Expanded(
), child: Column(
], children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.glassBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.glassBorder
.withValues(alpha: 0.2),
),
),
child: const FaIcon(
FontAwesomeIcons.listCheck,
size: 16,
color: AppColors.primaryColor,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText.caption(
text: AppLocalizations.of(context)
.totalServices,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
ThemedText(
AppLocalizations.of(context)
.subscriptionCount(
subscriptions.length),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
const SizedBox(height: 12),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.glassBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.glassBorder
.withValues(alpha: 0.2),
),
),
child: const FaIcon(
FontAwesomeIcons.chartLine,
size: 16,
color: AppColors.successColor,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText.caption(
text: AppLocalizations.of(context)
.averageCost,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
ThemedText(
CurrencyUtil
.formatTotalAmountWithLocale(
subscriptions.isEmpty
? 0
: totalExpense /
subscriptions.length,
locale),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
],
),
),
],
),
],
),
), ),
), ),
), ),
@@ -230,4 +249,4 @@ class TotalExpenseSummaryCard extends StatelessWidget {
), ),
); );
} }
} }

View File

@@ -1,18 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:math' as math; import 'dart:math' as math;
import '../utils/reduce_motion.dart';
/// 슬라이드 + 페이드 전환 /// 슬라이드 + 페이드 전환
class SlidePageRoute<T> extends PageRouteBuilder<T> { class SlidePageRoute<T> extends PageRouteBuilder<T> {
final Widget page; final Widget page;
final AxisDirection direction; final AxisDirection direction;
SlidePageRoute({ SlidePageRoute({
required this.page, required this.page,
this.direction = AxisDirection.right, this.direction = AxisDirection.right,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 300), ? Duration.zero
: const Duration(milliseconds: 300),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
Offset begin; Offset begin;
switch (direction) { switch (direction) {
@@ -29,20 +34,20 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
begin = const Offset(0.0, -1.0); begin = const Offset(0.0, -1.0);
break; break;
} }
const end = Offset.zero; const end = Offset.zero;
const curve = Curves.easeOutCubic; const curve = Curves.easeOutCubic;
var tween = Tween(begin: begin, end: end).chain( var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve), CurveTween(curve: curve),
); );
var offsetAnimation = animation.drive(tween); var offsetAnimation = animation.drive(tween);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain( var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve), CurveTween(curve: curve),
); );
var fadeAnimation = animation.drive(fadeTween); var fadeAnimation = animation.drive(fadeTween);
return SlideTransition( return SlideTransition(
position: offsetAnimation, position: offsetAnimation,
child: FadeTransition( child: FadeTransition(
@@ -58,27 +63,31 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
class ScalePageRoute<T> extends PageRouteBuilder<T> { class ScalePageRoute<T> extends PageRouteBuilder<T> {
final Widget page; final Widget page;
final Alignment alignment; final Alignment alignment;
ScalePageRoute({ ScalePageRoute({
required this.page, required this.page,
this.alignment = Alignment.center, this.alignment = Alignment.center,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 400), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 400), ? Duration.zero
: const Duration(milliseconds: 400),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.elasticOut; const curve = Curves.elasticOut;
var scaleTween = Tween(begin: 0.0, end: 1.0).chain( var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve), CurveTween(curve: curve),
); );
var scaleAnimation = animation.drive(scaleTween); var scaleAnimation = animation.drive(scaleTween);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain( var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: Curves.easeIn), CurveTween(curve: Curves.easeIn),
); );
var fadeAnimation = animation.drive(fadeTween); var fadeAnimation = animation.drive(fadeTween);
return ScaleTransition( return ScaleTransition(
scale: scaleAnimation, scale: scaleAnimation,
alignment: alignment, alignment: alignment,
@@ -94,25 +103,29 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
/// 회전 + 스케일 전환 /// 회전 + 스케일 전환
class RotatePageRoute<T> extends PageRouteBuilder<T> { class RotatePageRoute<T> extends PageRouteBuilder<T> {
final Widget page; final Widget page;
RotatePageRoute({required this.page}) RotatePageRoute({required this.page})
: super( : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 500), ? Duration.zero
: const Duration(milliseconds: 500),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.easeInOut; const curve = Curves.easeInOut;
var rotateTween = Tween(begin: -0.5, end: 0.0).chain( var rotateTween = Tween(begin: -0.5, end: 0.0).chain(
CurveTween(curve: curve), CurveTween(curve: curve),
); );
var rotateAnimation = animation.drive(rotateTween); var rotateAnimation = animation.drive(rotateTween);
var scaleTween = Tween(begin: 0.0, end: 1.0).chain( var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve), CurveTween(curve: curve),
); );
var scaleAnimation = animation.drive(scaleTween); var scaleAnimation = animation.drive(scaleTween);
return Transform( return Transform(
alignment: Alignment.center, alignment: Alignment.center,
transform: Matrix4.identity() transform: Matrix4.identity()
@@ -129,17 +142,22 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
class FlipPageRoute<T> extends PageRouteBuilder<T> { class FlipPageRoute<T> extends PageRouteBuilder<T> {
final Widget page; final Widget page;
final bool horizontal; final bool horizontal;
FlipPageRoute({ FlipPageRoute({
required this.page, required this.page,
this.horizontal = true, this.horizontal = true,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 800), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 800), ? Duration.zero
: const Duration(milliseconds: 800),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 800),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
final isAnimatingForward = animation.status == AnimationStatus.forward; final isAnimatingForward =
animation.status == AnimationStatus.forward;
final flipAnimation = Tween( final flipAnimation = Tween(
begin: 0.0, begin: 0.0,
end: isAnimatingForward ? -math.pi : math.pi, end: isAnimatingForward ? -math.pi : math.pi,
@@ -147,12 +165,12 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
parent: animation, parent: animation,
curve: Curves.easeInOut, curve: Curves.easeInOut,
)); ));
return AnimatedBuilder( return AnimatedBuilder(
animation: flipAnimation, animation: flipAnimation,
builder: (context, child) { builder: (context, child) {
final isShowingFront = flipAnimation.value.abs() < math.pi / 2; final isShowingFront = flipAnimation.value.abs() < math.pi / 2;
return Transform( return Transform(
alignment: Alignment.center, alignment: Alignment.center,
transform: Matrix4.identity() transform: Matrix4.identity()
@@ -181,15 +199,19 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
final Widget page; final Widget page;
final Widget startWidget; final Widget startWidget;
final BorderRadius? borderRadius; final BorderRadius? borderRadius;
ContainerTransformPageRoute({ ContainerTransformPageRoute({
required this.page, required this.page,
required this.startWidget, required this.startWidget,
this.borderRadius, this.borderRadius,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 500), ? Duration.zero
: const Duration(milliseconds: 500),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
return Stack( return Stack(
children: [ children: [
@@ -208,7 +230,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
final scale = 0.5 + (0.5 * progress); final scale = 0.5 + (0.5 * progress);
final radius = borderRadius?.topLeft.x ?? 0; final radius = borderRadius?.topLeft.x ?? 0;
final currentRadius = radius * (1 - progress); final currentRadius = radius * (1 - progress);
return Transform.scale( return Transform.scale(
scale: scale, scale: scale,
child: ClipRRect( child: ClipRRect(
@@ -229,7 +251,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
class CustomHeroPageRoute<T> extends PageRouteBuilder<T> { class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
final Widget page; final Widget page;
final String heroTag; final String heroTag;
CustomHeroPageRoute({ CustomHeroPageRoute({
required this.page, required this.page,
required this.heroTag, required this.heroTag,
@@ -253,18 +275,22 @@ class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
class SharedAxisPageRoute<T> extends PageRouteBuilder<T> { class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
final Widget page; final Widget page;
final SharedAxisTransitionType transitionType; final SharedAxisTransitionType transitionType;
SharedAxisPageRoute({ SharedAxisPageRoute({
required this.page, required this.page,
required this.transitionType, required this.transitionType,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 300), ? Duration.zero
: const Duration(milliseconds: 300),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
late final Offset begin; late final Offset begin;
late final Offset end; late final Offset end;
switch (transitionType) { switch (transitionType) {
case SharedAxisTransitionType.horizontal: case SharedAxisTransitionType.horizontal:
begin = const Offset(1.0, 0.0); begin = const Offset(1.0, 0.0);
@@ -279,17 +305,17 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
end = Offset.zero; end = Offset.zero;
break; break;
} }
final slideTween = Tween(begin: begin, end: end); final slideTween = Tween(begin: begin, end: end);
final fadeTween = Tween(begin: 0.0, end: 1.0); final fadeTween = Tween(begin: 0.0, end: 1.0);
final scaleTween = transitionType == SharedAxisTransitionType.scaled final scaleTween = transitionType == SharedAxisTransitionType.scaled
? Tween(begin: 0.8, end: 1.0) ? Tween(begin: 0.8, end: 1.0)
: Tween(begin: 1.0, end: 1.0); : Tween(begin: 1.0, end: 1.0);
final slideAnimation = animation.drive(slideTween); final slideAnimation = animation.drive(slideTween);
final fadeAnimation = animation.drive(fadeTween); final fadeAnimation = animation.drive(fadeTween);
final scaleAnimation = animation.drive(scaleTween); final scaleAnimation = animation.drive(scaleTween);
return SlideTransition( return SlideTransition(
position: slideAnimation, position: slideAnimation,
child: FadeTransition( child: FadeTransition(
@@ -308,4 +334,4 @@ enum SharedAxisTransitionType {
horizontal, horizontal,
vertical, vertical,
scaled, scaled,
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:math' as math; import 'dart:math' as math;
import '../utils/reduce_motion.dart';
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯 /// 웨이브 애니메이션 배경 효과를 제공하는 위젯
/// ///
@@ -16,6 +17,8 @@ class AnimatedWaveBackground extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final reduce = ReduceMotion.isEnabled(context);
final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일
return Stack( return Stack(
children: [ children: [
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용 // 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
@@ -25,15 +28,15 @@ class AnimatedWaveBackground extends StatelessWidget {
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성 // 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
final angle = controller.value * 2 * math.pi; final angle = controller.value * 2 * math.pi;
// 사인 함수를 사용하여 부드러운 움직임 생성 // 사인 함수를 사용하여 부드러운 움직임 생성
final xOffset = 20 * math.sin(angle); final xOffset = 20 * amp * math.sin(angle);
final yOffset = 10 * math.cos(angle); final yOffset = 10 * amp * math.cos(angle);
return Positioned( return Positioned(
right: -40 + xOffset, right: -40 + xOffset,
top: -60 + yOffset, top: -60 + yOffset,
child: Transform.rotate( child: Transform.rotate(
// 회전도 선형적으로 변화하도록 수정 // 회전도 선형적으로 변화하도록 수정
angle: 0.2 * math.sin(angle * 0.5), angle: 0.2 * amp * math.sin(angle * 0.5),
child: Container( child: Container(
width: 200, width: 200,
height: 200, height: 200,
@@ -51,15 +54,15 @@ class AnimatedWaveBackground extends StatelessWidget {
builder: (context, child) { builder: (context, child) {
// 첫 번째 원과 약간 다른 위상을 가지도록 설정 // 첫 번째 원과 약간 다른 위상을 가지도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi / 3); final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
final xOffset = 20 * math.cos(angle); final xOffset = 20 * amp * math.cos(angle);
final yOffset = 10 * math.sin(angle); final yOffset = 10 * amp * math.sin(angle);
return Positioned( return Positioned(
left: -80 + xOffset, left: -80 + xOffset,
bottom: -70 + yOffset, bottom: -70 + yOffset,
child: Transform.rotate( child: Transform.rotate(
// 반대 방향으로 회전하도록 설정 // 반대 방향으로 회전하도록 설정
angle: -0.3 * math.sin(angle * 0.5), angle: -0.3 * amp * math.sin(angle * 0.5),
child: Container( child: Container(
width: 220, width: 220,
height: 220, height: 220,
@@ -78,14 +81,14 @@ class AnimatedWaveBackground extends StatelessWidget {
builder: (context, child) { builder: (context, child) {
// 세 번째 원은 다른 위상으로 움직이도록 설정 // 세 번째 원은 다른 위상으로 움직이도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3); final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
final xOffset = 15 * math.sin(angle * 0.7); final xOffset = 15 * amp * math.sin(angle * 0.7);
final yOffset = 8 * math.cos(angle * 0.7); final yOffset = 8 * amp * math.cos(angle * 0.7);
return Positioned( return Positioned(
right: 40 + xOffset, right: 40 + xOffset,
bottom: -40 + yOffset, bottom: -40 + yOffset,
child: Transform.rotate( child: Transform.rotate(
angle: 0.4 * math.cos(angle * 0.5), angle: 0.4 * amp * math.cos(angle * 0.5),
child: Container( child: Container(
width: 120, width: 120,
height: 120, height: 120,
@@ -109,9 +112,8 @@ class AnimatedWaveBackground extends StatelessWidget {
width: 30, width: 30,
height: 30, height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: color: Colors.white.withValues(
0.1 + 0.1 * pulseController.value, alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
),
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
), ),
), ),

View File

@@ -6,6 +6,7 @@ import '../screens/app_lock_screen.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../utils/logger.dart';
import 'animated_page_transitions.dart'; import 'animated_page_transitions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -18,7 +19,7 @@ class AppNavigator {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>(); final navigationProvider = context.read<NavigationProvider>();
navigationProvider.clearHistoryAndGoHome(); navigationProvider.clearHistoryAndGoHome();
await Navigator.of(context).pushNamedAndRemoveUntil( await Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.main, AppRoutes.main,
(route) => false, (route) => false,
@@ -30,30 +31,31 @@ class AppNavigator {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>(); final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(1); navigationProvider.updateCurrentIndex(1);
await Navigator.of(context).pushNamed(AppRoutes.analysis); await Navigator.of(context).pushNamed(AppRoutes.analysis);
} }
/// 구독 추가 화면으로 네비게이션 /// 구독 추가 화면으로 네비게이션
static Future<void> toAddSubscription(BuildContext context) async { static Future<void> toAddSubscription(BuildContext context) async {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
await Navigator.of(context).pushNamed(AppRoutes.addSubscription); await Navigator.of(context).pushNamed(AppRoutes.addSubscription);
} }
/// 구독 상세 화면으로 네비게이션 /// 구독 상세 화면으로 네비게이션
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async { static Future<void> toDetail(
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); BuildContext context, SubscriptionModel subscription) async {
Log.d('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
try { try {
await Navigator.of(context).pushNamed( await Navigator.of(context).pushNamed(
AppRoutes.subscriptionDetail, AppRoutes.subscriptionDetail,
arguments: subscription, arguments: subscription,
); );
print('DetailScreen 네비게이션 성공'); Log.d('DetailScreen 네비게이션 성공');
} catch (e) { } catch (e) {
print('DetailScreen 네비게이션 오류: $e'); Log.e('DetailScreen 네비게이션 오류', e);
} }
} }
@@ -62,7 +64,7 @@ class AppNavigator {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>(); final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(3); navigationProvider.updateCurrentIndex(3);
await Navigator.of(context).pushNamed(AppRoutes.smsScanner); await Navigator.of(context).pushNamed(AppRoutes.smsScanner);
} }
@@ -71,14 +73,14 @@ class AppNavigator {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>(); final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(4); navigationProvider.updateCurrentIndex(4);
await Navigator.of(context).pushNamed(AppRoutes.settings); await Navigator.of(context).pushNamed(AppRoutes.settings);
} }
/// 카테고리 관리 화면으로 네비게이션 /// 카테고리 관리 화면으로 네비게이션
static Future<void> toCategoryManagement(BuildContext context) async { static Future<void> toCategoryManagement(BuildContext context) async {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
await Navigator.of(context).push( await Navigator.of(context).push(
SlidePageRoute( SlidePageRoute(
page: const CategoryManagementScreen(), page: const CategoryManagementScreen(),
@@ -101,20 +103,20 @@ class AppNavigator {
static Future<bool> handleBackButton(BuildContext context) async { static Future<bool> handleBackButton(BuildContext context) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final navigationProvider = context.read<NavigationProvider>(); final navigationProvider = context.read<NavigationProvider>();
// 네비게이션 스택이 있으면 팝 // 네비게이션 스택이 있으면 팝
if (navigator.canPop()) { if (navigator.canPop()) {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
// NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원 // NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원
if (navigationProvider.canPop()) { if (navigationProvider.canPop()) {
navigationProvider.pop(); navigationProvider.pop();
} }
navigator.pop(); navigator.pop();
return false; return false;
} }
// 앱 종료 확인 // 앱 종료 확인
final shouldExit = await showDialog<bool>( final shouldExit = await showDialog<bool>(
context: context, context: context,
@@ -133,7 +135,7 @@ class AppNavigator {
], ],
), ),
); );
return shouldExit ?? false; return shouldExit ?? false;
} }
@@ -141,17 +143,17 @@ class AppNavigator {
static void handleFloatingNavTap(BuildContext context, int index) { static void handleFloatingNavTap(BuildContext context, int index) {
final navigationProvider = context.read<NavigationProvider>(); final navigationProvider = context.read<NavigationProvider>();
final currentIndex = navigationProvider.currentIndex; final currentIndex = navigationProvider.currentIndex;
// 같은 탭을 다시 탭하면 아무 동작 안 함 // 같은 탭을 다시 탭하면 아무 동작 안 함
if (currentIndex == index) { if (currentIndex == index) {
return; return;
} }
// 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기 // 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기
if (Navigator.of(context).canPop()) { if (Navigator.of(context).canPop()) {
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
} }
// 선택된 인덱스에 따라 네비게이션 // 선택된 인덱스에 따라 네비게이션
switch (index) { switch (index) {
case 0: // 홈 case 0: // 홈
@@ -196,6 +198,7 @@ class AppNavigationObserver extends NavigatorObserver {
@override @override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) { void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute); super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); debugPrint(
'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
} }
} }

View File

@@ -66,13 +66,14 @@ class CategoryHeaderWidget extends StatelessWidget {
/// 통화별 합계를 표시하는 문자열을 생성합니다. /// 통화별 합계를 표시하는 문자열을 생성합니다.
String _buildCostDisplay(BuildContext context) { String _buildCostDisplay(BuildContext context) {
final parts = <String>[]; final parts = <String>[];
// 개수는 항상 표시 // 개수는 항상 표시
parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount)); parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
// 통화 부분을 별도로 처리 // 통화 부분을 별도로 처리
final currencyParts = <String>[]; final currencyParts = <String>[];
// 달러가 있는 경우 // 달러가 있는 경우
if (totalCostUSD > 0) { if (totalCostUSD > 0) {
final formatter = NumberFormat.currency( final formatter = NumberFormat.currency(
@@ -82,7 +83,7 @@ class CategoryHeaderWidget extends StatelessWidget {
); );
currencyParts.add(formatter.format(totalCostUSD)); currencyParts.add(formatter.format(totalCostUSD));
} }
// 원화가 있는 경우 // 원화가 있는 경우
if (totalCostKRW > 0) { if (totalCostKRW > 0) {
final formatter = NumberFormat.currency( final formatter = NumberFormat.currency(
@@ -92,7 +93,7 @@ class CategoryHeaderWidget extends StatelessWidget {
); );
currencyParts.add(formatter.format(totalCostKRW)); currencyParts.add(formatter.format(totalCostKRW));
} }
// 엔화가 있는 경우 // 엔화가 있는 경우
if (totalCostJPY > 0) { if (totalCostJPY > 0) {
final formatter = NumberFormat.currency( final formatter = NumberFormat.currency(
@@ -102,7 +103,7 @@ class CategoryHeaderWidget extends StatelessWidget {
); );
currencyParts.add(formatter.format(totalCostJPY)); currencyParts.add(formatter.format(totalCostJPY));
} }
// 위안화가 있는 경우 // 위안화가 있는 경우
if (totalCostCNY > 0) { if (totalCostCNY > 0) {
final formatter = NumberFormat.currency( final formatter = NumberFormat.currency(
@@ -112,14 +113,14 @@ class CategoryHeaderWidget extends StatelessWidget {
); );
currencyParts.add(formatter.format(totalCostCNY)); currencyParts.add(formatter.format(totalCostCNY));
} }
// 통화가 하나 이상 있는 경우 // 통화가 하나 이상 있는 경우
if (currencyParts.isNotEmpty) { if (currencyParts.isNotEmpty) {
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로 // 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
final currencyDisplay = currencyParts.join(' + '); final currencyDisplay = currencyParts.join(' + ');
parts.add(currencyDisplay); parts.add(currencyDisplay);
} }
return parts.join(' · '); return parts.join(' · ');
} }
} }

View File

@@ -1,174 +0,0 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 위험한 액션에 사용되는 Danger 버튼
/// 삭제, 취소, 종료 등의 위험한 액션에 사용됩니다.
class DangerButton extends StatefulWidget {
final String text;
final VoidCallback? onPressed;
final bool requireConfirmation;
final String? confirmationTitle;
final String? confirmationMessage;
final IconData? icon;
final double? width;
final double height;
final double fontSize;
final EdgeInsetsGeometry? padding;
final double borderRadius;
final bool enableHoverEffect;
const DangerButton({
super.key,
required this.text,
this.onPressed,
this.requireConfirmation = false,
this.confirmationTitle,
this.confirmationMessage,
this.icon,
this.width,
this.height = 60,
this.fontSize = 18,
this.padding,
this.borderRadius = 16,
this.enableHoverEffect = true,
});
@override
State<DangerButton> createState() => _DangerButtonState();
}
class _DangerButtonState extends State<DangerButton> {
bool _isHovered = false;
static const Color _dangerColor = AppColors.dangerColor;
Future<void> _handlePress() async {
if (widget.requireConfirmation) {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: Text(
widget.confirmationTitle ?? widget.text,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _dangerColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
widget.icon ?? Icons.warning_amber_rounded,
color: _dangerColor,
size: 48,
),
),
const SizedBox(height: 16),
Text(
widget.confirmationMessage ??
'이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
height: 1.5,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: _dangerColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
widget.text,
style: const TextStyle(color: AppColors.pureWhite),
),
),
],
),
);
if (confirmed == true) {
widget.onPressed?.call();
}
} else {
widget.onPressed?.call();
}
}
@override
Widget build(BuildContext context) {
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: widget.width ?? double.infinity,
height: widget.height,
transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02))
: Matrix4.identity(),
child: ElevatedButton(
onPressed: widget.onPressed != null ? _handlePress : null,
style: ElevatedButton.styleFrom(
backgroundColor: _dangerColor,
foregroundColor: AppColors.pureWhite,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.borderRadius),
),
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08),
disabledBackgroundColor: _dangerColor.withValues(alpha: 0.6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(
widget.icon,
color: AppColors.pureWhite,
size: _isHovered ? 24 : 20,
),
const SizedBox(width: 8),
],
Text(
widget.text,
style: TextStyle(
fontSize: widget.fontSize,
fontWeight: FontWeight.w600,
color: AppColors.pureWhite,
),
),
],
),
),
);
if (widget.enableHoverEffect) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: button,
);
}
return button;
}
}

View File

@@ -43,8 +43,10 @@ class _PrimaryButtonState extends State<PrimaryButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor; final effectiveBackgroundColor =
final effectiveForegroundColor = widget.foregroundColor ?? AppColors.pureWhite; widget.backgroundColor ?? theme.primaryColor;
final effectiveForegroundColor =
widget.foregroundColor ?? AppColors.pureWhite;
Widget button = AnimatedContainer( Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -64,7 +66,8 @@ class _PrimaryButtonState extends State<PrimaryButton> {
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0, elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08), shadowColor: Colors.black.withValues(alpha: 0.08),
disabledBackgroundColor: effectiveBackgroundColor.withValues(alpha: 0.6), disabledBackgroundColor:
effectiveBackgroundColor.withValues(alpha: 0.6),
), ),
child: widget.isLoading child: widget.isLoading
? SizedBox( ? SizedBox(
@@ -110,4 +113,4 @@ class _PrimaryButtonState extends State<PrimaryButton> {
return button; return button;
} }
} }

View File

@@ -42,7 +42,6 @@ class _SecondaryButtonState extends State<SecondaryButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor; final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor; final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;
@@ -61,18 +60,18 @@ class _SecondaryButtonState extends State<SecondaryButton> {
borderRadius: BorderRadius.circular(widget.borderRadius), borderRadius: BorderRadius.circular(widget.borderRadius),
), ),
side: BorderSide( side: BorderSide(
color: _isHovered color: _isHovered
? effectiveBorderColor.withValues(alpha: 0.4) ? effectiveBorderColor.withValues(alpha: 0.4)
: effectiveBorderColor, : effectiveBorderColor,
width: widget.borderWidth, width: widget.borderWidth,
), ),
padding: widget.padding ?? const EdgeInsets.symmetric( padding: widget.padding ??
vertical: 12, const EdgeInsets.symmetric(
horizontal: 24, vertical: 12,
), horizontal: 24,
backgroundColor: _isHovered ),
? AppColors.glassBackground backgroundColor:
: Colors.transparent, _isHovered ? AppColors.glassBackground : Colors.transparent,
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -146,7 +145,7 @@ class _TextLinkButtonState extends State<TextLinkButton> {
Widget button = AnimatedContainer( Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isHovered color: _isHovered
? theme.colorScheme.onSurface.withValues(alpha: 0.05) ? theme.colorScheme.onSurface.withValues(alpha: 0.05)
: Colors.transparent, : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -179,9 +178,8 @@ class _TextLinkButtonState extends State<TextLinkButton> {
fontSize: widget.fontSize, fontSize: widget.fontSize,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: effectiveColor, color: effectiveColor,
decoration: _isHovered decoration:
? TextDecoration.underline _isHovered ? TextDecoration.underline : TextDecoration.none,
: TextDecoration.none,
), ),
), ),
], ],
@@ -199,4 +197,4 @@ class _TextLinkButtonState extends State<TextLinkButton> {
return button; return button;
} }
} }

View File

@@ -1,229 +0,0 @@
import 'package:flutter/material.dart';
/// 섹션별 컨텐츠를 감싸는 기본 카드 위젯
/// 폼 섹션, 정보 표시 섹션 등에 사용됩니다.
class SectionCard extends StatelessWidget {
final String? title;
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final Color? backgroundColor;
final double borderRadius;
final List<BoxShadow>? boxShadow;
final Border? border;
final double? height;
final double? width;
final VoidCallback? onTap;
const SectionCard({
super.key,
this.title,
required this.child,
this.padding,
this.margin,
this.backgroundColor,
this.borderRadius = 20,
this.boxShadow,
this.border,
this.height,
this.width,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBackgroundColor = backgroundColor ?? Colors.white;
final effectiveShadow = boxShadow ?? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
];
Widget card = Container(
height: height,
width: width,
margin: margin,
decoration: BoxDecoration(
color: effectiveBackgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: effectiveShadow,
border: border,
),
child: Padding(
padding: padding ?? const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title != null) ...[
Text(
title!,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 16),
],
child,
],
),
),
);
if (onTap != null) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(borderRadius),
child: card,
);
}
return card;
}
}
/// 투명한 배경의 섹션 카드
/// 어두운 배경 위에서 사용하기 적합합니다.
class TransparentSectionCard extends StatelessWidget {
final String? title;
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double opacity;
final double borderRadius;
final Color? borderColor;
final VoidCallback? onTap;
const TransparentSectionCard({
super.key,
this.title,
required this.child,
this.padding,
this.margin,
this.opacity = 0.15,
this.borderRadius = 16,
this.borderColor,
this.onTap,
});
@override
Widget build(BuildContext context) {
Widget card = Container(
margin: margin,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: opacity),
borderRadius: BorderRadius.circular(borderRadius),
border: borderColor != null
? Border.all(color: borderColor!, width: 1)
: null,
),
child: Padding(
padding: padding ?? const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title != null) ...[
Text(
title!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.9),
),
),
const SizedBox(height: 12),
],
child,
],
),
),
);
if (onTap != null) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(borderRadius),
child: card,
);
}
return card;
}
}
/// 정보 표시용 카드
/// 읽기 전용 정보를 표시할 때 사용합니다.
class InfoCard extends StatelessWidget {
final String label;
final String value;
final IconData? icon;
final Color? iconColor;
final Color? backgroundColor;
final EdgeInsetsGeometry? padding;
final double borderRadius;
const InfoCard({
super.key,
required this.label,
required this.value,
this.icon,
this.iconColor,
this.backgroundColor,
this.padding,
this.borderRadius = 12,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: padding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: backgroundColor ?? theme.colorScheme.surface,
borderRadius: BorderRadius.circular(borderRadius),
),
child: Row(
children: [
if (icon != null) ...[
Icon(
icon,
size: 24,
color: iconColor ?? theme.colorScheme.primary,
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
),
);
}
}

View File

@@ -53,7 +53,8 @@ class ConfirmationDialog extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1), color:
(iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon( child: Icon(
@@ -350,4 +351,4 @@ class ErrorDialog extends StatelessWidget {
), ),
); );
} }
} }

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