25 Commits

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

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

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

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

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

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

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

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

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

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

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

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

i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
2025-09-07 21:32:16 +09:00
JiWoong Sul
d1a6cb9fe3 style: apply dart format across project 2025-09-07 19:33:11 +09:00
JiWoong Sul
f812d4b9fd feat(permissions): add SMS permission screen and settings button; route from splash on Android 2025-09-07 19:33:11 +09:00
JiWoong Sul
2a90e7c377 chore: add AGENTS.md, helper scripts, codex templates, and CI 2025-09-07 19:33:11 +09:00
154 changed files with 9212 additions and 9074 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`.

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

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

70
AGENTS.md Normal file
View File

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

View File

@@ -45,5 +45,5 @@ flutter {
} }
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
} }

View File

@@ -1,9 +1,14 @@
<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" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 재부팅 후 예약 복구를 위해 필요 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 정확 알람(결제/캘린더 등 정확 시각 필요시) 사용 -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application <application
android:label="구독 관리" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -14,7 +19,9 @@
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize"
android:showWhenLocked="true"
android:turnScreenOn="true">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues
@@ -33,6 +40,24 @@
<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" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
</application> </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

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.0" apply false id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "1.8.22" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false
} }
include(":app") include(":app")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

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

123
doc/ads.md Normal file
View File

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

View File

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

View File

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

113
doc/plan.md Normal file
View File

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

70
doc/plan_color.md Normal file
View File

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

View File

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

View File

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

View File

@@ -47,5 +47,8 @@
<true/> <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

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

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,14 @@ 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 'package:permission_handler/permission_handler.dart' as permission;
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController { class AddSubscriptionController {
@@ -105,6 +106,26 @@ class AddSubscriptionController {
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();
} }
@@ -158,13 +179,15 @@ class AddSubscriptionController {
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,
); );
@@ -174,12 +197,14 @@ class AddSubscriptionController {
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,7 +212,8 @@ 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();
@@ -211,11 +237,11 @@ 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,
@@ -223,12 +249,12 @@ class AddSubscriptionController {
} }
// 생산성 관련 키워드 // 생산성 관련 키워드
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,
@@ -236,10 +262,10 @@ class AddSubscriptionController {
} }
// 게임 관련 키워드 // 게임 관련 키워드
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,
@@ -247,10 +273,10 @@ class AddSubscriptionController {
} }
// 교육 관련 키워드 // 교육 관련 키워드
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,
@@ -258,10 +284,10 @@ class AddSubscriptionController {
} }
// 쇼핑 관련 키워드 // 쇼핑 관련 키워드
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,
@@ -280,25 +306,55 @@ class AddSubscriptionController {
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;
@@ -312,9 +368,11 @@ class AddSubscriptionController {
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');
} }
} }
@@ -327,12 +385,14 @@ class AddSubscriptionController {
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,
); );
@@ -396,7 +456,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 {
@@ -421,17 +482,25 @@ class AddSubscriptionController {
// 이벤트 가격 파싱 // 이벤트 가격 파싱
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);
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,
@@ -441,6 +510,16 @@ class AddSubscriptionController {
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); // 성공 여부 반환
} }
@@ -452,7 +531,8 @@ class AddSubscriptionController {
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()),
); );
} }
} }

View File

@@ -12,6 +12,7 @@ import 'package:intl/intl.dart';
import '../widgets/dialogs/delete_confirmation_dialog.dart'; import '../widgets/dialogs/delete_confirmation_dialog.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';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller /// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController extends ChangeNotifier { class DetailScreenController extends ChangeNotifier {
@@ -140,9 +141,12 @@ 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 초기화
@@ -261,10 +265,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,7 +281,8 @@ 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();
@@ -299,11 +306,11 @@ 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,
@@ -311,12 +318,12 @@ class DetailScreenController extends ChangeNotifier {
} }
// 생산성 관련 키워드 // 생산성 관련 키워드
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,
@@ -324,10 +331,10 @@ class DetailScreenController extends ChangeNotifier {
} }
// 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,
@@ -335,10 +342,10 @@ class DetailScreenController extends ChangeNotifier {
} }
// 교육 관련 키워드 // 교육 관련 키워드
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,
@@ -346,10 +353,10 @@ class DetailScreenController extends ChangeNotifier {
} }
// 쇼핑 관련 키워드 // 쇼핑 관련 키워드
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,
@@ -377,7 +384,8 @@ class DetailScreenController extends ChangeNotifier {
// 웹사이트 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);
} }
// 구독 정보 업데이트 // 구독 정보 업데이트
@@ -385,7 +393,8 @@ class DetailScreenController extends ChangeNotifier {
// 콤마 제거하고 숫자만 추출 // 콤마 제거하고 숫자만 추출
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;
@@ -393,13 +402,18 @@ class DetailScreenController extends ChangeNotifier {
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;
subscription.billingCycle = _billingCycle; subscription.billingCycle = _billingCycle;
subscription.nextBillingDate = _nextBillingDate; // 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장
final originalDateOnly = DateTime(
_nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day);
var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
subscription.nextBillingDate = adjustedNext;
subscription.categoryId = _selectedCategoryId; subscription.categoryId = _selectedCategoryId;
subscription.currency = _currency; subscription.currency = _currency;
@@ -425,6 +439,14 @@ class DetailScreenController extends ChangeNotifier {
'이벤트활성=${subscription.isEventActive}'); '이벤트활성=${subscription.isEventActive}');
// 구독 업데이트 // 구독 업데이트
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
);
}
await provider.updateSubscription(subscription); await provider.updateSubscription(subscription);
if (context.mounted) { if (context.mounted) {
@@ -445,30 +467,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();
@@ -484,7 +511,8 @@ class DetailScreenController extends ChangeNotifier {
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',
@@ -492,8 +520,10 @@ class DetailScreenController extends ChangeNotifier {
// 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(
@@ -515,6 +545,7 @@ class DetailScreenController extends ChangeNotifier {
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('DetailScreenController: 해지 페이지 열기 실패 - $e'); print('DetailScreenController: 해지 페이지 열기 실패 - $e');
} }
@@ -558,15 +589,5 @@ class DetailScreenController extends ChangeNotifier {
return colors[hash % colors.length]; return colors[hash % colors.length];
} }
/// 그라데이션 가져오기 // getGradient 제거됨 (그라데이션 미사용)
LinearGradient getGradient(Color baseColor) {
return LinearGradient(
colors: [
baseColor,
baseColor.withValues(alpha: 0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
} }

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/sms_scanner.dart'; import '../services/sms_scanner.dart';
import '../models/subscription.dart'; import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/sms_scan/subscription_converter.dart'; import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart'; import '../services/sms_scan/subscription_filter.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:provider/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/navigation_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -57,19 +59,48 @@ class SmsScanController extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { 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 스캔 실행 // SMS 스캔 실행
print('SMS 스캔 시작'); Log.i('SMS 스캔 시작');
final scannedSubscriptionModels = await _smsScanner.scanForSubscriptions(); final scannedSubscriptionModels =
print('스캔된 구독: ${scannedSubscriptionModels.length}'); await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) { if (scannedSubscriptionModels.isNotEmpty) {
print('첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
} }
if (!context.mounted) return; if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) { if (scannedSubscriptionModels.isEmpty) {
print('스캔된 구독이 없음'); Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound; _errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -77,18 +108,21 @@ class SmsScanController extends ChangeNotifier {
} }
// SubscriptionModel을 Subscription으로 변환 // SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions = _converter.convertModelsToSubscriptions(scannedSubscriptionModels); final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링 // 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions = _filter.filterByRepeatCount(scannedSubscriptions, 2); final repeatSubscriptions =
print('반복 결제된 구독: ${repeatSubscriptions.length}'); _filter.filterByRepeatCount(scannedSubscriptions, 2);
Log.d('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) { if (repeatSubscriptions.isNotEmpty) {
print('첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}'); Log.d(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
} }
if (repeatSubscriptions.isEmpty) { if (repeatSubscriptions.isEmpty) {
print('반복 결제된 구독이 없음'); Log.i('반복 결제된 구독이 없음');
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound; _errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -96,21 +130,24 @@ class SmsScanController extends ChangeNotifier {
} }
// 구독 목록 가져오기 // 구독 목록 가져오기
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions; final existingSubscriptions = provider.subscriptions;
print('기존 구독: ${existingSubscriptions.length}'); Log.d('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링 // 중복 구독 필터링
final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); final filteredSubscriptions =
print('중복 제거 후 구독: ${filteredSubscriptions.length}'); _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) { if (filteredSubscriptions.isNotEmpty) {
print('첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); Log.d(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
} }
// 중복 제거 후 신규 구독이 없는 경우 // 중복 제거 후 신규 구독이 없는 경우
if (filteredSubscriptions.isEmpty) { if (filteredSubscriptions.isEmpty) {
print('중복 제거 후 신규 구독이 없음'); Log.i('중복 제거 후 신규 구독이 없음');
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
return; return;
@@ -121,32 +158,62 @@ class SmsScanController extends ChangeNotifier {
websiteUrlController.text = ''; // URL 입력 필드 초기화 websiteUrlController.text = ''; // URL 입력 필드 초기화
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
print('SMS 스캔 중 오류 발생: $e'); Log.e('SMS 스캔 중 오류 발생', e);
if (context.mounted) { if (context.mounted) {
_errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); _errorMessage =
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
_isLoading = false; _isLoading = false;
notifyListeners(); 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 { Future<void> addCurrentSubscription(BuildContext context) async {
if (_currentIndex >= _scannedSubscriptions.length) return; if (_currentIndex >= _scannedSubscriptions.length) return;
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
try { try {
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider =
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false); Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ?? subscription.category ?? getDefaultCategoryId(categoryProvider); final finalCategoryId = _selectedCategoryId ??
subscription.category ??
getDefaultCategoryId(categoryProvider);
// websiteUrl 처리 // websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty final websiteUrl = websiteUrlController.text.trim().isNotEmpty
? websiteUrlController.text.trim() ? websiteUrlController.text.trim()
: subscription.websiteUrl; : subscription.websiteUrl;
print('구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); Log.d(
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
// addSubscription 호출 // addSubscription 호출
await provider.addSubscription( await provider.addSubscription(
@@ -162,19 +229,20 @@ class SmsScanController extends ChangeNotifier {
currency: subscription.currency, currency: subscription.currency,
); );
print('구독 추가 성공: ${subscription.serviceName}'); Log.i('구독 추가 성공: ${subscription.serviceName}');
if (!context.mounted) return;
moveToNextSubscription(context); moveToNextSubscription(context);
} catch (e) { } catch (e) {
print('구독 추가 중 오류 발생: $e'); Log.e('구독 추가 중 오류 발생', e);
// 오류가 있어도 다음 구독으로 이동 // 오류가 있어도 다음 구독으로 이동
if (!context.mounted) return;
moveToNextSubscription(context); moveToNextSubscription(context);
} }
} }
void skipCurrentSubscription(BuildContext context) { void skipCurrentSubscription(BuildContext context) {
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
print('구독 건너뛰기: ${subscription.serviceName}'); Log.i('구독 건너뛰기: ${subscription.serviceName}');
moveToNextSubscription(context); moveToNextSubscription(context);
} }
@@ -193,7 +261,8 @@ class SmsScanController extends ChangeNotifier {
void navigateToHome(BuildContext context) { void navigateToHome(BuildContext context) {
// NavigationProvider를 사용하여 홈 화면으로 이동 // NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false); final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0); navigationProvider.updateCurrentIndex(0);
} }
@@ -209,7 +278,7 @@ class SmsScanController extends ChangeNotifier {
(cat) => cat.name == 'other', (cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first, orElse: () => categoryProvider.categories.first,
); );
print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
return otherCategory.id; return otherCategory.id;
} }

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,14 +105,18 @@ 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';
String get openStore => _localizedStrings['openStore'] ?? 'Open Store';
String get appDescription => String get appDescription =>
_localizedStrings['appDescription'] ?? 'Subscription Management App'; _localizedStrings['appDescription'] ?? 'Subscription Management App';
String get developer => _localizedStrings['developer'] ?? 'Developer'; String get developer => _localizedStrings['developer'] ?? 'Developer';
@@ -102,7 +126,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,72 +137,133 @@ 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';
// 동적 메시지 생성 메서드 // 동적 메시지 생성 메서드
@@ -186,115 +272,169 @@ class AppLocalizations {
} }
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 eventDiscountEndsBeforeBilling =>
String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress'; _localizedStrings['eventDiscountEndsBeforeBilling'] ??
String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount'; 'Event discount ends before billing date';
String get inProgressUnit =>
_localizedStrings['inProgressUnit'] ?? 'in progress';
String get monthlySavingAmount =>
_localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
String get eventsInProgress =>
_localizedStrings['eventsInProgress'] ?? 'Events in Progress';
String get discountPercent =>
_localizedStrings['discountPercent'] ?? '% discount';
String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW'; 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') {
@@ -304,23 +444,37 @@ 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';
} }
@@ -328,7 +482,8 @@ class AppLocalizations {
// 새로 추가된 동적 메서드들 // 새로 추가된 동적 메서드들
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());
} }
@@ -338,27 +493,37 @@ class AppLocalizations {
} }
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';
@@ -368,48 +533,75 @@ class AppLocalizations {
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 =>
String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; _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) {
@@ -467,7 +659,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

@@ -22,11 +22,12 @@ 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 '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로 변경) // AdMob 활성화 플래그 (개발 중 false, 프로덕션 시 true로 변경)
const bool enableAdMob = false; const bool enableAdMob = true;
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -44,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);
} }
} }
@@ -125,7 +133,9 @@ class SubManagerApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
key: ValueKey(localeProvider.locale), key: ValueKey(localeProvider.locale),
title: 'Digital Rent Manager', // Localizations는 MaterialApp 내부에서 초기화되므로
// onGenerateTitle을 사용해 로딩 이후 로컬라이즈된 타이틀을 설정합니다.
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: themeProvider.getTheme(context), theme: themeProvider.getTheme(context),
locale: localeProvider.locale, locale: localeProvider.locale,

View File

@@ -37,7 +37,8 @@ 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) {
@@ -52,7 +53,8 @@ class AppNavigationObserver extends NavigatorObserver {
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');
@@ -69,7 +71,8 @@ class AppNavigationObserver extends NavigatorObserver {
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

@@ -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'},
]; ];

View File

@@ -114,6 +114,23 @@ class NotificationProvider extends ChangeNotifier {
// 알림이 활성화된 경우에만 알림 재예약 (비활성화 시에는 필요 없음) // 알림이 활성화된 경우에만 알림 재예약 (비활성화 시에는 필요 없음)
if (value) { if (value) {
final hasPermission = await NotificationService.checkPermission();
if (!hasPermission) {
final granted = await NotificationService.requestPermission();
if (!granted) {
debugPrint('알림 권한이 부여되지 않았습니다. 일부 알림이 제한될 수 있습니다.');
}
}
final canExact = await NotificationService.canScheduleExactAlarms();
if (!canExact) {
final exactGranted =
await NotificationService.requestExactAlarmsPermission();
if (!exactGranted) {
debugPrint('정확 알람 권한이 없어 근사 알림으로 예약됩니다.');
}
}
// 알림 설정 변경 시 모든 구독의 알림 재예약 // 알림 설정 변경 시 모든 구독의 알림 재예약
// 지연 실행으로 UI 응답성 향상 // 지연 실행으로 UI 응답성 향상
Future.microtask(() => _rescheduleNotificationsIfNeeded()); Future.microtask(() => _rescheduleNotificationsIfNeeded());
@@ -270,7 +287,8 @@ 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일 전 알림
@@ -278,7 +296,8 @@ class NotificationProvider extends ChangeNotifier {
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

@@ -20,7 +20,7 @@ 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,
@@ -28,10 +28,11 @@ class SubscriptionProvider extends ChangeNotifier {
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;
}, },
); );
@@ -102,6 +103,14 @@ class SubscriptionProvider extends ChangeNotifier {
} }
} }
Future<void> _reschedulePaymentNotifications() async {
try {
await NotificationService.reschedulAllNotifications(_subscriptions);
} catch (e) {
debugPrint('결제 알림 재예약 중 오류 발생: $e');
}
}
Future<void> addSubscription({ Future<void> addSubscription({
required String serviceName, required String serviceName,
required double monthlyCost, required double monthlyCost,
@@ -144,6 +153,8 @@ class SubscriptionProvider extends ChangeNotifier {
if (isEventActive && eventEndDate != null) { if (isEventActive && eventEndDate != null) {
await _scheduleEventEndNotification(subscription); await _scheduleEventEndNotification(subscription);
} }
await _reschedulePaymentNotifications();
} catch (e) { } catch (e) {
debugPrint('구독 추가 중 오류 발생: $e'); debugPrint('구독 추가 중 오류 발생: $e');
rethrow; rethrow;
@@ -175,6 +186,8 @@ class SubscriptionProvider extends ChangeNotifier {
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, ' debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}'); '현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners(); notifyListeners();
await _reschedulePaymentNotifications();
} catch (e) { } catch (e) {
debugPrint('구독 업데이트 중 오류 발생: $e'); debugPrint('구독 업데이트 중 오류 발생: $e');
rethrow; rethrow;
@@ -185,13 +198,14 @@ class SubscriptionProvider extends ChangeNotifier {
try { try {
await _subscriptionBox.delete(id); await _subscriptionBox.delete(id);
await refreshSubscriptions(); await refreshSubscriptions();
await _reschedulePaymentNotifications();
} catch (e) { } catch (e) {
debugPrint('구독 삭제 중 오류 발생: $e'); debugPrint('구독 삭제 중 오류 발생: $e');
rethrow; rethrow;
} }
} }
Future<void> clearAllSubscriptions() async { Future<void> clearAllSubscriptions() async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
@@ -213,11 +227,14 @@ class SubscriptionProvider extends ChangeNotifier {
} finally { } finally {
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
await _reschedulePaymentNotifications();
} }
} }
/// 이벤트 종료 알림을 스케줄링합니다. /// 이벤트 종료 알림을 스케줄링합니다.
Future<void> _scheduleEventEndNotification(SubscriptionModel subscription) async { Future<void> _scheduleEventEndNotification(
SubscriptionModel subscription) async {
if (subscription.eventEndDate != null && if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) { subscription.eventEndDate!.isAfter(DateTime.now())) {
await NotificationService.scheduleNotification( await NotificationService.scheduleNotification(
@@ -225,6 +242,7 @@ class SubscriptionProvider extends ChangeNotifier {
title: '이벤트 종료 알림', title: '이벤트 종료 알림',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.', body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
scheduledDate: subscription.eventEndDate!, scheduledDate: subscription.eventEndDate!,
channelId: NotificationService.expirationChannelId,
); );
} }
} }
@@ -238,7 +256,6 @@ class SubscriptionProvider extends ChangeNotifier {
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;
@@ -255,9 +272,8 @@ class SubscriptionProvider extends ChangeNotifier {
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;
@@ -265,7 +281,7 @@ class SubscriptionProvider extends ChangeNotifier {
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,
@@ -281,14 +297,14 @@ class SubscriptionProvider extends ChangeNotifier {
} }
/// 최근 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--) {
@@ -296,10 +312,12 @@ class SubscriptionProvider extends ChangeNotifier {
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}) 계산 중...');
} }
// 해당 월에 활성화된 구독 계산 // 해당 월에 활성화된 구독 계산
@@ -307,11 +325,13 @@ class SubscriptionProvider extends ChangeNotifier {
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,
@@ -325,7 +345,8 @@ class SubscriptionProvider extends ChangeNotifier {
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;
@@ -334,7 +355,8 @@ class SubscriptionProvider extends ChangeNotifier {
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 {
@@ -342,7 +364,8 @@ class SubscriptionProvider extends ChangeNotifier {
} }
// 통화 변환 // 통화 변환
final converted = await ExchangeRateService().convertBetweenCurrencies( final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost, cost,
subscription.currency, subscription.currency,
targetCurrency, targetCurrency,
@@ -354,7 +377,8 @@ class SubscriptionProvider extends ChangeNotifier {
} }
if (isCurrentMonth) { if (isCurrentMonth) {
debugPrint('[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency'); debugPrint(
'[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
} }
monthlyData.add({ monthlyData.add({
@@ -431,76 +455,87 @@ class SubscriptionProvider extends ChangeNotifier {
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;
subscription.categoryId = categoryId; await subscription.save();
await subscription.save(); migratedCount++;
migratedCount++; final categoryName =
final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name; categories.firstWhere((cat) => cat.id == categoryId).name;
debugPrint('${subscription.serviceName}$categoryName'); 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

@@ -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(),
}; };
} }
@@ -42,7 +45,8 @@ class AppRoutes {
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();
@@ -55,6 +59,9 @@ class AppRoutes {
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,

View File

@@ -5,11 +5,11 @@ import '../widgets/add_subscription/add_subscription_header.dart';
import '../widgets/add_subscription/add_subscription_form.dart'; import '../widgets/add_subscription/add_subscription_form.dart';
import '../widgets/add_subscription/add_subscription_event_section.dart'; import '../widgets/add_subscription/add_subscription_event_section.dart';
import '../widgets/add_subscription/add_subscription_save_button.dart'; import '../widgets/add_subscription/add_subscription_save_button.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
/// 새로운 구독을 추가하는 화면 /// 새로운 구독을 추가하는 화면
class AddSubscriptionScreen extends StatefulWidget { class AddSubscriptionScreen extends StatefulWidget {
const AddSubscriptionScreen({Key? key}) : super(key: key); const AddSubscriptionScreen({super.key});
@override @override
State<AddSubscriptionScreen> createState() => _AddSubscriptionScreenState(); State<AddSubscriptionScreen> createState() => _AddSubscriptionScreenState();
@@ -45,7 +45,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
_controller.scrollController.addListener(_onScroll); _controller.scrollController.addListener(_onScroll);
return Scaffold( return Scaffold(
backgroundColor: AppColors.backgroundColor, backgroundColor: Theme.of(context).colorScheme.surface,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
appBar: AddSubscriptionAppBar( appBar: AddSubscriptionAppBar(
controller: _controller, controller: _controller,

View File

@@ -48,7 +48,9 @@ class _AnalysisScreenState extends State<AnalysisScreen>
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading'); '현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드 // 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) { if (currentHash != _lastDataHash &&
!_isLoading &&
_lastDataHash.isNotEmpty) {
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작'); debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
_loadData(); _loadData();
} }
@@ -71,7 +73,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
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();
@@ -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,
), ),

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import '../widgets/detail/detail_form_section.dart';
import '../widgets/detail/detail_event_section.dart'; import '../widgets/detail/detail_event_section.dart';
import '../widgets/detail/detail_url_section.dart'; import '../widgets/detail/detail_url_section.dart';
import '../widgets/detail/detail_action_buttons.dart'; import '../widgets/detail/detail_action_buttons.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
/// 구독 상세 정보를 표시하고 편집할 수 있는 화면 /// 구독 상세 정보를 표시하고 편집할 수 있는 화면
@@ -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();
@@ -51,111 +50,111 @@ class _DetailScreenState extends State<DetailScreen>
return ChangeNotifierProvider<DetailScreenController>.value( return ChangeNotifierProvider<DetailScreenController>.value(
value: _controller, value: _controller,
child: Scaffold( child: Scaffold(
backgroundColor: AppColors.backgroundColor, backgroundColor: Theme.of(context).colorScheme.surface,
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(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.edit_rounded,
color: baseColor,
size: 20,
),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).editMode,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: baseColor,
),
),
const Spacer(),
Text(
AppLocalizations.of(context)
.changesAppliedAfterSave,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
const SizedBox(height: 16),
// 기본 정보 폼 섹션
DetailFormSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 이벤트 가격 섹션
DetailEventSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 웹사이트 URL 섹션
DetailUrlSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 32),
// 액션 버튼
DetailActionButtons(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
],
),
),
),
],
), ),
), ),
); );

View File

@@ -3,7 +3,8 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart'; import '../providers/app_lock_provider.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../theme/color_scheme_ext.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import 'analysis_screen.dart'; import 'analysis_screen.dart';
import 'app_lock_screen.dart'; import 'app_lock_screen.dart';
@@ -11,7 +12,6 @@ import 'settings_screen.dart';
import 'sms_scan_screen.dart'; import 'sms_scan_screen.dart';
import '../utils/animation_controller_helper.dart'; import '../utils/animation_controller_helper.dart';
import '../widgets/floating_navigation_bar.dart'; import '../widgets/floating_navigation_bar.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'; import '../utils/platform_helper.dart';
@@ -162,33 +162,34 @@ class _MainScreenState extends State<MainScreen>
if (result == true) { if (result == true) {
// 상단에 스낵바 표시 // 상단에 스낵바 표시
if (!context.mounted) return; if (!context.mounted) return;
final cs = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Row( content: Row(
children: [ children: [
const Icon( Icon(
Icons.check_circle, Icons.check_circle,
color: AppColors.pureWhite, color: cs.onPrimary,
size: 20, size: 20,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
AppLocalizations.of(context).subscriptionAdded, AppLocalizations.of(context).subscriptionAdded,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.pureWhite, color: cs.onPrimary,
), ),
), ),
], ],
), ),
backgroundColor: AppColors.successColor, backgroundColor: cs.success,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only( margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 8, // 더 상단으로 top: MediaQuery.of(context).padding.top + 8,
left: 16, left: 16,
right: 16, right: 16,
bottom: MediaQuery.of(context).size.height - 100, // 더 상단으로 bottom: MediaQuery.of(context).size.height - 100,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -223,8 +224,7 @@ class _MainScreenState extends State<MainScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigationProvider = context.watch<NavigationProvider>(); final navigationProvider = context.watch<NavigationProvider>();
// 메인 그라데이션 사용 // 그라데이션 제거: 단색 배경 사용
List<Color> backgroundGradient = AppColors.mainGradient;
// 현재 인덱스가 유효한지 확인 // 현재 인덱스가 유효한지 확인
int currentIndex = navigationProvider.currentIndex; int currentIndex = navigationProvider.currentIndex;
@@ -232,21 +232,31 @@ class _MainScreenState extends State<MainScreen>
currentIndex = 0; // 추가 버튼은 홈으로 표시 currentIndex = 0; // 추가 버튼은 홈으로 표시
} }
return GlassmorphicScaffold( return Stack(
body: IndexedStack( children: [
index: PlatformHelper.isIOS Positioned.fill(
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 child: Container(color: Theme.of(context).colorScheme.surface),
: (currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex), // Android: 기존 로직 ),
children: _screens, Scaffold(
), extendBody: true,
backgroundGradient: backgroundGradient, extendBodyBehindAppBar: true,
useFloatingNavBar: true, body: IndexedStack(
floatingNavBarIndex: navigationProvider.currentIndex, index: PlatformHelper.isIOS
onFloatingNavBarTapped: (index) { ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
_handleNavigation(index, context); : (currentIndex == 3
}, ? 3
enableParticles: false, : currentIndex == 4
enableWaveAnimation: false, ? 4
: currentIndex), // Android: 기존 로직
children: _screens,
),
),
FloatingNavigationBar(
selectedIndex: navigationProvider.currentIndex,
isVisible: true,
onItemTapped: (index) => _handleNavigation(index, context),
),
],
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
import 'dart:async'; 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,19 +77,19 @@ 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;
_particles.add({ _particles.add({
'size': size, 'size': size,
'x': x, 'x': x,
@@ -93,14 +97,25 @@ class _SplashScreenState extends State<SplashScreen>
'opacity': opacity, 'opacity': opacity,
'duration': duration, 'duration': duration,
'delay': delay, 'delay': delay,
'color': AppColors.blueGradient[colorIndex], // color computed at render from ColorScheme.primary
}); });
} }
} }
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,
@@ -120,23 +135,15 @@ class _SplashScreenState extends State<SplashScreen>
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
// 배경 그라디언트 // 단색 배경
Container( Container(color: Theme.of(context).colorScheme.surface),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.dayGradient[0],
AppColors.dayGradient[1],
],
),
),
),
// 글래스모피즘 오버레이 // 글래스모피즘 오버레이
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.pureWhite.withValues(alpha: 0.05), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
), ),
), ),
Stack( Stack(
@@ -163,11 +170,14 @@ class _SplashScreenState extends State<SplashScreen>
width: particle['size'], width: particle['size'],
height: particle['size'], height: particle['size'],
decoration: BoxDecoration( decoration: BoxDecoration(
color: particle['color'], color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: particle['color'].withValues(alpha: 0.3), color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
blurRadius: 10, blurRadius: 10,
spreadRadius: 1, spreadRadius: 1,
), ),
@@ -176,45 +186,25 @@ class _SplashScreenState extends State<SplashScreen>
), ),
), ),
); );
}).toList(), }),
// 상단 원형 그라데이션 // 상단 원형 장식 제거(단색 배경 유지)
Positioned( Positioned(
top: -size.height * 0.2, top: -size.height * 0.2,
right: -size.width * 0.2, right: -size.width * 0.2,
child: Container( child: SizedBox(
width: size.width * 0.8, width: size.width * 0.8,
height: size.width * 0.8, height: size.width * 0.8,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.pureWhite.withValues(alpha: 0.1),
AppColors.pureWhite.withValues(alpha: 0.0),
],
stops: const [0.2, 1.0],
),
),
), ),
), ),
// 하단 원형 그라데이션 // 하단 원형 장식 제거
Positioned( Positioned(
bottom: -size.height * 0.1, bottom: -size.height * 0.1,
left: -size.width * 0.3, left: -size.width * 0.3,
child: Container( child: SizedBox(
width: size.width * 0.9, width: size.width * 0.9,
height: size.width * 0.9, height: size.width * 0.9,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.pureWhite.withValues(alpha: 0.07),
AppColors.pureWhite.withValues(alpha: 0.0),
],
stops: const [0.4, 1.0],
),
),
), ),
), ),
@@ -244,61 +234,42 @@ class _SplashScreenState extends State<SplashScreen>
BorderRadius.circular(30), 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( color: Theme.of(context)
begin: Alignment.topLeft, .colorScheme
end: Alignment.bottomRight, .surface
colors: [ .withValues(alpha: 0.6),
AppColors.pureWhite
.withValues(alpha: 0.2),
AppColors.pureWhite
.withValues(alpha: 0.1),
],
),
borderRadius: borderRadius:
BorderRadius.circular(30), BorderRadius.circular(30),
border: Border.all( border: Border.all(
color: AppColors.pureWhite color: Theme.of(context)
.withValues(alpha: 0.3), .colorScheme
.outline
.withValues(alpha: 0.2),
width: 1.5, width: 1.5,
), ),
boxShadow: [
BoxShadow(
color:
AppColors.shadowBlack,
spreadRadius: 0,
blurRadius: 30,
offset: const Offset(0, 10),
),
],
), ),
child: Center( child: Center(
child: AnimatedBuilder( child: AnimatedBuilder(
animation: animation:
_animationController, _animationController,
builder: (context, _) { builder: (context, _) {
return ShaderMask( return Icon(
blendMode: Icons
BlendMode.srcIn, .subscriptions_outlined,
shaderCallback: (bounds) => size: 64,
const LinearGradient( color: Theme.of(context)
colors: AppColors .colorScheme
.blueGradient, .primary,
begin:
Alignment.topLeft,
end: Alignment
.bottomRight,
).createShader(bounds),
child: Icon(
Icons
.subscriptions_outlined,
size: 64,
color:
Theme.of(context)
.primaryColor,
),
); );
}), }),
), ),
@@ -328,7 +299,9 @@ class _SplashScreenState extends State<SplashScreen>
style: TextStyle( style: TextStyle(
fontSize: 36, fontSize: 36,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.primaryColor color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.9), .withValues(alpha: 0.9),
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
@@ -354,7 +327,9 @@ class _SplashScreenState extends State<SplashScreen>
AppLocalizations.of(context).appSubtitle, AppLocalizations.of(context).appSubtitle,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.primaryColor color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7), .withValues(alpha: 0.7),
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
@@ -376,18 +351,22 @@ class _SplashScreenState extends State<SplashScreen>
height: 60, height: 60,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.pureWhite color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.1), .withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
border: Border.all( border: Border.all(
color: AppColors.pureWhite color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.2), .withValues(alpha: 0.2),
width: 1, width: 1,
), ),
), ),
child: CircularProgressIndicator( child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>( color:
AppColors.pureWhite), Theme.of(context).colorScheme.primary,
strokeWidth: 3, strokeWidth: 3,
), ),
), ),
@@ -408,7 +387,10 @@ class _SplashScreenState extends State<SplashScreen>
'© 2025 NatureBridgeAI. All rights reserved.', '© 2025 NatureBridgeAI. All rights reserved.',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.pureWhite.withValues(alpha: 0.6), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),

View File

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

View File

@@ -1,10 +1,17 @@
import 'package:intl/intl.dart'; import '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) {
@@ -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;
} }
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로) /// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
@@ -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;
} }
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지 /// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지

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,7 +103,8 @@ 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) {
@@ -96,7 +125,8 @@ 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) {
@@ -118,10 +148,7 @@ 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;
} }
@@ -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,7 +244,8 @@ 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;

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb, compute;
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; 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 '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
class SmsScanner { class SmsScanner {
final SmsQuery _query = SmsQuery(); final SmsQuery _query = SmsQuery();
@@ -11,26 +13,26 @@ class SmsScanner {
Future<List<SubscriptionModel>> scanForSubscriptions() async { Future<List<SubscriptionModel>> scanForSubscriptions() async {
try { try {
List<dynamic> smsList; List<dynamic> smsList;
print('SmsScanner: 스캔 시작'); Log.d('SmsScanner: 스캔 시작');
// 플랫폼별 분기 처리 // 플랫폼별 분기 처리
if (kIsWeb) { 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) { } else if (PlatformHelper.isIOS) {
// iOS: SMS 접근 불가, 빈 리스트 반환 // iOS: SMS 접근 불가, 빈 리스트 반환
print('SmsScanner: iOS에서는 SMS 스캔 불가'); Log.w('SmsScanner: iOS에서는 SMS 스캔 불가');
return []; return [];
} else if (PlatformHelper.isAndroid) { } else if (PlatformHelper.isAndroid) {
// Android: flutter_sms_inbox 사용 // Android: flutter_sms_inbox 사용
print('SmsScanner: Android에서 실제 SMS 스캔'); Log.i('SmsScanner: Android에서 실제 SMS 스캔');
smsList = await _scanAndroidSms(); smsList = await _scanAndroidSms();
print('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); Log.d('SmsScanner: 스캔된 SMS 개수: ${smsList.length}');
} else { } else {
// 기타 플랫폼 // 기타 플랫폼
print('SmsScanner: 지원하지 않는 플랫폼'); Log.w('SmsScanner: 지원하지 않는 플랫폼');
return []; return [];
} }
@@ -47,32 +49,86 @@ 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 사용 // 결제일 패턴 유추를 위해 최근 2개의 결제일을 사용
final messages = [...entry.value];
messages.sort((a, b) {
final da = DateTime.tryParse(a['previousPaymentDate'] ?? '') ??
DateTime(1970);
final db = DateTime.tryParse(b['previousPaymentDate'] ?? '') ??
DateTime(1970);
return db.compareTo(da); // desc
});
final mostRecent = messages.first;
DateTime? recentDate =
DateTime.tryParse(mostRecent['previousPaymentDate'] ?? '');
DateTime? prevDate = messages.length > 1
? DateTime.tryParse(messages[1]['previousPaymentDate'] ?? '')
: null;
// 기본 결제 일자(일단위) 추정: 가장 최근 결제의 일자
int baseDay = recentDate?.day ?? DateTime.now().day;
// 이전 결제가 주말 이월로 보이는 패턴인지 검사하여 baseDay 보정
if (recentDate != null && prevDate != null) {
final candidate = DateTime(prevDate.year, prevDate.month, baseDay);
if (BusinessDayUtil.isWeekend(candidate)) {
final diff = prevDate.difference(candidate).inDays;
if (diff >= 1 && diff <= 3) {
// 예: 12일(토)→14일(월)
baseDay = baseDay; // 유지
} else {
// 차이가 크면 이전 달의 일자를 채택
baseDay = prevDate.day;
}
}
}
// 다음 결제일 계산: 기준 일자를 바탕으로 다음 달 또는 이번 달로 설정 후 영업일 보정
final DateTime now = DateTime.now();
int year = now.year;
int month = now.month;
if (now.day >= baseDay) {
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = baseDay.clamp(1, dim);
DateTime nextBilling = DateTime(year, month, day);
nextBilling = BusinessDayUtil.nextBusinessDay(nextBilling);
// 가장 최근 SMS 맵에 override 값으로 주입
final serviceSms = Map<String, dynamic>.from(mostRecent);
serviceSms['overrideNextBillingDate'] = nextBilling.toIso8601String();
final subscription = _parseSms(serviceSms, entry.value.length); 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');
} }
} }
@@ -81,167 +137,36 @@ class SmsScanner {
Future<List<dynamic>> _scanAndroidSms() async { Future<List<dynamic>> _scanAndroidSms() async {
try { try {
final messages = await _query.getAllSms; final messages = await _query.getAllSms;
final smsList = <Map<String, dynamic>>[];
// SMS 메시지를 분석하여 구독 서비스 감지 // Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가)
final serialized = <Map<String, dynamic>>[];
for (final message in messages) { for (final message in messages) {
final parsedData = _parseRawSms(message); serialized.add({
if (parsedData != null) { 'body': message.body ?? '',
smsList.add(parsedData); 'address': message.address ?? '',
} 'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch,
});
} }
// 대량 파싱은 별도 Isolate로 오프로딩
final List<Map<String, dynamic>> smsList =
await compute(_parseRawSmsBatch, serialized);
return smsList; return smsList;
} catch (e) { } catch (e) {
print('SmsScanner: Android SMS 스캔 실패: $e'); Log.e('SmsScanner: Android SMS 스캔 실패', e);
return []; return [];
} }
} }
// 실제 SMS 메시지싱하여 구독 정보 추출 // (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
Map<String, dynamic>? _parseRawSms(SmsMessage message) {
try {
final body = message.body ?? '';
final sender = message.address ?? '';
final date = message.date ?? DateTime.now();
// 구독 서비스 키워드 매칭
final subscriptionKeywords = [
'구독', '결제', '정기결제', '자동결제', '월정액',
'subscription', 'payment', 'billing', 'charge',
'넷플릭스', 'Netflix', '유튜브', 'YouTube', 'Spotify',
'멜론', '웨이브', 'Disney+', '디즈니플러스', 'Apple',
'Microsoft', 'GitHub', 'Adobe', 'Amazon'
];
// 구독 관련 키워드가 있는지 확인
bool isSubscription = subscriptionKeywords.any((keyword) =>
body.toLowerCase().contains(keyword.toLowerCase()) ||
sender.toLowerCase().contains(keyword.toLowerCase())
);
if (!isSubscription) {
return null;
}
// 서비스명 추출
String serviceName = _extractServiceName(body, sender);
// 금액 추출
double? amount = _extractAmount(body);
// 결제 주기 추출
String billingCycle = _extractBillingCycle(body);
return {
'serviceName': serviceName,
'monthlyCost': amount ?? 0.0,
'billingCycle': billingCycle,
'message': body,
'nextBillingDate': _calculateNextBillingFromDate(date, billingCycle).toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
};
} catch (e) {
print('SmsScanner: SMS 파싱 실패: $e');
return null;
}
}
// 서비스명 추출 로직
String _extractServiceName(String body, String sender) {
// 알려진 서비스 매핑
final servicePatterns = {
'netflix': '넷플릭스',
'youtube': '유튜브 프리미엄',
'spotify': 'Spotify',
'disney': '디즈니플러스',
'apple': 'Apple',
'microsoft': 'Microsoft',
'github': 'GitHub',
'adobe': 'Adobe',
'멜론': '멜론',
'웨이브': '웨이브',
};
// 메시지나 발신자에서 서비스명 찾기
final combinedText = '$body $sender'.toLowerCase();
for (final entry in servicePatterns.entries) {
if (combinedText.contains(entry.key)) {
return entry.value;
}
}
// 찾지 못한 경우
return _extractServiceNameFromSender(sender);
}
// 발신자 정보에서 서비스명 추출
String _extractServiceNameFromSender(String sender) {
// 숫자만 있으면 제거
if (RegExp(r'^\d+$').hasMatch(sender)) {
return '알 수 없는 서비스';
}
// 특수문자 제거하고 서비스명으로 사용
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
}
// 금액 추출 로직
double? _extractAmount(String body) {
// 다양한 금액 패턴 매칭
final patterns = [
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})*)'), // 결제 금액
];
for (final pattern in patterns) {
final match = pattern.firstMatch(body);
if (match != null) {
String amountStr = match.group(1) ?? '';
amountStr = amountStr.replaceAll(',', '');
return double.tryParse(amountStr);
}
}
return null;
}
// 결제 주기 추출 로직
String _extractBillingCycle(String body) {
if (body.contains('') || body.contains('monthly') || body.contains('매월')) {
return 'monthly';
} else if (body.contains('') || body.contains('yearly') || body.contains('annual')) {
return 'yearly';
} else if (body.contains('') || body.contains('weekly')) {
return 'weekly';
}
// 기본값
return 'monthly';
}
// 다음 결제일 계산
DateTime _calculateNextBillingFromDate(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));
}
}
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;
@@ -259,12 +184,16 @@ 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';
} }
DateTime? nextBillingDate; DateTime? nextBillingDate;
if (nextBillingDateStr != null) { // 외부에서 계산된 다음 결제일이 있으면 우선 사용
final overrideNext = sms['overrideNextBillingDate'] as String?;
if (overrideNext != null) {
nextBillingDate = DateTime.tryParse(overrideNext);
} else if (nextBillingDateStr != null) {
nextBillingDate = DateTime.tryParse(nextBillingDateStr); nextBillingDate = DateTime.tryParse(nextBillingDateStr);
} }
@@ -276,8 +205,12 @@ class SmsScanner {
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정 // 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
DateTime adjustedNextBillingDate = _calculateNextBillingDate( DateTime adjustedNextBillingDate = _calculateNextBillingDate(
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)), nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
billingCycle); billingCycle,
);
// 주말/공휴일 보정
adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
return SubscriptionModel( return SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(), id: DateTime.now().millisecondsSinceEpoch.toString(),
@@ -320,7 +253,9 @@ class SmsScanner {
} }
} }
return DateTime(year, month, billingDate.day); final dim = BusinessDayUtil.daysInMonth(year, month);
final day = billingDate.day.clamp(1, dim);
return DateTime(year, month, day);
} else if (billingCycle == 'yearly') { } else if (billingCycle == 'yearly') {
// 올해의 결제일이 지났는지 확인 // 올해의 결제일이 지났는지 확인
final thisYearBilling = final thisYearBilling =
@@ -369,8 +304,6 @@ class SmsScanner {
return serviceUrls[serviceName]; return serviceUrls[serviceName];
} }
// 메시지에서 통화 단위를 감지하는 함수 // 메시지에서 통화 단위를 감지하는 함수
String _detectCurrency(String message) { String _detectCurrency(String message) {
final dollarKeywords = [ final dollarKeywords = [
@@ -391,7 +324,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';
} }
} }
@@ -399,7 +332,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';
} }
} }
@@ -408,3 +341,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

@@ -29,7 +29,8 @@ class SubscriptionUrlMatcher {
// 2. 서비스 초기화 // 2. 서비스 초기화
_categoryMapper = CategoryMapperService(_dataRepository!); _categoryMapper = CategoryMapperService(_dataRepository!);
_urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!); _urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!);
_cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!); _cancellationService =
CancellationUrlService(_dataRepository!, _urlMatcher!);
_nameResolver = ServiceNameResolver(_dataRepository!); _nameResolver = ServiceNameResolver(_dataRepository!);
_smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!); _smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!);
} }
@@ -67,7 +68,8 @@ class SubscriptionUrlMatcher {
/// 서비스에 공식 해지 안내 페이지가 있는지 확인 /// 서비스에 공식 해지 안내 페이지가 있는지 확인
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async { static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
await initialize(); await initialize();
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false; return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ??
false;
} }
/// 서비스명으로 카테고리 찾기 /// 서비스명으로 카테고리 찾기
@@ -83,9 +85,10 @@ class SubscriptionUrlMatcher {
}) async { }) async {
await initialize(); await initialize();
return await _nameResolver?.getServiceDisplayName( return await _nameResolver?.getServiceDisplayName(
serviceName: serviceName, serviceName: serviceName,
locale: locale, locale: locale,
) ?? serviceName; ) ??
serviceName;
} }
/// SMS에서 URL과 서비스 정보 추출 /// SMS에서 URL과 서비스 정보 추출

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,17 +336,17 @@ 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) {

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../utils/logger.dart';
/// 서비스 데이터를 관리하는 저장소 클래스 /// 서비스 데이터를 관리하는 저장소 클래스
class ServiceDataRepository { class ServiceDataRepository {
@@ -11,12 +12,13 @@ class ServiceDataRepository {
if (_isInitialized) return; if (_isInitialized) return;
try { try {
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json'); final jsonString =
await rootBundle.loadString('assets/data/subscription_services.json');
_servicesData = json.decode(jsonString); _servicesData = json.decode(jsonString);
_isInitialized = true; _isInitialized = true;
print('ServiceDataRepository: JSON 데이터 로드 완료'); Log.i('ServiceDataRepository: JSON 데이터 로드 완료');
} catch (e) { } catch (e) {
print('ServiceDataRepository: JSON 로드 실패 - $e'); Log.w('ServiceDataRepository: JSON 로드 실패 - $e');
// 로드 실패시 기존 하드코딩 데이터 사용 // 로드 실패시 기존 하드코딩 데이터 사용
_isInitialized = true; _isInitialized = true;
} }

View File

@@ -25,18 +25,22 @@ class CancellationUrlService {
final lowerName = serviceName.toLowerCase().trim(); final lowerName = serviceName.toLowerCase().trim();
for (final categoryData in categories.values) { for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>; final services = (categoryData as Map<String, dynamic>)['services']
as Map<String, dynamic>;
for (final serviceData in services.values) { for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []); final names = List<String>.from(
(serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) { for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { if (lowerName.contains(name.toLowerCase()) ||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?; name.toLowerCase().contains(lowerName)) {
final cancellationUrls =
serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) { if (cancellationUrls != null) {
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환 // 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
return cancellationUrls[locale] ?? return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr']; cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
} }
} }
} }
@@ -49,17 +53,21 @@ class CancellationUrlService {
final domain = _urlMatcher.extractDomain(websiteUrl); final domain = _urlMatcher.extractDomain(websiteUrl);
if (domain != null) { if (domain != null) {
for (final categoryData in categories.values) { for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>; final services = (categoryData as Map<String, dynamic>)['services']
as Map<String, dynamic>;
for (final serviceData in services.values) { for (final serviceData in services.values) {
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []); final domains = List<String>.from(
(serviceData as Map<String, dynamic>)['domains'] ?? []);
for (final serviceDomain in domains) { for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { if (domain.contains(serviceDomain) ||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?; serviceDomain.contains(domain)) {
final cancellationUrls =
serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) { if (cancellationUrls != null) {
return cancellationUrls[locale] ?? return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr']; cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
} }
} }
} }

View File

@@ -24,10 +24,12 @@ class CategoryMapperService {
final services = categoryData['services'] as Map<String, dynamic>; final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceData in services.values) { for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []); final names = List<String>.from(
(serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) { for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { if (lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
return getCategoryIdByKey(categoryId); return getCategoryIdByKey(categoryId);
} }
} }
@@ -73,15 +75,33 @@ class CategoryMapperService {
String getCategoryForLegacyService(String serviceName) { String getCategoryForLegacyService(String serviceName) {
final lowerName = serviceName.toLowerCase(); final lowerName = serviceName.toLowerCase();
if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services'; if (LegacyServiceData.ottServices.containsKey(lowerName)) {
if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming'; return 'ott_services';
if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage'; }
if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services'; if (LegacyServiceData.musicServices.containsKey(lowerName)) {
if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools'; return 'music_streaming';
if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools'; }
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle'; if (LegacyServiceData.storageServices.containsKey(lowerName)) {
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping'; return 'cloud_storage';
if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom'; }
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'; return 'other';
} }

View File

@@ -21,7 +21,8 @@ class ServiceNameResolver {
// JSON에서 서비스 찾기 // JSON에서 서비스 찾기
for (final categoryData in categories.values) { for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>; final services = (categoryData as Map<String, dynamic>)['services']
as Map<String, dynamic>;
for (final serviceData in services.values) { for (final serviceData in services.values) {
final data = serviceData as Map<String, dynamic>; final data = serviceData as Map<String, dynamic>;

View File

@@ -36,7 +36,9 @@ class SmsExtractorService {
// 모든 서비스명 검사 // 모든 서비스명 검사
for (final entry in LegacyServiceData.allServices.entries) { for (final entry in LegacyServiceData.allServices.entries) {
if (lowerSms.contains(entry.key.toLowerCase())) { if (lowerSms.contains(entry.key.toLowerCase())) {
final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other'; final categoryId =
await _categoryMapper.findCategoryByServiceName(entry.key) ??
'other';
return ServiceInfo( return ServiceInfo(
serviceId: entry.key, serviceId: entry.key,

View File

@@ -2,6 +2,7 @@ import '../models/service_info.dart';
import '../data/service_data_repository.dart'; import '../data/service_data_repository.dart';
import '../data/legacy_service_data.dart'; import '../data/legacy_service_data.dart';
import 'category_mapper_service.dart'; import 'category_mapper_service.dart';
import '../../../utils/logger.dart';
/// URL 매칭 관련 기능을 제공하는 서비스 클래스 /// URL 매칭 관련 기능을 제공하는 서비스 클래스
class UrlMatcherService { class UrlMatcherService {
@@ -35,7 +36,7 @@ class UrlMatcherService {
return null; return null;
} catch (e) { } catch (e) {
print('UrlMatcherService: 도메인 추출 실패 - $e'); Log.e('UrlMatcherService: 도메인 추출 실패', e);
return null; return null;
} }
} }
@@ -62,7 +63,8 @@ class UrlMatcherService {
// 도메인이 일치하는지 확인 // 도메인이 일치하는지 확인
for (final serviceDomain in domains) { for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { if (domain.contains(serviceDomain) ||
serviceDomain.contains(domain)) {
final names = List<String>.from(serviceData['names'] ?? []); final names = List<String>.from(serviceData['names'] ?? []);
final urls = serviceData['urls'] as Map<String, dynamic>?; final urls = serviceData['urls'] as Map<String, dynamic>?;
@@ -106,7 +108,7 @@ class UrlMatcherService {
/// 서비스명으로 URL 찾기 /// 서비스명으로 URL 찾기
String? suggestUrl(String serviceName) { String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) { if (serviceName.isEmpty) {
print('UrlMatcherService: 빈 serviceName'); Log.w('UrlMatcherService: 빈 serviceName');
return null; return null;
} }
@@ -117,7 +119,7 @@ class UrlMatcherService {
// 정확한 매칭을 먼저 시도 // 정확한 매칭을 먼저 시도
for (final entry in LegacyServiceData.allServices.entries) { for (final entry in LegacyServiceData.allServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -125,7 +127,7 @@ class UrlMatcherService {
// OTT 서비스 검사 // OTT 서비스 검사
for (final entry in LegacyServiceData.ottServices.entries) { for (final entry in LegacyServiceData.ottServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -133,7 +135,7 @@ class UrlMatcherService {
// 음악 서비스 검사 // 음악 서비스 검사
for (final entry in LegacyServiceData.musicServices.entries) { for (final entry in LegacyServiceData.musicServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -141,7 +143,7 @@ class UrlMatcherService {
// AI 서비스 검사 // AI 서비스 검사
for (final entry in LegacyServiceData.aiServices.entries) { for (final entry in LegacyServiceData.aiServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -149,7 +151,7 @@ class UrlMatcherService {
// 프로그래밍 서비스 검사 // 프로그래밍 서비스 검사
for (final entry in LegacyServiceData.programmingServices.entries) { for (final entry in LegacyServiceData.programmingServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -157,7 +159,7 @@ class UrlMatcherService {
// 오피스 툴 검사 // 오피스 툴 검사
for (final entry in LegacyServiceData.officeTools.entries) { for (final entry in LegacyServiceData.officeTools.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -165,7 +167,7 @@ class UrlMatcherService {
// 기타 서비스 검사 // 기타 서비스 검사
for (final entry in LegacyServiceData.otherServices.entries) { for (final entry in LegacyServiceData.otherServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -174,15 +176,15 @@ class UrlMatcherService {
for (final entry in LegacyServiceData.allServices.entries) { for (final entry in LegacyServiceData.allServices.entries) {
final key = entry.key.toLowerCase(); final key = entry.key.toLowerCase();
if (key.contains(lowerName) || lowerName.contains(key)) { if (key.contains(lowerName) || lowerName.contains(key)) {
print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
print('UrlMatcherService: 매칭 실패 - $lowerName'); Log.d('UrlMatcherService: 매칭 실패 - $lowerName');
return null; return null;
} catch (e) { } catch (e) {
print('UrlMatcherService: suggestUrl 에러 - $e'); Log.e('UrlMatcherService: suggestUrl 에러', e);
return null; 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

@@ -10,152 +10,152 @@ class AdaptiveTheme {
/// 다크 테마 /// 다크 테마
static ThemeData get darkTheme { static ThemeData get darkTheme {
const scheme = ColorScheme.dark(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.errorColor,
surface: Color(0xFF1E1E1E),
);
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
colorScheme: const ColorScheme.dark( colorScheme: scheme,
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.dangerColor,
background: const Color(0xFF121212),
surface: const Color(0xFF1E1E1E),
),
scaffoldBackgroundColor: const Color(0xFF121212), scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: const Color(0xFF1E1E1E), color: scheme.surface,
elevation: 2, elevation: 1,
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: const Color(0xFFFFFFFF).withValues(alpha: 0.08),
width: 1,
),
), ),
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: scheme.surface,
foregroundColor: Colors.white, foregroundColor: scheme.onSurface,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: const TextStyle( // title/icon colors inherit from foregroundColor
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
iconTheme: IconThemeData(
color: Colors.white.withValues(alpha: 0.9),
size: 24,
),
), ),
textTheme: ThemeData.dark(useMaterial3: true)
textTheme: TextTheme( .textTheme
headlineLarge: const TextStyle( .copyWith(
color: Colors.white, headlineLarge: const TextStyle(
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: const TextStyle(
color: Colors.white, 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: const TextStyle( fontSize: 24,
color: Colors.white, fontWeight: FontWeight.w600,
fontSize: 24, letterSpacing: -0.25,
fontWeight: FontWeight.w600, height: 1.3,
letterSpacing: -0.25, ),
height: 1.3, titleLarge: const TextStyle(
), fontSize: 20,
titleLarge: const TextStyle( fontWeight: FontWeight.w600,
color: Colors.white, letterSpacing: -0.2,
fontSize: 20, height: 1.4,
fontWeight: FontWeight.w600, ),
letterSpacing: -0.2, titleMedium: const TextStyle(
height: 1.4, fontSize: 18,
), fontWeight: FontWeight.w600,
titleMedium: const TextStyle( letterSpacing: -0.1,
color: Colors.white, height: 1.4,
fontSize: 18, ),
fontWeight: FontWeight.w600, titleSmall: const TextStyle(
letterSpacing: -0.1, fontSize: 16,
height: 1.4, fontWeight: FontWeight.w600,
), height: 1.4,
titleSmall: const TextStyle( ),
color: Colors.white, bodyLarge: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w400,
letterSpacing: 0, letterSpacing: 0.1,
height: 1.4, height: 1.5,
), ),
bodyLarge: TextStyle( bodyMedium: const TextStyle(
color: Colors.white.withValues(alpha: 0.9), fontSize: 14,
fontSize: 16, fontWeight: FontWeight.w400,
fontWeight: FontWeight.w400, letterSpacing: 0.1,
letterSpacing: 0.1, height: 1.5,
height: 1.5, ),
), bodySmall: const TextStyle(
bodyMedium: TextStyle( fontSize: 12,
color: Colors.white.withValues(alpha: 0.7), fontWeight: FontWeight.w400,
fontSize: 14, letterSpacing: 0.2,
fontWeight: FontWeight.w400, height: 1.5,
letterSpacing: 0.1, ),
height: 1.5, labelLarge: const TextStyle(
), fontSize: 14,
bodySmall: TextStyle( fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5), letterSpacing: 0.1,
fontSize: 12, height: 1.4,
fontWeight: FontWeight.w400, ),
letterSpacing: 0.2, labelMedium: const TextStyle(
height: 1.5, fontSize: 12,
), fontWeight: FontWeight.w600,
), letterSpacing: 0.2,
height: 1.4,
),
labelSmall: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
)
.apply(bodyColor: scheme.onSurface, displayColor: scheme.onSurface),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: const Color(0xFF2A2A2A), fillColor: scheme.surface,
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: scheme.outline, width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), borderSide: BorderSide(color: scheme.primary, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), borderSide: BorderSide(color: scheme.error, width: 1),
), ),
labelStyle: TextStyle( labelStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.7), color: scheme.onSurfaceVariant,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
hintStyle: TextStyle( hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5), color: scheme.onSurfaceVariant,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor, backgroundColor: scheme.primary,
foregroundColor: Colors.white, foregroundColor: scheme.onPrimary,
minimumSize: const Size(0, 48), minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -164,9 +164,66 @@ class AdaptiveTheme {
elevation: 0, elevation: 0,
), ),
), ),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary.withValues(alpha: 0.5);
}
return scheme.surfaceContainerHighest.withValues(alpha: 0.5);
}),
),
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return Colors.transparent;
}),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: BorderSide(color: scheme.outline, width: 1.5),
),
radioTheme: RadioThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
),
sliderTheme: SliderThemeData(
activeTrackColor: scheme.primary,
inactiveTrackColor: scheme.onSurfaceVariant,
thumbColor: scheme.primary,
overlayColor: scheme.primary.withValues(alpha: 0.5),
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
),
tabBarTheme: TabBarThemeData(
labelColor: scheme.primary,
unselectedLabelColor: scheme.onSurfaceVariant,
indicatorColor: scheme.primary,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
dividerTheme: DividerThemeData( dividerTheme: DividerThemeData(
color: Colors.white.withValues(alpha: 0.1), color: scheme.outline,
thickness: 1, thickness: 1,
space: 16, space: 16,
), ),
@@ -175,20 +232,15 @@ class AdaptiveTheme {
/// OLED 최적화 다크 테마 /// OLED 최적화 다크 테마
static ThemeData get oledTheme { static ThemeData get oledTheme {
return darkTheme.copyWith( final base = darkTheme;
const oledSurface = Color(0xFF0A0A0A);
return base.copyWith(
scaffoldBackgroundColor: Colors.black, scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith( colorScheme: base.colorScheme.copyWith(surface: oledSurface),
background: Colors.black, cardTheme: base.cardTheme.copyWith(color: oledSurface),
surface: const Color(0xFF0A0A0A), appBarTheme: base.appBarTheme.copyWith(backgroundColor: Colors.black),
), inputDecorationTheme: base.inputDecorationTheme.copyWith(
cardTheme: darkTheme.cardTheme.copyWith( fillColor: oledSurface,
color: const Color(0xFF0A0A0A),
),
appBarTheme: darkTheme.appBarTheme.copyWith(
backgroundColor: Colors.black,
),
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
fillColor: const Color(0xFF0A0A0A),
), ),
); );
} }
@@ -203,10 +255,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,7 +284,6 @@ class AdaptiveTheme {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -242,7 +291,6 @@ class AdaptiveTheme {
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,
@@ -263,20 +311,17 @@ class AdaptiveTheme {
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
: Brightness.dark,
systemNavigationBarColor: isOled systemNavigationBarColor: isOled
? Colors.black ? Colors.black
: (brightness == Brightness.dark : (brightness == Brightness.dark
? const Color(0xFF121212) ? const Color(0xFF121212)
: Colors.white), : Colors.white),
systemNavigationBarIconBrightness: brightness == Brightness.dark systemNavigationBarIconBrightness:
? Brightness.light brightness == Brightness.dark ? Brightness.light : Brightness.dark,
: Brightness.dark,
)); ));
} }
@@ -357,12 +402,12 @@ class ThemeSettings {
} }
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(

View File

@@ -7,7 +7,8 @@ class AppColors {
static const successColor = Color(0xFF38BDF8); // 소프트 민트 static const successColor = Color(0xFF38BDF8); // 소프트 민트
static const infoColor = Color(0xFF6366F1); // 인디고 static const infoColor = Color(0xFF6366F1); // 인디고
static const warningColor = Color(0xFFF59E0B); // 앰버 static const warningColor = Color(0xFFF59E0B); // 앰버
static const dangerColor = Color(0xFFF472B6); // 핑크 액센트 static const dangerColor = Color(0xFFF472B6); // 핑크 액센트 (액센트 용도)
static const errorColor = Color(0xFFEF4444); // 레드 (오류 용도)
// 배경색 // 배경색
static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100 static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100
@@ -31,27 +32,7 @@ class AppColors {
// 그림자 (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 = [
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA) // 스카이 블루
];
static const List<Color> tealGradient = [
Color(0xFF14B8A6),
Color(0xFF0D9488)
];
static const List<Color> purpleGradient = [
Color(0xFF8B5CF6),
Color(0xFF7C3AED)
];
static const List<Color> amberGradient = [
Color(0xFFF59E0B),
Color(0xFFD97706)
];
static const List<Color> roseGradient = [
Color(0xFFF43F5E),
Color(0xFFE11D48)
];
// Glassmorphism 효과를 위한 색상 // Glassmorphism 효과를 위한 색상
static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity) static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity)
@@ -66,47 +47,9 @@ class AppColors {
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 = [
Color(0x33FFFFFF), // 20% white
Color(0x1AFFFFFF), // 10% white
];
static const List<Color> glassGradientDark = [ // (메인/액센트 그라데이션 제거됨)
Color(0x1A000000), // 10% black
Color(0x0F000000), // 6% black
];
// 메인 그라데이션 // (시간대별 배경 그라데이션 제거됨)
static const List<Color> mainGradient = [
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA), // 스카이 블루
Color(0xFFE0E7EF), // 라이트 그레이
];
static const List<Color> accentGradient = [
Color(0xFF38BDF8), // 소프트 민트
Color(0xFF60A5FA), // 스카이 블루
];
// 시간대별 배경 그라디언트
static const List<Color> morningGradient = [
Color(0xFFFED7AA), // 따뜻한 오렌지
Color(0xFFFBBF24), // 부드러운 노랑
];
static const List<Color> dayGradient = [
Color(0xFFDDEAFC), // 연한 하늘색
Color(0xFFBFDBFE), // 맑은 파랑
];
static const List<Color> eveningGradient = [
Color(0xFFFCA5A5), // 부드러운 핑크
Color(0xFFC084FC), // 연한 보라
];
static const List<Color> nightGradient = [
Color(0xFF4338CA), // 깊은 인디고
Color(0xFF1E1B4B), // 다크 네이비
];
} }

View File

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

View File

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

View File

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

View File

@@ -32,17 +32,9 @@ class AnimationControllerHelper {
pulseController.duration = const Duration(milliseconds: 1500); pulseController.duration = const Duration(milliseconds: 1500);
pulseController.repeat(reverse: true); pulseController.repeat(reverse: true);
// 웨이브 컨트롤러 초기화 // 웨이브 컨트롤러 초기화: 반복으로 부드럽게 루프
waveController.duration = const Duration(milliseconds: 8000); waveController.duration = const Duration(milliseconds: 8000);
waveController.forward(); waveController.repeat();
// 웨이브 애니메이션이 끝나면 다시 처음부터 부드럽게 시작하도록 설정
waveController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
waveController.reset();
waveController.forward();
}
});
} }
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드 /// 모든 애니메이션 컨트롤러를 재설정하는 메서드

View File

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

View File

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

View File

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

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';
/// 메모리 관리를 위한 헬퍼 클래스 /// 메모리 관리를 위한 헬퍼 클래스
@@ -57,15 +58,14 @@ class MemoryManager {
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);
@@ -123,7 +123,7 @@ class MemoryManager {
PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages(); PaintingBinding.instance.imageCache.clearLiveImages();
if (kDebugMode) { if (kDebugMode) {
print('🖼️ 이미지 캐시가 비워졌습니다.'); Log.d('🖼️ 이미지 캐시가 비워졌습니다.');
} }
} }
@@ -139,9 +139,7 @@ class MemoryManager {
/// 살아있는 위젯 수 확인 /// 살아있는 위젯 수 확인
int getAliveWidgetCount() { int getAliveWidgetCount() {
return _widgetReferences.values return _widgetReferences.values.where((ref) => ref.target != null).length;
.where((ref) => ref.target != null)
.length;
} }
/// 메모리 압박 시 대응 /// 메모리 압박 시 대응
@@ -158,7 +156,7 @@ class MemoryManager {
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2; imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
if (kDebugMode) { if (kDebugMode) {
print('⚠️ 메모리 압박 대응: 캐시 크기 감소'); Log.w('메모리 압박 대응: 캐시 크기 감소');
} }
} }
@@ -231,16 +229,17 @@ class ImageCacheStatus {
}); });
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),
}; };
} }
/// 메모리 효율적인 리스트 뷰 /// 메모리 효율적인 리스트 뷰
@@ -263,10 +262,8 @@ class MemoryEfficientListView<T> extends StatefulWidget {
_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;

View File

@@ -1,11 +1,13 @@
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();
@@ -104,7 +106,8 @@ class PerformanceOptimizer {
} }
/// 이미지 최적화 - 메모리 효율적인 크기로 조정 /// 이미지 최적화 - 메모리 효율적인 크기로 조정
static double getOptimalImageSize(BuildContext context, { static double getOptimalImageSize(
BuildContext context, {
required double originalSize, required double originalSize,
double maxSize = 1000, double maxSize = 1000,
}) { }) {
@@ -139,12 +142,12 @@ class PerformanceOptimizer {
/// 빌드 최적화를 위한 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 사용');
} }
} }
@@ -154,12 +157,12 @@ class PerformanceOptimizer {
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개 이상 생성됨. 메모리 누수 가능성!');
} }
} }
} }
@@ -176,8 +179,10 @@ class MemoryInfo {
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';
} }
/// 성능 측정 데코레이터 /// 성능 측정 데코레이터
@@ -192,11 +197,11 @@ class PerformanceMeasure {
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

@@ -18,7 +18,7 @@ class PlatformHelper {
static bool get isDesktop { static bool get isDesktop {
if (kIsWeb) return false; if (kIsWeb) return false;
return defaultTargetPlatform == TargetPlatform.linux || return defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows; 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

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

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

View File

@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart'; 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 {
@@ -41,19 +40,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassCard, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1), color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -66,7 +59,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(
@@ -95,10 +89,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
} }
return Text( return Text(
titleText, titleText,
style: const TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
); );
}, },
@@ -118,7 +112,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
} }
}); });
}, },
activeColor: controller.gradientColors[0], activeThumbColor: controller.gradientColors[0],
activeTrackColor:
controller.gradientColors[0].withValues(alpha: 0.5),
), ),
], ],
), ),
@@ -137,10 +133,16 @@ class AddSubscriptionEventSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08), color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3), color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
@@ -148,14 +150,15 @@ class AddSubscriptionEventSection extends StatelessWidget {
children: [ children: [
Icon( Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.infoColor, color: Theme.of(context).colorScheme.tertiary,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
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,13 +171,16 @@ 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: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: Theme.of(context)
.colorScheme
.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
); );
@@ -216,8 +222,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));
} }
}); });
}, },
@@ -238,7 +246,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Builder( Builder(
builder: (BuildContext innerContext) { builder: (BuildContext innerContext) {
// 현재 로케일 확인 // 현재 로케일 확인
final currentLocale = Localizations.localeOf(innerContext); final currentLocale =
Localizations.localeOf(innerContext);
// 로케일에 따라 직접 텍스트 설정 // 로케일에 따라 직접 텍스트 설정
String eventPriceLabel; String eventPriceLabel;
@@ -267,6 +276,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
currency: controller.currency, currency: controller.currency,
label: eventPriceLabel, label: eventPriceLabel,
hintText: eventPriceHint, hintText: eventPriceHint,
enabled: controller.isEventActive,
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
validator:
controller.isEventActive ? null : (_) => null,
); );
}, },
), ),

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ class AddSubscriptionSaveButton extends StatelessWidget {
? null ? null
: () => controller.saveSubscription(setState: setState), : () => controller.saveSubscription(setState: setState),
isLoading: controller.isLoading, isLoading: controller.isLoading,
backgroundColor: const Color(0xFF3B82F6), backgroundColor: Theme.of(context).colorScheme.primary,
), ),
), ),
), ),

View File

@@ -4,8 +4,7 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart'; import '../../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 {
@@ -27,7 +26,7 @@ class AnalysisBadge extends StatelessWidget {
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.pureWhite, color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: borderColor, color: borderColor,
@@ -35,7 +34,7 @@ class AnalysisBadge extends StatelessWidget {
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10, blurRadius: 10,
spreadRadius: 2, spreadRadius: 2,
), ),
@@ -49,10 +48,10 @@ class AnalysisBadge extends StatelessWidget {
subscription.serviceName.length > 5 subscription.serviceName.length > 5
? '${subscription.serviceName.substring(0, 5)}...' ? '${subscription.serviceName.substring(0, 5)}...'
: subscription.serviceName, : subscription.serviceName,
style: const TextStyle( style: TextStyle(
fontSize: 8, fontSize: 8,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 0), const SizedBox(height: 0),
@@ -69,19 +68,23 @@ 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,
style: const TextStyle( style: TextStyle(
fontSize: 7, fontSize: 7,
color: AppColors.navyGray, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
); );
} }

View File

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

View File

@@ -1,13 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/color_scheme_ext.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:provider/provider.dart'; import 'package:provider/provider.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'; // Glass 제거: Material 3 Card 사용
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 {
@@ -35,7 +36,8 @@ class MonthlyExpenseChartCard extends StatelessWidget {
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 {
// 소수점이 있는 통화 (달러, 위안) // 소수점이 있는 통화 (달러, 위안)
@@ -73,11 +75,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
} }
// 월간 지출 차트 데이터 // 월간 지출 차트 데이터
List<BarChartGroupData> _getMonthlyBarGroups(String locale) { List<BarChartGroupData> _getMonthlyBarGroups(
BuildContext context, String locale) {
final List<BarChartGroupData> barGroups = []; final List<BarChartGroupData> barGroups = [];
final calculatedMax = monthlyData.fold<double>( final calculatedMax = monthlyData.fold<double>(
0, (max, data) => math.max(max, data['totalExpense'] as double)); 0, (max, data) => math.max(max, data['totalExpense'] as double));
final maxAmount = _calculateChartMaxY(calculatedMax, locale); final maxAmount = _calculateChartMaxY(calculatedMax, locale);
final scheme = Theme.of(context).colorScheme;
for (int i = 0; i < monthlyData.length; i++) { for (int i = 0; i < monthlyData.length; i++) {
final data = monthlyData[i]; final data = monthlyData[i];
@@ -87,20 +91,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
barRods: [ barRods: [
BarChartRodData( BarChartRodData(
toY: data['totalExpense'], toY: data['totalExpense'],
gradient: LinearGradient( color: scheme.primary,
colors: [
const Color(0xFF3B82F6).withValues(alpha: 0.7),
const Color(0xFF60A5FA),
],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
),
width: 18, width: 18,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
backDrawRodData: BackgroundBarChartRodData( backDrawRodData: BackgroundBarChartRodData(
show: true, show: true,
toY: maxAmount, toY: maxAmount,
color: AppColors.navyGray.withValues(alpha: 0.1), color: scheme.onSurfaceVariant.withValues(alpha: 0.08),
), ),
), ),
], ],
@@ -130,10 +127,17 @@ class MonthlyExpenseChartCard extends StatelessWidget {
parent: animationController, parent: animationController,
curve: const Interval(0.4, 0.9, curve: Curves.easeOut), curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
)), )),
child: GlassmorphismCard( child: Card(
blur: 10, elevation: 3,
opacity: 0.1, shape: RoundedRectangleBorder(
borderRadius: 16, borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -153,108 +157,122 @@ 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(context, 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: Theme.of(context)
.colorScheme
.onSurfaceVariant
.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(
tooltipBorderRadius: BorderRadius.circular(8),
getTooltipColor: (group) => Theme.of(context)
.colorScheme
.inverseSurface,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
TextStyle(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
fontWeight: FontWeight.bold,
), ),
children: [
TextSpan(
text: CurrencyUtil
.formatTotalAmountWithLocale(
monthlyData[group.x]
['totalExpense'] as double,
locale),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.warning,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
); );
}, },
), ),
), ),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: AppColors.darkNavy,
tooltipRoundedRadius: 8,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
const TextStyle(
color: AppColors.pureWhite,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: CurrencyUtil.formatTotalAmountWithLocale(
monthlyData[group.x]['totalExpense']
as double,
locale),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
);
},
),
), ),
duration: ReduceMotion.isEnabled(context)
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
), ),
), ),
), ),
const SizedBox(height: 16), 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,

View File

@@ -4,12 +4,14 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../services/exchange_rate_service.dart'; import '../../services/exchange_rate_service.dart';
import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../../theme/color_scheme_ext.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart'; 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,23 +25,25 @@ 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; // kept for compatibility previously; computation now happens per build
String? _lastLocale; String? _lastLocale;
static const _chartColors = [ // 차트 팔레트: ColorScheme + 보조 상수(성공/경고/액센트)
Color(0xFF3B82F6), List<Color> _getChartColors(ColorScheme scheme) => [
Color(0xFF10B981), scheme.primary,
Color(0xFFF59E0B), scheme.success,
Color(0xFFEF4444), scheme.warning,
Color(0xFF8B5CF6), scheme.error,
Color(0xFF0EA5E9), scheme.tertiary,
Color(0xFFEC4899), scheme.secondary,
]; const Color(0xFFEC4899), // accent
];
@override @override
void initState() { void initState() {
@@ -60,7 +64,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
void _initializeFuture() { void _initializeFuture() {
_lastLocale = context.read<LocaleProvider>().locale.languageCode; _lastLocale = context.read<LocaleProvider>().locale.languageCode;
_pieSectionsFuture = _getPieSections(); // no-op: Future computed on demand in build
} }
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) { bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
@@ -78,12 +82,14 @@ 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 가져오기
final locale = context.read<LocaleProvider>().locale.languageCode; final locale = context.read<LocaleProvider>().locale.languageCode;
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale); final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
// Chart palette (capture scheme before any awaits)
final scheme = Theme.of(context).colorScheme;
final chartColors = _getChartColors(scheme);
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산) // 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
List<double> sectionValues = []; List<double> sectionValues = [];
@@ -97,11 +103,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
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 {
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우) // 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
@@ -118,7 +126,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 섹션 데이터 생성 (터치 상태 제외) // 섹션 데이터 생성 (터치 상태 제외)
final sections = List.generate(widget.subscriptions.length, (i) { final sections = List.generate(widget.subscriptions.length, (i) {
final percentage = (sectionValues[i] / sectionsTotal) * 100; final percentage = (sectionValues[i] / sectionsTotal) * 100;
final index = i % _chartColors.length; final index = i % chartColors.length;
return PieChartSectionData( return PieChartSectionData(
value: sectionValues[i], value: sectionValues[i],
@@ -126,12 +134,12 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
titleStyle: const TextStyle( titleStyle: const TextStyle(
fontSize: 12.0, fontSize: 12.0,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.pureWhite, color: Colors.white,
shadows: [ shadows: [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
], ],
), ),
color: _chartColors[index], color: chartColors[index],
radius: 100.0, radius: 100.0,
titlePositionPercentageOffset: 0.6, titlePositionPercentageOffset: 0.6,
badgeWidget: null, badgeWidget: null,
@@ -147,19 +155,21 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
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 chartColors = _getChartColors(Theme.of(context).colorScheme);
final colorIndex = index % chartColors.length;
return IgnorePointer( return IgnorePointer(
child: AnalysisBadge( child: AnalysisBadge(
size: 40, size: 40,
borderColor: _chartColors[colorIndex], borderColor: chartColors[colorIndex],
subscription: subscription, subscription: subscription,
), ),
); );
} }
// 터치 상태를 반영한 섹션 데이터 생성 // 터치 상태를 반영한 섹션 데이터 생성
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;
@@ -169,14 +179,16 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
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: Colors.white,
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,
@@ -204,10 +216,17 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
parent: widget.animationController, parent: widget.animationController,
curve: const Interval(0.0, 0.7, curve: Curves.easeOut), curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
)), )),
child: GlassmorphismCard( child: Card(
blur: 10, elevation: 3,
opacity: 0.1, shape: RoundedRectangleBorder(
borderRadius: 16, borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -217,38 +236,47 @@ 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,
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFE5F2FF), color: Theme.of(context)
borderRadius: .colorScheme
BorderRadius.circular(4), .primary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: const Color(0xFFBFDBFE), color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
child: Text( child: Text(
AppLocalizations.of(context).exchangeRateFormat(snapshot.data!), AppLocalizations.of(context)
style: const TextStyle( .exchangeRateFormat(snapshot.data!),
style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6), color:
Theme.of(context).colorScheme.primary,
), ),
), ),
); );
@@ -272,7 +300,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,
), ),
@@ -282,18 +311,21 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
: SizedBox( : SizedBox(
height: 250, height: 250,
child: FutureBuilder<List<PieChartSectionData>>( child: FutureBuilder<List<PieChartSectionData>>(
future: _pieSectionsFuture, future: _getPieSections(),
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,
), ),
@@ -301,53 +333,67 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
); );
} }
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 final touchedIndex =
.touchedSection! pieTouchResponse.touchedSection!
.touchedSectionIndex; .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;
});
}
}
},
),
), ),
duration: ReduceMotion.isEnabled(context)
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
), ),
); );
}, },
@@ -364,10 +410,12 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
(index) { (index) {
final subscription = final subscription =
widget.subscriptions[index]; widget.subscriptions[index];
final color = _chartColors[index % _chartColors.length]; final chartColors = _getChartColors(
Theme.of(context).colorScheme);
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 +433,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,
), ),
); );

View File

@@ -6,8 +6,9 @@ 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 '../../utils/haptic_feedback_helper.dart'; import '../../utils/haptic_feedback_helper.dart';
import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../../theme/color_scheme_ext.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -43,185 +44,221 @@ 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: Card(
opacity: 0.1, elevation: 3,
borderRadius: 16, shape: RoundedRectangleBorder(
child: Padding( borderRadius: BorderRadius.circular(16),
padding: const EdgeInsets.all(16), side: BorderSide(
child: Column( color: Theme.of(context)
crossAxisAlignment: CrossAxisAlignment.start, .colorScheme
children: [ .outline
Row( .withValues(alpha: 0.5),
mainAxisAlignment: MainAxisAlignment.spaceBetween, ),
children: [ ),
ThemedText.headline( child: Padding(
text: AppLocalizations.of(context).totalExpenseSummary, padding: const EdgeInsets.all(16),
style: const TextStyle( child: Column(
fontSize: 18, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: AppLocalizations.of(context)
.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),
),
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: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
),
),
child: FaIcon(
FontAwesomeIcons.listCheck,
size: 16,
color: Theme.of(context)
.colorScheme
.primary,
),
),
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: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
),
),
child: FaIcon(
FontAwesomeIcons.chartLine,
size: 16,
color: Theme.of(context)
.colorScheme
.success,
),
),
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,
),
),
],
),
],
),
],
),
),
],
),
],
),
), ),
), ),
), ),

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';
/// 슬라이드 + 페이드 전환 /// 슬라이드 + 페이드 전환
class SlidePageRoute<T> extends PageRouteBuilder<T> { class SlidePageRoute<T> extends PageRouteBuilder<T> {
@@ -11,8 +12,12 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
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) {
@@ -64,8 +69,12 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
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;
@@ -98,8 +107,12 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
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;
@@ -117,9 +130,11 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
alignment: Alignment.center, alignment: Alignment.center,
transform: Matrix4.identity() transform: Matrix4.identity()
..setEntry(3, 2, 0.001) ..setEntry(3, 2, 0.001)
..rotateZ(rotateAnimation.value) ..rotateZ(rotateAnimation.value),
..scale(scaleAnimation.value), child: Transform.scale(
child: child, scale: scaleAnimation.value,
child: child,
),
); );
}, },
); );
@@ -135,10 +150,15 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
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,
@@ -188,8 +208,12 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
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: [
@@ -197,7 +221,10 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
FadeTransition( FadeTransition(
opacity: animation, opacity: animation,
child: Container( child: Container(
color: Colors.black.withValues(alpha: 0.3), color: Theme.of(context)
.colorScheme
.scrim
.withValues(alpha: 0.3),
), ),
), ),
// 컨테이너 확장 애니메이션 // 컨테이너 확장 애니메이션
@@ -259,8 +286,12 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
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;

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';
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯 /// 웨이브 애니메이션 배경 효과를 제공하는 위젯
/// ///
@@ -9,13 +10,29 @@ class AnimatedWaveBackground extends StatelessWidget {
final AnimationController pulseController; final AnimationController pulseController;
const AnimatedWaveBackground({ const AnimatedWaveBackground({
Key? key, super.key,
required this.controller, required this.controller,
required this.pulseController, required this.pulseController,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final reduce = ReduceMotion.isEnabled(context);
final amp = reduce ? 0.3 : 1.0; // 기본 효과 강도 스케일
// 원 크기에 따라 속도/진폭 스케일을 동적으로 계산
// size가 클수록 느리고(차분), 작을수록 빠르고(활발) 크게 움직이게 함
MotionParams paramsFor(double size) {
const ref = 160.0; // 기준 크기
// 진폭 스케일: 0.6 ~ 1.4 사이 (연속)
final ampScale = _clamp(ref / size, 0.6, 1.4) * (reduce ? 0.6 : 1.0);
// 속도 배수: 1~3의 정수로 제한하여 래핑 시 연속성 보장
final raw = 0.8 + (ref / size) * 0.6; // 약 0.8~1.4 범위
int speedMult = raw < 1.2 ? 1 : (raw < 1.6 ? 2 : 3);
if (reduce && speedMult > 2) speedMult = 2; // 감속 모드 상한
return MotionParams(ampScale: ampScale, speedMult: speedMult);
}
return Stack( return Stack(
children: [ children: [
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용 // 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
@@ -23,22 +40,26 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller, animation: controller,
builder: (context, child) { builder: (context, child) {
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성 // 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
final angle = controller.value * 2 * math.pi; final p = paramsFor(200);
// 사인 함수를 사용하여 부드러운 움직임 생성 final angle = controller.value * 2 * math.pi * p.speedMult;
final xOffset = 20 * math.sin(angle); // 사인 함수를 사용하여 부드러운 움직임 생성 (큰 원: 차분)
final yOffset = 10 * math.cos(angle); final xOffset = 20 * amp * p.ampScale * math.sin(angle);
final yOffset = 10 * amp * p.ampScale * 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 * p.ampScale * math.sin(angle * 0.5),
child: Container( child: Container(
width: 200, width: 200,
height: 200, height: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(100), borderRadius: BorderRadius.circular(100),
), ),
), ),
@@ -50,21 +71,26 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller, animation: controller,
builder: (context, child) { builder: (context, child) {
// 첫 번째 원과 약간 다른 위상을 가지도록 설정 // 첫 번째 원과 약간 다른 위상을 가지도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi / 3); final p = paramsFor(220);
final xOffset = 20 * math.cos(angle); final angle =
final yOffset = 10 * math.sin(angle); (controller.value * 2 * math.pi * p.speedMult) + (math.pi / 3);
final xOffset = 20 * amp * p.ampScale * math.cos(angle);
final yOffset = 10 * amp * p.ampScale * 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 * p.ampScale * math.sin(angle * 0.5),
child: Container( child: Container(
width: 220, width: 220,
height: 220, height: 220,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(110), borderRadius: BorderRadius.circular(110),
), ),
), ),
@@ -77,20 +103,25 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller, animation: controller,
builder: (context, child) { builder: (context, child) {
// 세 번째 원은 다른 위상으로 움직이도록 설정 // 세 번째 원은 다른 위상으로 움직이도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3); final p = paramsFor(120);
final xOffset = 15 * math.sin(angle * 0.7); final angle = (controller.value * 2 * math.pi * p.speedMult) +
final yOffset = 8 * math.cos(angle * 0.7); (math.pi * 2 / 3);
final xOffset = 15 * amp * p.ampScale * math.sin(angle * 0.9);
final yOffset = 8 * amp * p.ampScale * math.cos(angle * 0.9);
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 * p.ampScale * math.cos(angle * 0.5),
child: Container( child: Container(
width: 120, width: 120,
height: 120, height: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(60), borderRadius: BorderRadius.circular(60),
), ),
), ),
@@ -109,9 +140,8 @@ class AnimatedWaveBackground extends StatelessWidget {
width: 30, width: 30,
height: 30, height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: color: Theme.of(context).colorScheme.onSurface.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),
), ),
), ),
@@ -122,3 +152,13 @@ class AnimatedWaveBackground extends StatelessWidget {
); );
} }
} }
// 내부 유틸리티: 값 범위 제한
double _clamp(double v, double min, double max) =>
v < min ? min : (v > max ? max : v);
class MotionParams {
final double ampScale;
final int speedMult;
MotionParams({required this.ampScale, required this.speedMult});
}

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';
@@ -42,8 +43,9 @@ class AppNavigator {
} }
/// 구독 상세 화면으로 네비게이션 /// 구독 상세 화면으로 네비게이션
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 {
@@ -51,9 +53,9 @@ class AppNavigator {
AppRoutes.subscriptionDetail, AppRoutes.subscriptionDetail,
arguments: subscription, arguments: subscription,
); );
print('DetailScreen 네비게이션 성공'); Log.d('DetailScreen 네비게이션 성공');
} catch (e) { } catch (e) {
print('DetailScreen 네비게이션 오류: $e'); Log.e('DetailScreen 네비게이션 오류', e);
} }
} }
@@ -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

@@ -15,14 +15,14 @@ class CategoryHeaderWidget extends StatelessWidget {
final double totalCostCNY; final double totalCostCNY;
const CategoryHeaderWidget({ const CategoryHeaderWidget({
Key? key, super.key,
required this.categoryName, required this.categoryName,
required this.subscriptionCount, required this.subscriptionCount,
required this.totalCostUSD, required this.totalCostUSD,
required this.totalCostKRW, required this.totalCostKRW,
required this.totalCostJPY, required this.totalCostJPY,
required this.totalCostCNY, required this.totalCostCNY,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -36,27 +36,27 @@ class CategoryHeaderWidget extends StatelessWidget {
children: [ children: [
Text( Text(
categoryName, categoryName,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF374151), color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
Text( Text(
_buildCostDisplay(context), _buildCostDisplay(context),
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF6B7280), color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Divider( Divider(
height: 1, height: 1,
thickness: 1, thickness: 1,
color: Color(0xFFEEEEEE), color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
), ),
], ],
), ),
@@ -68,7 +68,8 @@ class CategoryHeaderWidget extends StatelessWidget {
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>[];

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

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 주요 액션에 사용되는 Primary 버튼 /// 주요 액션에 사용되는 Primary 버튼
/// 저장, 추가, 확인 등의 주요 액션에 사용됩니다. /// 저장, 추가, 확인 등의 주요 액션에 사용됩니다.
@@ -43,28 +42,35 @@ 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.colorScheme.primary;
final effectiveForegroundColor =
widget.foregroundColor ?? theme.colorScheme.onPrimary;
Widget button = AnimatedContainer( Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
width: widget.width ?? double.infinity, width: widget.width ?? double.infinity,
height: widget.height, height: widget.height,
transform: widget.enableHoverEffect && _isHovered transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02)) ? Matrix4.diagonal3Values(1.02, 1.02, 1.0)
: Matrix4.identity(), : Matrix4.identity(),
child: ElevatedButton( child: ElevatedButton(
onPressed: widget.isLoading ? null : widget.onPressed, onPressed: widget.isLoading ? null : widget.onPressed,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: effectiveBackgroundColor, backgroundColor: effectiveBackgroundColor,
foregroundColor: effectiveForegroundColor, foregroundColor: effectiveForegroundColor,
// 고정 높이와 텍스트 잘림 방지를 위해 최소 사이즈 지정
minimumSize: Size.fromHeight(widget.height),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.borderRadius), borderRadius: BorderRadius.circular(widget.borderRadius),
), ),
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), // 컨테이너에서 높이를 관리하므로 수직 패딩은 0으로 두고
// 수평 여백만 부여하여 작은 높이(예: 48)에서 글자 잘림 방지
padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: 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(

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 부차적인 액션에 사용되는 Secondary 버튼 /// 부차적인 액션에 사용되는 Secondary 버튼
/// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다. /// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다.
@@ -43,15 +42,16 @@ class _SecondaryButtonState extends State<SecondaryButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor; final effectiveBorderColor =
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor; widget.borderColor ?? theme.colorScheme.outline;
final effectiveTextColor = widget.textColor ?? theme.colorScheme.primary;
Widget button = AnimatedContainer( Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
transform: widget.enableHoverEffect && _isHovered transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02)) ? Matrix4.diagonal3Values(1.02, 1.02, 1.0)
: Matrix4.identity(), : Matrix4.identity(),
child: OutlinedButton( child: OutlinedButton(
onPressed: widget.onPressed, onPressed: widget.onPressed,
@@ -66,12 +66,13 @@ class _SecondaryButtonState extends State<SecondaryButton> {
: 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 backgroundColor: _isHovered
? AppColors.glassBackground ? theme.colorScheme.onSurface.withValues(alpha: 0.06)
: Colors.transparent, : Colors.transparent,
), ),
child: Row( child: Row(
@@ -141,7 +142,7 @@ class _TextLinkButtonState extends State<TextLinkButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final effectiveColor = widget.color ?? AppColors.primaryColor; final effectiveColor = widget.color ?? theme.colorScheme.primary;
Widget button = AnimatedContainer( Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -179,9 +180,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,
), ),
), ),
], ],

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

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/color_scheme_ext.dart';
/// 확인 다이얼로그 위젯 /// 확인 다이얼로그 위젯
/// 사용자에게 중요한 작업을 확인받을 때 사용합니다. /// 사용자에게 중요한 작업을 확인받을 때 사용합니다.
@@ -53,7 +54,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(
@@ -98,7 +100,9 @@ class ConfirmationDialog extends StatelessWidget {
), ),
child: Text( child: Text(
confirmText, confirmText,
style: const TextStyle(color: Colors.white), style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
), ),
), ),
], ],
@@ -163,12 +167,13 @@ class SuccessDialog extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1), color:
Theme.of(context).colorScheme.success.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: Icon(
Icons.check_circle, Icons.check_circle,
color: Colors.green, color: Theme.of(context).colorScheme.success,
size: 64, size: 64,
), ),
), ),
@@ -187,7 +192,7 @@ class SuccessDialog extends StatelessWidget {
message!, message!,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey[600], color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -202,7 +207,7 @@ class SuccessDialog extends StatelessWidget {
onPressed?.call(); onPressed?.call();
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.green, backgroundColor: Theme.of(context).colorScheme.success,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -213,8 +218,8 @@ class SuccessDialog extends StatelessWidget {
), ),
child: Text( child: Text(
buttonText, buttonText,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Theme.of(context).colorScheme.onPrimary,
fontSize: 16, fontSize: 16,
), ),
), ),
@@ -271,12 +276,12 @@ class ErrorDialog extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1), color: Theme.of(context).colorScheme.error.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: Icon(
Icons.error_outline, Icons.error_outline,
color: Colors.red, color: Theme.of(context).colorScheme.error,
size: 64, size: 64,
), ),
), ),
@@ -295,7 +300,7 @@ class ErrorDialog extends StatelessWidget {
message!, message!,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey[600], color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -310,7 +315,7 @@ class ErrorDialog extends StatelessWidget {
onPressed?.call(); onPressed?.call();
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -321,8 +326,8 @@ class ErrorDialog extends StatelessWidget {
), ),
child: Text( child: Text(
buttonText, buttonText,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Theme.of(context).colorScheme.onPrimary,
fontSize: 16, fontSize: 16,
), ),
), ),

View File

@@ -1,238 +0,0 @@
import 'package:flutter/material.dart';
/// 로딩 오버레이 위젯
/// 비동기 작업 중 화면을 덮는 로딩 인디케이터를 표시합니다.
class LoadingOverlay extends StatelessWidget {
final bool isLoading;
final Widget child;
final String? message;
final Color? backgroundColor;
final Color? indicatorColor;
final double opacity;
const LoadingOverlay({
super.key,
required this.isLoading,
required this.child,
this.message,
this.backgroundColor,
this.indicatorColor,
this.opacity = 0.7,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
if (isLoading)
Container(
color: (backgroundColor ?? Colors.black).withValues(alpha: opacity),
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: indicatorColor ?? Theme.of(context).primaryColor,
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
),
],
);
}
}
/// 로딩 다이얼로그
/// 모달 형태의 로딩 인디케이터를 표시합니다.
class LoadingDialog {
static Future<void> show({
required BuildContext context,
String? message,
Color? barrierColor,
bool barrierDismissible = false,
}) {
return showDialog(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: barrierColor ?? Colors.black54,
builder: (context) => WillPopScope(
onWillPop: () async => barrierDismissible,
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: Theme.of(context).primaryColor,
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
),
);
}
static void hide(BuildContext context) {
Navigator.of(context).pop();
}
}
/// 커스텀 로딩 인디케이터
/// 다양한 스타일의 로딩 애니메이션을 제공합니다.
class CustomLoadingIndicator extends StatefulWidget {
final double size;
final Color? color;
final LoadingStyle style;
const CustomLoadingIndicator({
super.key,
this.size = 50,
this.color,
this.style = LoadingStyle.circular,
});
@override
State<CustomLoadingIndicator> createState() => _CustomLoadingIndicatorState();
}
class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..repeat();
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final effectiveColor = widget.color ?? Theme.of(context).primaryColor;
switch (widget.style) {
case LoadingStyle.circular:
return SizedBox(
width: widget.size,
height: widget.size,
child: CircularProgressIndicator(
color: effectiveColor,
strokeWidth: 3,
),
);
case LoadingStyle.dots:
return SizedBox(
width: widget.size,
height: widget.size / 3,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(3, (index) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
final delay = index * 0.2;
final value = (_animation.value - delay).clamp(0.0, 1.0);
return Container(
width: widget.size / 5,
height: widget.size / 5,
decoration: BoxDecoration(
color: effectiveColor.withValues(alpha: 0.3 + value * 0.7),
shape: BoxShape.circle,
),
);
},
);
}),
),
);
case LoadingStyle.pulse:
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: effectiveColor.withValues(alpha: 0.3),
),
child: Center(
child: Container(
width: widget.size * (0.3 + _animation.value * 0.5),
height: widget.size * (0.3 + _animation.value * 0.5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: effectiveColor.withValues(alpha: 1 - _animation.value),
),
),
),
);
},
);
}
}
}
enum LoadingStyle {
circular,
dots,
pulse,
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../theme/app_colors.dart'; // import '../../../theme/app_colors.dart';
/// 공통 텍스트 필드 위젯 /// 공통 텍스트 필드 위젯
/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다. /// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다.
@@ -69,7 +69,7 @@ class BaseTextField extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -89,21 +89,22 @@ class BaseTextField extends StatelessWidget {
maxLines: maxLines, maxLines: maxLines,
minLines: minLines, minLines: minLines,
readOnly: readOnly, readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor, cursorColor: cursorColor ?? theme.colorScheme.primary,
style: style ?? TextStyle( style: style ??
fontSize: 16, TextStyle(
color: AppColors.textPrimary, fontSize: 16,
), color: Theme.of(context).colorScheme.onSurface,
),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
hintStyle: TextStyle( hintStyle: TextStyle(
color: AppColors.textMuted, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
prefixIcon: prefixIcon, prefixIcon: prefixIcon,
prefixText: prefixText, prefixText: prefixText,
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
filled: true, filled: true,
fillColor: fillColor ?? AppColors.surfaceColorAlt, fillColor: fillColor ?? Theme.of(context).colorScheme.surface,
contentPadding: contentPadding ?? const EdgeInsets.all(16), contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -112,15 +113,15 @@ class BaseTextField extends StatelessWidget {
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide( borderSide: BorderSide(
color: theme.primaryColor, color: theme.colorScheme.primary,
width: 2, width: 2,
), ),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide( borderSide: BorderSide(
color: AppColors.borderColor.withValues(alpha: 0.7), color: theme.colorScheme.outline.withValues(alpha: 0.6),
width: 1.5, width: 1,
), ),
), ),
disabledBorder: OutlineInputBorder( disabledBorder: OutlineInputBorder(

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart'; // import '../../../theme/app_colors.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
/// 결제 주기 선택 위젯 /// 결제 주기 선택 위젯
@@ -8,8 +8,8 @@ class BillingCycleSelector extends StatelessWidget {
final String billingCycle; final String billingCycle;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
final Color? baseColor; final Color? baseColor;
final List<Color>? gradientColors; final List<Color>? gradientColors; // deprecated: ignored
final bool isGlassmorphism; final bool isGlassmorphism; // deprecated: ignored
const BillingCycleSelector({ const BillingCycleSelector({
super.key, super.key,
@@ -24,19 +24,12 @@ class BillingCycleSelector extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localization = AppLocalizations.of(context); final localization = AppLocalizations.of(context);
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시 // 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
final cycles = isGlassmorphism final cycles = [
? [ localization.monthly,
localization.billingCycleMonthly, localization.billingCycleQuarterly,
localization.billingCycleQuarterly, localization.billingCycleHalfYearly,
localization.billingCycleHalfYearly, localization.yearly,
localization.billingCycleYearly, ];
]
: [
localization.monthly,
localization.billingCycleQuarterly,
localization.billingCycleHalfYearly,
localization.yearly,
];
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -54,16 +47,16 @@ class BillingCycleSelector extends StatelessWidget {
vertical: 12, vertical: 12,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getBackgroundColor(isSelected), color: _getBackgroundColor(context, isSelected),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected), border: _getBorder(context, isSelected),
), ),
child: Text( child: Text(
cycle, cycle,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: _getTextColor(isSelected), color: _getTextColor(context, isSelected),
), ),
), ),
), ),
@@ -74,40 +67,22 @@ class BillingCycleSelector extends StatelessWidget {
); );
} }
Color _getBackgroundColor(bool isSelected) { Color _getBackgroundColor(BuildContext context, bool isSelected) {
if (!isSelected) { final scheme = Theme.of(context).colorScheme;
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
Border? _getBorder(bool isSelected) {
if (isSelected || !isGlassmorphism) {
return null;
}
return Border.all(
color: AppColors.borderColor.withValues(alpha: 0.5),
width: 1.5,
);
}
Color _getTextColor(bool isSelected) {
if (isSelected) { if (isSelected) {
return Colors.white; return baseColor ?? scheme.primary;
} }
return isGlassmorphism return scheme.surface;
? AppColors.darkNavy }
: Colors.grey[700]!;
Border? _getBorder(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
if (isSelected) return null;
return Border.all(color: scheme.outline.withValues(alpha: 0.6), width: 1);
}
Color _getTextColor(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
return isSelected ? scheme.onPrimary : scheme.onSurface;
} }
} }

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../theme/app_colors.dart'; // import '../../../theme/app_colors.dart';
import '../../../providers/category_provider.dart'; import '../../../providers/category_provider.dart';
/// 카테고리 선택 위젯 /// 카테고리 선택 위젯
@@ -10,8 +10,8 @@ class CategorySelector extends StatelessWidget {
final String? selectedCategoryId; final String? selectedCategoryId;
final ValueChanged<String?> onChanged; final ValueChanged<String?> onChanged;
final Color? baseColor; final Color? baseColor;
final List<Color>? gradientColors; final List<Color>? gradientColors; // deprecated: ignored
final bool isGlassmorphism; final bool isGlassmorphism; // deprecated: ignored
const CategorySelector({ const CategorySelector({
super.key, super.key,
@@ -39,9 +39,9 @@ class CategorySelector extends StatelessWidget {
vertical: 10, vertical: 10,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getBackgroundColor(isSelected), color: _getBackgroundColor(context, isSelected),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected), border: _getBorder(context, isSelected),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -49,17 +49,18 @@ class CategorySelector extends StatelessWidget {
Icon( Icon(
_getCategoryIcon(category), _getCategoryIcon(category),
size: 18, size: 18,
color: _getTextColor(isSelected), color: _getTextColor(context, isSelected),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Consumer<CategoryProvider>( Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) { builder: (context, categoryProvider, child) {
return Text( return Text(
categoryProvider.getLocalizedCategoryName(context, category.name), categoryProvider.getLocalizedCategoryName(
context, category.name),
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: _getTextColor(isSelected), color: _getTextColor(context, isSelected),
), ),
); );
}, },
@@ -99,40 +100,22 @@ class CategorySelector extends StatelessWidget {
} }
} }
Color _getBackgroundColor(bool isSelected) { Color _getBackgroundColor(BuildContext context, bool isSelected) {
if (!isSelected) { final scheme = Theme.of(context).colorScheme;
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
Border? _getBorder(bool isSelected) {
if (isSelected || !isGlassmorphism) {
return null;
}
return Border.all(
color: AppColors.borderColor.withValues(alpha: 0.5),
width: 1.5,
);
}
Color _getTextColor(bool isSelected) {
if (isSelected) { if (isSelected) {
return Colors.white; return baseColor ?? scheme.primary;
} }
return isGlassmorphism return scheme.surface;
? AppColors.darkNavy }
: Colors.grey[700]!;
Border? _getBorder(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
if (isSelected) return null;
return Border.all(color: scheme.outline.withValues(alpha: 0.6), width: 1);
}
Color _getTextColor(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
return isSelected ? scheme.onPrimary : scheme.onSurface;
} }
} }

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