Compare commits
19 Commits
a9a715d67c
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d111b5dd62 | ||
|
|
b944f6967d | ||
|
|
997c2f53a0 | ||
|
|
79f9aa3eb0 | ||
|
|
5b72fa196c | ||
|
|
6cd3b9720f | ||
|
|
5a7ef8039e | ||
|
|
10069a1800 | ||
|
|
b034f60510 | ||
|
|
eb6691ce6a | ||
|
|
10491af55b | ||
|
|
4673aed281 | ||
|
|
84b3fdd530 | ||
|
|
d37f66d526 | ||
|
|
d1a6cb9fe3 | ||
|
|
f812d4b9fd | ||
|
|
2a90e7c377 | ||
|
|
9f1d29c99d | ||
|
|
58727af659 |
13
.claude/agents/codex.md
Normal 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
@@ -0,0 +1,31 @@
|
|||||||
|
name: Flutter CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
|
||||||
|
- name: Flutter Pub Get
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: dart format --output=none --set-exit-if-changed .
|
||||||
|
|
||||||
|
- name: Analyze
|
||||||
|
run: flutter analyze
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: flutter test
|
||||||
|
|
||||||
69
AGENTS.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
Codex Agent Guide for SubManager
|
||||||
|
|
||||||
|
Scope
|
||||||
|
- Applies to the entire repository unless a more specific rule exists deeper in the tree.
|
||||||
|
- Precedence: project AGENTS.md > project .claude/agents > user ~/.claude > default Codex CLI rules. Direct system/developer instructions always win.
|
||||||
|
|
||||||
|
Goals
|
||||||
|
- Accelerate small, safe changes with consistent quality.
|
||||||
|
- Keep diffs minimal, focused, and aligned with Flutter best practices.
|
||||||
|
|
||||||
|
Guardrails
|
||||||
|
- Workspace only: modify files within this repo. Ask before adding dependencies or using network.
|
||||||
|
- Safety: avoid destructive actions (file deletions, rewrites, config changes) unless explicitly requested.
|
||||||
|
- Responses: be concise; code first, short rationale after. If uncertain, prefix with "Uncertain:". If multiple viable solutions, show the top 2 briefly.
|
||||||
|
- Planning: for multi‑step 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 one‑letter variable names; avoid inline comments unless requested.
|
||||||
|
- Structure: follow existing file/module patterns and naming. Do not introduce new frameworks or architectural shifts without approval.
|
||||||
|
- Tests: add or update tests when behavior changes or bugs are fixed (if feasible). Keep tests scoped to the change.
|
||||||
|
|
||||||
|
Validation
|
||||||
|
- Always run local checks via `scripts/check.sh` before proposing completion:
|
||||||
|
- formatting check: `dart format --set-exit-if-changed .`
|
||||||
|
- static analysis: `flutter analyze`
|
||||||
|
- unit/widget tests: `flutter test` (ok if none exist)
|
||||||
|
- UI changes: include brief description of visual impact; screenshots if readily available by the user.
|
||||||
|
|
||||||
|
Sensitive Areas (require explicit approval)
|
||||||
|
- Android/iOS/macOS build configs, signing, bundle identifiers, Gradle/Kotlin/Swift project settings.
|
||||||
|
- Dependency graph changes (pubspec.yaml add/remove/upgrade).
|
||||||
|
- Network access, calling external APIs, or adding secrets.
|
||||||
|
|
||||||
|
Operational Conventions
|
||||||
|
- Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-url-matcher`).
|
||||||
|
- Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`).
|
||||||
|
- PR description template:
|
||||||
|
- Summary: what/why
|
||||||
|
- Changes: key files and decisions
|
||||||
|
- Validation: how verified (analyze/tests/manual)
|
||||||
|
- Risk & Rollback: potential impact and quick rollback steps
|
||||||
|
|
||||||
|
Task Template (author-provided)
|
||||||
|
---
|
||||||
|
Next: <what to do>
|
||||||
|
Complexity: simple | medium | complex
|
||||||
|
|
||||||
|
Context
|
||||||
|
- Problem / goal:
|
||||||
|
- Constraints / non‑goals:
|
||||||
|
- Repro or commands:
|
||||||
|
|
||||||
|
Done When
|
||||||
|
- [ ] Behavior verified (`scripts/check.sh` passes)
|
||||||
|
- [ ] Tests/docs updated if applicable
|
||||||
|
---
|
||||||
|
|
||||||
|
Commands
|
||||||
|
- Lint/analyze/tests: `scripts/check.sh`
|
||||||
|
- Auto‑format: `scripts/fix.sh`
|
||||||
|
|
||||||
|
References & External Facts
|
||||||
|
- Prefer official docs and code‑local references. If citing sources, include plain URLs or file paths in PR descriptions (avoid footnote citation syntaxes).
|
||||||
|
|
||||||
|
Notes from ~/.claude (adapted)
|
||||||
|
- Few‑shot examples improve accuracy; include small before/after or sample input→output when helpful.
|
||||||
|
- Use structured thinking internally; present only concise, actionable outputs here.
|
||||||
@@ -6,13 +6,14 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.submanager"
|
namespace = "com.naturebridgeai.digitalrentmanager"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = "27.0.12077973"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
@@ -21,7 +22,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.example.submanager"
|
applicationId = "com.naturebridgeai.digitalrentmanager"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -42,3 +43,7 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.READ_SMS" />
|
<uses-permission android:name="android.permission.READ_SMS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:label="구독 관리"
|
android:label="구독 관리"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -32,6 +32,10 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<!-- Google AdMob App ID -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
|
android:value="ca-app-pub-6691216385521068~6638409932" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.example.submanager
|
package com.naturebridgeai.digitalrentmanager
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
BIN
assets/app_icon/house_check/1024.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/app_icon/house_check/128.png
Normal file
|
After Width: | Height: | Size: 985 B |
BIN
assets/app_icon/house_check/192.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/app_icon/house_check/256.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/app_icon/house_check/32.png
Normal file
|
After Width: | Height: | Size: 312 B |
BIN
assets/app_icon/house_check/48.png
Normal file
|
After Width: | Height: | Size: 439 B |
BIN
assets/app_icon/house_check/512.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
assets/app_icon/house_check/64.png
Normal file
|
After Width: | Height: | Size: 558 B |
BIN
assets/app_icon/house_check/96.png
Normal file
|
After Width: | Height: | Size: 776 B |
@@ -217,6 +217,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": "디지털 월세 관리자",
|
||||||
@@ -436,6 +447,17 @@
|
|||||||
"enterAmount": "금액을 입력하세요",
|
"enterAmount": "금액을 입력하세요",
|
||||||
"invalidAmount": "올바른 금액을 입력해주세요",
|
"invalidAmount": "올바른 금액을 입력해주세요",
|
||||||
"featureComingSoon": "이 기능은 곧 출시됩니다"
|
"featureComingSoon": "이 기능은 곧 출시됩니다"
|
||||||
|
,
|
||||||
|
"smsPermissionTitle": "SMS 권한 요청",
|
||||||
|
"smsPermissionReasonTitle": "이유",
|
||||||
|
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
|
||||||
|
"smsPermissionScopeTitle": "수집 범위",
|
||||||
|
"smsPermissionScopeBody": "결제 관련 문자 메시지의 패턴(서비스명/금액/날짜)만 로컬에서 처리하며, 외부로 전송하지 않습니다.",
|
||||||
|
"permanentlyDeniedMessage": "권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.",
|
||||||
|
"openSettings": "설정 열기",
|
||||||
|
"later": "나중에 하기",
|
||||||
|
"requesting": "요청 중...",
|
||||||
|
"smsPermissionLabel": "SMS 권한"
|
||||||
},
|
},
|
||||||
"ja": {
|
"ja": {
|
||||||
"appTitle": "デジタル月額管理者",
|
"appTitle": "デジタル月額管理者",
|
||||||
@@ -875,4 +897,4 @@
|
|||||||
"invalidAmount": "请输入有效的金额",
|
"invalidAmount": "请输入有效的金额",
|
||||||
"featureComingSoon": "此功能即将推出"
|
"featureComingSoon": "此功能即将推出"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
123
doc/ads.md
Normal 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` 로드 확인
|
||||||
|
|
||||||
70
doc/agents/codex_prompt_templates.md
Normal 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 / Non‑goals: <limits>
|
||||||
|
|
||||||
|
Done When
|
||||||
|
- scripts/check.sh passes; behavior verified via repro
|
||||||
|
- Tests/docs updated if applicable
|
||||||
|
---
|
||||||
|
|
||||||
|
Small Feature Prompt
|
||||||
|
---
|
||||||
|
Context
|
||||||
|
- Goal: <user‑visible behavior>
|
||||||
|
- Entry points: <screens/routes/widgets>
|
||||||
|
- Data/State impact: <provider/models/side effects>
|
||||||
|
- Constraints / Non‑goals: <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 follow‑ups
|
||||||
|
---
|
||||||
|
|
||||||
113
doc/plan.md
Normal 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, 간격 여유.
|
||||||
|
- 마이크로인터랙션: 진입/전환 120–200ms, 물리 기반 커브, 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% 및 구식 코드 제거.
|
||||||
|
|
||||||
|
---
|
||||||
|
작성자 메모: 본 계획은 코드 변경 없이 문서만 추가되었습니다. 승인 후 단계별 구현을 진행합니다.
|
||||||
@@ -495,7 +495,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -546,7 +546,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -677,7 +677,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -699,7 +699,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.submanager;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.digitalrentmanager;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,40 +2,42 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../models/subscription_model.dart';
|
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
import '../providers/category_provider.dart';
|
import '../providers/category_provider.dart';
|
||||||
import '../services/sms_service.dart';
|
import '../services/sms_service.dart';
|
||||||
import '../services/subscription_url_matcher.dart';
|
import '../services/subscription_url_matcher.dart';
|
||||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/billing_date_util.dart';
|
||||||
|
import '../utils/business_day_util.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||||
|
|
||||||
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
|
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
|
||||||
class AddSubscriptionController {
|
class AddSubscriptionController {
|
||||||
final BuildContext context;
|
final BuildContext context;
|
||||||
|
|
||||||
// Form Key
|
// Form Key
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
// Text Controllers
|
// Text Controllers
|
||||||
final serviceNameController = TextEditingController();
|
final serviceNameController = TextEditingController();
|
||||||
final monthlyCostController = TextEditingController();
|
final monthlyCostController = TextEditingController();
|
||||||
final nextBillingDateController = TextEditingController();
|
final nextBillingDateController = TextEditingController();
|
||||||
final websiteUrlController = TextEditingController();
|
final websiteUrlController = TextEditingController();
|
||||||
final eventPriceController = TextEditingController();
|
final eventPriceController = TextEditingController();
|
||||||
|
|
||||||
// Form State
|
// Form State
|
||||||
String billingCycle = 'monthly';
|
String billingCycle = 'monthly';
|
||||||
String currency = 'KRW';
|
String currency = 'KRW';
|
||||||
DateTime? nextBillingDate;
|
DateTime? nextBillingDate;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
String? selectedCategoryId;
|
String? selectedCategoryId;
|
||||||
|
|
||||||
// Event State
|
// Event State
|
||||||
bool isEventActive = false;
|
bool isEventActive = false;
|
||||||
DateTime? eventStartDate = DateTime.now();
|
DateTime? eventStartDate = DateTime.now();
|
||||||
DateTime? eventEndDate = DateTime.now().add(const Duration(days: 30));
|
DateTime? eventEndDate = DateTime.now().add(const Duration(days: 30));
|
||||||
|
|
||||||
// Focus Nodes
|
// Focus Nodes
|
||||||
final serviceNameFocus = FocusNode();
|
final serviceNameFocus = FocusNode();
|
||||||
final monthlyCostFocus = FocusNode();
|
final monthlyCostFocus = FocusNode();
|
||||||
@@ -44,20 +46,20 @@ class AddSubscriptionController {
|
|||||||
final websiteUrlFocus = FocusNode();
|
final websiteUrlFocus = FocusNode();
|
||||||
final categoryFocus = FocusNode();
|
final categoryFocus = FocusNode();
|
||||||
final currencyFocus = FocusNode();
|
final currencyFocus = FocusNode();
|
||||||
|
|
||||||
// Animation Controller
|
// Animation Controller
|
||||||
AnimationController? animationController;
|
AnimationController? animationController;
|
||||||
Animation<double>? fadeAnimation;
|
Animation<double>? fadeAnimation;
|
||||||
Animation<Offset>? slideAnimation;
|
Animation<Offset>? slideAnimation;
|
||||||
|
|
||||||
// Scroll Controller
|
// Scroll Controller
|
||||||
final ScrollController scrollController = ScrollController();
|
final ScrollController scrollController = ScrollController();
|
||||||
double scrollOffset = 0;
|
double scrollOffset = 0;
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
int currentEditingField = -1;
|
int currentEditingField = -1;
|
||||||
bool isSaveHovered = false;
|
bool isSaveHovered = false;
|
||||||
|
|
||||||
// Gradient Colors
|
// Gradient Colors
|
||||||
final List<Color> gradientColors = [
|
final List<Color> gradientColors = [
|
||||||
const Color(0xFF3B82F6),
|
const Color(0xFF3B82F6),
|
||||||
@@ -71,19 +73,19 @@ class AddSubscriptionController {
|
|||||||
void initialize({required TickerProvider vsync}) {
|
void initialize({required TickerProvider vsync}) {
|
||||||
// 결제일 기본값을 오늘 날짜로 설정
|
// 결제일 기본값을 오늘 날짜로 설정
|
||||||
nextBillingDate = DateTime.now();
|
nextBillingDate = DateTime.now();
|
||||||
|
|
||||||
// 서비스명 컨트롤러에 리스너 추가
|
// 서비스명 컨트롤러에 리스너 추가
|
||||||
serviceNameController.addListener(onServiceNameChanged);
|
serviceNameController.addListener(onServiceNameChanged);
|
||||||
|
|
||||||
// 웹사이트 URL 컨트롤러에 리스너 추가
|
// 웹사이트 URL 컨트롤러에 리스너 추가
|
||||||
websiteUrlController.addListener(onWebsiteUrlChanged);
|
websiteUrlController.addListener(onWebsiteUrlChanged);
|
||||||
|
|
||||||
// 애니메이션 컨트롤러 초기화
|
// 애니메이션 컨트롤러 초기화
|
||||||
animationController = AnimationController(
|
animationController = AnimationController(
|
||||||
vsync: vsync,
|
vsync: vsync,
|
||||||
duration: const Duration(milliseconds: 800),
|
duration: const Duration(milliseconds: 800),
|
||||||
);
|
);
|
||||||
|
|
||||||
fadeAnimation = Tween<double>(
|
fadeAnimation = Tween<double>(
|
||||||
begin: 0.0,
|
begin: 0.0,
|
||||||
end: 1.0,
|
end: 1.0,
|
||||||
@@ -91,7 +93,7 @@ class AddSubscriptionController {
|
|||||||
parent: animationController!,
|
parent: animationController!,
|
||||||
curve: Curves.easeIn,
|
curve: Curves.easeIn,
|
||||||
));
|
));
|
||||||
|
|
||||||
slideAnimation = Tween<Offset>(
|
slideAnimation = Tween<Offset>(
|
||||||
begin: const Offset(0.0, 0.2),
|
begin: const Offset(0.0, 0.2),
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
@@ -99,12 +101,32 @@ class AddSubscriptionController {
|
|||||||
parent: animationController!,
|
parent: animationController!,
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
));
|
));
|
||||||
|
|
||||||
// 스크롤 리스너
|
// 스크롤 리스너
|
||||||
scrollController.addListener(() {
|
scrollController.addListener(() {
|
||||||
scrollOffset = scrollController.offset;
|
scrollOffset = scrollController.offset;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 언어별 기본 통화 설정
|
||||||
|
try {
|
||||||
|
final lang = Localizations.localeOf(context).languageCode;
|
||||||
|
switch (lang) {
|
||||||
|
case 'ko':
|
||||||
|
currency = 'KRW';
|
||||||
|
break;
|
||||||
|
case 'ja':
|
||||||
|
currency = 'JPY';
|
||||||
|
break;
|
||||||
|
case 'zh':
|
||||||
|
currency = 'CNY';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
currency = 'USD';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Localizations가 아직 준비되지 않은 경우 기본값 유지
|
||||||
|
}
|
||||||
|
|
||||||
// 애니메이션 시작
|
// 애니메이션 시작
|
||||||
animationController!.forward();
|
animationController!.forward();
|
||||||
}
|
}
|
||||||
@@ -117,7 +139,7 @@ class AddSubscriptionController {
|
|||||||
nextBillingDateController.dispose();
|
nextBillingDateController.dispose();
|
||||||
websiteUrlController.dispose();
|
websiteUrlController.dispose();
|
||||||
eventPriceController.dispose();
|
eventPriceController.dispose();
|
||||||
|
|
||||||
// Focus Nodes
|
// Focus Nodes
|
||||||
serviceNameFocus.dispose();
|
serviceNameFocus.dispose();
|
||||||
monthlyCostFocus.dispose();
|
monthlyCostFocus.dispose();
|
||||||
@@ -126,10 +148,10 @@ class AddSubscriptionController {
|
|||||||
websiteUrlFocus.dispose();
|
websiteUrlFocus.dispose();
|
||||||
categoryFocus.dispose();
|
categoryFocus.dispose();
|
||||||
currencyFocus.dispose();
|
currencyFocus.dispose();
|
||||||
|
|
||||||
// Animation
|
// Animation
|
||||||
animationController?.dispose();
|
animationController?.dispose();
|
||||||
|
|
||||||
// Scroll
|
// Scroll
|
||||||
scrollController.dispose();
|
scrollController.dispose();
|
||||||
}
|
}
|
||||||
@@ -138,48 +160,52 @@ class AddSubscriptionController {
|
|||||||
void onServiceNameChanged() {
|
void onServiceNameChanged() {
|
||||||
autoSelectCategory();
|
autoSelectCategory();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 웹사이트 URL 변경시 호출
|
/// 웹사이트 URL 변경시 호출
|
||||||
void onWebsiteUrlChanged() async {
|
void onWebsiteUrlChanged() async {
|
||||||
final url = websiteUrlController.text.trim();
|
final url = websiteUrlController.text.trim();
|
||||||
|
|
||||||
// URL이 비어있거나 너무 짧으면 무시
|
// URL이 비어있거나 너무 짧으면 무시
|
||||||
if (url.isEmpty || url.length < 5) return;
|
if (url.isEmpty || url.length < 5) return;
|
||||||
|
|
||||||
// 이미 서비스명이 입력되어 있으면 자동 매칭하지 않음
|
// 이미 서비스명이 입력되어 있으면 자동 매칭하지 않음
|
||||||
if (serviceNameController.text.isNotEmpty) return;
|
if (serviceNameController.text.isNotEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// URL로 서비스 정보 찾기
|
// URL로 서비스 정보 찾기
|
||||||
final serviceInfo = await SubscriptionUrlMatcher.findServiceByUrl(url);
|
final serviceInfo = await SubscriptionUrlMatcher.findServiceByUrl(url);
|
||||||
|
|
||||||
if (serviceInfo != null && context.mounted) {
|
if (serviceInfo != null && context.mounted) {
|
||||||
// 서비스명 자동 입력
|
// 서비스명 자동 입력
|
||||||
serviceNameController.text = serviceInfo.serviceName;
|
serviceNameController.text = serviceInfo.serviceName;
|
||||||
|
|
||||||
// 카테고리 자동 선택
|
// 카테고리 자동 선택
|
||||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
final categoryProvider =
|
||||||
|
Provider.of<CategoryProvider>(context, listen: false);
|
||||||
final categories = categoryProvider.categories;
|
final categories = categoryProvider.categories;
|
||||||
|
|
||||||
// 카테고리 ID로 매칭
|
// 카테고리 ID로 매칭
|
||||||
final matchedCategory = categories.firstWhere(
|
final matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == serviceInfo.categoryNameKr ||
|
(cat) =>
|
||||||
cat.name == serviceInfo.categoryNameEn,
|
cat.name == serviceInfo.categoryNameKr ||
|
||||||
|
cat.name == serviceInfo.categoryNameEn,
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
selectedCategoryId = matchedCategory.id;
|
selectedCategoryId = matchedCategory.id;
|
||||||
|
|
||||||
// 스낵바로 알림
|
// 스낵바로 알림
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AppSnackBar.showSuccess(
|
AppSnackBar.showSuccess(
|
||||||
context: context,
|
context: context,
|
||||||
message: AppLocalizations.of(context).serviceRecognized(serviceInfo.serviceName),
|
message: AppLocalizations.of(context)
|
||||||
|
.serviceRecognized(serviceInfo.serviceName),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
|
// ignore: avoid_print
|
||||||
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
|
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,17 +213,18 @@ class AddSubscriptionController {
|
|||||||
|
|
||||||
/// 카테고리 자동 선택
|
/// 카테고리 자동 선택
|
||||||
void autoSelectCategory() {
|
void autoSelectCategory() {
|
||||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
final categoryProvider =
|
||||||
|
Provider.of<CategoryProvider>(context, listen: false);
|
||||||
final categories = categoryProvider.categories;
|
final categories = categoryProvider.categories;
|
||||||
|
|
||||||
final serviceName = serviceNameController.text.toLowerCase();
|
final serviceName = serviceNameController.text.toLowerCase();
|
||||||
|
|
||||||
// 서비스명에 기반한 카테고리 매칭 로직
|
// 서비스명에 기반한 카테고리 매칭 로직
|
||||||
dynamic matchedCategory;
|
dynamic matchedCategory;
|
||||||
|
|
||||||
// 엔터테인먼트 관련 키워드
|
// 엔터테인먼트 관련 키워드
|
||||||
if (serviceName.contains('netflix') ||
|
if (serviceName.contains('netflix') ||
|
||||||
serviceName.contains('youtube') ||
|
serviceName.contains('youtube') ||
|
||||||
serviceName.contains('disney') ||
|
serviceName.contains('disney') ||
|
||||||
serviceName.contains('왓챠') ||
|
serviceName.contains('왓챠') ||
|
||||||
serviceName.contains('티빙') ||
|
serviceName.contains('티빙') ||
|
||||||
@@ -210,64 +237,64 @@ class AddSubscriptionController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 음악 관련 키워드
|
// 음악 관련 키워드
|
||||||
else if (serviceName.contains('spotify') ||
|
else if (serviceName.contains('spotify') ||
|
||||||
serviceName.contains('apple music') ||
|
serviceName.contains('apple music') ||
|
||||||
serviceName.contains('멜론') ||
|
serviceName.contains('멜론') ||
|
||||||
serviceName.contains('지니') ||
|
serviceName.contains('지니') ||
|
||||||
serviceName.contains('플로') ||
|
serviceName.contains('플로') ||
|
||||||
serviceName.contains('벅스')) {
|
serviceName.contains('벅스')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == 'music',
|
(cat) => cat.name == 'music',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 생산성 관련 키워드
|
// 생산성 관련 키워드
|
||||||
else if (serviceName.contains('notion') ||
|
else if (serviceName.contains('notion') ||
|
||||||
serviceName.contains('microsoft') ||
|
serviceName.contains('microsoft') ||
|
||||||
serviceName.contains('office') ||
|
serviceName.contains('office') ||
|
||||||
serviceName.contains('google') ||
|
serviceName.contains('google') ||
|
||||||
serviceName.contains('dropbox') ||
|
serviceName.contains('dropbox') ||
|
||||||
serviceName.contains('icloud') ||
|
serviceName.contains('icloud') ||
|
||||||
serviceName.contains('adobe')) {
|
serviceName.contains('adobe')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '생산성',
|
(cat) => cat.name == '생산성',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 게임 관련 키워드
|
// 게임 관련 키워드
|
||||||
else if (serviceName.contains('xbox') ||
|
else if (serviceName.contains('xbox') ||
|
||||||
serviceName.contains('playstation') ||
|
serviceName.contains('playstation') ||
|
||||||
serviceName.contains('nintendo') ||
|
serviceName.contains('nintendo') ||
|
||||||
serviceName.contains('steam') ||
|
serviceName.contains('steam') ||
|
||||||
serviceName.contains('게임')) {
|
serviceName.contains('게임')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '게임',
|
(cat) => cat.name == '게임',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 교육 관련 키워드
|
// 교육 관련 키워드
|
||||||
else if (serviceName.contains('coursera') ||
|
else if (serviceName.contains('coursera') ||
|
||||||
serviceName.contains('udemy') ||
|
serviceName.contains('udemy') ||
|
||||||
serviceName.contains('인프런') ||
|
serviceName.contains('인프런') ||
|
||||||
serviceName.contains('패스트캠퍼스') ||
|
serviceName.contains('패스트캠퍼스') ||
|
||||||
serviceName.contains('클래스101')) {
|
serviceName.contains('클래스101')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '교육',
|
(cat) => cat.name == '교육',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 쇼핑 관련 키워드
|
// 쇼핑 관련 키워드
|
||||||
else if (serviceName.contains('쿠팡') ||
|
else if (serviceName.contains('쿠팡') ||
|
||||||
serviceName.contains('coupang') ||
|
serviceName.contains('coupang') ||
|
||||||
serviceName.contains('amazon') ||
|
serviceName.contains('amazon') ||
|
||||||
serviceName.contains('네이버') ||
|
serviceName.contains('네이버') ||
|
||||||
serviceName.contains('11번가')) {
|
serviceName.contains('11번가')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '쇼핑',
|
(cat) => cat.name == '쇼핑',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedCategory != null) {
|
if (matchedCategory != null) {
|
||||||
selectedCategoryId = matchedCategory.id;
|
selectedCategoryId = matchedCategory.id;
|
||||||
}
|
}
|
||||||
@@ -276,75 +303,109 @@ class AddSubscriptionController {
|
|||||||
/// SMS 스캔
|
/// SMS 스캔
|
||||||
Future<void> scanSMS({required Function setState}) async {
|
Future<void> scanSMS({required Function setState}) async {
|
||||||
if (kIsWeb) return;
|
if (kIsWeb) return;
|
||||||
|
|
||||||
setState(() => isLoading = true);
|
setState(() => isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final ctx = context;
|
||||||
if (!await SMSService.hasSMSPermission()) {
|
if (!await SMSService.hasSMSPermission()) {
|
||||||
final granted = await SMSService.requestSMSPermission();
|
final granted = await SMSService.requestSMSPermission();
|
||||||
|
if (!ctx.mounted) return;
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
if (context.mounted) {
|
if (ctx.mounted) {
|
||||||
AppSnackBar.showError(
|
// 영구 거부 여부 확인 후 설정 화면 안내
|
||||||
context: context,
|
final status = await permission.Permission.sms.status;
|
||||||
message: AppLocalizations.of(context).smsPermissionRequired,
|
if (!ctx.mounted) return;
|
||||||
);
|
if (status.isPermanentlyDenied) {
|
||||||
|
await showDialog(
|
||||||
|
context: ctx,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: Text(AppLocalizations.of(ctx).smsPermissionRequired),
|
||||||
|
content:
|
||||||
|
Text(AppLocalizations.of(ctx).permanentlyDeniedMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: Text(AppLocalizations.of(ctx).cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await permission.openAppSettings();
|
||||||
|
if (ctx.mounted) Navigator.of(ctx).pop();
|
||||||
|
},
|
||||||
|
child: Text(AppLocalizations.of(ctx).openSettings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
AppSnackBar.showError(
|
||||||
|
context: ctx,
|
||||||
|
message: AppLocalizations.of(ctx).smsPermissionRequired,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final subscriptions = await SMSService.scanSubscriptions();
|
final subscriptions = await SMSService.scanSubscriptions();
|
||||||
|
if (!ctx.mounted) return;
|
||||||
if (subscriptions.isEmpty) {
|
if (subscriptions.isEmpty) {
|
||||||
if (context.mounted) {
|
if (ctx.mounted) {
|
||||||
AppSnackBar.showWarning(
|
AppSnackBar.showWarning(
|
||||||
context: context,
|
context: ctx,
|
||||||
message: AppLocalizations.of(context).noSubscriptionSmsFound,
|
message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final subscription = subscriptions.first;
|
final subscription = subscriptions.first;
|
||||||
|
|
||||||
// SMS에서 서비스 정보 추출 시도
|
// SMS에서 서비스 정보 추출 시도
|
||||||
ServiceInfo? serviceInfo;
|
ServiceInfo? serviceInfo;
|
||||||
final smsContent = subscription['smsContent'] ?? '';
|
final smsContent = subscription['smsContent'] ?? '';
|
||||||
|
|
||||||
if (smsContent.isNotEmpty) {
|
if (smsContent.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
|
serviceInfo =
|
||||||
|
await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
|
// ignore: avoid_print
|
||||||
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
|
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
// 서비스 정보가 있으면 우선 사용, 없으면 SMS에서 추출한 정보 사용
|
// 서비스 정보가 있으면 우선 사용, 없으면 SMS에서 추출한 정보 사용
|
||||||
if (serviceInfo != null) {
|
if (serviceInfo != null) {
|
||||||
serviceNameController.text = serviceInfo.serviceName;
|
serviceNameController.text = serviceInfo.serviceName;
|
||||||
websiteUrlController.text = serviceInfo.serviceUrl ?? '';
|
websiteUrlController.text = serviceInfo.serviceUrl ?? '';
|
||||||
|
|
||||||
// 카테고리 자동 선택
|
// 카테고리 자동 선택
|
||||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
final categoryProvider =
|
||||||
|
Provider.of<CategoryProvider>(context, listen: false);
|
||||||
final categories = categoryProvider.categories;
|
final categories = categoryProvider.categories;
|
||||||
|
|
||||||
final matchedCategory = categories.firstWhere(
|
final matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == serviceInfo!.categoryNameKr ||
|
(cat) =>
|
||||||
cat.name == serviceInfo.categoryNameEn,
|
cat.name == serviceInfo!.categoryNameKr ||
|
||||||
|
cat.name == serviceInfo.categoryNameEn,
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
selectedCategoryId = matchedCategory.id;
|
selectedCategoryId = matchedCategory.id;
|
||||||
} else {
|
} else {
|
||||||
// 기존 로직 사용
|
// 기존 로직 사용
|
||||||
serviceNameController.text = subscription['serviceName'] ?? '';
|
serviceNameController.text = subscription['serviceName'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비용 처리 및 통화 단위 자동 감지
|
// 비용 처리 및 통화 단위 자동 감지
|
||||||
final costValue = subscription['monthlyCost']?.toString() ?? '';
|
final costValue = subscription['monthlyCost']?.toString() ?? '';
|
||||||
|
|
||||||
if (costValue.isNotEmpty) {
|
if (costValue.isNotEmpty) {
|
||||||
// 달러 표시가 있거나 소수점이 있으면 달러로 판단
|
// 달러 표시가 있거나 소수점이 있으면 달러로 판단
|
||||||
if (costValue.contains('\$') || costValue.contains('.')) {
|
if (costValue.contains('\$') || costValue.contains('.')) {
|
||||||
@@ -353,41 +414,41 @@ class AddSubscriptionController {
|
|||||||
if (!numericValue.contains('.')) {
|
if (!numericValue.contains('.')) {
|
||||||
numericValue = '$numericValue.00';
|
numericValue = '$numericValue.00';
|
||||||
}
|
}
|
||||||
final double parsedValue =
|
final double parsedValue =
|
||||||
double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0;
|
double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0;
|
||||||
monthlyCostController.text =
|
monthlyCostController.text =
|
||||||
NumberFormat('#,##0.00').format(parsedValue);
|
NumberFormat('#,##0.00').format(parsedValue);
|
||||||
} else {
|
} else {
|
||||||
currency = 'KRW';
|
currency = 'KRW';
|
||||||
String numericValue =
|
String numericValue =
|
||||||
costValue.replaceAll('₩', '').replaceAll(',', '').trim();
|
costValue.replaceAll('₩', '').replaceAll(',', '').trim();
|
||||||
final int parsedValue = int.tryParse(numericValue) ?? 0;
|
final int parsedValue = int.tryParse(numericValue) ?? 0;
|
||||||
monthlyCostController.text =
|
monthlyCostController.text =
|
||||||
NumberFormat.decimalPattern().format(parsedValue);
|
NumberFormat.decimalPattern().format(parsedValue);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
monthlyCostController.text = '';
|
monthlyCostController.text = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
billingCycle = subscription['billingCycle'] ?? '월간';
|
billingCycle = subscription['billingCycle'] ?? '월간';
|
||||||
nextBillingDate = subscription['nextBillingDate'] != null
|
nextBillingDate = subscription['nextBillingDate'] != null
|
||||||
? DateTime.parse(subscription['nextBillingDate'])
|
? DateTime.parse(subscription['nextBillingDate'])
|
||||||
: DateTime.now();
|
: DateTime.now();
|
||||||
|
|
||||||
// 서비스 정보가 없고 서비스명이 있으면 URL 자동 매칭 시도
|
// 서비스 정보가 없고 서비스명이 있으면 URL 자동 매칭 시도
|
||||||
if (serviceInfo == null &&
|
if (serviceInfo == null &&
|
||||||
subscription['serviceName'] != null &&
|
subscription['serviceName'] != null &&
|
||||||
subscription['serviceName'].isNotEmpty) {
|
subscription['serviceName'].isNotEmpty) {
|
||||||
final suggestedUrl =
|
final suggestedUrl =
|
||||||
SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']);
|
SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']);
|
||||||
if (suggestedUrl != null && websiteUrlController.text.isEmpty) {
|
if (suggestedUrl != null && websiteUrlController.text.isEmpty) {
|
||||||
websiteUrlController.text = suggestedUrl;
|
websiteUrlController.text = suggestedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서비스명 기반으로 카테고리 자동 선택
|
// 서비스명 기반으로 카테고리 자동 선택
|
||||||
autoSelectCategory();
|
autoSelectCategory();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 애니메이션 재생
|
// 애니메이션 재생
|
||||||
animationController!.reset();
|
animationController!.reset();
|
||||||
animationController!.forward();
|
animationController!.forward();
|
||||||
@@ -396,7 +457,8 @@ class AddSubscriptionController {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AppSnackBar.showError(
|
AppSnackBar.showError(
|
||||||
context: context,
|
context: context,
|
||||||
message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()),
|
message: AppLocalizations.of(context)
|
||||||
|
.smsScanErrorWithMessage(e.toString()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -412,26 +474,35 @@ class AddSubscriptionController {
|
|||||||
setState(() {
|
setState(() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 콤마 제거하고 숫자만 추출
|
// 콤마 제거하고 숫자만 추출
|
||||||
final monthlyCost =
|
final monthlyCost =
|
||||||
double.parse(monthlyCostController.text.replaceAll(',', ''));
|
double.parse(monthlyCostController.text.replaceAll(',', ''));
|
||||||
|
|
||||||
// 이벤트 가격 파싱
|
// 이벤트 가격 파싱
|
||||||
double? eventPrice;
|
double? eventPrice;
|
||||||
if (isEventActive && eventPriceController.text.isNotEmpty) {
|
if (isEventActive && eventPriceController.text.isNotEmpty) {
|
||||||
eventPrice = double.tryParse(
|
eventPrice =
|
||||||
eventPriceController.text.replaceAll(',', '')
|
double.tryParse(eventPriceController.text.replaceAll(',', ''));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
|
||||||
|
final originalDateOnly = DateTime(
|
||||||
|
nextBillingDate!.year,
|
||||||
|
nextBillingDate!.month,
|
||||||
|
nextBillingDate!.day,
|
||||||
|
);
|
||||||
|
var adjustedNext =
|
||||||
|
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
|
||||||
|
adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext);
|
||||||
|
|
||||||
await Provider.of<SubscriptionProvider>(context, listen: false)
|
await Provider.of<SubscriptionProvider>(context, listen: false)
|
||||||
.addSubscription(
|
.addSubscription(
|
||||||
serviceName: serviceNameController.text.trim(),
|
serviceName: serviceNameController.text.trim(),
|
||||||
monthlyCost: monthlyCost,
|
monthlyCost: monthlyCost,
|
||||||
billingCycle: billingCycle,
|
billingCycle: billingCycle,
|
||||||
nextBillingDate: nextBillingDate!,
|
nextBillingDate: adjustedNext,
|
||||||
websiteUrl: websiteUrlController.text.trim(),
|
websiteUrl: websiteUrlController.text.trim(),
|
||||||
categoryId: selectedCategoryId,
|
categoryId: selectedCategoryId,
|
||||||
currency: currency,
|
currency: currency,
|
||||||
@@ -440,7 +511,17 @@ class AddSubscriptionController {
|
|||||||
eventEndDate: eventEndDate,
|
eventEndDate: eventEndDate,
|
||||||
eventPrice: eventPrice,
|
eventPrice: eventPrice,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 자동 보정이 발생했으면 안내
|
||||||
|
if (adjustedNext.isAfter(originalDateOnly)) {
|
||||||
|
if (context.mounted) {
|
||||||
|
AppSnackBar.showInfo(
|
||||||
|
context: context,
|
||||||
|
message: '다음 결제 예정일로 저장됨',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context, true); // 성공 여부 반환
|
Navigator.pop(context, true); // 성공 여부 반환
|
||||||
}
|
}
|
||||||
@@ -448,11 +529,12 @@ class AddSubscriptionController {
|
|||||||
setState(() {
|
setState(() {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AppSnackBar.showError(
|
AppSnackBar.showError(
|
||||||
context: context,
|
context: context,
|
||||||
message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
|
message:
|
||||||
|
AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,4 +546,4 @@ class AddSubscriptionController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ import '../l10n/app_localizations.dart';
|
|||||||
class DetailScreenController extends ChangeNotifier {
|
class DetailScreenController extends ChangeNotifier {
|
||||||
final BuildContext context;
|
final BuildContext context;
|
||||||
final SubscriptionModel subscription;
|
final SubscriptionModel subscription;
|
||||||
|
|
||||||
// Text Controllers
|
// Text Controllers
|
||||||
late TextEditingController serviceNameController;
|
late TextEditingController serviceNameController;
|
||||||
late TextEditingController monthlyCostController;
|
late TextEditingController monthlyCostController;
|
||||||
late TextEditingController websiteUrlController;
|
late TextEditingController websiteUrlController;
|
||||||
late TextEditingController eventPriceController;
|
late TextEditingController eventPriceController;
|
||||||
|
|
||||||
// Display Names
|
// Display Names
|
||||||
String? _displayName;
|
String? _displayName;
|
||||||
String? get displayName => _displayName;
|
String? get displayName => _displayName;
|
||||||
|
|
||||||
// Form State
|
// Form State
|
||||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
late String _billingCycle;
|
late String _billingCycle;
|
||||||
@@ -35,12 +35,12 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
String? _selectedCategoryId;
|
String? _selectedCategoryId;
|
||||||
late String _currency;
|
late String _currency;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
// Event State
|
// Event State
|
||||||
late bool _isEventActive;
|
late bool _isEventActive;
|
||||||
DateTime? _eventStartDate;
|
DateTime? _eventStartDate;
|
||||||
DateTime? _eventEndDate;
|
DateTime? _eventEndDate;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
String get billingCycle => _billingCycle;
|
String get billingCycle => _billingCycle;
|
||||||
DateTime get nextBillingDate => _nextBillingDate;
|
DateTime get nextBillingDate => _nextBillingDate;
|
||||||
@@ -50,7 +50,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
bool get isEventActive => _isEventActive;
|
bool get isEventActive => _isEventActive;
|
||||||
DateTime? get eventStartDate => _eventStartDate;
|
DateTime? get eventStartDate => _eventStartDate;
|
||||||
DateTime? get eventEndDate => _eventEndDate;
|
DateTime? get eventEndDate => _eventEndDate;
|
||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
set billingCycle(String value) {
|
set billingCycle(String value) {
|
||||||
if (_billingCycle != value) {
|
if (_billingCycle != value) {
|
||||||
@@ -58,21 +58,21 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set nextBillingDate(DateTime value) {
|
set nextBillingDate(DateTime value) {
|
||||||
if (_nextBillingDate != value) {
|
if (_nextBillingDate != value) {
|
||||||
_nextBillingDate = value;
|
_nextBillingDate = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set selectedCategoryId(String? value) {
|
set selectedCategoryId(String? value) {
|
||||||
if (_selectedCategoryId != value) {
|
if (_selectedCategoryId != value) {
|
||||||
_selectedCategoryId = value;
|
_selectedCategoryId = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set currency(String value) {
|
set currency(String value) {
|
||||||
if (_currency != value) {
|
if (_currency != value) {
|
||||||
_currency = value;
|
_currency = value;
|
||||||
@@ -80,35 +80,35 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set isLoading(bool value) {
|
set isLoading(bool value) {
|
||||||
if (_isLoading != value) {
|
if (_isLoading != value) {
|
||||||
_isLoading = value;
|
_isLoading = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set isEventActive(bool value) {
|
set isEventActive(bool value) {
|
||||||
if (_isEventActive != value) {
|
if (_isEventActive != value) {
|
||||||
_isEventActive = value;
|
_isEventActive = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set eventStartDate(DateTime? value) {
|
set eventStartDate(DateTime? value) {
|
||||||
if (_eventStartDate != value) {
|
if (_eventStartDate != value) {
|
||||||
_eventStartDate = value;
|
_eventStartDate = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set eventEndDate(DateTime? value) {
|
set eventEndDate(DateTime? value) {
|
||||||
if (_eventEndDate != value) {
|
if (_eventEndDate != value) {
|
||||||
_eventEndDate = value;
|
_eventEndDate = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus Nodes
|
// Focus Nodes
|
||||||
final serviceNameFocus = FocusNode();
|
final serviceNameFocus = FocusNode();
|
||||||
final monthlyCostFocus = FocusNode();
|
final monthlyCostFocus = FocusNode();
|
||||||
@@ -117,7 +117,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
final websiteUrlFocus = FocusNode();
|
final websiteUrlFocus = FocusNode();
|
||||||
final categoryFocus = FocusNode();
|
final categoryFocus = FocusNode();
|
||||||
final currencyFocus = FocusNode();
|
final currencyFocus = FocusNode();
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
final ScrollController scrollController = ScrollController();
|
final ScrollController scrollController = ScrollController();
|
||||||
double scrollOffset = 0;
|
double scrollOffset = 0;
|
||||||
@@ -125,7 +125,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
bool isDeleteHovered = false;
|
bool isDeleteHovered = false;
|
||||||
bool isSaveHovered = false;
|
bool isSaveHovered = false;
|
||||||
bool isCancelHovered = false;
|
bool isCancelHovered = false;
|
||||||
|
|
||||||
// Animation Controller
|
// Animation Controller
|
||||||
AnimationController? animationController;
|
AnimationController? animationController;
|
||||||
Animation<double>? fadeAnimation;
|
Animation<double>? fadeAnimation;
|
||||||
@@ -140,42 +140,45 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
/// 초기화
|
/// 초기화
|
||||||
void initialize({required TickerProvider vsync}) {
|
void initialize({required TickerProvider vsync}) {
|
||||||
// Text Controllers 초기화
|
// Text Controllers 초기화
|
||||||
serviceNameController = TextEditingController(text: subscription.serviceName);
|
serviceNameController =
|
||||||
monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString());
|
TextEditingController(text: subscription.serviceName);
|
||||||
websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? '');
|
monthlyCostController =
|
||||||
|
TextEditingController(text: subscription.monthlyCost.toString());
|
||||||
|
websiteUrlController =
|
||||||
|
TextEditingController(text: subscription.websiteUrl ?? '');
|
||||||
eventPriceController = TextEditingController();
|
eventPriceController = TextEditingController();
|
||||||
|
|
||||||
// Form State 초기화
|
// Form State 초기화
|
||||||
_billingCycle = subscription.billingCycle;
|
_billingCycle = subscription.billingCycle;
|
||||||
_nextBillingDate = subscription.nextBillingDate;
|
_nextBillingDate = subscription.nextBillingDate;
|
||||||
_selectedCategoryId = subscription.categoryId;
|
_selectedCategoryId = subscription.categoryId;
|
||||||
_currency = subscription.currency;
|
_currency = subscription.currency;
|
||||||
|
|
||||||
// Event State 초기화
|
// Event State 초기화
|
||||||
_isEventActive = subscription.isEventActive;
|
_isEventActive = subscription.isEventActive;
|
||||||
_eventStartDate = subscription.eventStartDate;
|
_eventStartDate = subscription.eventStartDate;
|
||||||
_eventEndDate = subscription.eventEndDate;
|
_eventEndDate = subscription.eventEndDate;
|
||||||
|
|
||||||
// 이벤트 가격 초기화
|
// 이벤트 가격 초기화
|
||||||
if (subscription.eventPrice != null) {
|
if (subscription.eventPrice != null) {
|
||||||
if (currency == 'KRW') {
|
if (currency == 'KRW') {
|
||||||
eventPriceController.text = NumberFormat.decimalPattern()
|
eventPriceController.text = NumberFormat.decimalPattern()
|
||||||
.format(subscription.eventPrice!.toInt());
|
.format(subscription.eventPrice!.toInt());
|
||||||
} else {
|
} else {
|
||||||
eventPriceController.text =
|
eventPriceController.text =
|
||||||
NumberFormat('#,##0.00').format(subscription.eventPrice!);
|
NumberFormat('#,##0.00').format(subscription.eventPrice!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 통화 단위에 따른 금액 표시 형식 조정
|
// 통화 단위에 따른 금액 표시 형식 조정
|
||||||
_updateMonthlyCostFormat();
|
_updateMonthlyCostFormat();
|
||||||
|
|
||||||
// 애니메이션 초기화
|
// 애니메이션 초기화
|
||||||
animationController = AnimationController(
|
animationController = AnimationController(
|
||||||
duration: const Duration(milliseconds: 800),
|
duration: const Duration(milliseconds: 800),
|
||||||
vsync: vsync,
|
vsync: vsync,
|
||||||
);
|
);
|
||||||
|
|
||||||
fadeAnimation = Tween<double>(
|
fadeAnimation = Tween<double>(
|
||||||
begin: 0.0,
|
begin: 0.0,
|
||||||
end: 1.0,
|
end: 1.0,
|
||||||
@@ -183,7 +186,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
parent: animationController!,
|
parent: animationController!,
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
));
|
));
|
||||||
|
|
||||||
slideAnimation = Tween<Offset>(
|
slideAnimation = Tween<Offset>(
|
||||||
begin: const Offset(0.0, 0.3),
|
begin: const Offset(0.0, 0.3),
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
@@ -191,7 +194,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
parent: animationController!,
|
parent: animationController!,
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
));
|
));
|
||||||
|
|
||||||
rotateAnimation = Tween<double>(
|
rotateAnimation = Tween<double>(
|
||||||
begin: 0.0,
|
begin: 0.0,
|
||||||
end: 1.0,
|
end: 1.0,
|
||||||
@@ -199,16 +202,16 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
parent: animationController!,
|
parent: animationController!,
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
));
|
));
|
||||||
|
|
||||||
// 애니메이션 시작
|
// 애니메이션 시작
|
||||||
animationController!.forward();
|
animationController!.forward();
|
||||||
|
|
||||||
// 로케일에 맞는 서비스명 로드
|
// 로케일에 맞는 서비스명 로드
|
||||||
_loadDisplayName();
|
_loadDisplayName();
|
||||||
|
|
||||||
// 서비스명 변경 감지 리스너
|
// 서비스명 변경 감지 리스너
|
||||||
serviceNameController.addListener(onServiceNameChanged);
|
serviceNameController.addListener(onServiceNameChanged);
|
||||||
|
|
||||||
// 스크롤 리스너
|
// 스크롤 리스너
|
||||||
scrollController.addListener(() {
|
scrollController.addListener(() {
|
||||||
scrollOffset = scrollController.offset;
|
scrollOffset = scrollController.offset;
|
||||||
@@ -219,16 +222,16 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
Future<void> _loadDisplayName() async {
|
Future<void> _loadDisplayName() async {
|
||||||
final localeProvider = context.read<LocaleProvider>();
|
final localeProvider = context.read<LocaleProvider>();
|
||||||
final locale = localeProvider.locale.languageCode;
|
final locale = localeProvider.locale.languageCode;
|
||||||
|
|
||||||
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
||||||
serviceName: subscription.serviceName,
|
serviceName: subscription.serviceName,
|
||||||
locale: locale,
|
locale: locale,
|
||||||
);
|
);
|
||||||
|
|
||||||
_displayName = displayName;
|
_displayName = displayName;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 리소스 정리
|
/// 리소스 정리
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -237,7 +240,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
monthlyCostController.dispose();
|
monthlyCostController.dispose();
|
||||||
websiteUrlController.dispose();
|
websiteUrlController.dispose();
|
||||||
eventPriceController.dispose();
|
eventPriceController.dispose();
|
||||||
|
|
||||||
// Focus Nodes
|
// Focus Nodes
|
||||||
serviceNameFocus.dispose();
|
serviceNameFocus.dispose();
|
||||||
monthlyCostFocus.dispose();
|
monthlyCostFocus.dispose();
|
||||||
@@ -246,13 +249,13 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
websiteUrlFocus.dispose();
|
websiteUrlFocus.dispose();
|
||||||
categoryFocus.dispose();
|
categoryFocus.dispose();
|
||||||
currencyFocus.dispose();
|
currencyFocus.dispose();
|
||||||
|
|
||||||
// Animation
|
// Animation
|
||||||
animationController?.dispose();
|
animationController?.dispose();
|
||||||
|
|
||||||
// Scroll
|
// Scroll
|
||||||
scrollController.dispose();
|
scrollController.dispose();
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,10 +264,12 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
if (_currency == 'KRW') {
|
if (_currency == 'KRW') {
|
||||||
// 원화는 소수점 없이 표시
|
// 원화는 소수점 없이 표시
|
||||||
final intValue = subscription.monthlyCost.toInt();
|
final intValue = subscription.monthlyCost.toInt();
|
||||||
monthlyCostController.text = NumberFormat.decimalPattern().format(intValue);
|
monthlyCostController.text =
|
||||||
|
NumberFormat.decimalPattern().format(intValue);
|
||||||
} else {
|
} else {
|
||||||
// 달러는 소수점 2자리까지 표시
|
// 달러는 소수점 2자리까지 표시
|
||||||
monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost);
|
monthlyCostController.text =
|
||||||
|
NumberFormat('#,##0.00').format(subscription.monthlyCost);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,17 +280,18 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 카테고리 자동 선택
|
/// 카테고리 자동 선택
|
||||||
void autoSelectCategory() {
|
void autoSelectCategory() {
|
||||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
final categoryProvider =
|
||||||
|
Provider.of<CategoryProvider>(context, listen: false);
|
||||||
final categories = categoryProvider.categories;
|
final categories = categoryProvider.categories;
|
||||||
|
|
||||||
final serviceName = serviceNameController.text.toLowerCase();
|
final serviceName = serviceNameController.text.toLowerCase();
|
||||||
|
|
||||||
// 서비스명에 기반한 카테고리 매칭 로직
|
// 서비스명에 기반한 카테고리 매칭 로직
|
||||||
CategoryModel? matchedCategory;
|
CategoryModel? matchedCategory;
|
||||||
|
|
||||||
// 엔터테인먼트 관련 키워드
|
// 엔터테인먼트 관련 키워드
|
||||||
if (serviceName.contains('netflix') ||
|
if (serviceName.contains('netflix') ||
|
||||||
serviceName.contains('youtube') ||
|
serviceName.contains('youtube') ||
|
||||||
serviceName.contains('disney') ||
|
serviceName.contains('disney') ||
|
||||||
serviceName.contains('왓챠') ||
|
serviceName.contains('왓챠') ||
|
||||||
serviceName.contains('티빙') ||
|
serviceName.contains('티빙') ||
|
||||||
@@ -298,64 +304,64 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 음악 관련 키워드
|
// 음악 관련 키워드
|
||||||
else if (serviceName.contains('spotify') ||
|
else if (serviceName.contains('spotify') ||
|
||||||
serviceName.contains('apple music') ||
|
serviceName.contains('apple music') ||
|
||||||
serviceName.contains('멜론') ||
|
serviceName.contains('멜론') ||
|
||||||
serviceName.contains('지니') ||
|
serviceName.contains('지니') ||
|
||||||
serviceName.contains('플로') ||
|
serviceName.contains('플로') ||
|
||||||
serviceName.contains('벅스')) {
|
serviceName.contains('벅스')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == 'music',
|
(cat) => cat.name == 'music',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 생산성 관련 키워드
|
// 생산성 관련 키워드
|
||||||
else if (serviceName.contains('notion') ||
|
else if (serviceName.contains('notion') ||
|
||||||
serviceName.contains('microsoft') ||
|
serviceName.contains('microsoft') ||
|
||||||
serviceName.contains('office') ||
|
serviceName.contains('office') ||
|
||||||
serviceName.contains('google') ||
|
serviceName.contains('google') ||
|
||||||
serviceName.contains('dropbox') ||
|
serviceName.contains('dropbox') ||
|
||||||
serviceName.contains('icloud') ||
|
serviceName.contains('icloud') ||
|
||||||
serviceName.contains('adobe')) {
|
serviceName.contains('adobe')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == 'collaborationOffice',
|
(cat) => cat.name == 'collaborationOffice',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// AI 관련 키워드
|
// AI 관련 키워드
|
||||||
else if (serviceName.contains('chatgpt') ||
|
else if (serviceName.contains('chatgpt') ||
|
||||||
serviceName.contains('claude') ||
|
serviceName.contains('claude') ||
|
||||||
serviceName.contains('gemini') ||
|
serviceName.contains('gemini') ||
|
||||||
serviceName.contains('copilot') ||
|
serviceName.contains('copilot') ||
|
||||||
serviceName.contains('midjourney')) {
|
serviceName.contains('midjourney')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == 'aiService',
|
(cat) => cat.name == 'aiService',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 교육 관련 키워드
|
// 교육 관련 키워드
|
||||||
else if (serviceName.contains('coursera') ||
|
else if (serviceName.contains('coursera') ||
|
||||||
serviceName.contains('udemy') ||
|
serviceName.contains('udemy') ||
|
||||||
serviceName.contains('인프런') ||
|
serviceName.contains('인프런') ||
|
||||||
serviceName.contains('패스트캠퍼스') ||
|
serviceName.contains('패스트캠퍼스') ||
|
||||||
serviceName.contains('클래스101')) {
|
serviceName.contains('클래스101')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == 'programming',
|
(cat) => cat.name == 'programming',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 쇼핑 관련 키워드
|
// 쇼핑 관련 키워드
|
||||||
else if (serviceName.contains('쿠팡') ||
|
else if (serviceName.contains('쿠팡') ||
|
||||||
serviceName.contains('coupang') ||
|
serviceName.contains('coupang') ||
|
||||||
serviceName.contains('amazon') ||
|
serviceName.contains('amazon') ||
|
||||||
serviceName.contains('네이버') ||
|
serviceName.contains('네이버') ||
|
||||||
serviceName.contains('11번가')) {
|
serviceName.contains('11번가')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == 'other',
|
(cat) => cat.name == 'other',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedCategory != null) {
|
if (matchedCategory != null) {
|
||||||
selectedCategoryId = matchedCategory.id;
|
selectedCategoryId = matchedCategory.id;
|
||||||
}
|
}
|
||||||
@@ -371,30 +377,32 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||||
|
|
||||||
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
|
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
|
||||||
String? websiteUrl = websiteUrlController.text;
|
String? websiteUrl = websiteUrlController.text;
|
||||||
if (websiteUrl.isEmpty) {
|
if (websiteUrl.isEmpty) {
|
||||||
websiteUrl = SubscriptionUrlMatcher.suggestUrl(serviceNameController.text);
|
websiteUrl =
|
||||||
|
SubscriptionUrlMatcher.suggestUrl(serviceNameController.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 구독 정보 업데이트
|
// 구독 정보 업데이트
|
||||||
|
|
||||||
// 콤마 제거하고 숫자만 추출
|
// 콤마 제거하고 숫자만 추출
|
||||||
double monthlyCost = 0.0;
|
double monthlyCost = 0.0;
|
||||||
try {
|
try {
|
||||||
monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', ''));
|
monthlyCost =
|
||||||
|
double.parse(monthlyCostController.text.replaceAll(',', ''));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 파싱 오류 발생 시 기본값 사용
|
// 파싱 오류 발생 시 기본값 사용
|
||||||
monthlyCost = subscription.monthlyCost;
|
monthlyCost = subscription.monthlyCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
|
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
|
||||||
'${subscription.serviceName} → ${serviceNameController.text}, '
|
'${subscription.serviceName} → ${serviceNameController.text}, '
|
||||||
'금액: ${subscription.monthlyCost} → $monthlyCost ${_currency}');
|
'금액: $subscription.monthlyCost → $monthlyCost $_currency');
|
||||||
|
|
||||||
subscription.serviceName = serviceNameController.text;
|
subscription.serviceName = serviceNameController.text;
|
||||||
subscription.monthlyCost = monthlyCost;
|
subscription.monthlyCost = monthlyCost;
|
||||||
subscription.websiteUrl = websiteUrl;
|
subscription.websiteUrl = websiteUrl;
|
||||||
@@ -402,16 +410,16 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
subscription.nextBillingDate = _nextBillingDate;
|
subscription.nextBillingDate = _nextBillingDate;
|
||||||
subscription.categoryId = _selectedCategoryId;
|
subscription.categoryId = _selectedCategoryId;
|
||||||
subscription.currency = _currency;
|
subscription.currency = _currency;
|
||||||
|
|
||||||
// 이벤트 정보 업데이트
|
// 이벤트 정보 업데이트
|
||||||
subscription.isEventActive = _isEventActive;
|
subscription.isEventActive = _isEventActive;
|
||||||
subscription.eventStartDate = _eventStartDate;
|
subscription.eventStartDate = _eventStartDate;
|
||||||
subscription.eventEndDate = _eventEndDate;
|
subscription.eventEndDate = _eventEndDate;
|
||||||
|
|
||||||
// 이벤트 가격 파싱
|
// 이벤트 가격 파싱
|
||||||
if (_isEventActive && eventPriceController.text.isNotEmpty) {
|
if (_isEventActive && eventPriceController.text.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
subscription.eventPrice =
|
subscription.eventPrice =
|
||||||
double.parse(eventPriceController.text.replaceAll(',', ''));
|
double.parse(eventPriceController.text.replaceAll(',', ''));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
subscription.eventPrice = null;
|
subscription.eventPrice = null;
|
||||||
@@ -419,20 +427,20 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
subscription.eventPrice = null;
|
subscription.eventPrice = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('[DetailScreenController] 업데이트 정보: '
|
debugPrint('[DetailScreenController] 업데이트 정보: '
|
||||||
'현재가격=${subscription.currentPrice}, '
|
'현재가격=${subscription.currentPrice}, '
|
||||||
'이벤트활성=${subscription.isEventActive}');
|
'이벤트활성=${subscription.isEventActive}');
|
||||||
|
|
||||||
// 구독 업데이트
|
// 구독 업데이트
|
||||||
await provider.updateSubscription(subscription);
|
await provider.updateSubscription(subscription);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AppSnackBar.showSuccess(
|
AppSnackBar.showSuccess(
|
||||||
context: context,
|
context: context,
|
||||||
message: AppLocalizations.of(context).subscriptionUpdated,
|
message: AppLocalizations.of(context).subscriptionUpdated,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
|
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -445,30 +453,35 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
Future<void> deleteSubscription() async {
|
Future<void> deleteSubscription() async {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
// 로케일에 맞는 서비스명 가져오기
|
// 로케일에 맞는 서비스명 가져오기
|
||||||
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
|
final localeProvider =
|
||||||
|
Provider.of<LocaleProvider>(context, listen: false);
|
||||||
final locale = localeProvider.locale.languageCode;
|
final locale = localeProvider.locale.languageCode;
|
||||||
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
||||||
serviceName: subscription.serviceName,
|
serviceName: subscription.serviceName,
|
||||||
locale: locale,
|
locale: locale,
|
||||||
);
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
// 삭제 확인 다이얼로그 표시
|
// 삭제 확인 다이얼로그 표시
|
||||||
final shouldDelete = await DeleteConfirmationDialog.show(
|
final shouldDelete = await DeleteConfirmationDialog.show(
|
||||||
context: context,
|
context: context,
|
||||||
serviceName: displayName,
|
serviceName: displayName,
|
||||||
);
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
if (!shouldDelete) return;
|
if (!shouldDelete) return;
|
||||||
|
|
||||||
// 사용자가 확인한 경우에만 삭제 진행
|
// 사용자가 확인한 경우에만 삭제 진행
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
final provider =
|
||||||
|
Provider.of<SubscriptionProvider>(context, listen: false);
|
||||||
await provider.deleteSubscription(subscription.id);
|
await provider.deleteSubscription(subscription.id);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AppSnackBar.showError(
|
AppSnackBar.showError(
|
||||||
context: context,
|
context: context,
|
||||||
message: AppLocalizations.of(context).subscriptionDeleted(displayName),
|
message:
|
||||||
|
AppLocalizations.of(context).subscriptionDeleted(displayName),
|
||||||
icon: Icons.delete_forever_rounded,
|
icon: Icons.delete_forever_rounded,
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -482,19 +495,22 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
// 1. 현재 언어 설정 가져오기
|
// 1. 현재 언어 설정 가져오기
|
||||||
final locale = Localizations.localeOf(context).languageCode;
|
final locale = Localizations.localeOf(context).languageCode;
|
||||||
|
|
||||||
// 2. 해지 안내 URL 찾기
|
// 2. 해지 안내 URL 찾기
|
||||||
String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl(
|
String? cancellationUrl =
|
||||||
|
await SubscriptionUrlMatcher.findCancellationUrl(
|
||||||
serviceName: subscription.serviceName,
|
serviceName: subscription.serviceName,
|
||||||
websiteUrl: subscription.websiteUrl,
|
websiteUrl: subscription.websiteUrl,
|
||||||
locale: locale == 'ko' ? 'kr' : 'en',
|
locale: locale == 'ko' ? 'kr' : 'en',
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. 해지 안내 URL이 없으면 구글 검색
|
// 3. 해지 안내 URL이 없으면 구글 검색
|
||||||
if (cancellationUrl == null) {
|
if (cancellationUrl == null) {
|
||||||
final searchQuery = '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
|
final searchQuery =
|
||||||
cancellationUrl = 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
|
'${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
|
||||||
|
cancellationUrl =
|
||||||
|
'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AppSnackBar.showInfo(
|
AppSnackBar.showInfo(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -502,7 +518,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. URL 열기
|
// 4. URL 열기
|
||||||
final Uri url = Uri.parse(cancellationUrl);
|
final Uri url = Uri.parse(cancellationUrl);
|
||||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||||
@@ -515,11 +531,12 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
|
// ignore: avoid_print
|
||||||
print('DetailScreenController: 해지 페이지 열기 실패 - $e');
|
print('DetailScreenController: 해지 페이지 열기 실패 - $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 오류 발생시 일반 웹사이트로 폴백
|
// 오류 발생시 일반 웹사이트로 폴백
|
||||||
if (subscription.websiteUrl != null &&
|
if (subscription.websiteUrl != null &&
|
||||||
subscription.websiteUrl!.isNotEmpty) {
|
subscription.websiteUrl!.isNotEmpty) {
|
||||||
final Uri url = Uri.parse(subscription.websiteUrl!);
|
final Uri url = Uri.parse(subscription.websiteUrl!);
|
||||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||||
@@ -554,7 +571,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
const Color(0xFF0EA5E9), // 하늘
|
const Color(0xFF0EA5E9), // 하늘
|
||||||
const Color(0xFFEC4899), // 분홍
|
const Color(0xFFEC4899), // 분홍
|
||||||
];
|
];
|
||||||
|
|
||||||
return colors[hash % colors.length];
|
return colors[hash % colors.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,4 +586,4 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,33 +158,63 @@ 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 =
|
||||||
final finalCategoryId = _selectedCategoryId ?? subscription.category ?? getDefaultCategoryId(categoryProvider);
|
Provider.of<CategoryProvider>(context, listen: false);
|
||||||
|
|
||||||
|
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(
|
||||||
serviceName: subscription.serviceName,
|
serviceName: subscription.serviceName,
|
||||||
@@ -161,20 +228,21 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
categoryId: finalCategoryId,
|
categoryId: finalCategoryId,
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,13 +255,14 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||||
navigateToHome(context);
|
navigateToHome(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,4 +290,4 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,21 @@ class AppLocalizations {
|
|||||||
|
|
||||||
// JSON 파일에서 번역 데이터 로드
|
// JSON 파일에서 번역 데이터 로드
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
String jsonString =
|
String jsonString = await rootBundle.loadString('assets/data/text.json');
|
||||||
await rootBundle.loadString('assets/data/text.json');
|
|
||||||
Map<String, dynamic> jsonMap = json.decode(jsonString);
|
Map<String, dynamic> jsonMap = json.decode(jsonString);
|
||||||
_localizedStrings = jsonMap[locale.languageCode];
|
_localizedStrings = jsonMap[locale.languageCode];
|
||||||
}
|
}
|
||||||
|
|
||||||
String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager';
|
String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager';
|
||||||
String get appSubtitle => _localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily';
|
String get appSubtitle =>
|
||||||
|
_localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily';
|
||||||
String get subscriptionManagement =>
|
String get subscriptionManagement =>
|
||||||
_localizedStrings['subscriptionManagement'] ?? 'Subscription Management';
|
_localizedStrings['subscriptionManagement'] ?? 'Subscription Management';
|
||||||
String get addSubscription =>
|
String get addSubscription =>
|
||||||
_localizedStrings['addSubscription'] ?? 'Add Subscription';
|
_localizedStrings['addSubscription'] ?? 'Add Subscription';
|
||||||
String get subscriptionName =>
|
String get subscriptionName =>
|
||||||
_localizedStrings['subscriptionName'] ?? 'Service Name';
|
_localizedStrings['subscriptionName'] ?? 'Service Name';
|
||||||
String get monthlyCost =>
|
String get monthlyCost => _localizedStrings['monthlyCost'] ?? 'Monthly Cost';
|
||||||
_localizedStrings['monthlyCost'] ?? 'Monthly Cost';
|
|
||||||
String get billingCycle =>
|
String get billingCycle =>
|
||||||
_localizedStrings['billingCycle'] ?? 'Billing Cycle';
|
_localizedStrings['billingCycle'] ?? 'Billing Cycle';
|
||||||
String get nextBillingDate =>
|
String get nextBillingDate =>
|
||||||
@@ -55,29 +54,50 @@ class AppLocalizations {
|
|||||||
_localizedStrings['categoryManagement'] ?? 'Category Management';
|
_localizedStrings['categoryManagement'] ?? 'Category Management';
|
||||||
String get categoryName =>
|
String get categoryName =>
|
||||||
_localizedStrings['categoryName'] ?? 'Category Name';
|
_localizedStrings['categoryName'] ?? 'Category Name';
|
||||||
String get selectColor =>
|
String get selectColor => _localizedStrings['selectColor'] ?? 'Select Color';
|
||||||
_localizedStrings['selectColor'] ?? 'Select Color';
|
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
|
||||||
String get selectIcon =>
|
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
|
||||||
_localizedStrings['selectIcon'] ?? 'Select Icon';
|
|
||||||
String get addCategory =>
|
|
||||||
_localizedStrings['addCategory'] ?? 'Add Category';
|
|
||||||
String get settings => _localizedStrings['settings'] ?? 'Settings';
|
String get settings => _localizedStrings['settings'] ?? 'Settings';
|
||||||
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
|
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
|
||||||
String get language => _localizedStrings['language'] ?? 'Language';
|
String get language => _localizedStrings['language'] ?? 'Language';
|
||||||
String get notifications =>
|
String get notifications =>
|
||||||
_localizedStrings['notifications'] ?? 'Notifications';
|
_localizedStrings['notifications'] ?? 'Notifications';
|
||||||
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
|
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
|
||||||
|
// SMS 권한 온보딩/설정
|
||||||
|
String get smsPermissionTitle =>
|
||||||
|
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
|
||||||
|
String get smsPermissionReasonTitle =>
|
||||||
|
_localizedStrings['smsPermissionReasonTitle'] ?? 'Why';
|
||||||
|
String get smsPermissionReasonBody =>
|
||||||
|
_localizedStrings['smsPermissionReasonBody'] ??
|
||||||
|
'We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.';
|
||||||
|
String get smsPermissionScopeTitle =>
|
||||||
|
_localizedStrings['smsPermissionScopeTitle'] ?? 'Scope';
|
||||||
|
String get smsPermissionScopeBody =>
|
||||||
|
_localizedStrings['smsPermissionScopeBody'] ??
|
||||||
|
'We scan only payment-related SMS patterns (service/amount/date) locally; no data leaves your device.';
|
||||||
|
String get permanentlyDeniedMessage =>
|
||||||
|
_localizedStrings['permanentlyDeniedMessage'] ??
|
||||||
|
'Permission is permanently denied. Enable it in Settings.';
|
||||||
|
String get openSettings =>
|
||||||
|
_localizedStrings['openSettings'] ?? 'Open Settings';
|
||||||
|
String get later => _localizedStrings['later'] ?? 'Later';
|
||||||
|
String get requesting => _localizedStrings['requesting'] ?? 'Requesting...';
|
||||||
|
String get smsPermissionLabel =>
|
||||||
|
_localizedStrings['smsPermissionLabel'] ?? 'SMS Permission';
|
||||||
// 알림 설정
|
// 알림 설정
|
||||||
String get notificationPermission =>
|
String get notificationPermission =>
|
||||||
_localizedStrings['notificationPermission'] ?? 'Notification Permission';
|
_localizedStrings['notificationPermission'] ?? 'Notification Permission';
|
||||||
String get notificationPermissionDesc =>
|
String get notificationPermissionDesc =>
|
||||||
_localizedStrings['notificationPermissionDesc'] ?? 'Permission is required to receive notifications';
|
_localizedStrings['notificationPermissionDesc'] ??
|
||||||
|
'Permission is required to receive notifications';
|
||||||
String get requestPermission =>
|
String get requestPermission =>
|
||||||
_localizedStrings['requestPermission'] ?? 'Request Permission';
|
_localizedStrings['requestPermission'] ?? 'Request Permission';
|
||||||
String get paymentNotification =>
|
String get paymentNotification =>
|
||||||
_localizedStrings['paymentNotification'] ?? 'Payment Due Notification';
|
_localizedStrings['paymentNotification'] ?? 'Payment Due Notification';
|
||||||
String get paymentNotificationDesc =>
|
String get paymentNotificationDesc =>
|
||||||
_localizedStrings['paymentNotificationDesc'] ?? 'Receive notification on payment due date';
|
_localizedStrings['paymentNotificationDesc'] ??
|
||||||
|
'Receive notification on payment due date';
|
||||||
String get notificationTiming =>
|
String get notificationTiming =>
|
||||||
_localizedStrings['notificationTiming'] ?? 'Notification Timing';
|
_localizedStrings['notificationTiming'] ?? 'Notification Timing';
|
||||||
String get notificationTime =>
|
String get notificationTime =>
|
||||||
@@ -85,11 +105,14 @@ class AppLocalizations {
|
|||||||
String get dailyReminder =>
|
String get dailyReminder =>
|
||||||
_localizedStrings['dailyReminder'] ?? 'Daily Reminder';
|
_localizedStrings['dailyReminder'] ?? 'Daily Reminder';
|
||||||
String get dailyReminderEnabled =>
|
String get dailyReminderEnabled =>
|
||||||
_localizedStrings['dailyReminderEnabled'] ?? 'Receive daily notifications until payment date';
|
_localizedStrings['dailyReminderEnabled'] ??
|
||||||
|
'Receive daily notifications until payment date';
|
||||||
String get dailyReminderDisabled =>
|
String get dailyReminderDisabled =>
|
||||||
_localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment';
|
_localizedStrings['dailyReminderDisabled'] ??
|
||||||
|
'Receive notification @ day(s) before payment';
|
||||||
String get notificationPermissionDenied =>
|
String get notificationPermissionDenied =>
|
||||||
_localizedStrings['notificationPermissionDenied'] ?? 'Notification permission denied';
|
_localizedStrings['notificationPermissionDenied'] ??
|
||||||
|
'Notification permission denied';
|
||||||
// 앱 정보
|
// 앱 정보
|
||||||
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
|
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
|
||||||
String get version => _localizedStrings['version'] ?? 'Version';
|
String get version => _localizedStrings['version'] ?? 'Version';
|
||||||
@@ -102,7 +125,8 @@ class AppLocalizations {
|
|||||||
String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light';
|
String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light';
|
||||||
String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark';
|
String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark';
|
||||||
String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black';
|
String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black';
|
||||||
String get systemTheme => _localizedStrings['systemTheme'] ?? 'System Default';
|
String get systemTheme =>
|
||||||
|
_localizedStrings['systemTheme'] ?? 'System Default';
|
||||||
// 기타 메시지
|
// 기타 메시지
|
||||||
String get subscriptionAdded =>
|
String get subscriptionAdded =>
|
||||||
_localizedStrings['subscriptionAdded'] ?? 'Subscription added';
|
_localizedStrings['subscriptionAdded'] ?? 'Subscription added';
|
||||||
@@ -112,190 +136,302 @@ class AppLocalizations {
|
|||||||
String get japanese => _localizedStrings['japanese'] ?? '日本語';
|
String get japanese => _localizedStrings['japanese'] ?? '日本語';
|
||||||
String get chinese => _localizedStrings['chinese'] ?? '中文';
|
String get chinese => _localizedStrings['chinese'] ?? '中文';
|
||||||
// 날짜
|
// 날짜
|
||||||
String get oneDayBefore => _localizedStrings['oneDayBefore'] ?? '1 day before';
|
String get oneDayBefore =>
|
||||||
String get twoDaysBefore => _localizedStrings['twoDaysBefore'] ?? '2 days before';
|
_localizedStrings['oneDayBefore'] ?? '1 day before';
|
||||||
String get threeDaysBefore => _localizedStrings['threeDaysBefore'] ?? '3 days before';
|
String get twoDaysBefore =>
|
||||||
|
_localizedStrings['twoDaysBefore'] ?? '2 days before';
|
||||||
|
String get threeDaysBefore =>
|
||||||
|
_localizedStrings['threeDaysBefore'] ?? '3 days before';
|
||||||
// 추가 메시지
|
// 추가 메시지
|
||||||
String get requiredFieldsError => _localizedStrings['requiredFieldsError'] ?? 'Please fill in all required fields';
|
String get requiredFieldsError =>
|
||||||
String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated';
|
_localizedStrings['requiredFieldsError'] ??
|
||||||
String get officialCancelPageNotFound => _localizedStrings['officialCancelPageNotFound'] ?? 'Official cancellation page not found. Redirecting to Google search.';
|
'Please fill in all required fields';
|
||||||
String get cannotOpenWebsite => _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website';
|
String get subscriptionUpdated =>
|
||||||
String get noWebsiteInfo => _localizedStrings['noWebsiteInfo'] ?? 'No website information available. Please cancel through the website.';
|
_localizedStrings['subscriptionUpdated'] ??
|
||||||
|
'Subscription information has been updated';
|
||||||
|
String get officialCancelPageNotFound =>
|
||||||
|
_localizedStrings['officialCancelPageNotFound'] ??
|
||||||
|
'Official cancellation page not found. Redirecting to Google search.';
|
||||||
|
String get cannotOpenWebsite =>
|
||||||
|
_localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website';
|
||||||
|
String get noWebsiteInfo =>
|
||||||
|
_localizedStrings['noWebsiteInfo'] ??
|
||||||
|
'No website information available. Please cancel through the website.';
|
||||||
String get editMode => _localizedStrings['editMode'] ?? 'Edit Mode';
|
String get editMode => _localizedStrings['editMode'] ?? 'Edit Mode';
|
||||||
String get changesAppliedAfterSave => _localizedStrings['changesAppliedAfterSave'] ?? 'Changes will be applied after saving';
|
String get changesAppliedAfterSave =>
|
||||||
|
_localizedStrings['changesAppliedAfterSave'] ??
|
||||||
|
'Changes will be applied after saving';
|
||||||
String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes';
|
String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes';
|
||||||
String get monthlyExpense => _localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
|
String get monthlyExpense =>
|
||||||
|
_localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
|
||||||
String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL';
|
String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL';
|
||||||
String get websiteUrlOptional => _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
|
String get websiteUrlOptional =>
|
||||||
|
_localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
|
||||||
String get eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price';
|
String get eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price';
|
||||||
String get eventPriceHint => _localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
|
String get eventPriceHint =>
|
||||||
String get eventPriceRequired => _localizedStrings['eventPriceRequired'] ?? 'Please enter event price';
|
_localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
|
||||||
String get invalidPrice => _localizedStrings['invalidPrice'] ?? 'Please enter a valid price';
|
String get eventPriceRequired =>
|
||||||
|
_localizedStrings['eventPriceRequired'] ?? 'Please enter event price';
|
||||||
|
String get invalidPrice =>
|
||||||
|
_localizedStrings['invalidPrice'] ?? 'Please enter a valid price';
|
||||||
String get smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS';
|
String get smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS';
|
||||||
String get home => _localizedStrings['home'] ?? 'Home';
|
String get home => _localizedStrings['home'] ?? 'Home';
|
||||||
String get analysis => _localizedStrings['analysis'] ?? 'Analysis';
|
String get analysis => _localizedStrings['analysis'] ?? 'Analysis';
|
||||||
String get back => _localizedStrings['back'] ?? 'Back';
|
String get back => _localizedStrings['back'] ?? 'Back';
|
||||||
String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App';
|
String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App';
|
||||||
String get exitAppConfirm => _localizedStrings['exitAppConfirm'] ?? 'Are you sure you want to exit SubManager?';
|
String get exitAppConfirm =>
|
||||||
|
_localizedStrings['exitAppConfirm'] ??
|
||||||
|
'Are you sure you want to exit SubManager?';
|
||||||
String get exit => _localizedStrings['exit'] ?? 'Exit';
|
String get exit => _localizedStrings['exit'] ?? 'Exit';
|
||||||
String get pageNotFound => _localizedStrings['pageNotFound'] ?? 'Page not found';
|
String get pageNotFound =>
|
||||||
String get serviceNameExample => _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
|
_localizedStrings['pageNotFound'] ?? 'Page not found';
|
||||||
String get urlExample => _localizedStrings['urlExample'] ?? 'https://example.com';
|
String get serviceNameExample =>
|
||||||
String get appLockDesc => _localizedStrings['appLockDesc'] ?? 'App lock with biometric authentication';
|
_localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
|
||||||
String get unlockWithBiometric => _localizedStrings['unlockWithBiometric'] ?? 'Unlock with biometric authentication';
|
String get urlExample =>
|
||||||
String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.';
|
_localizedStrings['urlExample'] ?? 'https://example.com';
|
||||||
String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
|
String get appLockDesc =>
|
||||||
String get noSubscriptionSmsFound => _localizedStrings['noSubscriptionSmsFound'] ?? 'No subscription related SMS found';
|
_localizedStrings['appLockDesc'] ??
|
||||||
String get smsScanError => _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan';
|
'App lock with biometric authentication';
|
||||||
String get saveError => _localizedStrings['saveError'] ?? 'Error occurred while saving';
|
String get unlockWithBiometric =>
|
||||||
String get newSubscriptionSmsNotFound => _localizedStrings['newSubscriptionSmsNotFound'] ?? 'No new subscription SMS found';
|
_localizedStrings['unlockWithBiometric'] ??
|
||||||
String get subscriptionAddError => _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription';
|
'Unlock with biometric authentication';
|
||||||
String get allSubscriptionsProcessed => _localizedStrings['allSubscriptionsProcessed'] ?? 'All subscriptions have been processed.';
|
String get authenticationFailed =>
|
||||||
String get websiteUrlExtracted => _localizedStrings['websiteUrlExtracted'] ?? 'Website URL (Auto-extracted)';
|
_localizedStrings['authenticationFailed'] ??
|
||||||
|
'Authentication failed. Please try again.';
|
||||||
|
String get smsPermissionRequired =>
|
||||||
|
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
|
||||||
|
String get noSubscriptionSmsFound =>
|
||||||
|
_localizedStrings['noSubscriptionSmsFound'] ??
|
||||||
|
'No subscription related SMS found';
|
||||||
|
String get smsScanError =>
|
||||||
|
_localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan';
|
||||||
|
String get saveError =>
|
||||||
|
_localizedStrings['saveError'] ?? 'Error occurred while saving';
|
||||||
|
String get newSubscriptionSmsNotFound =>
|
||||||
|
_localizedStrings['newSubscriptionSmsNotFound'] ??
|
||||||
|
'No new subscription SMS found';
|
||||||
|
String get subscriptionAddError =>
|
||||||
|
_localizedStrings['subscriptionAddError'] ?? 'Error adding subscription';
|
||||||
|
String get allSubscriptionsProcessed =>
|
||||||
|
_localizedStrings['allSubscriptionsProcessed'] ??
|
||||||
|
'All subscriptions have been processed.';
|
||||||
|
String get websiteUrlExtracted =>
|
||||||
|
_localizedStrings['websiteUrlExtracted'] ??
|
||||||
|
'Website URL (Auto-extracted)';
|
||||||
String get startDate => _localizedStrings['startDate'] ?? 'Start Date';
|
String get startDate => _localizedStrings['startDate'] ?? 'Start Date';
|
||||||
String get endDate => _localizedStrings['endDate'] ?? 'End Date';
|
String get endDate => _localizedStrings['endDate'] ?? 'End Date';
|
||||||
|
|
||||||
// 새로 추가된 항목들
|
// 새로 추가된 항목들
|
||||||
String get monthlyTotalSubscriptionCost => _localizedStrings['monthlyTotalSubscriptionCost'] ?? 'Total Monthly Subscription Cost';
|
String get monthlyTotalSubscriptionCost =>
|
||||||
String get todaysExchangeRate => _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
|
_localizedStrings['monthlyTotalSubscriptionCost'] ??
|
||||||
|
'Total Monthly Subscription Cost';
|
||||||
|
String get todaysExchangeRate =>
|
||||||
|
_localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
|
||||||
String get won => _localizedStrings['won'] ?? 'KRW';
|
String get won => _localizedStrings['won'] ?? 'KRW';
|
||||||
String get estimatedAnnualCost => _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
|
String get estimatedAnnualCost =>
|
||||||
String get totalSubscriptionServices => _localizedStrings['totalSubscriptionServices'] ?? 'Total Subscription Services';
|
_localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
|
||||||
|
String get totalSubscriptionServices =>
|
||||||
|
_localizedStrings['totalSubscriptionServices'] ??
|
||||||
|
'Total Subscription Services';
|
||||||
String get services => _localizedStrings['services'] ?? 'services';
|
String get services => _localizedStrings['services'] ?? 'services';
|
||||||
String get eventDiscountActive => _localizedStrings['eventDiscountActive'] ?? 'Event Discount Active';
|
String get eventDiscountActive =>
|
||||||
|
_localizedStrings['eventDiscountActive'] ?? 'Event Discount Active';
|
||||||
String get saving => _localizedStrings['saving'] ?? 'Saving';
|
String get saving => _localizedStrings['saving'] ?? 'Saving';
|
||||||
String get paymentDueToday => _localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
|
String get paymentDueToday =>
|
||||||
String get paymentInfoNeeded => _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
|
_localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
|
||||||
|
String get paymentInfoNeeded =>
|
||||||
|
_localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
|
||||||
String get event => _localizedStrings['event'] ?? 'Event';
|
String get event => _localizedStrings['event'] ?? 'Event';
|
||||||
|
|
||||||
// 카테고리 getter들
|
// 카테고리 getter들
|
||||||
String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music';
|
String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music';
|
||||||
String get categoryOttVideo => _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
|
String get categoryOttVideo =>
|
||||||
String get categoryStorageCloud => _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
|
_localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
|
||||||
String get categoryTelecomInternetTv => _localizedStrings['categoryTelecomInternetTv'] ?? 'Telecom · Internet · TV';
|
String get categoryStorageCloud =>
|
||||||
String get categoryLifestyle => _localizedStrings['categoryLifestyle'] ?? 'Lifestyle';
|
_localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
|
||||||
String get categoryShoppingEcommerce => _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce';
|
String get categoryTelecomInternetTv =>
|
||||||
String get categoryProgramming => _localizedStrings['categoryProgramming'] ?? 'Programming';
|
_localizedStrings['categoryTelecomInternetTv'] ??
|
||||||
String get categoryCollaborationOffice => _localizedStrings['categoryCollaborationOffice'] ?? 'Collaboration/Office';
|
'Telecom · Internet · TV';
|
||||||
String get categoryAiService => _localizedStrings['categoryAiService'] ?? 'AI Service';
|
String get categoryLifestyle =>
|
||||||
|
_localizedStrings['categoryLifestyle'] ?? 'Lifestyle';
|
||||||
|
String get categoryShoppingEcommerce =>
|
||||||
|
_localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce';
|
||||||
|
String get categoryProgramming =>
|
||||||
|
_localizedStrings['categoryProgramming'] ?? 'Programming';
|
||||||
|
String get categoryCollaborationOffice =>
|
||||||
|
_localizedStrings['categoryCollaborationOffice'] ??
|
||||||
|
'Collaboration/Office';
|
||||||
|
String get categoryAiService =>
|
||||||
|
_localizedStrings['categoryAiService'] ?? 'AI Service';
|
||||||
String get categoryOther => _localizedStrings['categoryOther'] ?? 'Other';
|
String get categoryOther => _localizedStrings['categoryOther'] ?? 'Other';
|
||||||
|
|
||||||
// 동적 메시지 생성 메서드
|
// 동적 메시지 생성 메서드
|
||||||
String daysBefore(int days) {
|
String daysBefore(int days) {
|
||||||
return '$days${_localizedStrings['daysBefore'] ?? 'day(s) before'}';
|
return '$days${_localizedStrings['daysBefore'] ?? 'day(s) before'}';
|
||||||
}
|
}
|
||||||
|
|
||||||
String dailyReminderDisabledWithDays(int days) {
|
String dailyReminderDisabledWithDays(int days) {
|
||||||
final template = _localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment';
|
final template = _localizedStrings['dailyReminderDisabled'] ??
|
||||||
|
'Receive notification @ day(s) before payment';
|
||||||
return template.replaceAll('@', days.toString());
|
return template.replaceAll('@', days.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
String subscriptionAddedWithName(String serviceName) {
|
String subscriptionAddedWithName(String serviceName) {
|
||||||
final template = _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
|
final template =
|
||||||
|
_localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
|
||||||
return template.replaceAll('@', serviceName);
|
return template.replaceAll('@', serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
String subscriptionDeleted(String serviceName) {
|
String subscriptionDeleted(String serviceName) {
|
||||||
final template = _localizedStrings['subscriptionDeleted'] ?? '@ subscription has been deleted';
|
final template = _localizedStrings['subscriptionDeleted'] ??
|
||||||
|
'@ subscription has been deleted';
|
||||||
return template.replaceAll('@', serviceName);
|
return template.replaceAll('@', serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
String totalExpenseCopied(String amount) {
|
String totalExpenseCopied(String amount) {
|
||||||
final template = _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
|
final template =
|
||||||
|
_localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
|
||||||
return template.replaceAll('@', amount);
|
return template.replaceAll('@', amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
String serviceRecognized(String serviceName) {
|
String serviceRecognized(String serviceName) {
|
||||||
final template = _localizedStrings['serviceRecognized'] ?? '@ service has been recognized automatically.';
|
final template = _localizedStrings['serviceRecognized'] ??
|
||||||
|
'@ service has been recognized automatically.';
|
||||||
return template.replaceAll('@', serviceName);
|
return template.replaceAll('@', serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
String smsScanErrorWithMessage(String error) {
|
String smsScanErrorWithMessage(String error) {
|
||||||
final template = _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan: @';
|
final template = _localizedStrings['smsScanError'] ??
|
||||||
|
'Error occurred during SMS scan: @';
|
||||||
return template.replaceAll('@', error);
|
return template.replaceAll('@', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
String saveErrorWithMessage(String error) {
|
String saveErrorWithMessage(String error) {
|
||||||
final template = _localizedStrings['saveError'] ?? 'Error occurred while saving: @';
|
final template =
|
||||||
|
_localizedStrings['saveError'] ?? 'Error occurred while saving: @';
|
||||||
return template.replaceAll('@', error);
|
return template.replaceAll('@', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
String subscriptionAddErrorWithMessage(String error) {
|
String subscriptionAddErrorWithMessage(String error) {
|
||||||
final template = _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription: @';
|
final template = _localizedStrings['subscriptionAddError'] ??
|
||||||
|
'Error adding subscription: @';
|
||||||
return template.replaceAll('@', error);
|
return template.replaceAll('@', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
String subscriptionSkipped(String serviceName) {
|
String subscriptionSkipped(String serviceName) {
|
||||||
final template = _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
|
final template =
|
||||||
|
_localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
|
||||||
return template.replaceAll('@', serviceName);
|
return template.replaceAll('@', serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 홈화면 관련
|
// 홈화면 관련
|
||||||
String get mySubscriptions => _localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
|
String get mySubscriptions =>
|
||||||
|
_localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
|
||||||
|
|
||||||
String subscriptionCount(int count) {
|
String subscriptionCount(int count) {
|
||||||
if (locale.languageCode == 'ko') {
|
if (locale.languageCode == 'ko') {
|
||||||
return '${count}개';
|
return '$count개';
|
||||||
} else if (locale.languageCode == 'ja') {
|
} else if (locale.languageCode == 'ja') {
|
||||||
return '${count}個';
|
return '$count個';
|
||||||
} else if (locale.languageCode == 'zh') {
|
} else if (locale.languageCode == 'zh') {
|
||||||
return '${count}个';
|
return '$count个';
|
||||||
} else {
|
} else {
|
||||||
return count.toString();
|
return count.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 분석화면 관련
|
// 분석화면 관련
|
||||||
String get monthlyExpenseTitle => _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
|
String get monthlyExpenseTitle =>
|
||||||
String get recentSixMonthsTrend => _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
|
_localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
|
||||||
String get monthlySubscriptionExpense => _localizedStrings['monthlySubscriptionExpense'] ?? 'Monthly subscription expense';
|
String get recentSixMonthsTrend =>
|
||||||
String get subscriptionServiceRatio => _localizedStrings['subscriptionServiceRatio'] ?? 'Subscription Service Ratio';
|
_localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
|
||||||
String get monthlyExpenseBasis => _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense';
|
String get monthlySubscriptionExpense =>
|
||||||
String get noSubscriptionServices => _localizedStrings['noSubscriptionServices'] ?? 'No subscription services';
|
_localizedStrings['monthlySubscriptionExpense'] ??
|
||||||
String get totalExpenseSummary => _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary';
|
'Monthly subscription expense';
|
||||||
String get monthlyTotalAmount => _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount';
|
String get subscriptionServiceRatio =>
|
||||||
String get totalExpense => _localizedStrings['totalExpense'] ?? 'Total Expense';
|
_localizedStrings['subscriptionServiceRatio'] ??
|
||||||
String get totalServices => _localizedStrings['totalServices'] ?? 'Total Services';
|
'Subscription Service Ratio';
|
||||||
|
String get monthlyExpenseBasis =>
|
||||||
|
_localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense';
|
||||||
|
String get noSubscriptionServices =>
|
||||||
|
_localizedStrings['noSubscriptionServices'] ?? 'No subscription services';
|
||||||
|
String get totalExpenseSummary =>
|
||||||
|
_localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary';
|
||||||
|
String get monthlyTotalAmount =>
|
||||||
|
_localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount';
|
||||||
|
String get totalExpense =>
|
||||||
|
_localizedStrings['totalExpense'] ?? 'Total Expense';
|
||||||
|
String get totalServices =>
|
||||||
|
_localizedStrings['totalServices'] ?? 'Total Services';
|
||||||
String get servicesUnit => _localizedStrings['servicesUnit'] ?? 'services';
|
String get servicesUnit => _localizedStrings['servicesUnit'] ?? 'services';
|
||||||
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
|
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
|
||||||
String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
|
String get eventDiscountStatus =>
|
||||||
String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress';
|
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
|
||||||
String get monthlySavingAmount => _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
|
String get inProgressUnit =>
|
||||||
String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress';
|
_localizedStrings['inProgressUnit'] ?? 'in progress';
|
||||||
String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount';
|
String get monthlySavingAmount =>
|
||||||
|
_localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
|
||||||
|
String get eventsInProgress =>
|
||||||
|
_localizedStrings['eventsInProgress'] ?? 'Events in Progress';
|
||||||
|
String get discountPercent =>
|
||||||
|
_localizedStrings['discountPercent'] ?? '% discount';
|
||||||
String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW';
|
String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW';
|
||||||
|
|
||||||
// SMS 스캔 관련
|
// SMS 스캔 관련
|
||||||
String get scanningMessages => _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
|
String get scanningMessages =>
|
||||||
String get findingSubscriptions => _localizedStrings['findingSubscriptions'] ?? 'Finding subscription services';
|
_localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
|
||||||
String get subscriptionNotFound => _localizedStrings['subscriptionNotFound'] ?? 'Subscription information not found.';
|
String get findingSubscriptions =>
|
||||||
String get repeatSubscriptionNotFound => _localizedStrings['repeatSubscriptionNotFound'] ?? 'No repeated subscription information found.';
|
_localizedStrings['findingSubscriptions'] ??
|
||||||
String get newSubscriptionNotFound => _localizedStrings['newSubscriptionNotFound'] ?? 'No new subscription SMS found';
|
'Finding subscription services';
|
||||||
String get findRepeatSubscriptions => _localizedStrings['findRepeatSubscriptions'] ?? 'Find subscriptions paid 2+ times';
|
String get subscriptionNotFound =>
|
||||||
String get scanTextMessages => _localizedStrings['scanTextMessages'] ?? 'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.';
|
_localizedStrings['subscriptionNotFound'] ??
|
||||||
String get startScanning => _localizedStrings['startScanning'] ?? 'Start Scanning';
|
'Subscription information not found.';
|
||||||
String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription';
|
String get repeatSubscriptionNotFound =>
|
||||||
|
_localizedStrings['repeatSubscriptionNotFound'] ??
|
||||||
|
'No repeated subscription information found.';
|
||||||
|
String get newSubscriptionNotFound =>
|
||||||
|
_localizedStrings['newSubscriptionNotFound'] ??
|
||||||
|
'No new subscription SMS found';
|
||||||
|
String get findRepeatSubscriptions =>
|
||||||
|
_localizedStrings['findRepeatSubscriptions'] ??
|
||||||
|
'Find subscriptions paid 2+ times';
|
||||||
|
String get scanTextMessages =>
|
||||||
|
_localizedStrings['scanTextMessages'] ??
|
||||||
|
'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.';
|
||||||
|
String get startScanning =>
|
||||||
|
_localizedStrings['startScanning'] ?? 'Start Scanning';
|
||||||
|
String get foundSubscription =>
|
||||||
|
_localizedStrings['foundSubscription'] ?? 'Found subscription';
|
||||||
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
|
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
|
||||||
String get nextBillingDateLabel => _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
|
String get nextBillingDateLabel =>
|
||||||
|
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
|
||||||
String get category => _localizedStrings['category'] ?? 'Category';
|
String get category => _localizedStrings['category'] ?? 'Category';
|
||||||
String get websiteUrlAuto => _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)';
|
String get websiteUrlAuto =>
|
||||||
String get websiteUrlHint => _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty';
|
_localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)';
|
||||||
|
String get websiteUrlHint =>
|
||||||
|
_localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty';
|
||||||
String get skip => _localizedStrings['skip'] ?? 'Skip';
|
String get skip => _localizedStrings['skip'] ?? 'Skip';
|
||||||
String get add => _localizedStrings['add'] ?? 'Add';
|
String get add => _localizedStrings['add'] ?? 'Add';
|
||||||
String get nextBillingDateRequired => _localizedStrings['nextBillingDateRequired'] ?? 'Next billing date verification required';
|
String get nextBillingDateRequired =>
|
||||||
|
_localizedStrings['nextBillingDateRequired'] ??
|
||||||
|
'Next billing date verification required';
|
||||||
|
|
||||||
String nextBillingDateEstimated(String date, int days) {
|
String nextBillingDateEstimated(String date, int days) {
|
||||||
final template = _localizedStrings['nextBillingDateEstimated'] ?? 'Next estimated billing date: @ (# days later)';
|
final template = _localizedStrings['nextBillingDateEstimated'] ??
|
||||||
|
'Next estimated billing date: @ (# days later)';
|
||||||
return template.replaceAll('@', date).replaceAll('#', days.toString());
|
return template.replaceAll('@', date).replaceAll('#', days.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
String nextBillingDateInfo(String date, int days) {
|
String nextBillingDateInfo(String date, int days) {
|
||||||
final template = _localizedStrings['nextBillingDateInfo'] ?? 'Next billing date: @ (# days later)';
|
final template = _localizedStrings['nextBillingDateInfo'] ??
|
||||||
|
'Next billing date: @ (# days later)';
|
||||||
return template.replaceAll('@', date).replaceAll('#', days.toString());
|
return template.replaceAll('@', date).replaceAll('#', days.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
String get nextBillingDatePastRequired => _localizedStrings['nextBillingDatePastRequired'] ?? 'Next billing date verification required (past date)';
|
String get nextBillingDatePastRequired =>
|
||||||
|
_localizedStrings['nextBillingDatePastRequired'] ??
|
||||||
|
'Next billing date verification required (past date)';
|
||||||
|
|
||||||
String formatDate(DateTime date) {
|
String formatDate(DateTime date) {
|
||||||
if (locale.languageCode == 'ko') {
|
if (locale.languageCode == 'ko') {
|
||||||
return '${date.year}년 ${date.month}월 ${date.day}일';
|
return '${date.year}년 ${date.month}월 ${date.day}일';
|
||||||
@@ -304,113 +440,165 @@ class AppLocalizations {
|
|||||||
} else if (locale.languageCode == 'zh') {
|
} else if (locale.languageCode == 'zh') {
|
||||||
return '${date.year}年${date.month}月${date.day}日';
|
return '${date.year}年${date.month}月${date.day}日';
|
||||||
} else {
|
} else {
|
||||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
final months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec'
|
||||||
|
];
|
||||||
return '${months[date.month - 1]} ${date.day}, ${date.year}';
|
return '${months[date.month - 1]} ${date.day}, ${date.year}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String repeatCountDetected(int count) {
|
String repeatCountDetected(int count) {
|
||||||
final template = _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected';
|
final template =
|
||||||
|
_localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected';
|
||||||
return template.replaceAll('@', count.toString());
|
return template.replaceAll('@', count.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
String servicesInProgress(int count) {
|
String servicesInProgress(int count) {
|
||||||
if (locale.languageCode == 'ko') {
|
if (locale.languageCode == 'ko') {
|
||||||
return '${count}개 진행중';
|
return '$count개 진행중';
|
||||||
} else if (locale.languageCode == 'ja') {
|
} else if (locale.languageCode == 'ja') {
|
||||||
return '${count}個進行中';
|
return '$count個進行中';
|
||||||
} else if (locale.languageCode == 'zh') {
|
} else if (locale.languageCode == 'zh') {
|
||||||
return '${count}个进行中';
|
return '$count个进行中';
|
||||||
} else {
|
} else {
|
||||||
return '$count in progress';
|
return '$count in progress';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로 추가된 동적 메서드들
|
// 새로 추가된 동적 메서드들
|
||||||
String paymentDueInDays(int days) {
|
String paymentDueInDays(int days) {
|
||||||
final template = _localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days';
|
final template =
|
||||||
|
_localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days';
|
||||||
return template.replaceAll('@', days.toString());
|
return template.replaceAll('@', days.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
String daysRemaining(int days) {
|
String daysRemaining(int days) {
|
||||||
final template = _localizedStrings['daysRemaining'] ?? '@ days remaining';
|
final template = _localizedStrings['daysRemaining'] ?? '@ days remaining';
|
||||||
return template.replaceAll('@', days.toString());
|
return template.replaceAll('@', days.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
String exchangeRateFormat(String rate) {
|
String exchangeRateFormat(String rate) {
|
||||||
final template = _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
|
final template =
|
||||||
|
_localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
|
||||||
return template.replaceAll('@', rate);
|
return template.replaceAll('@', rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 결제 주기 결제 메시지
|
// 결제 주기 결제 메시지
|
||||||
String get billingCyclePayment => _localizedStrings['billingCyclePayment'] ?? '@ Payment';
|
String get billingCyclePayment =>
|
||||||
|
_localizedStrings['billingCyclePayment'] ?? '@ Payment';
|
||||||
|
|
||||||
// 할인 금액 표시 getter들
|
// 할인 금액 표시 getter들
|
||||||
String get discountAmountWon => _localizedStrings['discountAmountWon'] ?? 'Save ₩@';
|
String get discountAmountWon =>
|
||||||
String get discountAmountDollar => _localizedStrings['discountAmountDollar'] ?? 'Save \$@';
|
_localizedStrings['discountAmountWon'] ?? 'Save ₩@';
|
||||||
String get discountAmountYen => _localizedStrings['discountAmountYen'] ?? 'Save ¥@';
|
String get discountAmountDollar =>
|
||||||
String get discountAmountYuan => _localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
|
_localizedStrings['discountAmountDollar'] ?? 'Save \$@';
|
||||||
|
String get discountAmountYen =>
|
||||||
|
_localizedStrings['discountAmountYen'] ?? 'Save ¥@';
|
||||||
|
String get discountAmountYuan =>
|
||||||
|
_localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
|
||||||
|
|
||||||
// 결제 주기 관련 getter
|
// 결제 주기 관련 getter
|
||||||
String get monthly => _localizedStrings['monthly'] ?? 'Monthly';
|
String get monthly => _localizedStrings['monthly'] ?? 'Monthly';
|
||||||
String get weekly => _localizedStrings['weekly'] ?? 'Weekly';
|
String get weekly => _localizedStrings['weekly'] ?? 'Weekly';
|
||||||
String get yearly => _localizedStrings['yearly'] ?? 'Yearly';
|
String get yearly => _localizedStrings['yearly'] ?? 'Yearly';
|
||||||
String get billingCycleMonthly => _localizedStrings['billingCycleMonthly'] ?? 'Monthly';
|
String get billingCycleMonthly =>
|
||||||
String get billingCycleQuarterly => _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
|
_localizedStrings['billingCycleMonthly'] ?? 'Monthly';
|
||||||
String get billingCycleHalfYearly => _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
|
String get billingCycleQuarterly =>
|
||||||
String get billingCycleYearly => _localizedStrings['billingCycleYearly'] ?? 'Yearly';
|
_localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
|
||||||
|
String get billingCycleHalfYearly =>
|
||||||
|
_localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
|
||||||
|
String get billingCycleYearly =>
|
||||||
|
_localizedStrings['billingCycleYearly'] ?? 'Yearly';
|
||||||
|
|
||||||
// 색상 관련 getter
|
// 색상 관련 getter
|
||||||
String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue';
|
String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue';
|
||||||
String get colorGreen => _localizedStrings['colorGreen'] ?? 'Green';
|
String get colorGreen => _localizedStrings['colorGreen'] ?? 'Green';
|
||||||
String get colorOrange => _localizedStrings['colorOrange'] ?? 'Orange';
|
String get colorOrange => _localizedStrings['colorOrange'] ?? 'Orange';
|
||||||
String get colorRed => _localizedStrings['colorRed'] ?? 'Red';
|
String get colorRed => _localizedStrings['colorRed'] ?? 'Red';
|
||||||
String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple';
|
String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple';
|
||||||
|
|
||||||
// 날짜 형식 관련 getter
|
// 날짜 형식 관련 getter
|
||||||
String get dateFormatFull => _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
|
String get dateFormatFull =>
|
||||||
|
_localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
|
||||||
String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd';
|
String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd';
|
||||||
|
|
||||||
// USD 환율 표시 형식
|
// USD 환율 표시 형식
|
||||||
String get exchangeRateDisplay => _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
|
String get exchangeRateDisplay =>
|
||||||
|
_localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
|
||||||
|
|
||||||
// 라벨 및 힌트 텍스트
|
// 라벨 및 힌트 텍스트
|
||||||
String get labelServiceName => _localizedStrings['labelServiceName'] ?? 'Service Name';
|
String get labelServiceName =>
|
||||||
String get hintServiceName => _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
|
_localizedStrings['labelServiceName'] ?? 'Service Name';
|
||||||
String get labelMonthlyExpense => _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
|
String get hintServiceName =>
|
||||||
String get labelNextBillingDate => _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date';
|
_localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
|
||||||
String get labelWebsiteUrl => _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)';
|
String get labelMonthlyExpense =>
|
||||||
String get hintWebsiteUrl => _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com';
|
_localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
|
||||||
String get labelEventPrice => _localizedStrings['labelEventPrice'] ?? 'Event Price';
|
String get labelNextBillingDate =>
|
||||||
String get hintEventPrice => _localizedStrings['hintEventPrice'] ?? 'Enter discounted price';
|
_localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date';
|
||||||
|
String get labelWebsiteUrl =>
|
||||||
|
_localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)';
|
||||||
|
String get hintWebsiteUrl =>
|
||||||
|
_localizedStrings['hintWebsiteUrl'] ?? 'https://example.com';
|
||||||
|
String get labelEventPrice =>
|
||||||
|
_localizedStrings['labelEventPrice'] ?? 'Event Price';
|
||||||
|
String get hintEventPrice =>
|
||||||
|
_localizedStrings['hintEventPrice'] ?? 'Enter discounted price';
|
||||||
String get labelCategory => _localizedStrings['labelCategory'] ?? 'Category';
|
String get labelCategory => _localizedStrings['labelCategory'] ?? 'Category';
|
||||||
|
|
||||||
// 기타 번역
|
// 기타 번역
|
||||||
String get subscription => _localizedStrings['subscription'] ?? 'Subscription';
|
String get subscription =>
|
||||||
|
_localizedStrings['subscription'] ?? 'Subscription';
|
||||||
String get movie => _localizedStrings['movie'] ?? 'Movie';
|
String get movie => _localizedStrings['movie'] ?? 'Movie';
|
||||||
String get music => _localizedStrings['music'] ?? 'Music';
|
String get music => _localizedStrings['music'] ?? 'Music';
|
||||||
String get exercise => _localizedStrings['exercise'] ?? 'Exercise';
|
String get exercise => _localizedStrings['exercise'] ?? 'Exercise';
|
||||||
String get shopping => _localizedStrings['shopping'] ?? 'Shopping';
|
String get shopping => _localizedStrings['shopping'] ?? 'Shopping';
|
||||||
String get currency => _localizedStrings['currency'] ?? 'Currency';
|
String get currency => _localizedStrings['currency'] ?? 'Currency';
|
||||||
String get websiteInfo => _localizedStrings['websiteInfo'] ?? 'Website Information';
|
String get websiteInfo =>
|
||||||
String get cancelGuide => _localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
|
_localizedStrings['websiteInfo'] ?? 'Website Information';
|
||||||
String get cancelServiceGuide => _localizedStrings['cancelServiceGuide'] ?? 'To cancel this service, please go to the cancellation page through the link below.';
|
String get cancelGuide =>
|
||||||
String get goToCancelPage => _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page';
|
_localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
|
||||||
String get urlAutoMatchInfo => _localizedStrings['urlAutoMatchInfo'] ?? 'If URL is empty, it will be automatically matched based on the service name';
|
String get cancelServiceGuide =>
|
||||||
|
_localizedStrings['cancelServiceGuide'] ??
|
||||||
|
'To cancel this service, please go to the cancellation page through the link below.';
|
||||||
|
String get goToCancelPage =>
|
||||||
|
_localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page';
|
||||||
|
String get urlAutoMatchInfo =>
|
||||||
|
_localizedStrings['urlAutoMatchInfo'] ??
|
||||||
|
'If URL is empty, it will be automatically matched based on the service name';
|
||||||
String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select';
|
String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select';
|
||||||
|
|
||||||
// 새로 추가된 getter들
|
// 새로 추가된 getter들
|
||||||
String get serviceInfo => _localizedStrings['serviceInfo'] ?? 'Service Information';
|
String get serviceInfo =>
|
||||||
String get newSubscriptionAdd => _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
|
_localizedStrings['serviceInfo'] ?? 'Service Information';
|
||||||
String get enterServiceInfo => _localizedStrings['enterServiceInfo'] ?? 'Enter service information';
|
String get newSubscriptionAdd =>
|
||||||
String get addSubscriptionButton => _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription';
|
_localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
|
||||||
String get serviceNameRequired => _localizedStrings['serviceNameRequired'] ?? 'Please enter service name';
|
String get enterServiceInfo =>
|
||||||
String get amountRequired => _localizedStrings['amountRequired'] ?? 'Please enter amount';
|
_localizedStrings['enterServiceInfo'] ?? 'Enter service information';
|
||||||
String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
|
String get addSubscriptionButton =>
|
||||||
|
_localizedStrings['addSubscriptionButton'] ?? 'Add Subscription';
|
||||||
|
String get serviceNameRequired =>
|
||||||
|
_localizedStrings['serviceNameRequired'] ?? 'Please enter service name';
|
||||||
|
String get amountRequired =>
|
||||||
|
_localizedStrings['amountRequired'] ?? 'Please enter amount';
|
||||||
|
String get subscriptionDetail =>
|
||||||
|
_localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
|
||||||
String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
|
String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
|
||||||
String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
|
String get invalidAmount =>
|
||||||
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) {
|
||||||
switch (billingCycleKey) {
|
switch (billingCycleKey) {
|
||||||
@@ -433,7 +621,7 @@ class AppLocalizations {
|
|||||||
return billingCycleKey; // 매칭되지 않으면 원본 반환
|
return billingCycleKey; // 매칭되지 않으면 원본 반환
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 이름을 키로 변환하여 번역된 이름 반환
|
// 카테고리 이름을 키로 변환하여 번역된 이름 반환
|
||||||
String getCategoryName(String categoryKey) {
|
String getCategoryName(String categoryKey) {
|
||||||
switch (categoryKey) {
|
switch (categoryKey) {
|
||||||
@@ -467,7 +655,8 @@ class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
|
|||||||
const AppLocalizationsDelegate();
|
const AppLocalizationsDelegate();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isSupported(Locale locale) => ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode);
|
bool isSupported(Locale locale) =>
|
||||||
|
['en', 'ko', 'ja', 'zh'].contains(locale.languageCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AppLocalizations> load(Locale locale) async {
|
Future<AppLocalizations> load(Locale locale) async {
|
||||||
|
|||||||
@@ -20,16 +20,21 @@ import 'navigation/app_navigation_observer.dart';
|
|||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
import 'dart:async' show unawaited;
|
||||||
import 'utils/memory_manager.dart';
|
import 'utils/memory_manager.dart';
|
||||||
|
import 'utils/logger.dart';
|
||||||
import 'utils/performance_optimizer.dart';
|
import 'utils/performance_optimizer.dart';
|
||||||
import 'navigator_key.dart';
|
import 'navigator_key.dart';
|
||||||
|
|
||||||
|
// AdMob 활성화 플래그 (개발 중 false, 프로덕션 시 true로 변경)
|
||||||
|
const bool enableAdMob = true;
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만)
|
// 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만)
|
||||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && enableAdMob) {
|
||||||
await MobileAds.instance.initialize();
|
unawaited(MobileAds.instance.initialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성능 최적화 설정
|
// 성능 최적화 설정
|
||||||
@@ -40,16 +45,23 @@ Future<void> main() async {
|
|||||||
try {
|
try {
|
||||||
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
|
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
|
||||||
|
|
||||||
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
|
// 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화
|
||||||
await DefaultCacheManager().emptyCache();
|
// 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요.
|
||||||
|
const bool clearCacheOnStartup = bool.fromEnvironment(
|
||||||
|
'CLEAR_CACHE_ON_STARTUP',
|
||||||
|
defaultValue: false,
|
||||||
|
);
|
||||||
|
if (clearCacheOnStartup) {
|
||||||
|
await DefaultCacheManager().emptyCache();
|
||||||
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('이미지 캐시 관리 초기화 완료');
|
Log.d('이미지 캐시 관리 초기화 완료');
|
||||||
PerformanceOptimizer.checkConstOptimization();
|
PerformanceOptimizer.checkConstOptimization();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('캐시 초기화 오류: $e');
|
Log.e('캐시 초기화 오류', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class SubscriptionModel extends HiveObject {
|
|||||||
if (!isEventActive || eventStartDate == null || eventEndDate == null) {
|
if (!isEventActive || eventStartDate == null || eventEndDate == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
return now.isAfter(eventStartDate!) && now.isBefore(eventEndDate!);
|
return now.isAfter(eventStartDate!) && now.isBefore(eventEndDate!);
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ class SubscriptionModel extends HiveObject {
|
|||||||
|
|
||||||
// 원래 가격 (이벤트와 관계없이 항상 정상 가격)
|
// 원래 가격 (이벤트와 관계없이 항상 정상 가격)
|
||||||
double get originalPrice => monthlyCost;
|
double get originalPrice => monthlyCost;
|
||||||
|
|
||||||
// 결제 주기를 영어 키값으로 정규화
|
// 결제 주기를 영어 키값으로 정규화
|
||||||
static String normalizeBillingCycle(String cycle) {
|
static String normalizeBillingCycle(String cycle) {
|
||||||
switch (cycle.toLowerCase()) {
|
switch (cycle.toLowerCase()) {
|
||||||
@@ -121,7 +121,7 @@ class SubscriptionModel extends HiveObject {
|
|||||||
return 'monthly'; // 기본값은 monthly
|
return 'monthly'; // 기본값은 monthly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 결제 주기를 영어 키값으로 반환 (내부 사용)
|
// 결제 주기를 영어 키값으로 반환 (내부 사용)
|
||||||
String get billingCycleKey => normalizeBillingCycle(billingCycle);
|
String get billingCycleKey => normalizeBillingCycle(billingCycle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,22 +37,24 @@ class AppNavigationObserver extends NavigatorObserver {
|
|||||||
if (newRoute != null) {
|
if (newRoute != null) {
|
||||||
_updateNavigationState(newRoute);
|
_updateNavigationState(newRoute);
|
||||||
}
|
}
|
||||||
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
debugPrint(
|
||||||
|
'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateNavigationState(Route<dynamic> route) {
|
void _updateNavigationState(Route<dynamic> route) {
|
||||||
if (navigator?.context == null) return;
|
if (navigator?.context == null) return;
|
||||||
|
|
||||||
final routeName = route.settings.name;
|
final routeName = route.settings.name;
|
||||||
if (routeName == null) return;
|
if (routeName == null) return;
|
||||||
|
|
||||||
// build 완료 후 업데이트하도록 변경
|
// build 완료 후 업데이트하도록 변경
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (navigator?.context == null) return;
|
if (navigator?.context == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final context = navigator!.context;
|
final context = navigator!.context;
|
||||||
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
final navigationProvider =
|
||||||
|
Provider.of<NavigationProvider>(context, listen: false);
|
||||||
navigationProvider.updateByRoute(routeName);
|
navigationProvider.updateByRoute(routeName);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to update navigation state: $e');
|
debugPrint('Failed to update navigation state: $e');
|
||||||
@@ -62,18 +64,19 @@ class AppNavigationObserver extends NavigatorObserver {
|
|||||||
|
|
||||||
void _handlePopWithProvider() {
|
void _handlePopWithProvider() {
|
||||||
if (navigator?.context == null) return;
|
if (navigator?.context == null) return;
|
||||||
|
|
||||||
// build 완료 후 업데이트하도록 변경
|
// build 완료 후 업데이트하도록 변경
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (navigator?.context == null) return;
|
if (navigator?.context == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final context = navigator!.context;
|
final context = navigator!.context;
|
||||||
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
final navigationProvider =
|
||||||
|
Provider.of<NavigationProvider>(context, listen: false);
|
||||||
navigationProvider.pop();
|
navigationProvider.pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to handle pop with provider: $e');
|
debugPrint('Failed to handle pop with provider: $e');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ class CategoryProvider extends ChangeNotifier {
|
|||||||
sortedCategories.sort((a, b) {
|
sortedCategories.sort((a, b) {
|
||||||
final aIndex = _categoryOrder.indexOf(a.name);
|
final aIndex = _categoryOrder.indexOf(a.name);
|
||||||
final bIndex = _categoryOrder.indexOf(b.name);
|
final bIndex = _categoryOrder.indexOf(b.name);
|
||||||
|
|
||||||
// 순서 목록에 없는 카테고리는 맨 뒤로
|
// 순서 목록에 없는 카테고리는 맨 뒤로
|
||||||
if (aIndex == -1) return 1;
|
if (aIndex == -1) return 1;
|
||||||
if (bIndex == -1) return -1;
|
if (bIndex == -1) return -1;
|
||||||
|
|
||||||
return aIndex.compareTo(bIndex);
|
return aIndex.compareTo(bIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
return sortedCategories;
|
return sortedCategories;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +59,17 @@ class CategoryProvider extends ChangeNotifier {
|
|||||||
{'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'},
|
{'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'},
|
||||||
{'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'},
|
{'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'},
|
||||||
{'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'},
|
{'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'},
|
||||||
{'name': 'shoppingEcommerce', 'color': '#FF9800', 'icon': 'shopping_cart'},
|
{
|
||||||
|
'name': 'shoppingEcommerce',
|
||||||
|
'color': '#FF9800',
|
||||||
|
'icon': 'shopping_cart'
|
||||||
|
},
|
||||||
{'name': 'programming', 'color': '#795548', 'icon': 'code'},
|
{'name': 'programming', 'color': '#795548', 'icon': 'code'},
|
||||||
{'name': 'collaborationOffice', 'color': '#607D8B', 'icon': 'business_center'},
|
{
|
||||||
|
'name': 'collaborationOffice',
|
||||||
|
'color': '#607D8B',
|
||||||
|
'icon': 'business_center'
|
||||||
|
},
|
||||||
{'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'},
|
{'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'},
|
||||||
{'name': 'other', 'color': '#9E9E9E', 'icon': 'category'},
|
{'name': 'other', 'color': '#9E9E9E', 'icon': 'category'},
|
||||||
];
|
];
|
||||||
@@ -117,7 +125,7 @@ class CategoryProvider extends ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 이름을 현재 언어에 맞게 반환
|
// 카테고리 이름을 현재 언어에 맞게 반환
|
||||||
String getLocalizedCategoryName(BuildContext context, String categoryKey) {
|
String getLocalizedCategoryName(BuildContext context, String categoryKey) {
|
||||||
final localizations = AppLocalizations.of(context);
|
final localizations = AppLocalizations.of(context);
|
||||||
|
|||||||
@@ -5,24 +5,24 @@ import 'dart:ui' as ui;
|
|||||||
class LocaleProvider extends ChangeNotifier {
|
class LocaleProvider extends ChangeNotifier {
|
||||||
late Box<String> _localeBox;
|
late Box<String> _localeBox;
|
||||||
Locale _locale = const Locale('ko');
|
Locale _locale = const Locale('ko');
|
||||||
|
|
||||||
static const List<String> supportedLanguages = ['en', 'ko', 'ja', 'zh'];
|
static const List<String> supportedLanguages = ['en', 'ko', 'ja', 'zh'];
|
||||||
|
|
||||||
Locale get locale => _locale;
|
Locale get locale => _locale;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
_localeBox = await Hive.openBox<String>('locale');
|
_localeBox = await Hive.openBox<String>('locale');
|
||||||
|
|
||||||
// 저장된 언어 설정 확인
|
// 저장된 언어 설정 확인
|
||||||
final savedLocale = _localeBox.get('locale');
|
final savedLocale = _localeBox.get('locale');
|
||||||
|
|
||||||
if (savedLocale != null) {
|
if (savedLocale != null) {
|
||||||
// 저장된 언어가 있으면 사용
|
// 저장된 언어가 있으면 사용
|
||||||
_locale = Locale(savedLocale);
|
_locale = Locale(savedLocale);
|
||||||
} else {
|
} else {
|
||||||
// 저장된 언어가 없으면 시스템 언어 감지
|
// 저장된 언어가 없으면 시스템 언어 감지
|
||||||
final systemLocale = ui.PlatformDispatcher.instance.locale;
|
final systemLocale = ui.PlatformDispatcher.instance.locale;
|
||||||
|
|
||||||
// 시스템 언어가 지원되는 언어인지 확인
|
// 시스템 언어가 지원되는 언어인지 확인
|
||||||
if (supportedLanguages.contains(systemLocale.languageCode)) {
|
if (supportedLanguages.contains(systemLocale.languageCode)) {
|
||||||
_locale = Locale(systemLocale.languageCode);
|
_locale = Locale(systemLocale.languageCode);
|
||||||
@@ -30,11 +30,11 @@ class LocaleProvider extends ChangeNotifier {
|
|||||||
// 지원되지 않는 언어면 영어 사용
|
// 지원되지 않는 언어면 영어 사용
|
||||||
_locale = const Locale('en');
|
_locale = const Locale('en');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 감지된 언어 저장
|
// 감지된 언어 저장
|
||||||
await _localeBox.put('locale', _locale.languageCode);
|
await _localeBox.put('locale', _locale.languageCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,25 +36,25 @@ class NavigationProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
void updateCurrentIndex(int index, {bool addToHistory = true}) {
|
void updateCurrentIndex(int index, {bool addToHistory = true}) {
|
||||||
if (_currentIndex == index) return;
|
if (_currentIndex == index) return;
|
||||||
|
|
||||||
_currentIndex = index;
|
_currentIndex = index;
|
||||||
_currentRoute = indexToRoute[index] ?? '/';
|
_currentRoute = indexToRoute[index] ?? '/';
|
||||||
_currentTitle = indexToTitle[index] ?? 'home';
|
_currentTitle = indexToTitle[index] ?? 'home';
|
||||||
|
|
||||||
if (addToHistory && index >= 0) {
|
if (addToHistory && index >= 0) {
|
||||||
_navigationHistory.add(index);
|
_navigationHistory.add(index);
|
||||||
if (_navigationHistory.length > 10) {
|
if (_navigationHistory.length > 10) {
|
||||||
_navigationHistory.removeAt(0);
|
_navigationHistory.removeAt(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateByRoute(String route) {
|
void updateByRoute(String route) {
|
||||||
final index = routeToIndex[route] ?? 0;
|
final index = routeToIndex[route] ?? 0;
|
||||||
_currentRoute = route;
|
_currentRoute = route;
|
||||||
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
_currentIndex = index;
|
_currentIndex = index;
|
||||||
_currentTitle = indexToTitle[index] ?? 'home';
|
_currentTitle = indexToTitle[index] ?? 'home';
|
||||||
@@ -70,7 +70,7 @@ class NavigationProvider extends ChangeNotifier {
|
|||||||
_currentTitle = 'home';
|
_currentTitle = 'home';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,4 +103,4 @@ class NavigationProvider extends ChangeNotifier {
|
|||||||
_navigationHistory.add(0);
|
_navigationHistory.add(0);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,12 +86,12 @@ class NotificationProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
_isEnabled = value;
|
_isEnabled = value;
|
||||||
await NotificationService.setNotificationEnabled(value);
|
await NotificationService.setNotificationEnabled(value);
|
||||||
|
|
||||||
// 첫 권한 부여 시 기본 설정 적용
|
// 첫 권한 부여 시 기본 설정 적용
|
||||||
if (value) {
|
if (value) {
|
||||||
await initializeDefaultSettingsOnFirstPermission();
|
await initializeDefaultSettingsOnFirstPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('알림 활성화 설정 중 오류 발생: $e');
|
debugPrint('알림 활성화 설정 중 오류 발생: $e');
|
||||||
@@ -270,15 +270,17 @@ class NotificationProvider extends ChangeNotifier {
|
|||||||
// 첫 권한 부여 시 기본 설정 초기화
|
// 첫 권한 부여 시 기본 설정 초기화
|
||||||
Future<void> initializeDefaultSettingsOnFirstPermission() async {
|
Future<void> initializeDefaultSettingsOnFirstPermission() async {
|
||||||
try {
|
try {
|
||||||
final firstGranted = await _secureStorage.read(key: _firstPermissionGrantedKey);
|
final firstGranted =
|
||||||
|
await _secureStorage.read(key: _firstPermissionGrantedKey);
|
||||||
if (firstGranted != 'true') {
|
if (firstGranted != 'true') {
|
||||||
// 첫 권한 부여 시 기본값 설정
|
// 첫 권한 부여 시 기본값 설정
|
||||||
await setReminderDays(2); // 2일 전 알림
|
await setReminderDays(2); // 2일 전 알림
|
||||||
await setDailyReminderEnabled(true); // 반복 알림 활성화
|
await setDailyReminderEnabled(true); // 반복 알림 활성화
|
||||||
await setPaymentEnabled(true); // 결제 예정 알림 활성화
|
await setPaymentEnabled(true); // 결제 예정 알림 활성화
|
||||||
|
|
||||||
// 첫 권한 부여 플래그 저장
|
// 첫 권한 부여 플래그 저장
|
||||||
await _secureStorage.write(key: _firstPermissionGrantedKey, value: 'true');
|
await _secureStorage.write(
|
||||||
|
key: _firstPermissionGrantedKey, value: 'true');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('기본 설정 초기화 중 오류 발생: $e');
|
debugPrint('기본 설정 초기화 중 오류 발생: $e');
|
||||||
|
|||||||
@@ -19,23 +19,24 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
double get totalMonthlyExpense {
|
double get totalMonthlyExpense {
|
||||||
final exchangeRateService = ExchangeRateService();
|
final exchangeRateService = ExchangeRateService();
|
||||||
final rate = exchangeRateService.cachedUsdToKrwRate ??
|
final rate = exchangeRateService.cachedUsdToKrwRate ??
|
||||||
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
|
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
|
||||||
|
|
||||||
final total = _subscriptions.fold(
|
final total = _subscriptions.fold(
|
||||||
0.0,
|
0.0,
|
||||||
(sum, subscription) {
|
(sum, subscription) {
|
||||||
final price = subscription.currentPrice;
|
final price = subscription.currentPrice;
|
||||||
if (subscription.currency == 'USD') {
|
if (subscription.currency == 'USD') {
|
||||||
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
|
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
|
||||||
'\$${price} × ₩$rate = ₩${price * rate}');
|
'\$$price × ₩$rate = ₩${price * rate}');
|
||||||
return sum + (price * rate);
|
return sum + (price * rate);
|
||||||
}
|
}
|
||||||
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
|
debugPrint(
|
||||||
|
'[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
|
||||||
return sum + price;
|
return sum + price;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: '
|
debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: '
|
||||||
'${_subscriptions.length}개 구독, 총액 ₩$total');
|
'${_subscriptions.length}개 구독, 총액 ₩$total');
|
||||||
return total;
|
return total;
|
||||||
@@ -69,10 +70,10 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
|
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
|
||||||
await refreshSubscriptions();
|
await refreshSubscriptions();
|
||||||
|
|
||||||
// categoryId 마이그레이션
|
// categoryId 마이그레이션
|
||||||
await _migrateCategoryIds();
|
await _migrateCategoryIds();
|
||||||
|
|
||||||
// 앱 시작 시 이벤트 상태 확인
|
// 앱 시작 시 이벤트 상태 확인
|
||||||
await checkAndUpdateEventStatus();
|
await checkAndUpdateEventStatus();
|
||||||
|
|
||||||
@@ -90,11 +91,11 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
_subscriptions = _subscriptionBox.values.toList()
|
_subscriptions = _subscriptionBox.values.toList()
|
||||||
..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
|
..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
|
||||||
|
|
||||||
debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: '
|
debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: '
|
||||||
'${_subscriptions.length}개 구독, '
|
'${_subscriptions.length}개 구독, '
|
||||||
'총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
|
'총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('구독 목록 새로고침 중 오류 발생: $e');
|
debugPrint('구독 목록 새로고침 중 오류 발생: $e');
|
||||||
@@ -139,7 +140,7 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
await _subscriptionBox.put(subscription.id, subscription);
|
await _subscriptionBox.put(subscription.id, subscription);
|
||||||
await refreshSubscriptions();
|
await refreshSubscriptions();
|
||||||
|
|
||||||
// 이벤트가 활성화된 경우 알림 스케줄 재설정
|
// 이벤트가 활성화된 경우 알림 스케줄 재설정
|
||||||
if (isEventActive && eventEndDate != null) {
|
if (isEventActive && eventEndDate != null) {
|
||||||
await _scheduleEventEndNotification(subscription);
|
await _scheduleEventEndNotification(subscription);
|
||||||
@@ -191,7 +192,6 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> clearAllSubscriptions() async {
|
Future<void> clearAllSubscriptions() async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -217,8 +217,9 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 이벤트 종료 알림을 스케줄링합니다.
|
/// 이벤트 종료 알림을 스케줄링합니다.
|
||||||
Future<void> _scheduleEventEndNotification(SubscriptionModel subscription) async {
|
Future<void> _scheduleEventEndNotification(
|
||||||
if (subscription.eventEndDate != null &&
|
SubscriptionModel subscription) async {
|
||||||
|
if (subscription.eventEndDate != null &&
|
||||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||||
await NotificationService.scheduleNotification(
|
await NotificationService.scheduleNotification(
|
||||||
id: '${subscription.id}_event_end'.hashCode,
|
id: '${subscription.id}_event_end'.hashCode,
|
||||||
@@ -232,19 +233,18 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
/// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다.
|
/// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다.
|
||||||
Future<void> checkAndUpdateEventStatus() async {
|
Future<void> checkAndUpdateEventStatus() async {
|
||||||
bool hasChanges = false;
|
bool hasChanges = false;
|
||||||
|
|
||||||
for (var subscription in _subscriptions) {
|
for (var subscription in _subscriptions) {
|
||||||
// 이벤트가 종료되었지만 아직 활성화되어 있는 경우
|
// 이벤트가 종료되었지만 아직 활성화되어 있는 경우
|
||||||
if (subscription.isEventActive &&
|
if (subscription.isEventActive &&
|
||||||
subscription.eventEndDate != null &&
|
subscription.eventEndDate != null &&
|
||||||
subscription.eventEndDate!.isBefore(DateTime.now())) {
|
subscription.eventEndDate!.isBefore(DateTime.now())) {
|
||||||
|
|
||||||
subscription.isEventActive = false;
|
subscription.isEventActive = false;
|
||||||
await _subscriptionBox.put(subscription.id, subscription);
|
await _subscriptionBox.put(subscription.id, subscription);
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
await refreshSubscriptions();
|
await refreshSubscriptions();
|
||||||
}
|
}
|
||||||
@@ -253,70 +253,73 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
|
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
|
||||||
Future<double> calculateTotalExpense({String? locale}) async {
|
Future<double> calculateTotalExpense({String? locale}) async {
|
||||||
if (_subscriptions.isEmpty) return 0.0;
|
if (_subscriptions.isEmpty) return 0.0;
|
||||||
|
|
||||||
// locale이 제공되지 않으면 현재 로케일 사용
|
// locale이 제공되지 않으면 현재 로케일 사용
|
||||||
final targetCurrency = locale != null
|
final targetCurrency =
|
||||||
? CurrencyUtil.getDefaultCurrency(locale)
|
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
|
||||||
: 'KRW'; // 기본값
|
|
||||||
|
|
||||||
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
|
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
|
||||||
double total = 0.0;
|
double total = 0.0;
|
||||||
|
|
||||||
for (final subscription in _subscriptions) {
|
for (final subscription in _subscriptions) {
|
||||||
final currentPrice = subscription.currentPrice;
|
final currentPrice = subscription.currentPrice;
|
||||||
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
|
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
|
||||||
'${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
||||||
|
|
||||||
final converted = await ExchangeRateService().convertBetweenCurrencies(
|
final converted = await ExchangeRateService().convertBetweenCurrencies(
|
||||||
currentPrice,
|
currentPrice,
|
||||||
subscription.currency,
|
subscription.currency,
|
||||||
targetCurrency,
|
targetCurrency,
|
||||||
);
|
);
|
||||||
|
|
||||||
total += converted ?? currentPrice;
|
total += converted ?? currentPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency');
|
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency');
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
|
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
|
||||||
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({String? locale}) async {
|
Future<List<Map<String, dynamic>>> getMonthlyExpenseData(
|
||||||
|
{String? locale}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final List<Map<String, dynamic>> monthlyData = [];
|
final List<Map<String, dynamic>> monthlyData = [];
|
||||||
|
|
||||||
// locale이 제공되지 않으면 현재 로케일 사용
|
// locale이 제공되지 않으면 현재 로케일 사용
|
||||||
final targetCurrency = locale != null
|
final targetCurrency =
|
||||||
? CurrencyUtil.getDefaultCurrency(locale)
|
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
|
||||||
: 'KRW'; // 기본값
|
|
||||||
|
|
||||||
// 최근 6개월 데이터 생성
|
// 최근 6개월 데이터 생성
|
||||||
for (int i = 5; i >= 0; i--) {
|
for (int i = 5; i >= 0; i--) {
|
||||||
final month = DateTime(now.year, now.month - i, 1);
|
final month = DateTime(now.year, now.month - i, 1);
|
||||||
double monthTotal = 0.0;
|
double monthTotal = 0.0;
|
||||||
|
|
||||||
// 현재 월인지 확인
|
// 현재 월인지 확인
|
||||||
final isCurrentMonth = (month.year == now.year && month.month == now.month);
|
final isCurrentMonth =
|
||||||
|
(month.year == now.year && month.month == now.month);
|
||||||
|
|
||||||
if (isCurrentMonth) {
|
if (isCurrentMonth) {
|
||||||
debugPrint('[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
|
debugPrint(
|
||||||
|
'[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 해당 월에 활성화된 구독 계산
|
// 해당 월에 활성화된 구독 계산
|
||||||
for (final subscription in _subscriptions) {
|
for (final subscription in _subscriptions) {
|
||||||
if (isCurrentMonth) {
|
if (isCurrentMonth) {
|
||||||
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
|
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
|
||||||
final cost = subscription.currentPrice;
|
final cost = subscription.currentPrice;
|
||||||
debugPrint('[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
|
debugPrint(
|
||||||
'${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
|
||||||
|
'$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
||||||
|
|
||||||
// 통화 변환
|
// 통화 변환
|
||||||
final converted = await ExchangeRateService().convertBetweenCurrencies(
|
final converted =
|
||||||
|
await ExchangeRateService().convertBetweenCurrencies(
|
||||||
cost,
|
cost,
|
||||||
subscription.currency,
|
subscription.currency,
|
||||||
targetCurrency,
|
targetCurrency,
|
||||||
);
|
);
|
||||||
|
|
||||||
monthTotal += converted ?? cost;
|
monthTotal += converted ?? cost;
|
||||||
} else {
|
} else {
|
||||||
// 과거 월인 경우: 기존 로직 유지
|
// 과거 월인 경우: 기존 로직 유지
|
||||||
@@ -324,46 +327,50 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
final subscriptionStartDate = subscription.nextBillingDate.subtract(
|
final subscriptionStartDate = subscription.nextBillingDate.subtract(
|
||||||
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
|
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
if (subscriptionStartDate
|
||||||
|
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
||||||
subscription.nextBillingDate.isAfter(month)) {
|
subscription.nextBillingDate.isAfter(month)) {
|
||||||
// 해당 월의 비용 계산 (이벤트 가격 고려)
|
// 해당 월의 비용 계산 (이벤트 가격 고려)
|
||||||
double cost;
|
double cost;
|
||||||
|
|
||||||
if (subscription.isEventActive &&
|
if (subscription.isEventActive &&
|
||||||
subscription.eventStartDate != null &&
|
subscription.eventStartDate != null &&
|
||||||
subscription.eventEndDate != null &&
|
subscription.eventEndDate != null &&
|
||||||
// 이벤트 기간과 해당 월이 겹치는지 확인
|
// 이벤트 기간과 해당 월이 겹치는지 확인
|
||||||
subscription.eventStartDate!.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
subscription.eventStartDate!
|
||||||
|
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
||||||
subscription.eventEndDate!.isAfter(month)) {
|
subscription.eventEndDate!.isAfter(month)) {
|
||||||
cost = subscription.eventPrice ?? subscription.monthlyCost;
|
cost = subscription.eventPrice ?? subscription.monthlyCost;
|
||||||
} else {
|
} else {
|
||||||
cost = subscription.monthlyCost;
|
cost = subscription.monthlyCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 통화 변환
|
// 통화 변환
|
||||||
final converted = await ExchangeRateService().convertBetweenCurrencies(
|
final converted =
|
||||||
|
await ExchangeRateService().convertBetweenCurrencies(
|
||||||
cost,
|
cost,
|
||||||
subscription.currency,
|
subscription.currency,
|
||||||
targetCurrency,
|
targetCurrency,
|
||||||
);
|
);
|
||||||
|
|
||||||
monthTotal += converted ?? cost;
|
monthTotal += converted ?? cost;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCurrentMonth) {
|
if (isCurrentMonth) {
|
||||||
debugPrint('[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
|
debugPrint(
|
||||||
|
'[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
|
||||||
}
|
}
|
||||||
|
|
||||||
monthlyData.add({
|
monthlyData.add({
|
||||||
'month': month,
|
'month': month,
|
||||||
'totalExpense': monthTotal,
|
'totalExpense': monthTotal,
|
||||||
'monthName': _getMonthLabel(month, locale ?? 'en'),
|
'monthName': _getMonthLabel(month, locale ?? 'en'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return monthlyData;
|
return monthlyData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,98 +416,109 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
/// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당
|
/// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당
|
||||||
Future<void> _migrateCategoryIds() async {
|
Future<void> _migrateCategoryIds() async {
|
||||||
debugPrint('❎ CategoryId 마이그레이션 시작...');
|
debugPrint('❎ CategoryId 마이그레이션 시작...');
|
||||||
|
|
||||||
final categoryProvider = CategoryProvider();
|
final categoryProvider = CategoryProvider();
|
||||||
await categoryProvider.init();
|
await categoryProvider.init();
|
||||||
final categories = categoryProvider.categories;
|
final categories = categoryProvider.categories;
|
||||||
|
|
||||||
int migratedCount = 0;
|
int migratedCount = 0;
|
||||||
|
|
||||||
for (var subscription in _subscriptions) {
|
for (var subscription in _subscriptions) {
|
||||||
if (subscription.categoryId == null) {
|
if (subscription.categoryId == null) {
|
||||||
final serviceName = subscription.serviceName.toLowerCase();
|
final serviceName = subscription.serviceName.toLowerCase();
|
||||||
String? categoryId;
|
String? categoryId;
|
||||||
|
|
||||||
debugPrint('🔍 ${subscription.serviceName} 카테고리 매칭 시도...');
|
debugPrint('🔍 ${subscription.serviceName} 카테고리 매칭 시도...');
|
||||||
|
|
||||||
// OTT 서비스
|
// OTT 서비스
|
||||||
if (serviceName.contains('netflix') ||
|
if (serviceName.contains('netflix') ||
|
||||||
serviceName.contains('youtube') ||
|
serviceName.contains('youtube') ||
|
||||||
serviceName.contains('disney') ||
|
serviceName.contains('disney') ||
|
||||||
serviceName.contains('왓차') ||
|
serviceName.contains('왓차') ||
|
||||||
serviceName.contains('티빙') ||
|
serviceName.contains('티빙') ||
|
||||||
serviceName.contains('디즈니') ||
|
serviceName.contains('디즈니') ||
|
||||||
serviceName.contains('넷플릭스')) {
|
serviceName.contains('넷플릭스')) {
|
||||||
categoryId = categories.firstWhere(
|
categoryId = categories
|
||||||
(cat) => cat.name == 'OTT 서비스',
|
.firstWhere(
|
||||||
orElse: () => categories.first,
|
(cat) => cat.name == 'OTT 서비스',
|
||||||
).id;
|
orElse: () => categories.first,
|
||||||
|
)
|
||||||
|
.id;
|
||||||
}
|
}
|
||||||
// 음악 서비스
|
// 음악 서비스
|
||||||
else if (serviceName.contains('spotify') ||
|
else if (serviceName.contains('spotify') ||
|
||||||
serviceName.contains('apple music') ||
|
serviceName.contains('apple music') ||
|
||||||
serviceName.contains('멜론') ||
|
serviceName.contains('멜론') ||
|
||||||
serviceName.contains('지니') ||
|
serviceName.contains('지니') ||
|
||||||
serviceName.contains('플로') ||
|
serviceName.contains('플로') ||
|
||||||
serviceName.contains('벡스')) {
|
serviceName.contains('벡스')) {
|
||||||
categoryId = categories.firstWhere(
|
categoryId = categories
|
||||||
(cat) => cat.name == 'music',
|
.firstWhere(
|
||||||
orElse: () => categories.first,
|
(cat) => cat.name == 'music',
|
||||||
).id;
|
orElse: () => categories.first,
|
||||||
|
)
|
||||||
|
.id;
|
||||||
}
|
}
|
||||||
// AI 서비스
|
// AI 서비스
|
||||||
else if (serviceName.contains('chatgpt') ||
|
else if (serviceName.contains('chatgpt') ||
|
||||||
serviceName.contains('claude') ||
|
serviceName.contains('claude') ||
|
||||||
serviceName.contains('midjourney') ||
|
serviceName.contains('midjourney') ||
|
||||||
serviceName.contains('copilot')) {
|
serviceName.contains('copilot')) {
|
||||||
categoryId = categories.firstWhere(
|
categoryId = categories
|
||||||
(cat) => cat.name == 'aiService',
|
.firstWhere(
|
||||||
orElse: () => categories.first,
|
(cat) => cat.name == 'aiService',
|
||||||
).id;
|
orElse: () => categories.first,
|
||||||
|
)
|
||||||
|
.id;
|
||||||
}
|
}
|
||||||
// 프로그래밍/개발
|
// 프로그래밍/개발
|
||||||
else if (serviceName.contains('github') ||
|
else if (serviceName.contains('github') ||
|
||||||
serviceName.contains('intellij') ||
|
serviceName.contains('intellij') ||
|
||||||
serviceName.contains('webstorm') ||
|
serviceName.contains('webstorm') ||
|
||||||
serviceName.contains('jetbrains')) {
|
serviceName.contains('jetbrains')) {
|
||||||
categoryId = categories.firstWhere(
|
categoryId = categories
|
||||||
(cat) => cat.name == 'programming',
|
.firstWhere(
|
||||||
orElse: () => categories.first,
|
(cat) => cat.name == 'programming',
|
||||||
).id;
|
orElse: () => categories.first,
|
||||||
|
)
|
||||||
|
.id;
|
||||||
}
|
}
|
||||||
// 오피스/협업 툴
|
// 오피스/협업 툴
|
||||||
else if (serviceName.contains('notion') ||
|
else if (serviceName.contains('notion') ||
|
||||||
serviceName.contains('microsoft') ||
|
serviceName.contains('microsoft') ||
|
||||||
serviceName.contains('office') ||
|
serviceName.contains('office') ||
|
||||||
serviceName.contains('slack') ||
|
serviceName.contains('slack') ||
|
||||||
serviceName.contains('figma') ||
|
serviceName.contains('figma') ||
|
||||||
serviceName.contains('icloud') ||
|
serviceName.contains('icloud') ||
|
||||||
serviceName.contains('아이클라우드')) {
|
serviceName.contains('아이클라우드')) {
|
||||||
categoryId = categories.firstWhere(
|
categoryId = categories
|
||||||
(cat) => cat.name == 'collaborationOffice',
|
.firstWhere(
|
||||||
orElse: () => categories.first,
|
(cat) => cat.name == 'collaborationOffice',
|
||||||
).id;
|
orElse: () => categories.first,
|
||||||
|
)
|
||||||
|
.id;
|
||||||
}
|
}
|
||||||
// 기타 서비스 (기본값)
|
// 기타 서비스 (기본값)
|
||||||
else {
|
else {
|
||||||
categoryId = categories.firstWhere(
|
categoryId = categories
|
||||||
(cat) => cat.name == 'other',
|
.firstWhere(
|
||||||
orElse: () => categories.first,
|
(cat) => cat.name == 'other',
|
||||||
).id;
|
orElse: () => categories.first,
|
||||||
}
|
)
|
||||||
|
.id;
|
||||||
if (categoryId != null) {
|
|
||||||
subscription.categoryId = categoryId;
|
|
||||||
await subscription.save();
|
|
||||||
migratedCount++;
|
|
||||||
final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name;
|
|
||||||
debugPrint('✅ ${subscription.serviceName} → $categoryName');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subscription.categoryId = categoryId;
|
||||||
|
await subscription.save();
|
||||||
|
migratedCount++;
|
||||||
|
final categoryName =
|
||||||
|
categories.firstWhere((cat) => cat.id == categoryId).name;
|
||||||
|
debugPrint('✅ ${subscription.serviceName} → $categoryName');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (migratedCount > 0) {
|
if (migratedCount > 0) {
|
||||||
debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료');
|
debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료');
|
||||||
await refreshSubscriptions();
|
await refreshSubscriptions();
|
||||||
} else {
|
} else {
|
||||||
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');
|
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');
|
||||||
|
|||||||
@@ -7,24 +7,24 @@ import '../theme/adaptive_theme.dart';
|
|||||||
class ThemeProvider extends ChangeNotifier {
|
class ThemeProvider extends ChangeNotifier {
|
||||||
static const String _themeBoxName = 'theme_settings';
|
static const String _themeBoxName = 'theme_settings';
|
||||||
static const String _themeKey = 'theme_settings';
|
static const String _themeKey = 'theme_settings';
|
||||||
|
|
||||||
late Box<Map> _themeBox;
|
late Box<Map> _themeBox;
|
||||||
ThemeSettings _themeSettings = const ThemeSettings();
|
ThemeSettings _themeSettings = const ThemeSettings();
|
||||||
|
|
||||||
ThemeSettings get themeSettings => _themeSettings;
|
ThemeSettings get themeSettings => _themeSettings;
|
||||||
|
|
||||||
AppThemeMode get themeMode => _themeSettings.mode;
|
AppThemeMode get themeMode => _themeSettings.mode;
|
||||||
bool get useSystemColors => _themeSettings.useSystemColors;
|
bool get useSystemColors => _themeSettings.useSystemColors;
|
||||||
bool get largeText => _themeSettings.largeText;
|
bool get largeText => _themeSettings.largeText;
|
||||||
bool get reduceMotion => _themeSettings.reduceMotion;
|
bool get reduceMotion => _themeSettings.reduceMotion;
|
||||||
bool get highContrast => _themeSettings.highContrast;
|
bool get highContrast => _themeSettings.highContrast;
|
||||||
|
|
||||||
/// Provider 초기화
|
/// Provider 초기화
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
_themeBox = await Hive.openBox<Map>(_themeBoxName);
|
_themeBox = await Hive.openBox<Map>(_themeBoxName);
|
||||||
await _loadThemeSettings();
|
await _loadThemeSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 저장된 테마 설정 로드
|
/// 저장된 테마 설정 로드
|
||||||
Future<void> _loadThemeSettings() async {
|
Future<void> _loadThemeSettings() async {
|
||||||
final savedSettings = _themeBox.get(_themeKey);
|
final savedSettings = _themeBox.get(_themeKey);
|
||||||
@@ -35,53 +35,53 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 테마 설정 저장
|
/// 테마 설정 저장
|
||||||
Future<void> _saveThemeSettings() async {
|
Future<void> _saveThemeSettings() async {
|
||||||
await _themeBox.put(_themeKey, _themeSettings.toJson());
|
await _themeBox.put(_themeKey, _themeSettings.toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 테마 모드 변경
|
/// 테마 모드 변경
|
||||||
Future<void> setThemeMode(AppThemeMode mode) async {
|
Future<void> setThemeMode(AppThemeMode mode) async {
|
||||||
_themeSettings = _themeSettings.copyWith(mode: mode);
|
_themeSettings = _themeSettings.copyWith(mode: mode);
|
||||||
await _saveThemeSettings();
|
await _saveThemeSettings();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 시스템 색상 사용 설정
|
/// 시스템 색상 사용 설정
|
||||||
Future<void> setUseSystemColors(bool value) async {
|
Future<void> setUseSystemColors(bool value) async {
|
||||||
_themeSettings = _themeSettings.copyWith(useSystemColors: value);
|
_themeSettings = _themeSettings.copyWith(useSystemColors: value);
|
||||||
await _saveThemeSettings();
|
await _saveThemeSettings();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 큰 텍스트 설정
|
/// 큰 텍스트 설정
|
||||||
Future<void> setLargeText(bool value) async {
|
Future<void> setLargeText(bool value) async {
|
||||||
_themeSettings = _themeSettings.copyWith(largeText: value);
|
_themeSettings = _themeSettings.copyWith(largeText: value);
|
||||||
await _saveThemeSettings();
|
await _saveThemeSettings();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 모션 감소 설정
|
/// 모션 감소 설정
|
||||||
Future<void> setReduceMotion(bool value) async {
|
Future<void> setReduceMotion(bool value) async {
|
||||||
_themeSettings = _themeSettings.copyWith(reduceMotion: value);
|
_themeSettings = _themeSettings.copyWith(reduceMotion: value);
|
||||||
await _saveThemeSettings();
|
await _saveThemeSettings();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 고대비 설정
|
/// 고대비 설정
|
||||||
Future<void> setHighContrast(bool value) async {
|
Future<void> setHighContrast(bool value) async {
|
||||||
_themeSettings = _themeSettings.copyWith(highContrast: value);
|
_themeSettings = _themeSettings.copyWith(highContrast: value);
|
||||||
await _saveThemeSettings();
|
await _saveThemeSettings();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 현재 설정에 따른 테마 가져오기
|
/// 현재 설정에 따른 테마 가져오기
|
||||||
ThemeData getTheme(BuildContext context) {
|
ThemeData getTheme(BuildContext context) {
|
||||||
final platformBrightness = MediaQuery.of(context).platformBrightness;
|
final platformBrightness = MediaQuery.of(context).platformBrightness;
|
||||||
|
|
||||||
ThemeData baseTheme;
|
ThemeData baseTheme;
|
||||||
|
|
||||||
switch (_themeSettings.mode) {
|
switch (_themeSettings.mode) {
|
||||||
case AppThemeMode.light:
|
case AppThemeMode.light:
|
||||||
baseTheme = AdaptiveTheme.lightTheme;
|
baseTheme = AdaptiveTheme.lightTheme;
|
||||||
@@ -98,7 +98,7 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
: AdaptiveTheme.lightTheme;
|
: AdaptiveTheme.lightTheme;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 접근성 설정 적용
|
// 접근성 설정 적용
|
||||||
return AdaptiveTheme.getAccessibleTheme(
|
return AdaptiveTheme.getAccessibleTheme(
|
||||||
baseTheme,
|
baseTheme,
|
||||||
@@ -107,11 +107,11 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
highContrast: _themeSettings.highContrast,
|
highContrast: _themeSettings.highContrast,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 현재 테마가 다크 모드인지 확인
|
/// 현재 테마가 다크 모드인지 확인
|
||||||
bool isDarkMode(BuildContext context) {
|
bool isDarkMode(BuildContext context) {
|
||||||
final platformBrightness = MediaQuery.of(context).platformBrightness;
|
final platformBrightness = MediaQuery.of(context).platformBrightness;
|
||||||
|
|
||||||
switch (_themeSettings.mode) {
|
switch (_themeSettings.mode) {
|
||||||
case AppThemeMode.light:
|
case AppThemeMode.light:
|
||||||
return false;
|
return false;
|
||||||
@@ -122,7 +122,7 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
return platformBrightness == Brightness.dark;
|
return platformBrightness == Brightness.dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 테마 토글 (라이트/다크)
|
/// 테마 토글 (라이트/다크)
|
||||||
Future<void> toggleTheme() async {
|
Future<void> toggleTheme() async {
|
||||||
if (_themeSettings.mode == AppThemeMode.light) {
|
if (_themeSettings.mode == AppThemeMode.light) {
|
||||||
@@ -137,7 +137,7 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
class AnimatedThemeBuilder extends StatelessWidget {
|
class AnimatedThemeBuilder extends StatelessWidget {
|
||||||
final Widget Function(BuildContext, ThemeData) builder;
|
final Widget Function(BuildContext, ThemeData) builder;
|
||||||
final Duration duration;
|
final Duration duration;
|
||||||
|
|
||||||
const AnimatedThemeBuilder({
|
const AnimatedThemeBuilder({
|
||||||
super.key,
|
super.key,
|
||||||
required this.builder,
|
required this.builder,
|
||||||
@@ -148,7 +148,7 @@ class AnimatedThemeBuilder extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
final theme = themeProvider.getTheme(context);
|
final theme = themeProvider.getTheme(context);
|
||||||
|
|
||||||
return AnimatedTheme(
|
return AnimatedTheme(
|
||||||
data: theme,
|
data: theme,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
@@ -164,7 +164,7 @@ class ThemedColor extends StatelessWidget {
|
|||||||
final Color lightColor;
|
final Color lightColor;
|
||||||
final Color darkColor;
|
final Color darkColor;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const ThemedColor({
|
const ThemedColor({
|
||||||
super.key,
|
super.key,
|
||||||
required this.lightColor,
|
required this.lightColor,
|
||||||
@@ -175,7 +175,7 @@ class ThemedColor extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = context.read<ThemeProvider>().isDarkMode(context);
|
final isDark = context.read<ThemeProvider>().isDarkMode(context);
|
||||||
|
|
||||||
return Theme(
|
return Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
primaryColor: isDark ? darkColor : lightColor,
|
primaryColor: isDark ? darkColor : lightColor,
|
||||||
@@ -183,4 +183,4 @@ class ThemedColor extends StatelessWidget {
|
|||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:submanager/screens/sms_scan_screen.dart';
|
|||||||
import 'package:submanager/screens/analysis_screen.dart';
|
import 'package:submanager/screens/analysis_screen.dart';
|
||||||
import 'package:submanager/screens/settings_screen.dart';
|
import 'package:submanager/screens/settings_screen.dart';
|
||||||
import 'package:submanager/screens/splash_screen.dart';
|
import 'package:submanager/screens/splash_screen.dart';
|
||||||
|
import 'package:submanager/screens/sms_permission_screen.dart';
|
||||||
import 'package:submanager/models/subscription_model.dart';
|
import 'package:submanager/models/subscription_model.dart';
|
||||||
|
|
||||||
class AppRoutes {
|
class AppRoutes {
|
||||||
@@ -16,6 +17,7 @@ class AppRoutes {
|
|||||||
static const String smsScanner = '/sms-scanner';
|
static const String smsScanner = '/sms-scanner';
|
||||||
static const String analysis = '/analysis';
|
static const String analysis = '/analysis';
|
||||||
static const String settings = '/settings';
|
static const String settings = '/settings';
|
||||||
|
static const String smsPermission = '/sms-permission';
|
||||||
|
|
||||||
static Map<String, WidgetBuilder> getRoutes() {
|
static Map<String, WidgetBuilder> getRoutes() {
|
||||||
return {
|
return {
|
||||||
@@ -25,6 +27,7 @@ class AppRoutes {
|
|||||||
smsScanner: (context) => const SmsScanScreen(),
|
smsScanner: (context) => const SmsScanScreen(),
|
||||||
analysis: (context) => const AnalysisScreen(),
|
analysis: (context) => const AnalysisScreen(),
|
||||||
settings: (context) => const SettingsScreen(),
|
settings: (context) => const SettingsScreen(),
|
||||||
|
smsPermission: (context) => const SmsPermissionScreen(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,29 +35,33 @@ class AppRoutes {
|
|||||||
switch (routeSettings.name) {
|
switch (routeSettings.name) {
|
||||||
case splash:
|
case splash:
|
||||||
return _buildRoute(const SplashScreen(), routeSettings);
|
return _buildRoute(const SplashScreen(), routeSettings);
|
||||||
|
|
||||||
case main:
|
case main:
|
||||||
return _buildRoute(const MainScreen(), routeSettings);
|
return _buildRoute(const MainScreen(), routeSettings);
|
||||||
|
|
||||||
case addSubscription:
|
case addSubscription:
|
||||||
return _buildRoute(const AddSubscriptionScreen(), routeSettings);
|
return _buildRoute(const AddSubscriptionScreen(), routeSettings);
|
||||||
|
|
||||||
case subscriptionDetail:
|
case subscriptionDetail:
|
||||||
final subscription = routeSettings.arguments as SubscriptionModel?;
|
final subscription = routeSettings.arguments as SubscriptionModel?;
|
||||||
if (subscription != null) {
|
if (subscription != null) {
|
||||||
return _buildRoute(DetailScreen(subscription: subscription), routeSettings);
|
return _buildRoute(
|
||||||
|
DetailScreen(subscription: subscription), routeSettings);
|
||||||
}
|
}
|
||||||
return _errorRoute();
|
return _errorRoute();
|
||||||
|
|
||||||
case smsScanner:
|
case smsScanner:
|
||||||
return _buildRoute(const SmsScanScreen(), routeSettings);
|
return _buildRoute(const SmsScanScreen(), routeSettings);
|
||||||
|
|
||||||
case analysis:
|
case analysis:
|
||||||
return _buildRoute(const AnalysisScreen(), routeSettings);
|
return _buildRoute(const AnalysisScreen(), routeSettings);
|
||||||
|
|
||||||
case settings:
|
case settings:
|
||||||
return _buildRoute(const SettingsScreen(), routeSettings);
|
return _buildRoute(const SettingsScreen(), routeSettings);
|
||||||
|
|
||||||
|
case smsPermission:
|
||||||
|
return _buildRoute(const SmsPermissionScreen(), routeSettings);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return _errorRoute();
|
return _errorRoute();
|
||||||
}
|
}
|
||||||
@@ -77,15 +84,18 @@ class AppRoutes {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void navigateTo(BuildContext context, String routeName, {Object? arguments}) {
|
static void navigateTo(BuildContext context, String routeName,
|
||||||
|
{Object? arguments}) {
|
||||||
Navigator.pushNamed(context, routeName, arguments: arguments);
|
Navigator.pushNamed(context, routeName, arguments: arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void navigateAndReplace(BuildContext context, String routeName, {Object? arguments}) {
|
static void navigateAndReplace(BuildContext context, String routeName,
|
||||||
|
{Object? arguments}) {
|
||||||
Navigator.pushReplacementNamed(context, routeName, arguments: arguments);
|
Navigator.pushReplacementNamed(context, routeName, arguments: arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void navigateAndRemoveUntil(BuildContext context, String routeName, {Object? arguments}) {
|
static void navigateAndRemoveUntil(BuildContext context, String routeName,
|
||||||
|
{Object? arguments}) {
|
||||||
Navigator.pushNamedAndRemoveUntil(
|
Navigator.pushNamedAndRemoveUntil(
|
||||||
context,
|
context,
|
||||||
routeName,
|
routeName,
|
||||||
@@ -103,4 +113,4 @@ class AppRoutes {
|
|||||||
static bool canPop(BuildContext context) {
|
static bool canPop(BuildContext context) {
|
||||||
return Navigator.canPop(context);
|
return Navigator.canPop(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: MediaQuery.of(context).padding.top + 60),
|
SizedBox(height: MediaQuery.of(context).padding.top + 60),
|
||||||
|
|
||||||
// 헤더 섹션
|
// 헤더 섹션
|
||||||
AddSubscriptionHeader(
|
AddSubscriptionHeader(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
fadeAnimation: _controller.fadeAnimation!,
|
fadeAnimation: _controller.fadeAnimation!,
|
||||||
slideAnimation: _controller.slideAnimation!,
|
slideAnimation: _controller.slideAnimation!,
|
||||||
),
|
),
|
||||||
|
|
||||||
// 서비스 정보 폼
|
// 서비스 정보 폼
|
||||||
AddSubscriptionForm(
|
AddSubscriptionForm(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
@@ -78,7 +78,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
setState: setState,
|
setState: setState,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 이벤트/할인 섹션
|
// 이벤트/할인 섹션
|
||||||
AddSubscriptionEventSection(
|
AddSubscriptionEventSection(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
@@ -87,7 +87,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
setState: setState,
|
setState: setState,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// 저장 버튼
|
// 저장 버튼
|
||||||
AddSubscriptionSaveButton(
|
AddSubscriptionSaveButton(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
@@ -101,4 +101,4 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
// Provider 변경 감지
|
// Provider 변경 감지
|
||||||
final provider = Provider.of<SubscriptionProvider>(context);
|
final provider = Provider.of<SubscriptionProvider>(context);
|
||||||
final currentHash = _calculateDataHash(provider);
|
final currentHash = _calculateDataHash(provider);
|
||||||
|
|
||||||
debugPrint('[AnalysisScreen] didChangeDependencies: '
|
debugPrint('[AnalysisScreen] didChangeDependencies: '
|
||||||
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
|
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
|
||||||
|
|
||||||
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
|
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
|
||||||
if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) {
|
if (currentHash != _lastDataHash &&
|
||||||
|
!_isLoading &&
|
||||||
|
_lastDataHash.isNotEmpty) {
|
||||||
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
|
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
@@ -65,15 +67,16 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
String _calculateDataHash(SubscriptionProvider provider) {
|
String _calculateDataHash(SubscriptionProvider provider) {
|
||||||
final subscriptions = provider.subscriptions;
|
final subscriptions = provider.subscriptions;
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
buffer.write(subscriptions.length);
|
buffer.write(subscriptions.length);
|
||||||
buffer.write('_');
|
buffer.write('_');
|
||||||
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
|
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
|
||||||
|
|
||||||
for (final sub in subscriptions) {
|
for (final sub in subscriptions) {
|
||||||
buffer.write('_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
|
buffer.write(
|
||||||
|
'_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +151,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 네이티브 광고 위젯
|
// 네이티브 광고 위젯
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _buildAnimatedAd(),
|
child: _buildAnimatedAd(),
|
||||||
@@ -166,7 +169,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
|
|
||||||
// 2. 총 지출 요약 카드
|
// 2. 총 지출 요약 카드
|
||||||
TotalExpenseSummaryCard(
|
TotalExpenseSummaryCard(
|
||||||
key: ValueKey('total_expense_${_lastDataHash}'),
|
key: ValueKey('total_expense_$_lastDataHash'),
|
||||||
subscriptions: subscriptions,
|
subscriptions: subscriptions,
|
||||||
totalExpense: _totalExpense,
|
totalExpense: _totalExpense,
|
||||||
animationController: _animationController,
|
animationController: _animationController,
|
||||||
@@ -176,7 +179,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
|
|
||||||
// 3. 월별 지출 차트
|
// 3. 월별 지출 차트
|
||||||
MonthlyExpenseChartCard(
|
MonthlyExpenseChartCard(
|
||||||
key: ValueKey('monthly_expense_${_lastDataHash}'),
|
key: ValueKey('monthly_expense_$_lastDataHash'),
|
||||||
monthlyData: _monthlyData,
|
monthlyData: _monthlyData,
|
||||||
animationController: _animationController,
|
animationController: _animationController,
|
||||||
),
|
),
|
||||||
@@ -197,4 +200,4 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.lock_outline,
|
Icons.lock_outline,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: AppColors.navyGray,
|
color: AppColors.navyGray,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
const Text(
|
||||||
'앱이 잠겨 있습니다',
|
'앱이 잠겨 있습니다',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
@@ -28,7 +28,7 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
const Text(
|
||||||
'생체 인증으로 잠금을 해제하세요',
|
'생체 인증으로 잠금을 해제하세요',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -42,7 +42,7 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
final success = await appLock.authenticate();
|
final success = await appLock.authenticate();
|
||||||
if (!success && context.mounted) {
|
if (!success && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'인증에 실패했습니다. 다시 시도해주세요.',
|
'인증에 실패했습니다. 다시 시도해주세요.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: const Text(
|
||||||
'카테고리 관리',
|
'카테고리 관리',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.pureWhite,
|
color: AppColors.pureWhite,
|
||||||
@@ -66,7 +66,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '카테고리 이름',
|
labelText: '카테고리 이름',
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: AppColors.navyGray,
|
color: AppColors.navyGray,
|
||||||
@@ -82,7 +82,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: _selectedColor,
|
value: _selectedColor,
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '색상 선택',
|
labelText: '색상 선택',
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: AppColors.navyGray,
|
color: AppColors.navyGray,
|
||||||
@@ -90,15 +90,35 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
),
|
),
|
||||||
items: [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#1976D2', child: Text(AppLocalizations.of(context).colorBlue, style: TextStyle(color: AppColors.darkNavy))),
|
value: '#1976D2',
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context).colorBlue,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#4CAF50', child: Text(AppLocalizations.of(context).colorGreen, style: TextStyle(color: AppColors.darkNavy))),
|
value: '#4CAF50',
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context).colorGreen,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#FF9800', child: Text(AppLocalizations.of(context).colorOrange, style: TextStyle(color: AppColors.darkNavy))),
|
value: '#FF9800',
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context).colorOrange,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#F44336', child: Text(AppLocalizations.of(context).colorRed, style: TextStyle(color: AppColors.darkNavy))),
|
value: '#F44336',
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context).colorRed,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#9C27B0', child: Text(AppLocalizations.of(context).colorPurple, style: TextStyle(color: AppColors.darkNavy))),
|
value: '#9C27B0',
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context).colorPurple,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.darkNavy))),
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -109,22 +129,38 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: _selectedIcon,
|
value: _selectedIcon,
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '아이콘 선택',
|
labelText: '아이콘 선택',
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: AppColors.navyGray,
|
color: AppColors.navyGray,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
items: [
|
items: const [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'subscriptions', child: Text('구독', style: TextStyle(color: AppColors.darkNavy))),
|
value: 'subscriptions',
|
||||||
DropdownMenuItem(value: 'movie', child: Text('영화', style: TextStyle(color: AppColors.darkNavy))),
|
child: Text('구독',
|
||||||
|
style:
|
||||||
|
TextStyle(color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'music_note', child: Text('음악', style: TextStyle(color: AppColors.darkNavy))),
|
value: 'movie',
|
||||||
|
child: Text('영화',
|
||||||
|
style:
|
||||||
|
TextStyle(color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'fitness_center', child: Text('운동', style: TextStyle(color: AppColors.darkNavy))),
|
value: 'music_note',
|
||||||
|
child: Text('음악',
|
||||||
|
style:
|
||||||
|
TextStyle(color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'shopping_cart', child: Text('쇼핑', style: TextStyle(color: AppColors.darkNavy))),
|
value: 'fitness_center',
|
||||||
|
child: Text('운동',
|
||||||
|
style:
|
||||||
|
TextStyle(color: AppColors.darkNavy))),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'shopping_cart',
|
||||||
|
child: Text('쇼핑',
|
||||||
|
style:
|
||||||
|
TextStyle(color: AppColors.darkNavy))),
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -135,7 +171,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _addCategory,
|
onPressed: _addCategory,
|
||||||
child: Text(
|
child: const Text(
|
||||||
'카테고리 추가',
|
'카테고리 추가',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.pureWhite,
|
color: AppColors.pureWhite,
|
||||||
@@ -163,8 +199,9 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
int.parse(category.color.replaceAll('#', '0xFF'))),
|
int.parse(category.color.replaceAll('#', '0xFF'))),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
provider.getLocalizedCategoryName(context, category.name),
|
provider.getLocalizedCategoryName(
|
||||||
style: TextStyle(
|
context, category.name),
|
||||||
|
style: const TextStyle(
|
||||||
color: AppColors.darkNavy,
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final baseColor = _controller.getCardColor();
|
final baseColor = _controller.getCardColor();
|
||||||
@@ -53,111 +52,112 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: AppColors.backgroundColor,
|
backgroundColor: AppColors.backgroundColor,
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: _controller.scrollController,
|
controller: _controller.scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
// 상단 헤더 섹션
|
// 상단 헤더 섹션
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: DetailHeaderSection(
|
child: DetailHeaderSection(
|
||||||
subscription: widget.subscription,
|
subscription: widget.subscription,
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
fadeAnimation: _controller.fadeAnimation!,
|
fadeAnimation: _controller.fadeAnimation!,
|
||||||
slideAnimation: _controller.slideAnimation!,
|
slideAnimation: _controller.slideAnimation!,
|
||||||
rotateAnimation: _controller.rotateAnimation!,
|
rotateAnimation: _controller.rotateAnimation!,
|
||||||
),
|
|
||||||
),
|
|
||||||
// 본문 콘텐츠
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// 편집 모드 안내
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
baseColor.withValues(alpha: 0.15),
|
|
||||||
baseColor.withValues(alpha: 0.08),
|
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: baseColor.withValues(alpha: 0.2),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.edit_rounded,
|
|
||||||
color: baseColor,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context).editMode,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: baseColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context).changesAppliedAfterSave,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.darkNavy,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 기본 정보 폼 섹션
|
|
||||||
DetailFormSection(
|
|
||||||
controller: _controller,
|
|
||||||
fadeAnimation: _controller.fadeAnimation!,
|
|
||||||
slideAnimation: _controller.slideAnimation!,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 이벤트 가격 섹션
|
|
||||||
DetailEventSection(
|
|
||||||
controller: _controller,
|
|
||||||
fadeAnimation: _controller.fadeAnimation!,
|
|
||||||
slideAnimation: _controller.slideAnimation!,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 웹사이트 URL 섹션
|
|
||||||
DetailUrlSection(
|
|
||||||
controller: _controller,
|
|
||||||
fadeAnimation: _controller.fadeAnimation!,
|
|
||||||
slideAnimation: _controller.slideAnimation!,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// 액션 버튼
|
|
||||||
DetailActionButtons(
|
|
||||||
controller: _controller,
|
|
||||||
fadeAnimation: _controller.fadeAnimation!,
|
|
||||||
slideAnimation: _controller.slideAnimation!,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// 본문 콘텐츠
|
||||||
],
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 편집 모드 안내
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
baseColor.withValues(alpha: 0.15),
|
||||||
|
baseColor.withValues(alpha: 0.08),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: baseColor.withValues(alpha: 0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.edit_rounded,
|
||||||
|
color: baseColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context).editMode,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: baseColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context)
|
||||||
|
.changesAppliedAfterSave,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.darkNavy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 기본 정보 폼 섹션
|
||||||
|
DetailFormSection(
|
||||||
|
controller: _controller,
|
||||||
|
fadeAnimation: _controller.fadeAnimation!,
|
||||||
|
slideAnimation: _controller.slideAnimation!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 이벤트 가격 섹션
|
||||||
|
DetailEventSection(
|
||||||
|
controller: _controller,
|
||||||
|
fadeAnimation: _controller.fadeAnimation!,
|
||||||
|
slideAnimation: _controller.slideAnimation!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 웹사이트 URL 섹션
|
||||||
|
DetailUrlSection(
|
||||||
|
controller: _controller,
|
||||||
|
fadeAnimation: _controller.fadeAnimation!,
|
||||||
|
slideAnimation: _controller.slideAnimation!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// 액션 버튼
|
||||||
|
DetailActionButtons(
|
||||||
|
controller: _controller,
|
||||||
|
fadeAnimation: _controller.fadeAnimation!,
|
||||||
|
slideAnimation: _controller.slideAnimation!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
late AnimationController _waveController;
|
late AnimationController _waveController;
|
||||||
late ScrollController _scrollController;
|
late ScrollController _scrollController;
|
||||||
late FloatingNavBarScrollController _navBarScrollController;
|
late FloatingNavBarScrollController _navBarScrollController;
|
||||||
|
|
||||||
// 화면 목록
|
// 화면 목록
|
||||||
late final List<Widget> _screens;
|
late final List<Widget> _screens;
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
_scrollController = ScrollController();
|
_scrollController = ScrollController();
|
||||||
|
|
||||||
_navBarScrollController = FloatingNavBarScrollController(
|
_navBarScrollController = FloatingNavBarScrollController(
|
||||||
scrollController: _scrollController,
|
scrollController: _scrollController,
|
||||||
onHide: () {},
|
onHide: () {},
|
||||||
@@ -157,7 +157,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
AppRoutes.addSubscription,
|
AppRoutes.addSubscription,
|
||||||
).then((result) {
|
).then((result) {
|
||||||
_resetAnimations();
|
_resetAnimations();
|
||||||
|
|
||||||
// 구독이 성공적으로 추가된 경우
|
// 구독이 성공적으로 추가된 경우
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
// 상단에 스낵바 표시
|
// 상단에 스낵바 표시
|
||||||
@@ -203,18 +203,18 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
|
|
||||||
void _handleNavigation(int index, BuildContext context) {
|
void _handleNavigation(int index, BuildContext context) {
|
||||||
final navigationProvider = context.read<NavigationProvider>();
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
|
|
||||||
// 이미 같은 인덱스면 무시
|
// 이미 같은 인덱스면 무시
|
||||||
if (navigationProvider.currentIndex == index) {
|
if (navigationProvider.currentIndex == index) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 추가 버튼은 별도 처리
|
// 추가 버튼은 별도 처리
|
||||||
if (index == 2) {
|
if (index == 2) {
|
||||||
_navigateToAddSubscription(context);
|
_navigateToAddSubscription(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인덱스 업데이트
|
// 인덱스 업데이트
|
||||||
navigationProvider.updateCurrentIndex(index);
|
navigationProvider.updateCurrentIndex(index);
|
||||||
}
|
}
|
||||||
@@ -222,7 +222,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final navigationProvider = context.watch<NavigationProvider>();
|
final navigationProvider = context.watch<NavigationProvider>();
|
||||||
|
|
||||||
// 메인 그라데이션 사용
|
// 메인 그라데이션 사용
|
||||||
List<Color> backgroundGradient = AppColors.mainGradient;
|
List<Color> backgroundGradient = AppColors.mainGradient;
|
||||||
|
|
||||||
@@ -235,8 +235,12 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
return GlassmorphicScaffold(
|
return GlassmorphicScaffold(
|
||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
index: PlatformHelper.isIOS
|
index: PlatformHelper.isIOS
|
||||||
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
|
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
|
||||||
: (currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex), // Android: 기존 로직
|
: (currentIndex == 3
|
||||||
|
? 3
|
||||||
|
: currentIndex == 4
|
||||||
|
? 4
|
||||||
|
: currentIndex), // Android: 기존 로직
|
||||||
children: _screens,
|
children: _screens,
|
||||||
),
|
),
|
||||||
backgroundGradient: backgroundGradient,
|
backgroundGradient: backgroundGradient,
|
||||||
@@ -249,4 +253,4 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
enableWaveAnimation: false,
|
enableWaveAnimation: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import '../providers/notification_provider.dart';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../theme/adaptive_theme.dart';
|
|
||||||
import '../widgets/glassmorphism_card.dart';
|
import '../widgets/glassmorphism_card.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../widgets/native_ad_widget.dart';
|
import '../widgets/native_ad_widget.dart';
|
||||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/locale_provider.dart';
|
import '../providers/locale_provider.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||||
|
import '../services/sms_service.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -228,6 +229,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
if (granted) {
|
if (granted) {
|
||||||
await provider.setEnabled(true);
|
await provider.setEnabled(true);
|
||||||
} else {
|
} else {
|
||||||
|
if (!context.mounted) return;
|
||||||
AppSnackBar.showError(
|
AppSnackBar.showError(
|
||||||
context: context,
|
context: context,
|
||||||
message: AppLocalizations.of(context)
|
message: AppLocalizations.of(context)
|
||||||
@@ -271,7 +273,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.surfaceVariant
|
.surfaceContainerHighest
|
||||||
.withValues(alpha: 0.3),
|
.withValues(alpha: 0.3),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -412,7 +414,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.surfaceVariant
|
.surfaceContainerHighest
|
||||||
.withValues(alpha: 0.3),
|
.withValues(alpha: 0.3),
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(8),
|
BorderRadius.circular(8),
|
||||||
@@ -476,6 +478,89 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// SMS 권한 설정
|
||||||
|
if (!kIsWeb && Platform.isAndroid)
|
||||||
|
GlassmorphismCard(
|
||||||
|
margin:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: FutureBuilder<permission.PermissionStatus>(
|
||||||
|
future: permission.Permission.sms.status,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final isLoading =
|
||||||
|
snapshot.connectionState == ConnectionState.waiting;
|
||||||
|
final status = snapshot.data;
|
||||||
|
final hasPermission = status?.isGranted ?? false;
|
||||||
|
final isPermanentlyDenied =
|
||||||
|
status?.isPermanentlyDenied ?? false;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.sms,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
AppLocalizations.of(context).smsPermissionLabel,
|
||||||
|
style: const TextStyle(color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
|
subtitle: !hasPermission
|
||||||
|
? Text(
|
||||||
|
isPermanentlyDenied
|
||||||
|
? AppLocalizations.of(context)
|
||||||
|
.permanentlyDeniedMessage
|
||||||
|
: AppLocalizations.of(context)
|
||||||
|
.smsPermissionRequired,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textSecondary),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child:
|
||||||
|
CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: hasPermission
|
||||||
|
? const Padding(
|
||||||
|
padding:
|
||||||
|
EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: Icon(Icons.check_circle,
|
||||||
|
color: Colors.green),
|
||||||
|
)
|
||||||
|
: isPermanentlyDenied
|
||||||
|
? TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await permission.openAppSettings();
|
||||||
|
},
|
||||||
|
child: Text(AppLocalizations.of(context)
|
||||||
|
.openSettings),
|
||||||
|
)
|
||||||
|
: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final granted = await SMSService
|
||||||
|
.requestSMSPermission();
|
||||||
|
if (!granted) {
|
||||||
|
final newStatus = await permission
|
||||||
|
.Permission.sms.status;
|
||||||
|
if (newStatus.isPermanentlyDenied) {
|
||||||
|
await permission
|
||||||
|
.openAppSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (context.mounted) {
|
||||||
|
(context as Element)
|
||||||
|
.markNeedsBuild();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(AppLocalizations.of(context)
|
||||||
|
.requestPermission),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 앱 정보
|
// 앱 정보
|
||||||
GlassmorphismCard(
|
GlassmorphismCard(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
|||||||
151
lib/screens/sms_permission_screen.dart
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../widgets/glassmorphism_card.dart';
|
||||||
|
import '../routes/app_routes.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../services/sms_service.dart';
|
||||||
|
import '../utils/platform_helper.dart';
|
||||||
|
|
||||||
|
class SmsPermissionScreen extends StatefulWidget {
|
||||||
|
const SmsPermissionScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SmsPermissionScreen> createState() => _SmsPermissionScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
|
||||||
|
bool _requesting = false;
|
||||||
|
|
||||||
|
Future<void> _handleRequest() async {
|
||||||
|
if (_requesting) return;
|
||||||
|
setState(() => _requesting = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!PlatformHelper.isAndroid) {
|
||||||
|
// iOS/Web은 권한 흐름 없이 메인으로 이동
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final status = await permission.Permission.sms.status;
|
||||||
|
if (status.isGranted) {
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 요청
|
||||||
|
final granted = await SMSService.requestSMSPermission();
|
||||||
|
if (mounted) {
|
||||||
|
if (granted) {
|
||||||
|
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
|
||||||
|
} else {
|
||||||
|
final newStatus = await permission.Permission.sms.status;
|
||||||
|
if (newStatus.isPermanentlyDenied) {
|
||||||
|
// 설정 열기 유도
|
||||||
|
_showSettingsDialog();
|
||||||
|
}
|
||||||
|
// 거부지만 영구 거부가 아니라면 그대로 대기 (사용자가 다시 시도 가능)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _requesting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSettingsDialog() {
|
||||||
|
final loc = AppLocalizations.of(context);
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: Text(loc.smsPermissionRequired),
|
||||||
|
content: Text(loc.permanentlyDeniedMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(AppLocalizations.of(context).cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await permission.openAppSettings();
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(loc.openSettings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context);
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.sms, size: 64, color: AppColors.primaryColor),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
loc.smsPermissionTitle,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
loc.smsPermissionRequired,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GlassmorphismCard(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(loc.smsPermissionReasonTitle,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(loc.smsPermissionReasonBody),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(loc.smsPermissionScopeTitle,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(loc.smsPermissionScopeBody),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _requesting ? null : _handleRequest,
|
||||||
|
icon: const Icon(Icons.lock_open),
|
||||||
|
label: Text(
|
||||||
|
_requesting ? loc.requesting : loc.requestPermission),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context)
|
||||||
|
.pushReplacementNamed(AppRoutes.main),
|
||||||
|
child: Text(loc.later),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -75,7 +74,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,
|
||||||
@@ -119,4 +119,4 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import 'dart:async';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import '../services/sms_service.dart';
|
||||||
|
import '../utils/platform_helper.dart';
|
||||||
import '../routes/app_routes.dart';
|
import '../routes/app_routes.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
@@ -63,7 +66,8 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
));
|
));
|
||||||
|
|
||||||
// 랜덤 파티클 생성
|
// 랜덤 파티클 생성
|
||||||
_generateParticles();
|
// 접근성(모션 축소) 고려한 파티클 생성
|
||||||
|
_generateParticles(reduced: ReduceMotion.platform());
|
||||||
|
|
||||||
_animationController.forward();
|
_animationController.forward();
|
||||||
|
|
||||||
@@ -73,15 +77,17 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _generateParticles() {
|
void _generateParticles({bool reduced = false}) {
|
||||||
final random = DateTime.now().millisecondsSinceEpoch;
|
final random = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final total = reduced ? 6 : 20;
|
||||||
|
|
||||||
for (int i = 0; i < 20; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
|
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
|
||||||
final x = (random % 100) / 100 * 300; // 랜덤 X 위치
|
final x = (random % 100) / 100 * 300; // 랜덤 X 위치
|
||||||
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
|
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
|
||||||
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
|
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
|
||||||
final duration = (random % 10) / 10 * 3000 + 2000; // 2-5초 사이의 지속시간
|
final duration = (random % 10) / 10 * (reduced ? 1800 : 3000) +
|
||||||
|
(reduced ? 1200 : 2000); // 축소 시 더 짧게
|
||||||
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
||||||
|
|
||||||
int colorIndex = (random + i) % AppColors.blueGradient.length;
|
int colorIndex = (random + i) % AppColors.blueGradient.length;
|
||||||
@@ -98,9 +104,20 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void navigateToNextScreen() {
|
Future<void> navigateToNextScreen() async {
|
||||||
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
|
// Android에서 SMS 권한이 없으면 권한 안내 화면으로 이동
|
||||||
// 모든 이전 라우트를 제거하고 홈으로 이동
|
if (PlatformHelper.isAndroid) {
|
||||||
|
final hasPermission = await SMSService.hasSMSPermission();
|
||||||
|
if (!hasPermission && mounted) {
|
||||||
|
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||||
|
AppRoutes.smsPermission,
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||||
AppRoutes.main,
|
AppRoutes.main,
|
||||||
(route) => false,
|
(route) => false,
|
||||||
@@ -244,7 +261,14 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
BorderRadius.circular(30),
|
BorderRadius.circular(30),
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(
|
filter: ImageFilter.blur(
|
||||||
sigmaX: 20, sigmaY: 20),
|
sigmaX: ReduceMotion.scale(
|
||||||
|
context,
|
||||||
|
normal: 20,
|
||||||
|
reduced: 8),
|
||||||
|
sigmaY: ReduceMotion.scale(
|
||||||
|
context,
|
||||||
|
normal: 20,
|
||||||
|
reduced: 8)),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -269,7 +293,11 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
color:
|
color:
|
||||||
AppColors.shadowBlack,
|
AppColors.shadowBlack,
|
||||||
spreadRadius: 0,
|
spreadRadius: 0,
|
||||||
blurRadius: 30,
|
blurRadius:
|
||||||
|
ReduceMotion.scale(
|
||||||
|
context,
|
||||||
|
normal: 30,
|
||||||
|
reduced: 12),
|
||||||
offset: const Offset(0, 10),
|
offset: const Offset(0, 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -385,7 +413,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: CircularProgressIndicator(
|
child: const CircularProgressIndicator(
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
AppColors.pureWhite),
|
AppColors.pureWhite),
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
|
|||||||
97
lib/services/cache_manager.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import 'exchange_rate_service.dart';
|
import 'exchange_rate_service.dart';
|
||||||
|
import 'cache_manager.dart';
|
||||||
|
|
||||||
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
||||||
class CurrencyUtil {
|
class CurrencyUtil {
|
||||||
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||||
|
static final SimpleCacheManager<String> _fmtCache =
|
||||||
|
SimpleCacheManager<String>(
|
||||||
|
maxEntries: 256,
|
||||||
|
maxBytes: 256 * 1024,
|
||||||
|
ttl: const Duration(minutes: 15),
|
||||||
|
);
|
||||||
|
|
||||||
/// 언어에 따른 기본 통화 반환
|
/// 언어에 따른 기본 통화 반환
|
||||||
static String getDefaultCurrency(String locale) {
|
static String getDefaultCurrency(String locale) {
|
||||||
@@ -66,7 +73,7 @@ class CurrencyUtil {
|
|||||||
final locale = _getLocaleForCurrency(currency);
|
final locale = _getLocaleForCurrency(currency);
|
||||||
final symbol = getCurrencySymbol(currency);
|
final symbol = getCurrencySymbol(currency);
|
||||||
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
|
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
|
||||||
|
|
||||||
return NumberFormat.currency(
|
return NumberFormat.currency(
|
||||||
locale: locale,
|
locale: locale,
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
@@ -80,30 +87,46 @@ class CurrencyUtil {
|
|||||||
String currency,
|
String currency,
|
||||||
String locale,
|
String locale,
|
||||||
) async {
|
) async {
|
||||||
|
// 캐시 조회
|
||||||
|
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
|
||||||
|
final key = 'fmt:$locale:$currency:${amount.toStringAsFixed(decimals)}';
|
||||||
|
final cached = _fmtCache.get(key);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
final defaultCurrency = getDefaultCurrency(locale);
|
final defaultCurrency = getDefaultCurrency(locale);
|
||||||
|
|
||||||
// 입력 통화가 기본 통화인 경우
|
// 입력 통화가 기본 통화인 경우
|
||||||
if (currency == defaultCurrency) {
|
if (currency == defaultCurrency) {
|
||||||
return _formatSingleCurrency(amount, currency);
|
final result = _formatSingleCurrency(amount, currency);
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// USD 입력인 경우 - 기본 통화로 변환하여 표시
|
// USD 입력인 경우 - 기본 통화로 변환하여 표시
|
||||||
if (currency == 'USD' && defaultCurrency != 'USD') {
|
if (currency == 'USD' && defaultCurrency != 'USD') {
|
||||||
final convertedAmount = await _exchangeRateService.convertUsdToTarget(amount, defaultCurrency);
|
final convertedAmount = await _exchangeRateService.convertUsdToTarget(
|
||||||
|
amount, defaultCurrency);
|
||||||
if (convertedAmount != null) {
|
if (convertedAmount != null) {
|
||||||
final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency);
|
final primaryFormatted =
|
||||||
|
_formatSingleCurrency(convertedAmount, defaultCurrency);
|
||||||
final usdFormatted = _formatSingleCurrency(amount, 'USD');
|
final usdFormatted = _formatSingleCurrency(amount, 'USD');
|
||||||
return '$primaryFormatted ($usdFormatted)';
|
final result = '$primaryFormatted ($usdFormatted)';
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 영어 사용자가 KRW 선택한 경우
|
// 영어 사용자가 KRW 선택한 경우
|
||||||
if (locale == 'en' && currency == 'KRW') {
|
if (locale == 'en' && currency == 'KRW') {
|
||||||
return _formatSingleCurrency(amount, currency);
|
final result = _formatSingleCurrency(amount, currency);
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기타 통화 입력인 경우
|
// 기타 통화 입력인 경우
|
||||||
return _formatSingleCurrency(amount, currency);
|
final result = _formatSingleCurrency(amount, currency);
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
|
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
|
||||||
@@ -116,13 +139,13 @@ class CurrencyUtil {
|
|||||||
|
|
||||||
for (var subscription in subscriptions) {
|
for (var subscription in subscriptions) {
|
||||||
final price = subscription.currentPrice;
|
final price = subscription.currentPrice;
|
||||||
|
|
||||||
final converted = await _exchangeRateService.convertBetweenCurrencies(
|
final converted = await _exchangeRateService.convertBetweenCurrencies(
|
||||||
price,
|
price,
|
||||||
subscription.currency,
|
subscription.currency,
|
||||||
defaultCurrency,
|
defaultCurrency,
|
||||||
);
|
);
|
||||||
|
|
||||||
total += converted ?? price;
|
total += converted ?? price;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +162,20 @@ class CurrencyUtil {
|
|||||||
static Future<String> formatSubscriptionAmountWithLocale(
|
static Future<String> formatSubscriptionAmountWithLocale(
|
||||||
SubscriptionModel subscription, String locale) async {
|
SubscriptionModel subscription, String locale) async {
|
||||||
final price = subscription.currentPrice;
|
final price = subscription.currentPrice;
|
||||||
return formatAmountWithLocale(price, subscription.currency, locale);
|
// 구독 단위 캐시 키 (통화/가격/locale + id)
|
||||||
|
final decimals =
|
||||||
|
(subscription.currency == 'KRW' || subscription.currency == 'JPY')
|
||||||
|
? 0
|
||||||
|
: 2;
|
||||||
|
final key =
|
||||||
|
'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}';
|
||||||
|
final cached = _fmtCache.get(key);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await formatAmountWithLocale(price, subscription.currency, locale);
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지
|
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지
|
||||||
@@ -178,13 +214,13 @@ class CurrencyUtil {
|
|||||||
for (var subscription in subscriptions) {
|
for (var subscription in subscriptions) {
|
||||||
if (subscription.isCurrentlyInEvent) {
|
if (subscription.isCurrentlyInEvent) {
|
||||||
final savings = subscription.eventSavings;
|
final savings = subscription.eventSavings;
|
||||||
|
|
||||||
final converted = await _exchangeRateService.convertBetweenCurrencies(
|
final converted = await _exchangeRateService.convertBetweenCurrencies(
|
||||||
savings,
|
savings,
|
||||||
subscription.currency,
|
subscription.currency,
|
||||||
defaultCurrency,
|
defaultCurrency,
|
||||||
);
|
);
|
||||||
|
|
||||||
total += converted ?? savings;
|
total += converted ?? savings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +240,7 @@ class CurrencyUtil {
|
|||||||
if (!subscription.isCurrentlyInEvent) {
|
if (!subscription.isCurrentlyInEvent) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
final savings = subscription.eventSavings;
|
final savings = subscription.eventSavings;
|
||||||
return formatAmountWithLocale(savings, subscription.currency, locale);
|
return formatAmountWithLocale(savings, subscription.currency, locale);
|
||||||
}
|
}
|
||||||
@@ -225,4 +261,4 @@ class CurrencyUtil {
|
|||||||
static Future<String> formatAmount(double amount, String currency) async {
|
static Future<String> formatAmount(double amount, String currency) async {
|
||||||
return formatAmountWithCurrencyAndLocale(amount, currency, 'ko');
|
return formatAmountWithCurrencyAndLocale(amount, currency, 'ko');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import '../utils/logger.dart';
|
||||||
|
import 'cache_manager.dart';
|
||||||
|
|
||||||
/// 환율 정보 서비스 클래스
|
/// 환율 정보 서비스 클래스
|
||||||
class ExchangeRateService {
|
class ExchangeRateService {
|
||||||
@@ -15,18 +17,34 @@ class ExchangeRateService {
|
|||||||
// 내부 생성자
|
// 내부 생성자
|
||||||
ExchangeRateService._internal();
|
ExchangeRateService._internal();
|
||||||
|
|
||||||
|
// 포맷된 환율 문자열 캐시 (언어별)
|
||||||
|
static final SimpleCacheManager<String> _fmtCache =
|
||||||
|
SimpleCacheManager<String>(
|
||||||
|
maxEntries: 64,
|
||||||
|
maxBytes: 64 * 1024,
|
||||||
|
ttl: const Duration(minutes: 30),
|
||||||
|
);
|
||||||
|
|
||||||
// 캐싱된 환율 정보
|
// 캐싱된 환율 정보
|
||||||
double? _usdToKrwRate;
|
double? _usdToKrwRate;
|
||||||
double? _usdToJpyRate;
|
double? _usdToJpyRate;
|
||||||
double? _usdToCnyRate;
|
double? _usdToCnyRate;
|
||||||
DateTime? _lastUpdated;
|
DateTime? _lastUpdated;
|
||||||
|
|
||||||
// API 요청 URL (ExchangeRate-API 사용)
|
// API 요청 URL (ExchangeRate-API 등) - 빌드 타임 오버라이드 가능
|
||||||
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD';
|
static const String _defaultApiUrl =
|
||||||
|
'https://api.exchangerate-api.com/v4/latest/USD';
|
||||||
|
final String _apiUrl = const String.fromEnvironment(
|
||||||
|
'EXCHANGE_RATE_API_URL',
|
||||||
|
defaultValue: _defaultApiUrl,
|
||||||
|
);
|
||||||
|
|
||||||
// 기본 환율 상수
|
// 기본 환율 상수
|
||||||
|
// ignore: constant_identifier_names
|
||||||
static const double DEFAULT_USD_TO_KRW_RATE = 1350.0;
|
static const double DEFAULT_USD_TO_KRW_RATE = 1350.0;
|
||||||
|
// ignore: constant_identifier_names
|
||||||
static const double DEFAULT_USD_TO_JPY_RATE = 150.0;
|
static const double DEFAULT_USD_TO_JPY_RATE = 150.0;
|
||||||
|
// ignore: constant_identifier_names
|
||||||
static const double DEFAULT_USD_TO_CNY_RATE = 7.2;
|
static const double DEFAULT_USD_TO_CNY_RATE = 7.2;
|
||||||
|
|
||||||
// 캐싱된 환율 반환 (동기적)
|
// 캐싱된 환율 반환 (동기적)
|
||||||
@@ -44,18 +62,28 @@ class ExchangeRateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// API 요청
|
// API 요청 (네트워크 불가 환경에서는 예외 발생 가능)
|
||||||
final response = await http.get(Uri.parse(_apiUrl));
|
final response = await http.get(Uri.parse(_apiUrl));
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = json.decode(response.body);
|
final data = json.decode(response.body);
|
||||||
_usdToKrwRate = data['rates']['KRW']?.toDouble();
|
_usdToKrwRate = (data['rates']['KRW'] as num?)?.toDouble();
|
||||||
_usdToJpyRate = data['rates']['JPY']?.toDouble();
|
_usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
|
||||||
_usdToCnyRate = data['rates']['CNY']?.toDouble();
|
_usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
|
||||||
_lastUpdated = DateTime.now();
|
_lastUpdated = DateTime.now();
|
||||||
|
// 환율 갱신 시 포맷 캐시 무효화
|
||||||
|
_fmtCache.clear();
|
||||||
|
Log.d(
|
||||||
|
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
Log.w(
|
||||||
|
'환율 API 응답 코드: ${response.statusCode} (${response.reasonPhrase})');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
// 오류 발생 시 기본값 사용
|
// 네트워크 실패 시 캐시/기본값 폴백
|
||||||
|
Log.w('환율 API 요청 실패. 캐시/기본값 사용');
|
||||||
|
Log.e('환율 API 에러', e, st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +103,10 @@ class ExchangeRateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// USD 금액을 지정된 통화로 변환합니다.
|
/// USD 금액을 지정된 통화로 변환합니다.
|
||||||
Future<double?> convertUsdToTarget(double usdAmount, String targetCurrency) async {
|
Future<double?> convertUsdToTarget(
|
||||||
|
double usdAmount, String targetCurrency) async {
|
||||||
await _fetchAllRatesIfNeeded();
|
await _fetchAllRatesIfNeeded();
|
||||||
|
|
||||||
switch (targetCurrency) {
|
switch (targetCurrency) {
|
||||||
case 'KRW':
|
case 'KRW':
|
||||||
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
||||||
@@ -96,9 +125,10 @@ class ExchangeRateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 지정된 통화를 USD로 변환합니다.
|
/// 지정된 통화를 USD로 변환합니다.
|
||||||
Future<double?> convertTargetToUsd(double amount, String sourceCurrency) async {
|
Future<double?> convertTargetToUsd(
|
||||||
|
double amount, String sourceCurrency) async {
|
||||||
await _fetchAllRatesIfNeeded();
|
await _fetchAllRatesIfNeeded();
|
||||||
|
|
||||||
switch (sourceCurrency) {
|
switch (sourceCurrency) {
|
||||||
case 'KRW':
|
case 'KRW':
|
||||||
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
||||||
@@ -118,25 +148,22 @@ class ExchangeRateService {
|
|||||||
|
|
||||||
/// 두 통화 간 변환을 수행합니다. (USD를 거쳐서 변환)
|
/// 두 통화 간 변환을 수행합니다. (USD를 거쳐서 변환)
|
||||||
Future<double?> convertBetweenCurrencies(
|
Future<double?> convertBetweenCurrencies(
|
||||||
double amount,
|
double amount, String fromCurrency, String toCurrency) async {
|
||||||
String fromCurrency,
|
|
||||||
String toCurrency
|
|
||||||
) async {
|
|
||||||
if (fromCurrency == toCurrency) {
|
if (fromCurrency == toCurrency) {
|
||||||
return amount;
|
return amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fromCurrency → USD → toCurrency
|
// fromCurrency → USD → toCurrency
|
||||||
double? usdAmount;
|
double? usdAmount;
|
||||||
|
|
||||||
if (fromCurrency == 'USD') {
|
if (fromCurrency == 'USD') {
|
||||||
usdAmount = amount;
|
usdAmount = amount;
|
||||||
} else {
|
} else {
|
||||||
usdAmount = await convertTargetToUsd(amount, fromCurrency);
|
usdAmount = await convertTargetToUsd(amount, fromCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usdAmount == null) return null;
|
if (usdAmount == null) return null;
|
||||||
|
|
||||||
if (toCurrency == 'USD') {
|
if (toCurrency == 'USD') {
|
||||||
return usdAmount;
|
return usdAmount;
|
||||||
} else {
|
} else {
|
||||||
@@ -161,32 +188,45 @@ class ExchangeRateService {
|
|||||||
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
|
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
|
||||||
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
|
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
|
||||||
await _fetchAllRatesIfNeeded();
|
await _fetchAllRatesIfNeeded();
|
||||||
|
// 캐시 키 (locale 기준)
|
||||||
|
final key = 'fx:fmt:$locale';
|
||||||
|
final cached = _fmtCache.get(key);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
String result = '';
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
case 'ko':
|
case 'ko':
|
||||||
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
||||||
return NumberFormat.currency(
|
result = NumberFormat.currency(
|
||||||
locale: 'ko_KR',
|
locale: 'ko_KR',
|
||||||
symbol: '₩',
|
symbol: '₩',
|
||||||
decimalDigits: 0,
|
decimalDigits: 0,
|
||||||
).format(rate);
|
).format(rate);
|
||||||
|
break;
|
||||||
case 'ja':
|
case 'ja':
|
||||||
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
|
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
|
||||||
return NumberFormat.currency(
|
result = NumberFormat.currency(
|
||||||
locale: 'ja_JP',
|
locale: 'ja_JP',
|
||||||
symbol: '¥',
|
symbol: '¥',
|
||||||
decimalDigits: 0,
|
decimalDigits: 0,
|
||||||
).format(rate);
|
).format(rate);
|
||||||
|
break;
|
||||||
case 'zh':
|
case 'zh':
|
||||||
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
|
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
|
||||||
return NumberFormat.currency(
|
result = NumberFormat.currency(
|
||||||
locale: 'zh_CN',
|
locale: 'zh_CN',
|
||||||
symbol: '¥',
|
symbol: '¥',
|
||||||
decimalDigits: 2,
|
decimalDigits: 2,
|
||||||
).format(rate);
|
).format(rate);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return '';
|
result = '';
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 대략적인 사이즈(문자 길이)로 캐시 저장
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||||
@@ -204,12 +244,13 @@ class ExchangeRateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다.
|
/// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||||
Future<String> getFormattedAmountForLocale(double usdAmount, String locale) async {
|
Future<String> getFormattedAmountForLocale(
|
||||||
|
double usdAmount, String locale) async {
|
||||||
String targetCurrency;
|
String targetCurrency;
|
||||||
String localeCode;
|
String localeCode;
|
||||||
String symbol;
|
String symbol;
|
||||||
int decimalDigits;
|
int decimalDigits;
|
||||||
|
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
case 'ko':
|
case 'ko':
|
||||||
targetCurrency = 'KRW';
|
targetCurrency = 'KRW';
|
||||||
@@ -232,7 +273,7 @@ class ExchangeRateService {
|
|||||||
default:
|
default:
|
||||||
return '\$$usdAmount';
|
return '\$$usdAmount';
|
||||||
}
|
}
|
||||||
|
|
||||||
final convertedAmount = await convertUsdToTarget(usdAmount, targetCurrency);
|
final convertedAmount = await convertUsdToTarget(usdAmount, targetCurrency);
|
||||||
if (convertedAmount != null) {
|
if (convertedAmount != null) {
|
||||||
final formattedAmount = NumberFormat.currency(
|
final formattedAmount = NumberFormat.currency(
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:timezone/timezone.dart' as tz;
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
import 'package:timezone/data/latest_all.dart' as tz;
|
import 'package:timezone/data/latest_all.dart' as tz;
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
class NotificationService {
|
class NotificationService {
|
||||||
static final FlutterLocalNotificationsPlugin _notifications =
|
static final FlutterLocalNotificationsPlugin _notifications =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
static final _secureStorage = const FlutterSecureStorage();
|
static const _secureStorage = FlutterSecureStorage();
|
||||||
|
|
||||||
static const _notificationEnabledKey = 'notification_enabled';
|
static const _notificationEnabledKey = 'notification_enabled';
|
||||||
static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
|
static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
|
||||||
@@ -149,13 +149,73 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static Future<bool> requestPermission() async {
|
static Future<bool> requestPermission() async {
|
||||||
final result = await _notifications
|
// 웹 플랫폼인 경우 false 반환
|
||||||
.resolvePlatformSpecificImplementation<
|
if (_isWeb) return false;
|
||||||
AndroidFlutterLocalNotificationsPlugin>()
|
|
||||||
?.requestPermission();
|
// iOS 처리
|
||||||
return result ?? false;
|
if (Platform.isIOS) {
|
||||||
|
final iosImplementation =
|
||||||
|
_notifications.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin>();
|
||||||
|
|
||||||
|
if (iosImplementation != null) {
|
||||||
|
final granted = await iosImplementation.requestPermissions(
|
||||||
|
alert: true,
|
||||||
|
badge: true,
|
||||||
|
sound: true,
|
||||||
|
);
|
||||||
|
return granted ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 처리
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final androidImplementation =
|
||||||
|
_notifications.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
|
||||||
|
if (androidImplementation != null) {
|
||||||
|
final granted =
|
||||||
|
await androidImplementation.requestNotificationsPermission();
|
||||||
|
return granted ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 상태 확인
|
||||||
|
static Future<bool> checkPermission() async {
|
||||||
|
// 웹 플랫폼인 경우 false 반환
|
||||||
|
if (_isWeb) return false;
|
||||||
|
|
||||||
|
// Android 처리
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final androidImplementation =
|
||||||
|
_notifications.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
|
||||||
|
if (androidImplementation != null) {
|
||||||
|
// Android 13 이상에서만 권한 확인 필요
|
||||||
|
final isEnabled = await androidImplementation.areNotificationsEnabled();
|
||||||
|
return isEnabled ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS 처리
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
final iosImplementation =
|
||||||
|
_notifications.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin>();
|
||||||
|
|
||||||
|
if (iosImplementation != null) {
|
||||||
|
final settings = await iosImplementation.checkPermissions();
|
||||||
|
return settings?.isEnabled ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // 기본값
|
||||||
}
|
}
|
||||||
|
|
||||||
// 알림 스케줄 설정
|
// 알림 스케줄 설정
|
||||||
@@ -170,7 +230,7 @@ class NotificationService {
|
|||||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const androidDetails = AndroidNotificationDetails(
|
const androidDetails = AndroidNotificationDetails(
|
||||||
'subscription_channel',
|
'subscription_channel',
|
||||||
@@ -180,8 +240,8 @@ class NotificationService {
|
|||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
);
|
);
|
||||||
|
|
||||||
final iosDetails = const DarwinNotificationDetails();
|
const iosDetails = DarwinNotificationDetails();
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// tz.local 초기화 확인 및 재시도
|
||||||
tz.Location location;
|
tz.Location location;
|
||||||
try {
|
try {
|
||||||
@@ -205,10 +265,10 @@ class NotificationService {
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
tz.TZDateTime.from(scheduledDate, location),
|
tz.TZDateTime.from(scheduledDate, location),
|
||||||
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
const NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||||
androidAllowWhileIdle: true,
|
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('알림 예약 중 오류 발생: $e');
|
debugPrint('알림 예약 중 오류 발생: $e');
|
||||||
@@ -243,7 +303,7 @@ class NotificationService {
|
|||||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final notificationId = subscription.id.hashCode;
|
final notificationId = subscription.id.hashCode;
|
||||||
|
|
||||||
@@ -265,7 +325,7 @@ class NotificationService {
|
|||||||
android: androidDetails,
|
android: androidDetails,
|
||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// tz.local 초기화 확인 및 재시도
|
||||||
tz.Location location;
|
tz.Location location;
|
||||||
try {
|
try {
|
||||||
@@ -290,9 +350,9 @@ class NotificationService {
|
|||||||
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
||||||
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
||||||
notificationDetails,
|
notificationDetails,
|
||||||
androidAllowWhileIdle: true,
|
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
||||||
@@ -318,11 +378,11 @@ class NotificationService {
|
|||||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final paymentDate = subscription.nextBillingDate;
|
final paymentDate = subscription.nextBillingDate;
|
||||||
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// tz.local 초기화 확인 및 재시도
|
||||||
tz.Location location;
|
tz.Location location;
|
||||||
try {
|
try {
|
||||||
@@ -355,9 +415,9 @@ class NotificationService {
|
|||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
androidAllowWhileIdle: true,
|
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||||
@@ -371,11 +431,11 @@ class NotificationService {
|
|||||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final expirationDate = subscription.nextBillingDate;
|
final expirationDate = subscription.nextBillingDate;
|
||||||
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// tz.local 초기화 확인 및 재시도
|
||||||
tz.Location location;
|
tz.Location location;
|
||||||
try {
|
try {
|
||||||
@@ -395,7 +455,7 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _notifications.zonedSchedule(
|
await _notifications.zonedSchedule(
|
||||||
(subscription.id + '_expiration').hashCode,
|
('${subscription.id}_expiration').hashCode,
|
||||||
'구독 만료 예정 알림',
|
'구독 만료 예정 알림',
|
||||||
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
|
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
|
||||||
tz.TZDateTime.from(reminderDate, location),
|
tz.TZDateTime.from(reminderDate, location),
|
||||||
@@ -408,9 +468,9 @@ class NotificationService {
|
|||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
androidAllowWhileIdle: true,
|
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
||||||
@@ -448,16 +508,17 @@ class NotificationService {
|
|||||||
location = tz.UTC;
|
location = tz.UTC;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 알림 예약 (지정된 일수 전)
|
// 기본 알림 예약 (지정된 일수 전)
|
||||||
final scheduledDate =
|
final scheduledDate = subscription.nextBillingDate
|
||||||
subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith(
|
.subtract(Duration(days: reminderDays))
|
||||||
hour: reminderHour,
|
.copyWith(
|
||||||
minute: reminderMinute,
|
hour: reminderHour,
|
||||||
second: 0,
|
minute: reminderMinute,
|
||||||
millisecond: 0,
|
second: 0,
|
||||||
microsecond: 0,
|
millisecond: 0,
|
||||||
);
|
microsecond: 0,
|
||||||
|
);
|
||||||
|
|
||||||
// 남은 일수에 따른 메시지 생성
|
// 남은 일수에 따른 메시지 생성
|
||||||
String daysText = '$reminderDays일 후';
|
String daysText = '$reminderDays일 후';
|
||||||
@@ -467,19 +528,21 @@ class NotificationService {
|
|||||||
|
|
||||||
// 이벤트 종료로 인한 가격 변동 확인
|
// 이벤트 종료로 인한 가격 변동 확인
|
||||||
String notificationBody;
|
String notificationBody;
|
||||||
if (subscription.isEventActive &&
|
if (subscription.isEventActive &&
|
||||||
subscription.eventEndDate != null &&
|
subscription.eventEndDate != null &&
|
||||||
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
||||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||||
// 이벤트가 결제일 전에 종료되는 경우
|
// 이벤트가 결제일 전에 종료되는 경우
|
||||||
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
|
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
|
||||||
final normalPrice = subscription.monthlyCost;
|
final normalPrice = subscription.monthlyCost;
|
||||||
notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
|
notificationBody =
|
||||||
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
|
||||||
|
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
||||||
} else {
|
} else {
|
||||||
// 일반 알림
|
// 일반 알림
|
||||||
final currentPrice = subscription.currentPrice;
|
final currentPrice = subscription.currentPrice;
|
||||||
notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
|
notificationBody =
|
||||||
|
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
await _notifications.zonedSchedule(
|
await _notifications.zonedSchedule(
|
||||||
@@ -506,13 +569,14 @@ class NotificationService {
|
|||||||
if (isDailyReminder && reminderDays >= 2) {
|
if (isDailyReminder && reminderDays >= 2) {
|
||||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||||
for (int i = reminderDays - 1; i >= 1; i--) {
|
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||||
final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
final dailyDate =
|
||||||
hour: reminderHour,
|
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
||||||
minute: reminderMinute,
|
hour: reminderHour,
|
||||||
second: 0,
|
minute: reminderMinute,
|
||||||
millisecond: 0,
|
second: 0,
|
||||||
microsecond: 0,
|
millisecond: 0,
|
||||||
);
|
microsecond: 0,
|
||||||
|
);
|
||||||
|
|
||||||
// 남은 일수에 따른 메시지 생성
|
// 남은 일수에 따른 메시지 생성
|
||||||
String remainingDaysText = '$i일 후';
|
String remainingDaysText = '$i일 후';
|
||||||
@@ -522,17 +586,21 @@ class NotificationService {
|
|||||||
|
|
||||||
// 각 날짜에 대한 이벤트 종료 확인
|
// 각 날짜에 대한 이벤트 종료 확인
|
||||||
String dailyNotificationBody;
|
String dailyNotificationBody;
|
||||||
if (subscription.isEventActive &&
|
if (subscription.isEventActive &&
|
||||||
subscription.eventEndDate != null &&
|
subscription.eventEndDate != null &&
|
||||||
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
subscription.eventEndDate!
|
||||||
|
.isBefore(subscription.nextBillingDate) &&
|
||||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||||
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
|
final eventPrice =
|
||||||
|
subscription.eventPrice ?? subscription.monthlyCost;
|
||||||
final normalPrice = subscription.monthlyCost;
|
final normalPrice = subscription.monthlyCost;
|
||||||
dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
|
dailyNotificationBody =
|
||||||
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
|
||||||
|
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
||||||
} else {
|
} else {
|
||||||
final currentPrice = subscription.currentPrice;
|
final currentPrice = subscription.currentPrice;
|
||||||
dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
|
dailyNotificationBody =
|
||||||
|
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
await _notifications.zonedSchedule(
|
await _notifications.zonedSchedule(
|
||||||
|
|||||||
@@ -3,16 +3,21 @@ 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) {
|
||||||
try {
|
try {
|
||||||
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,4 +81,4 @@ class SubscriptionConverter {
|
|||||||
return 'monthly'; // 기본값
|
return 'monthly'; // 기본값
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +57,9 @@ class SubscriptionFilter {
|
|||||||
List<Subscription> filterByCategories(
|
List<Subscription> filterByCategories(
|
||||||
List<Subscription> subscriptions, List<String> categoryIds) {
|
List<Subscription> subscriptions, List<String> categoryIds) {
|
||||||
if (categoryIds.isEmpty) return subscriptions;
|
if (categoryIds.isEmpty) return subscriptions;
|
||||||
|
|
||||||
return subscriptions.where((sub) {
|
return subscriptions.where((sub) {
|
||||||
return sub.category != null && categoryIds.contains(sub.category);
|
return sub.category != null && categoryIds.contains(sub.category);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
@@ -11,26 +12,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 +48,32 @@ class SmsScanner {
|
|||||||
serviceGroups[serviceName]!.add(sms);
|
serviceGroups[serviceName]!.add(sms);
|
||||||
}
|
}
|
||||||
|
|
||||||
print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
||||||
|
|
||||||
// 그룹화된 데이터로 구독 분석
|
// 그룹화된 데이터로 구독 분석
|
||||||
for (final entry in serviceGroups.entries) {
|
for (final entry in serviceGroups.entries) {
|
||||||
print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
|
Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
|
||||||
|
|
||||||
// 2회 이상 반복된 서비스만 구독으로 간주
|
// 2회 이상 반복된 서비스만 구독으로 간주
|
||||||
if (entry.value.length >= 2) {
|
if (entry.value.length >= 2) {
|
||||||
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
|
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
|
||||||
final subscription = _parseSms(serviceSms, entry.value.length);
|
final subscription = _parseSms(serviceSms, entry.value.length);
|
||||||
if (subscription != null) {
|
if (subscription != null) {
|
||||||
print(
|
Log.i(
|
||||||
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
||||||
subscriptions.add(subscription);
|
subscriptions.add(subscription);
|
||||||
} else {
|
} else {
|
||||||
print('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
||||||
return subscriptions;
|
return subscriptions;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('SmsScanner: 예외 발생: $e');
|
Log.e('SmsScanner: 예외 발생', e);
|
||||||
throw Exception('SMS 스캔 중 오류 발생: $e');
|
throw Exception('SMS 스캔 중 오류 발생: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,167 +82,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>>[];
|
|
||||||
|
// Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가)
|
||||||
// SMS 메시지를 분석하여 구독 서비스 감지
|
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,7 +129,7 @@ class SmsScanner {
|
|||||||
'Spotify Premium'
|
'Spotify Premium'
|
||||||
];
|
];
|
||||||
if (dollarServices.any((service) => serviceName.contains(service))) {
|
if (dollarServices.any((service) => serviceName.contains(service))) {
|
||||||
print('서비스명 $serviceName으로 USD 통화 단위 확정');
|
Log.d('서비스명 $serviceName으로 USD 통화 단위 확정');
|
||||||
currency = 'USD';
|
currency = 'USD';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,8 +239,6 @@ class SmsScanner {
|
|||||||
return serviceUrls[serviceName];
|
return serviceUrls[serviceName];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 메시지에서 통화 단위를 감지하는 함수
|
// 메시지에서 통화 단위를 감지하는 함수
|
||||||
String _detectCurrency(String message) {
|
String _detectCurrency(String message) {
|
||||||
final dollarKeywords = [
|
final dollarKeywords = [
|
||||||
@@ -391,7 +259,7 @@ class SmsScanner {
|
|||||||
// 서비스명 기반 통화 단위 확인
|
// 서비스명 기반 통화 단위 확인
|
||||||
for (final service in serviceCurrencyMap.keys) {
|
for (final service in serviceCurrencyMap.keys) {
|
||||||
if (message.contains(service)) {
|
if (message.contains(service)) {
|
||||||
print('_detectCurrency: ${service}는 USD 서비스로 판별됨');
|
Log.d('_detectCurrency: $service는 USD 서비스로 판별됨');
|
||||||
return 'USD';
|
return 'USD';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,7 +267,7 @@ class SmsScanner {
|
|||||||
// 메시지에 달러 관련 키워드가 있는지 확인
|
// 메시지에 달러 관련 키워드가 있는지 확인
|
||||||
for (final keyword in dollarKeywords) {
|
for (final keyword in dollarKeywords) {
|
||||||
if (message.toLowerCase().contains(keyword.toLowerCase())) {
|
if (message.toLowerCase().contains(keyword.toLowerCase())) {
|
||||||
print('_detectCurrency: USD 키워드 발견: $keyword');
|
Log.d('_detectCurrency: USD 키워드 발견: $keyword');
|
||||||
return 'USD';
|
return 'USD';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,4 +275,149 @@ 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,26 +9,26 @@ class SMSService {
|
|||||||
static Future<bool> requestSMSPermission() async {
|
static Future<bool> requestSMSPermission() async {
|
||||||
// 웹이나 iOS에서는 SMS 권한 불필요
|
// 웹이나 iOS에서는 SMS 권한 불필요
|
||||||
if (kIsWeb || PlatformHelper.isIOS) return true;
|
if (kIsWeb || PlatformHelper.isIOS) return true;
|
||||||
|
|
||||||
// Android에서만 권한 요청
|
// Android에서만 권한 요청
|
||||||
if (PlatformHelper.isAndroid) {
|
if (PlatformHelper.isAndroid) {
|
||||||
final status = await permission.Permission.sms.request();
|
final status = await permission.Permission.sms.request();
|
||||||
return status.isGranted;
|
return status.isGranted;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> hasSMSPermission() async {
|
static Future<bool> hasSMSPermission() async {
|
||||||
// 웹이나 iOS에서는 항상 true 반환 (권한 불필요)
|
// 웹이나 iOS에서는 항상 true 반환 (권한 불필요)
|
||||||
if (kIsWeb || PlatformHelper.isIOS) return true;
|
if (kIsWeb || PlatformHelper.isIOS) return true;
|
||||||
|
|
||||||
// Android에서만 실제 권한 확인
|
// Android에서만 실제 권한 확인
|
||||||
if (PlatformHelper.isAndroid) {
|
if (PlatformHelper.isAndroid) {
|
||||||
final status = await permission.Permission.sms.status;
|
final status = await permission.Permission.sms.status;
|
||||||
return status.isGranted;
|
return status.isGranted;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,39 +17,40 @@ class SubscriptionUrlMatcher {
|
|||||||
static CancellationUrlService? _cancellationService;
|
static CancellationUrlService? _cancellationService;
|
||||||
static ServiceNameResolver? _nameResolver;
|
static ServiceNameResolver? _nameResolver;
|
||||||
static SmsExtractorService? _smsExtractor;
|
static SmsExtractorService? _smsExtractor;
|
||||||
|
|
||||||
/// 서비스 초기화
|
/// 서비스 초기화
|
||||||
static Future<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
if (_dataRepository != null && _dataRepository!.isInitialized) return;
|
if (_dataRepository != null && _dataRepository!.isInitialized) return;
|
||||||
|
|
||||||
// 1. 데이터 저장소 초기화
|
// 1. 데이터 저장소 초기화
|
||||||
_dataRepository = ServiceDataRepository();
|
_dataRepository = ServiceDataRepository();
|
||||||
await _dataRepository!.initialize();
|
await _dataRepository!.initialize();
|
||||||
|
|
||||||
// 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!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 도메인 추출 (www와 TLD 제외)
|
/// 도메인 추출 (www와 TLD 제외)
|
||||||
static String? extractDomain(String url) {
|
static String? extractDomain(String url) {
|
||||||
return _urlMatcher?.extractDomain(url);
|
return _urlMatcher?.extractDomain(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL로 서비스 찾기
|
/// URL로 서비스 찾기
|
||||||
static Future<ServiceInfo?> findServiceByUrl(String url) async {
|
static Future<ServiceInfo?> findServiceByUrl(String url) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
return _urlMatcher?.findServiceByUrl(url);
|
return _urlMatcher?.findServiceByUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
|
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
|
||||||
static String? suggestUrl(String serviceName) {
|
static String? suggestUrl(String serviceName) {
|
||||||
return _urlMatcher?.suggestUrl(serviceName);
|
return _urlMatcher?.suggestUrl(serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
|
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
|
||||||
static Future<String?> findCancellationUrl({
|
static Future<String?> findCancellationUrl({
|
||||||
String? serviceName,
|
String? serviceName,
|
||||||
@@ -63,19 +64,20 @@ class SubscriptionUrlMatcher {
|
|||||||
locale: locale,
|
locale: locale,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 서비스명으로 카테고리 찾기
|
/// 서비스명으로 카테고리 찾기
|
||||||
static Future<String?> findCategoryByServiceName(String serviceName) async {
|
static Future<String?> findCategoryByServiceName(String serviceName) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
return _categoryMapper?.findCategoryByServiceName(serviceName);
|
return _categoryMapper?.findCategoryByServiceName(serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
||||||
static Future<String> getServiceDisplayName({
|
static Future<String> getServiceDisplayName({
|
||||||
required String serviceName,
|
required String serviceName,
|
||||||
@@ -83,17 +85,18 @@ 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과 서비스 정보 추출
|
||||||
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
return _smsExtractor?.extractServiceFromSms(smsText);
|
return _smsExtractor?.extractServiceFromSms(smsText);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL이 알려진 서비스 URL인지 확인
|
/// URL이 알려진 서비스 URL인지 확인
|
||||||
static Future<bool> isKnownServiceUrl(String url) async {
|
static Future<bool> isKnownServiceUrl(String url) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
@@ -104,4 +107,4 @@ class SubscriptionUrlMatcher {
|
|||||||
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
||||||
return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch);
|
return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -336,22 +336,22 @@ class LegacyServiceData {
|
|||||||
|
|
||||||
// 모든 서비스 매핑을 합친 맵
|
// 모든 서비스 매핑을 합친 맵
|
||||||
static Map<String, String> get allServices => {
|
static Map<String, String> get allServices => {
|
||||||
...ottServices,
|
...ottServices,
|
||||||
...musicServices,
|
...musicServices,
|
||||||
...storageServices,
|
...storageServices,
|
||||||
...aiServices,
|
...aiServices,
|
||||||
...programmingServices,
|
...programmingServices,
|
||||||
...officeTools,
|
...officeTools,
|
||||||
...lifestyleServices,
|
...lifestyleServices,
|
||||||
...shoppingServices,
|
...shoppingServices,
|
||||||
...telecomServices,
|
...telecomServices,
|
||||||
...otherServices,
|
...otherServices,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 서비스 카테고리 찾기
|
/// 서비스 카테고리 찾기
|
||||||
static String? getCategoryForService(String serviceName) {
|
static String? getCategoryForService(String serviceName) {
|
||||||
final lowerName = serviceName.toLowerCase();
|
final lowerName = serviceName.toLowerCase();
|
||||||
|
|
||||||
if (ottServices.containsKey(lowerName)) return 'ott';
|
if (ottServices.containsKey(lowerName)) return 'ott';
|
||||||
if (musicServices.containsKey(lowerName)) return 'music';
|
if (musicServices.containsKey(lowerName)) return 'music';
|
||||||
if (storageServices.containsKey(lowerName)) return 'storage';
|
if (storageServices.containsKey(lowerName)) return 'storage';
|
||||||
@@ -362,7 +362,7 @@ class LegacyServiceData {
|
|||||||
if (shoppingServices.containsKey(lowerName)) return 'shopping';
|
if (shoppingServices.containsKey(lowerName)) return 'shopping';
|
||||||
if (telecomServices.containsKey(lowerName)) return 'telecom';
|
if (telecomServices.containsKey(lowerName)) return 'telecom';
|
||||||
if (otherServices.containsKey(lowerName)) return 'other';
|
if (otherServices.containsKey(lowerName)) return 'other';
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../../../utils/logger.dart';
|
||||||
|
|
||||||
/// 서비스 데이터를 관리하는 저장소 클래스
|
/// 서비스 데이터를 관리하는 저장소 클래스
|
||||||
class ServiceDataRepository {
|
class ServiceDataRepository {
|
||||||
Map<String, dynamic>? _servicesData;
|
Map<String, dynamic>? _servicesData;
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
|
||||||
/// JSON 데이터 초기화
|
/// JSON 데이터 초기화
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 서비스 데이터 가져오기
|
/// 서비스 데이터 가져오기
|
||||||
Map<String, dynamic>? getServicesData() => _servicesData;
|
Map<String, dynamic>? getServicesData() => _servicesData;
|
||||||
|
|
||||||
/// 초기화 여부 확인
|
/// 초기화 여부 확인
|
||||||
bool get isInitialized => _isInitialized;
|
bool get isInitialized => _isInitialized;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class ServiceInfo {
|
|||||||
final String categoryId;
|
final String categoryId;
|
||||||
final String categoryNameKr;
|
final String categoryNameKr;
|
||||||
final String categoryNameEn;
|
final String categoryNameEn;
|
||||||
|
|
||||||
ServiceInfo({
|
ServiceInfo({
|
||||||
required this.serviceId,
|
required this.serviceId,
|
||||||
required this.serviceName,
|
required this.serviceName,
|
||||||
@@ -17,4 +17,4 @@ class ServiceInfo {
|
|||||||
required this.categoryNameKr,
|
required this.categoryNameKr,
|
||||||
required this.categoryNameEn,
|
required this.categoryNameEn,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import 'url_matcher_service.dart';
|
|||||||
class CancellationUrlService {
|
class CancellationUrlService {
|
||||||
final ServiceDataRepository _dataRepository;
|
final ServiceDataRepository _dataRepository;
|
||||||
final UrlMatcherService _urlMatcher;
|
final UrlMatcherService _urlMatcher;
|
||||||
|
|
||||||
CancellationUrlService(this._dataRepository, this._urlMatcher);
|
CancellationUrlService(this._dataRepository, this._urlMatcher);
|
||||||
|
|
||||||
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
|
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
|
||||||
Future<String?> findCancellationUrl({
|
Future<String?> findCancellationUrl({
|
||||||
String? serviceName,
|
String? serviceName,
|
||||||
@@ -19,47 +19,55 @@ class CancellationUrlService {
|
|||||||
final servicesData = _dataRepository.getServicesData();
|
final servicesData = _dataRepository.getServicesData();
|
||||||
if (servicesData != null) {
|
if (servicesData != null) {
|
||||||
final categories = servicesData['categories'] as Map<String, dynamic>;
|
final categories = servicesData['categories'] as Map<String, dynamic>;
|
||||||
|
|
||||||
// 1. 서비스명으로 찾기
|
// 1. 서비스명으로 찾기
|
||||||
if (serviceName != null && serviceName.isNotEmpty) {
|
if (serviceName != null && serviceName.isNotEmpty) {
|
||||||
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'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. URL로 찾기
|
// 2. URL로 찾기
|
||||||
if (websiteUrl != null && websiteUrl.isNotEmpty) {
|
if (websiteUrl != null && websiteUrl.isNotEmpty) {
|
||||||
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'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +76,7 @@ class CancellationUrlService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||||
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
|
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
|
||||||
}
|
}
|
||||||
@@ -126,4 +134,4 @@ class CancellationUrlService {
|
|||||||
);
|
);
|
||||||
return cancellationUrl != null;
|
return cancellationUrl != null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,41 +4,43 @@ import '../data/legacy_service_data.dart';
|
|||||||
/// 카테고리 매핑 관련 기능을 제공하는 서비스 클래스
|
/// 카테고리 매핑 관련 기능을 제공하는 서비스 클래스
|
||||||
class CategoryMapperService {
|
class CategoryMapperService {
|
||||||
final ServiceDataRepository _dataRepository;
|
final ServiceDataRepository _dataRepository;
|
||||||
|
|
||||||
CategoryMapperService(this._dataRepository);
|
CategoryMapperService(this._dataRepository);
|
||||||
|
|
||||||
/// 서비스명으로 카테고리 찾기
|
/// 서비스명으로 카테고리 찾기
|
||||||
Future<String?> findCategoryByServiceName(String serviceName) async {
|
Future<String?> findCategoryByServiceName(String serviceName) async {
|
||||||
if (serviceName.isEmpty) return null;
|
if (serviceName.isEmpty) return null;
|
||||||
|
|
||||||
final lowerName = serviceName.toLowerCase().trim();
|
final lowerName = serviceName.toLowerCase().trim();
|
||||||
|
|
||||||
// JSON 데이터가 있으면 JSON에서 찾기
|
// JSON 데이터가 있으면 JSON에서 찾기
|
||||||
final servicesData = _dataRepository.getServicesData();
|
final servicesData = _dataRepository.getServicesData();
|
||||||
if (servicesData != null) {
|
if (servicesData != null) {
|
||||||
final categories = servicesData['categories'] as Map<String, dynamic>;
|
final categories = servicesData['categories'] as Map<String, dynamic>;
|
||||||
|
|
||||||
for (final categoryEntry in categories.entries) {
|
for (final categoryEntry in categories.entries) {
|
||||||
final categoryId = categoryEntry.key;
|
final categoryId = categoryEntry.key;
|
||||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
|
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
|
||||||
return getCategoryForLegacyService(serviceName);
|
return getCategoryForLegacyService(serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 카테고리 키를 실제 카테고리 ID로 매핑
|
/// 카테고리 키를 실제 카테고리 ID로 매핑
|
||||||
String getCategoryIdByKey(String key) {
|
String getCategoryIdByKey(String key) {
|
||||||
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
|
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
|
||||||
@@ -68,21 +70,39 @@ class CategoryMapperService {
|
|||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 레거시 서비스명으로 카테고리 추측
|
/// 레거시 서비스명으로 카테고리 추측
|
||||||
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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import '../data/service_data_repository.dart';
|
|||||||
/// 서비스명 관련 기능을 제공하는 서비스 클래스
|
/// 서비스명 관련 기능을 제공하는 서비스 클래스
|
||||||
class ServiceNameResolver {
|
class ServiceNameResolver {
|
||||||
final ServiceDataRepository _dataRepository;
|
final ServiceDataRepository _dataRepository;
|
||||||
|
|
||||||
ServiceNameResolver(this._dataRepository);
|
ServiceNameResolver(this._dataRepository);
|
||||||
|
|
||||||
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
||||||
Future<String> getServiceDisplayName({
|
Future<String> getServiceDisplayName({
|
||||||
required String serviceName,
|
required String serviceName,
|
||||||
@@ -15,22 +15,23 @@ class ServiceNameResolver {
|
|||||||
if (servicesData == null) {
|
if (servicesData == null) {
|
||||||
return serviceName;
|
return serviceName;
|
||||||
}
|
}
|
||||||
|
|
||||||
final lowerName = serviceName.toLowerCase().trim();
|
final lowerName = serviceName.toLowerCase().trim();
|
||||||
final categories = servicesData['categories'] as Map<String, dynamic>;
|
final categories = servicesData['categories'] as Map<String, dynamic>;
|
||||||
|
|
||||||
// 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>;
|
||||||
final names = List<String>.from(data['names'] ?? []);
|
final names = List<String>.from(data['names'] ?? []);
|
||||||
|
|
||||||
// names 배열에 있는지 확인
|
// names 배열에 있는지 확인
|
||||||
for (final name in names) {
|
for (final name in names) {
|
||||||
if (lowerName == name.toLowerCase() ||
|
if (lowerName == name.toLowerCase() ||
|
||||||
lowerName.contains(name.toLowerCase()) ||
|
lowerName.contains(name.toLowerCase()) ||
|
||||||
name.toLowerCase().contains(lowerName)) {
|
name.toLowerCase().contains(lowerName)) {
|
||||||
// 로케일에 따라 적절한 이름 반환
|
// 로케일에 따라 적절한 이름 반환
|
||||||
if (locale == 'ko' || locale == 'kr') {
|
if (locale == 'ko' || locale == 'kr') {
|
||||||
@@ -40,11 +41,11 @@ class ServiceNameResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nameKr/nameEn에 직접 매칭 확인
|
// nameKr/nameEn에 직접 매칭 확인
|
||||||
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
|
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
|
||||||
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
|
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
|
||||||
|
|
||||||
if (lowerName == nameKr || lowerName == nameEn) {
|
if (lowerName == nameKr || lowerName == nameEn) {
|
||||||
if (locale == 'ko' || locale == 'kr') {
|
if (locale == 'ko' || locale == 'kr') {
|
||||||
return data['nameKr'] ?? serviceName;
|
return data['nameKr'] ?? serviceName;
|
||||||
@@ -54,8 +55,8 @@ class ServiceNameResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 찾지 못한 경우 원래 이름 반환
|
// 찾지 못한 경우 원래 이름 반환
|
||||||
return serviceName;
|
return serviceName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import 'category_mapper_service.dart';
|
|||||||
class SmsExtractorService {
|
class SmsExtractorService {
|
||||||
final UrlMatcherService _urlMatcher;
|
final UrlMatcherService _urlMatcher;
|
||||||
final CategoryMapperService _categoryMapper;
|
final CategoryMapperService _categoryMapper;
|
||||||
|
|
||||||
SmsExtractorService(this._urlMatcher, this._categoryMapper);
|
SmsExtractorService(this._urlMatcher, this._categoryMapper);
|
||||||
|
|
||||||
/// SMS에서 URL과 서비스 정보 추출
|
/// SMS에서 URL과 서비스 정보 추출
|
||||||
Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
||||||
// URL 패턴 찾기
|
// URL 패턴 찾기
|
||||||
@@ -17,9 +17,9 @@ class SmsExtractorService {
|
|||||||
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
|
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
final matches = urlPattern.allMatches(smsText);
|
final matches = urlPattern.allMatches(smsText);
|
||||||
|
|
||||||
for (final match in matches) {
|
for (final match in matches) {
|
||||||
final url = match.group(0);
|
final url = match.group(0);
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
@@ -29,15 +29,17 @@ class SmsExtractorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL로 못 찾았으면 서비스명으로 시도
|
// URL로 못 찾았으면 서비스명으로 시도
|
||||||
final lowerSms = smsText.toLowerCase();
|
final lowerSms = smsText.toLowerCase();
|
||||||
|
|
||||||
// 모든 서비스명 검사
|
// 모든 서비스명 검사
|
||||||
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,
|
||||||
serviceName: entry.key,
|
serviceName: entry.key,
|
||||||
@@ -49,7 +51,7 @@ class SmsExtractorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,29 @@ 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 {
|
||||||
final ServiceDataRepository _dataRepository;
|
final ServiceDataRepository _dataRepository;
|
||||||
final CategoryMapperService _categoryMapper;
|
final CategoryMapperService _categoryMapper;
|
||||||
|
|
||||||
UrlMatcherService(this._dataRepository, this._categoryMapper);
|
UrlMatcherService(this._dataRepository, this._categoryMapper);
|
||||||
|
|
||||||
/// 도메인 추출 (www와 TLD 제외)
|
/// 도메인 추출 (www와 TLD 제외)
|
||||||
String? extractDomain(String url) {
|
String? extractDomain(String url) {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri.parse(url);
|
||||||
final host = uri.host.toLowerCase();
|
final host = uri.host.toLowerCase();
|
||||||
|
|
||||||
// 도메인 부분 추출
|
// 도메인 부분 추출
|
||||||
var parts = host.split('.');
|
var parts = host.split('.');
|
||||||
|
|
||||||
// www 제거
|
// www 제거
|
||||||
if (parts.isNotEmpty && parts[0] == 'www') {
|
if (parts.isNotEmpty && parts[0] == 'www') {
|
||||||
parts = parts.sublist(1);
|
parts = parts.sublist(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서브도메인 처리 (예: music.youtube.com)
|
// 서브도메인 처리 (예: music.youtube.com)
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
// 서브도메인 포함 전체 도메인 반환
|
// 서브도메인 포함 전체 도메인 반환
|
||||||
@@ -32,40 +33,41 @@ class UrlMatcherService {
|
|||||||
// 메인 도메인만 반환
|
// 메인 도메인만 반환
|
||||||
return parts[0];
|
return parts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('UrlMatcherService: 도메인 추출 실패 - $e');
|
Log.e('UrlMatcherService: 도메인 추출 실패', e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL로 서비스 찾기
|
/// URL로 서비스 찾기
|
||||||
Future<ServiceInfo?> findServiceByUrl(String url) async {
|
Future<ServiceInfo?> findServiceByUrl(String url) async {
|
||||||
final domain = extractDomain(url);
|
final domain = extractDomain(url);
|
||||||
if (domain == null) return null;
|
if (domain == null) return null;
|
||||||
|
|
||||||
// JSON 데이터가 있으면 JSON에서 찾기
|
// JSON 데이터가 있으면 JSON에서 찾기
|
||||||
final servicesData = _dataRepository.getServicesData();
|
final servicesData = _dataRepository.getServicesData();
|
||||||
if (servicesData != null) {
|
if (servicesData != null) {
|
||||||
final categories = servicesData['categories'] as Map<String, dynamic>;
|
final categories = servicesData['categories'] as Map<String, dynamic>;
|
||||||
|
|
||||||
for (final categoryEntry in categories.entries) {
|
for (final categoryEntry in categories.entries) {
|
||||||
final categoryId = categoryEntry.key;
|
final categoryId = categoryEntry.key;
|
||||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||||
final services = categoryData['services'] as Map<String, dynamic>;
|
final services = categoryData['services'] as Map<String, dynamic>;
|
||||||
|
|
||||||
for (final serviceEntry in services.entries) {
|
for (final serviceEntry in services.entries) {
|
||||||
final serviceId = serviceEntry.key;
|
final serviceId = serviceEntry.key;
|
||||||
final serviceData = serviceEntry.value as Map<String, dynamic>;
|
final serviceData = serviceEntry.value as Map<String, dynamic>;
|
||||||
final domains = List<String>.from(serviceData['domains'] ?? []);
|
final domains = List<String>.from(serviceData['domains'] ?? []);
|
||||||
|
|
||||||
// 도메인이 일치하는지 확인
|
// 도메인이 일치하는지 확인
|
||||||
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>?;
|
||||||
|
|
||||||
return ServiceInfo(
|
return ServiceInfo(
|
||||||
serviceId: serviceId,
|
serviceId: serviceId,
|
||||||
serviceName: names.isNotEmpty ? names[0] : serviceId,
|
serviceName: names.isNotEmpty ? names[0] : serviceId,
|
||||||
@@ -80,13 +82,13 @@ class UrlMatcherService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||||
for (final entry in LegacyServiceData.allServices.entries) {
|
for (final entry in LegacyServiceData.allServices.entries) {
|
||||||
final serviceUrl = entry.value;
|
final serviceUrl = entry.value;
|
||||||
final serviceDomain = extractDomain(serviceUrl);
|
final serviceDomain = extractDomain(serviceUrl);
|
||||||
|
|
||||||
if (serviceDomain != null &&
|
if (serviceDomain != null &&
|
||||||
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
|
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
|
||||||
return ServiceInfo(
|
return ServiceInfo(
|
||||||
serviceId: entry.key,
|
serviceId: entry.key,
|
||||||
@@ -99,14 +101,14 @@ class UrlMatcherService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 서비스명으로 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,19 +176,19 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL이 알려진 서비스 URL인지 확인
|
/// URL이 알려진 서비스 URL인지 확인
|
||||||
Future<bool> isKnownServiceUrl(String url) async {
|
Future<bool> isKnownServiceUrl(String url) async {
|
||||||
final serviceInfo = await findServiceByUrl(url);
|
final serviceInfo = await findServiceByUrl(url);
|
||||||
@@ -232,4 +234,4 @@ class UrlMatcherService {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
/// URL Matcher 패키지의 export 파일
|
/// URL Matcher 패키지의 export 파일
|
||||||
export 'models/service_info.dart';
|
export 'models/service_info.dart';
|
||||||
|
|||||||
@@ -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++) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'app_theme.dart';
|
|||||||
class AdaptiveTheme {
|
class AdaptiveTheme {
|
||||||
/// 라이트 테마
|
/// 라이트 테마
|
||||||
static ThemeData get lightTheme => AppTheme.lightTheme;
|
static ThemeData get lightTheme => AppTheme.lightTheme;
|
||||||
|
|
||||||
/// 다크 테마
|
/// 다크 테마
|
||||||
static ThemeData get darkTheme {
|
static ThemeData get darkTheme {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@@ -19,24 +19,21 @@ class AdaptiveTheme {
|
|||||||
secondary: AppColors.secondaryColor,
|
secondary: AppColors.secondaryColor,
|
||||||
tertiary: AppColors.infoColor,
|
tertiary: AppColors.infoColor,
|
||||||
error: AppColors.dangerColor,
|
error: AppColors.dangerColor,
|
||||||
background: const Color(0xFF121212),
|
surface: Color(0xFF1E1E1E),
|
||||||
surface: const Color(0xFF1E1E1E),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
cardTheme: CardTheme(
|
|
||||||
color: const Color(0xFF1E1E1E),
|
color: const Color(0xFF1E1E1E),
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shadowColor: Colors.black.withValues(alpha: 0.3),
|
shadowColor: Colors.black.withValues(alpha: 0.3),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5),
|
side: BorderSide(
|
||||||
|
color: Colors.white.withValues(alpha: 0.1), width: 0.5),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
),
|
),
|
||||||
|
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: const Color(0xFF1E1E1E),
|
backgroundColor: const Color(0xFF1E1E1E),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
@@ -53,7 +50,6 @@ class AdaptiveTheme {
|
|||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
textTheme: TextTheme(
|
textTheme: TextTheme(
|
||||||
headlineLarge: const TextStyle(
|
headlineLarge: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -119,22 +115,24 @@ class AdaptiveTheme {
|
|||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFF2A2A2A),
|
fillColor: const Color(0xFF2A2A2A),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
|
borderSide:
|
||||||
|
BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5),
|
borderSide:
|
||||||
|
const BorderSide(color: AppColors.primaryColor, width: 1.5),
|
||||||
),
|
),
|
||||||
errorBorder: OutlineInputBorder(
|
errorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -151,7 +149,6 @@ class AdaptiveTheme {
|
|||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryColor,
|
backgroundColor: AppColors.primaryColor,
|
||||||
@@ -164,7 +161,6 @@ class AdaptiveTheme {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: Colors.white.withValues(alpha: 0.1),
|
color: Colors.white.withValues(alpha: 0.1),
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
@@ -172,13 +168,12 @@ class AdaptiveTheme {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OLED 최적화 다크 테마
|
/// OLED 최적화 다크 테마
|
||||||
static ThemeData get oledTheme {
|
static ThemeData get oledTheme {
|
||||||
return darkTheme.copyWith(
|
return darkTheme.copyWith(
|
||||||
scaffoldBackgroundColor: Colors.black,
|
scaffoldBackgroundColor: Colors.black,
|
||||||
colorScheme: darkTheme.colorScheme.copyWith(
|
colorScheme: darkTheme.colorScheme.copyWith(
|
||||||
background: Colors.black,
|
|
||||||
surface: const Color(0xFF0A0A0A),
|
surface: const Color(0xFF0A0A0A),
|
||||||
),
|
),
|
||||||
cardTheme: darkTheme.cardTheme.copyWith(
|
cardTheme: darkTheme.cardTheme.copyWith(
|
||||||
@@ -192,7 +187,7 @@ class AdaptiveTheme {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 고대비 테마
|
/// 고대비 테마
|
||||||
static ThemeData get highContrastTheme {
|
static ThemeData get highContrastTheme {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@@ -203,10 +198,8 @@ class AdaptiveTheme {
|
|||||||
secondary: Colors.black87,
|
secondary: Colors.black87,
|
||||||
tertiary: Colors.black54,
|
tertiary: Colors.black54,
|
||||||
error: Colors.red,
|
error: Colors.red,
|
||||||
background: Colors.white,
|
|
||||||
surface: Colors.white,
|
surface: Colors.white,
|
||||||
),
|
),
|
||||||
|
|
||||||
textTheme: const TextTheme(
|
textTheme: const TextTheme(
|
||||||
headlineLarge: TextStyle(
|
headlineLarge: TextStyle(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
@@ -234,15 +227,13 @@ class AdaptiveTheme {
|
|||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
cardTheme: CardTheme(
|
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
side: const BorderSide(color: Colors.black, width: 2),
|
side: const BorderSide(color: Colors.black, width: 2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
@@ -255,31 +246,28 @@ class AdaptiveTheme {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 시스템 테마에 따른 상태바 스타일 적용
|
/// 시스템 테마에 따른 상태바 스타일 적용
|
||||||
static void applySystemUIOverlay(BuildContext context) {
|
static void applySystemUIOverlay(BuildContext context) {
|
||||||
final brightness = Theme.of(context).brightness;
|
final brightness = Theme.of(context).brightness;
|
||||||
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
|
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
|
||||||
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
statusBarIconBrightness: brightness == Brightness.dark
|
statusBarIconBrightness:
|
||||||
? Brightness.light
|
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
|
||||||
: Brightness.dark,
|
statusBarBrightness:
|
||||||
statusBarBrightness: brightness == Brightness.dark
|
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
|
||||||
? Brightness.light
|
systemNavigationBarColor: isOled
|
||||||
: Brightness.dark,
|
? Colors.black
|
||||||
systemNavigationBarColor: isOled
|
: (brightness == Brightness.dark
|
||||||
? Colors.black
|
? const Color(0xFF121212)
|
||||||
: (brightness == Brightness.dark
|
|
||||||
? const Color(0xFF121212)
|
|
||||||
: Colors.white),
|
: Colors.white),
|
||||||
systemNavigationBarIconBrightness: brightness == Brightness.dark
|
systemNavigationBarIconBrightness:
|
||||||
? Brightness.light
|
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
|
||||||
: Brightness.dark,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 접근성 설정에 따른 테마 조정
|
/// 접근성 설정에 따른 테마 조정
|
||||||
static ThemeData getAccessibleTheme(
|
static ThemeData getAccessibleTheme(
|
||||||
ThemeData baseTheme, {
|
ThemeData baseTheme, {
|
||||||
@@ -290,9 +278,9 @@ class AdaptiveTheme {
|
|||||||
if (highContrast) {
|
if (highContrast) {
|
||||||
return highContrastTheme;
|
return highContrastTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
ThemeData theme = baseTheme;
|
ThemeData theme = baseTheme;
|
||||||
|
|
||||||
if (largeText) {
|
if (largeText) {
|
||||||
theme = theme.copyWith(
|
theme = theme.copyWith(
|
||||||
textTheme: theme.textTheme.apply(
|
textTheme: theme.textTheme.apply(
|
||||||
@@ -300,7 +288,7 @@ class AdaptiveTheme {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reduceMotion) {
|
if (reduceMotion) {
|
||||||
theme = theme.copyWith(
|
theme = theme.copyWith(
|
||||||
pageTransitionsTheme: const PageTransitionsTheme(
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
@@ -311,7 +299,7 @@ class AdaptiveTheme {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return theme;
|
return theme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,7 +319,7 @@ class ThemeSettings {
|
|||||||
final bool largeText;
|
final bool largeText;
|
||||||
final bool reduceMotion;
|
final bool reduceMotion;
|
||||||
final bool highContrast;
|
final bool highContrast;
|
||||||
|
|
||||||
const ThemeSettings({
|
const ThemeSettings({
|
||||||
this.mode = AppThemeMode.system,
|
this.mode = AppThemeMode.system,
|
||||||
this.useSystemColors = false,
|
this.useSystemColors = false,
|
||||||
@@ -339,7 +327,7 @@ class ThemeSettings {
|
|||||||
this.reduceMotion = false,
|
this.reduceMotion = false,
|
||||||
this.highContrast = false,
|
this.highContrast = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
ThemeSettings copyWith({
|
ThemeSettings copyWith({
|
||||||
AppThemeMode? mode,
|
AppThemeMode? mode,
|
||||||
bool? useSystemColors,
|
bool? useSystemColors,
|
||||||
@@ -355,15 +343,15 @@ class ThemeSettings {
|
|||||||
highContrast: highContrast ?? this.highContrast,
|
highContrast: highContrast ?? this.highContrast,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'mode': mode.name,
|
'mode': mode.name,
|
||||||
'useSystemColors': useSystemColors,
|
'useSystemColors': useSystemColors,
|
||||||
'largeText': largeText,
|
'largeText': largeText,
|
||||||
'reduceMotion': reduceMotion,
|
'reduceMotion': reduceMotion,
|
||||||
'highContrast': highContrast,
|
'highContrast': highContrast,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
|
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
|
||||||
return ThemeSettings(
|
return ThemeSettings(
|
||||||
mode: AppThemeMode.values.firstWhere(
|
mode: AppThemeMode.values.firstWhere(
|
||||||
@@ -376,4 +364,4 @@ class ThemeSettings {
|
|||||||
highContrast: json['highContrast'] ?? false,
|
highContrast: json['highContrast'] ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ class AppColors {
|
|||||||
// 보더 & 디바이더
|
// 보더 & 디바이더
|
||||||
static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200
|
static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200
|
||||||
static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200
|
static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200
|
||||||
|
|
||||||
// 그림자 (color.md 가이드)
|
// 그림자 (color.md 가이드)
|
||||||
static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity
|
static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity
|
||||||
|
|
||||||
// 그라데이션 컬러 - 다양한 효과를 위한 조합
|
// 그라데이션 컬러 - 다양한 효과를 위한 조합
|
||||||
static const List<Color> blueGradient = [
|
static const List<Color> blueGradient = [
|
||||||
Color(0xFF2563EB), // 딥 블루
|
Color(0xFF2563EB), // 딥 블루
|
||||||
Color(0xFF60A5FA) // 스카이 블루
|
Color(0xFF60A5FA) // 스카이 블루
|
||||||
];
|
];
|
||||||
static const List<Color> tealGradient = [
|
static const List<Color> tealGradient = [
|
||||||
Color(0xFF14B8A6),
|
Color(0xFF14B8A6),
|
||||||
@@ -59,52 +59,52 @@ class AppColors {
|
|||||||
static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity)
|
static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity)
|
||||||
static const glassBorder = Color(0xFF2563EB); // 딥 블루 테두리
|
static const glassBorder = Color(0xFF2563EB); // 딥 블루 테두리
|
||||||
static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity)
|
static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity)
|
||||||
|
|
||||||
// 다크 모드용 Glassmorphism 색상
|
// 다크 모드용 Glassmorphism 색상
|
||||||
static const glassSurfaceDark = Color(0x0F000000); // 매우 연한 검정 (6% opacity)
|
static const glassSurfaceDark = Color(0x0F000000); // 매우 연한 검정 (6% opacity)
|
||||||
static const glassBackgroundDark = Color(0x1A000000); // 연한 검정 (10% opacity)
|
static const glassBackgroundDark = Color(0x1A000000); // 연한 검정 (10% opacity)
|
||||||
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity)
|
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity)
|
||||||
static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity)
|
static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity)
|
||||||
|
|
||||||
// 백드롭 블러 효과를 위한 그라디언트
|
// 백드롭 블러 효과를 위한 그라디언트
|
||||||
static const List<Color> glassGradient = [
|
static const List<Color> glassGradient = [
|
||||||
Color(0x33FFFFFF), // 20% white
|
Color(0x33FFFFFF), // 20% white
|
||||||
Color(0x1AFFFFFF), // 10% white
|
Color(0x1AFFFFFF), // 10% white
|
||||||
];
|
];
|
||||||
|
|
||||||
static const List<Color> glassGradientDark = [
|
static const List<Color> glassGradientDark = [
|
||||||
Color(0x1A000000), // 10% black
|
Color(0x1A000000), // 10% black
|
||||||
Color(0x0F000000), // 6% black
|
Color(0x0F000000), // 6% black
|
||||||
];
|
];
|
||||||
|
|
||||||
// 메인 그라데이션
|
// 메인 그라데이션
|
||||||
static const List<Color> mainGradient = [
|
static const List<Color> mainGradient = [
|
||||||
Color(0xFF2563EB), // 딥 블루
|
Color(0xFF2563EB), // 딥 블루
|
||||||
Color(0xFF60A5FA), // 스카이 블루
|
Color(0xFF60A5FA), // 스카이 블루
|
||||||
Color(0xFFE0E7EF), // 라이트 그레이
|
Color(0xFFE0E7EF), // 라이트 그레이
|
||||||
];
|
];
|
||||||
|
|
||||||
static const List<Color> accentGradient = [
|
static const List<Color> accentGradient = [
|
||||||
Color(0xFF38BDF8), // 소프트 민트
|
Color(0xFF38BDF8), // 소프트 민트
|
||||||
Color(0xFF60A5FA), // 스카이 블루
|
Color(0xFF60A5FA), // 스카이 블루
|
||||||
];
|
];
|
||||||
|
|
||||||
// 시간대별 배경 그라디언트
|
// 시간대별 배경 그라디언트
|
||||||
static const List<Color> morningGradient = [
|
static const List<Color> morningGradient = [
|
||||||
Color(0xFFFED7AA), // 따뜻한 오렌지
|
Color(0xFFFED7AA), // 따뜻한 오렌지
|
||||||
Color(0xFFFBBF24), // 부드러운 노랑
|
Color(0xFFFBBF24), // 부드러운 노랑
|
||||||
];
|
];
|
||||||
|
|
||||||
static const List<Color> dayGradient = [
|
static const List<Color> dayGradient = [
|
||||||
Color(0xFFDDEAFC), // 연한 하늘색
|
Color(0xFFDDEAFC), // 연한 하늘색
|
||||||
Color(0xFFBFDBFE), // 맑은 파랑
|
Color(0xFFBFDBFE), // 맑은 파랑
|
||||||
];
|
];
|
||||||
|
|
||||||
static const List<Color> eveningGradient = [
|
static const List<Color> eveningGradient = [
|
||||||
Color(0xFFFCA5A5), // 부드러운 핑크
|
Color(0xFFFCA5A5), // 부드러운 핑크
|
||||||
Color(0xFFC084FC), // 연한 보라
|
Color(0xFFC084FC), // 연한 보라
|
||||||
];
|
];
|
||||||
|
|
||||||
static const List<Color> nightGradient = [
|
static const List<Color> nightGradient = [
|
||||||
Color(0xFF4338CA), // 깊은 인디고
|
Color(0xFF4338CA), // 깊은 인디고
|
||||||
Color(0xFF1E1B4B), // 다크 네이비
|
Color(0xFF1E1B4B), // 다크 네이비
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ class AppTheme {
|
|||||||
secondary: AppColors.secondaryColor,
|
secondary: AppColors.secondaryColor,
|
||||||
tertiary: AppColors.infoColor,
|
tertiary: AppColors.infoColor,
|
||||||
error: AppColors.dangerColor,
|
error: AppColors.dangerColor,
|
||||||
background: AppColors.backgroundColor,
|
|
||||||
surface: AppColors.surfaceColor,
|
surface: AppColors.surfaceColor,
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -18,7 +17,7 @@ class AppTheme {
|
|||||||
scaffoldBackgroundColor: AppColors.backgroundColor,
|
scaffoldBackgroundColor: AppColors.backgroundColor,
|
||||||
|
|
||||||
// 카드 스타일 - 글래스모피즘 효과
|
// 카드 스타일 - 글래스모피즘 효과
|
||||||
cardTheme: CardTheme(
|
cardTheme: CardThemeData(
|
||||||
color: AppColors.glassCard,
|
color: AppColors.glassCard,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shadowColor: AppColors.shadowBlack,
|
shadowColor: AppColors.shadowBlack,
|
||||||
@@ -36,13 +35,13 @@ class AppTheme {
|
|||||||
foregroundColor: AppColors.textPrimary,
|
foregroundColor: AppColors.textPrimary,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: const TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
),
|
),
|
||||||
iconTheme: const IconThemeData(
|
iconTheme: IconThemeData(
|
||||||
color: AppColors.primaryColor,
|
color: AppColors.primaryColor,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
@@ -51,22 +50,22 @@ class AppTheme {
|
|||||||
// 타이포그래피 - Metronic Tailwind 스타일
|
// 타이포그래피 - Metronic Tailwind 스타일
|
||||||
textTheme: const TextTheme(
|
textTheme: const TextTheme(
|
||||||
// 헤드라인 - 페이지 제목
|
// 헤드라인 - 페이지 제목
|
||||||
headlineLarge: const TextStyle(
|
headlineLarge: TextStyle(
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
headlineMedium: const TextStyle(
|
headlineMedium: TextStyle(
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
headlineSmall: const TextStyle(
|
headlineSmall: TextStyle(
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: -0.25,
|
letterSpacing: -0.25,
|
||||||
@@ -74,22 +73,22 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// 타이틀 - 카드, 섹션 제목
|
// 타이틀 - 카드, 섹션 제목
|
||||||
titleLarge: const TextStyle(
|
titleLarge: TextStyle(
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
titleMedium: TextStyle(
|
titleMedium: TextStyle(
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: -0.1,
|
letterSpacing: -0.1,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
titleSmall: TextStyle(
|
titleSmall: TextStyle(
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
@@ -98,21 +97,21 @@ class AppTheme {
|
|||||||
|
|
||||||
// 본문 텍스트
|
// 본문 텍스트
|
||||||
bodyLarge: TextStyle(
|
bodyLarge: TextStyle(
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
bodyMedium: TextStyle(
|
bodyMedium: TextStyle(
|
||||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
bodySmall: TextStyle(
|
bodySmall: TextStyle(
|
||||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.2,
|
||||||
@@ -121,21 +120,21 @@ class AppTheme {
|
|||||||
|
|
||||||
// 라벨 텍스트
|
// 라벨 텍스트
|
||||||
labelLarge: TextStyle(
|
labelLarge: TextStyle(
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
labelMedium: TextStyle(
|
labelMedium: TextStyle(
|
||||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.2,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
labelSmall: TextStyle(
|
labelSmall: TextStyle(
|
||||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.2,
|
||||||
@@ -257,14 +256,14 @@ class AppTheme {
|
|||||||
|
|
||||||
// 스위치 스타일
|
// 스위치 스타일
|
||||||
switchTheme: SwitchThemeData(
|
switchTheme: SwitchThemeData(
|
||||||
thumbColor: MaterialStateProperty.resolveWith<Color>((states) {
|
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
if (states.contains(MaterialState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return AppColors.primaryColor;
|
return AppColors.primaryColor;
|
||||||
}
|
}
|
||||||
return Colors.white;
|
return Colors.white;
|
||||||
}),
|
}),
|
||||||
trackColor: MaterialStateProperty.resolveWith<Color>((states) {
|
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
if (states.contains(MaterialState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return AppColors.secondaryColor.withValues(alpha: 0.5);
|
return AppColors.secondaryColor.withValues(alpha: 0.5);
|
||||||
}
|
}
|
||||||
return AppColors.borderColor;
|
return AppColors.borderColor;
|
||||||
@@ -273,8 +272,8 @@ class AppTheme {
|
|||||||
|
|
||||||
// 체크박스 스타일
|
// 체크박스 스타일
|
||||||
checkboxTheme: CheckboxThemeData(
|
checkboxTheme: CheckboxThemeData(
|
||||||
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
|
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
if (states.contains(MaterialState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return AppColors.primaryColor;
|
return AppColors.primaryColor;
|
||||||
}
|
}
|
||||||
return Colors.transparent;
|
return Colors.transparent;
|
||||||
@@ -287,8 +286,8 @@ class AppTheme {
|
|||||||
|
|
||||||
// 라디오 버튼 스타일
|
// 라디오 버튼 스타일
|
||||||
radioTheme: RadioThemeData(
|
radioTheme: RadioThemeData(
|
||||||
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
|
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
if (states.contains(MaterialState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return AppColors.primaryColor;
|
return AppColors.primaryColor;
|
||||||
}
|
}
|
||||||
return AppColors.textSecondary;
|
return AppColors.textSecondary;
|
||||||
@@ -307,16 +306,16 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// 탭바 스타일
|
// 탭바 스타일
|
||||||
tabBarTheme: const TabBarTheme(
|
tabBarTheme: const TabBarThemeData(
|
||||||
labelColor: AppColors.primaryColor,
|
labelColor: AppColors.primaryColor,
|
||||||
unselectedLabelColor: AppColors.textSecondary,
|
unselectedLabelColor: AppColors.textSecondary,
|
||||||
indicatorColor: AppColors.primaryColor,
|
indicatorColor: AppColors.primaryColor,
|
||||||
labelStyle: const TextStyle(
|
labelStyle: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
),
|
),
|
||||||
unselectedLabelStyle: const TextStyle(
|
unselectedLabelStyle: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
|
|||||||
@@ -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일 후';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,42 +4,42 @@ import 'dart:io' show Platform;
|
|||||||
/// 햅틱 피드백을 관리하는 헬퍼 클래스
|
/// 햅틱 피드백을 관리하는 헬퍼 클래스
|
||||||
class HapticFeedbackHelper {
|
class HapticFeedbackHelper {
|
||||||
static bool _isEnabled = true;
|
static bool _isEnabled = true;
|
||||||
|
|
||||||
/// 햅틱 피드백 활성화 여부 설정
|
/// 햅틱 피드백 활성화 여부 설정
|
||||||
static void setEnabled(bool enabled) {
|
static void setEnabled(bool enabled) {
|
||||||
_isEnabled = enabled;
|
_isEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 가벼운 햅틱 피드백
|
/// 가벼운 햅틱 피드백
|
||||||
static Future<void> lightImpact() async {
|
static Future<void> lightImpact() async {
|
||||||
if (!_isEnabled || !_isPlatformSupported()) return;
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
await HapticFeedback.lightImpact();
|
await HapticFeedback.lightImpact();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 중간 강도 햅틱 피드백
|
/// 중간 강도 햅틱 피드백
|
||||||
static Future<void> mediumImpact() async {
|
static Future<void> mediumImpact() async {
|
||||||
if (!_isEnabled || !_isPlatformSupported()) return;
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
await HapticFeedback.mediumImpact();
|
await HapticFeedback.mediumImpact();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 강한 햅틱 피드백
|
/// 강한 햅틱 피드백
|
||||||
static Future<void> heavyImpact() async {
|
static Future<void> heavyImpact() async {
|
||||||
if (!_isEnabled || !_isPlatformSupported()) return;
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
await HapticFeedback.heavyImpact();
|
await HapticFeedback.heavyImpact();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 선택 햅틱 피드백 (iOS의 경우 Taptic Engine)
|
/// 선택 햅틱 피드백 (iOS의 경우 Taptic Engine)
|
||||||
static Future<void> selectionClick() async {
|
static Future<void> selectionClick() async {
|
||||||
if (!_isEnabled || !_isPlatformSupported()) return;
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
await HapticFeedback.selectionClick();
|
await HapticFeedback.selectionClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 진동 패턴 (Android)
|
/// 진동 패턴 (Android)
|
||||||
static Future<void> vibrate({int duration = 50}) async {
|
static Future<void> vibrate({int duration = 50}) async {
|
||||||
if (!_isEnabled || !_isPlatformSupported()) return;
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
await HapticFeedback.vibrate();
|
await HapticFeedback.vibrate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 성공 피드백 패턴
|
/// 성공 피드백 패턴
|
||||||
static Future<void> success() async {
|
static Future<void> success() async {
|
||||||
if (!_isEnabled || !_isPlatformSupported()) return;
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
@@ -47,7 +47,7 @@ class HapticFeedbackHelper {
|
|||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
await HapticFeedback.lightImpact();
|
await HapticFeedback.lightImpact();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 에러 피드백 패턴
|
/// 에러 피드백 패턴
|
||||||
static Future<void> error() async {
|
static Future<void> error() async {
|
||||||
if (!_isEnabled || !_isPlatformSupported()) return;
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
@@ -55,13 +55,13 @@ class HapticFeedbackHelper {
|
|||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
await HapticFeedback.heavyImpact();
|
await HapticFeedback.heavyImpact();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 경고 피드백 패턴
|
/// 경고 피드백 패턴
|
||||||
static Future<void> warning() async {
|
static Future<void> warning() async {
|
||||||
if (!_isEnabled || !_isPlatformSupported()) return;
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
await HapticFeedback.mediumImpact();
|
await HapticFeedback.mediumImpact();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 플랫폼이 햅틱 피드백을 지원하는지 확인
|
/// 플랫폼이 햅틱 피드백을 지원하는지 확인
|
||||||
static bool _isPlatformSupported() {
|
static bool _isPlatformSupported() {
|
||||||
try {
|
try {
|
||||||
@@ -71,4 +71,4 @@ class HapticFeedbackHelper {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
lib/utils/logger.dart
Normal file
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'logger.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
/// 메모리 관리를 위한 헬퍼 클래스
|
/// 메모리 관리를 위한 헬퍼 클래스
|
||||||
@@ -7,19 +8,19 @@ class MemoryManager {
|
|||||||
static final MemoryManager _instance = MemoryManager._internal();
|
static final MemoryManager _instance = MemoryManager._internal();
|
||||||
factory MemoryManager() => _instance;
|
factory MemoryManager() => _instance;
|
||||||
MemoryManager._internal();
|
MemoryManager._internal();
|
||||||
|
|
||||||
// 캐시 관리
|
// 캐시 관리
|
||||||
final Map<String, _CacheEntry> _cache = {};
|
final Map<String, _CacheEntry> _cache = {};
|
||||||
final int _maxCacheSize = 100;
|
final int _maxCacheSize = 100;
|
||||||
final Duration _defaultTTL = const Duration(minutes: 5);
|
final Duration _defaultTTL = const Duration(minutes: 5);
|
||||||
|
|
||||||
// 이미지 캐시 관리
|
// 이미지 캐시 관리
|
||||||
static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB
|
static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB
|
||||||
static const int maxImageCacheCount = 100;
|
static const int maxImageCacheCount = 100;
|
||||||
|
|
||||||
// 위젯 참조 추적
|
// 위젯 참조 추적
|
||||||
final Map<String, WeakReference<State>> _widgetReferences = {};
|
final Map<String, WeakReference<State>> _widgetReferences = {};
|
||||||
|
|
||||||
/// 캐시에 데이터 저장
|
/// 캐시에 데이터 저장
|
||||||
void cacheData<T>({
|
void cacheData<T>({
|
||||||
required String key,
|
required String key,
|
||||||
@@ -27,86 +28,85 @@ class MemoryManager {
|
|||||||
Duration? ttl,
|
Duration? ttl,
|
||||||
}) {
|
}) {
|
||||||
_cleanupExpiredCache();
|
_cleanupExpiredCache();
|
||||||
|
|
||||||
if (_cache.length >= _maxCacheSize) {
|
if (_cache.length >= _maxCacheSize) {
|
||||||
_evictOldestEntry();
|
_evictOldestEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
_cache[key] = _CacheEntry(
|
_cache[key] = _CacheEntry(
|
||||||
data: data,
|
data: data,
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
ttl: ttl ?? _defaultTTL,
|
ttl: ttl ?? _defaultTTL,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 캐시에서 데이터 가져오기
|
/// 캐시에서 데이터 가져오기
|
||||||
T? getCachedData<T>(String key) {
|
T? getCachedData<T>(String key) {
|
||||||
final entry = _cache[key];
|
final entry = _cache[key];
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
|
|
||||||
if (entry.isExpired) {
|
if (entry.isExpired) {
|
||||||
_cache.remove(key);
|
_cache.remove(key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.lastAccess = DateTime.now();
|
entry.lastAccess = DateTime.now();
|
||||||
return entry.data as T?;
|
return entry.data as T?;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 캐시 비우기
|
/// 캐시 비우기
|
||||||
void clearCache() {
|
void clearCache() {
|
||||||
_cache.clear();
|
_cache.clear();
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('🧹 메모리 캐시가 비워졌습니다.');
|
Log.d('🧹 메모리 캐시가 비워졌습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 특정 패턴의 캐시 제거
|
/// 특정 패턴의 캐시 제거
|
||||||
void clearCacheByPattern(String pattern) {
|
void clearCacheByPattern(String pattern) {
|
||||||
final keysToRemove = _cache.keys
|
final keysToRemove =
|
||||||
.where((key) => key.contains(pattern))
|
_cache.keys.where((key) => key.contains(pattern)).toList();
|
||||||
.toList();
|
|
||||||
|
|
||||||
for (final key in keysToRemove) {
|
for (final key in keysToRemove) {
|
||||||
_cache.remove(key);
|
_cache.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 만료된 캐시 정리
|
/// 만료된 캐시 정리
|
||||||
void _cleanupExpiredCache() {
|
void _cleanupExpiredCache() {
|
||||||
final expiredKeys = _cache.entries
|
final expiredKeys = _cache.entries
|
||||||
.where((entry) => entry.value.isExpired)
|
.where((entry) => entry.value.isExpired)
|
||||||
.map((entry) => entry.key)
|
.map((entry) => entry.key)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
for (final key in expiredKeys) {
|
for (final key in expiredKeys) {
|
||||||
_cache.remove(key);
|
_cache.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 가장 오래된 캐시 항목 제거
|
/// 가장 오래된 캐시 항목 제거
|
||||||
void _evictOldestEntry() {
|
void _evictOldestEntry() {
|
||||||
if (_cache.isEmpty) return;
|
if (_cache.isEmpty) return;
|
||||||
|
|
||||||
var oldestKey = _cache.keys.first;
|
var oldestKey = _cache.keys.first;
|
||||||
var oldestTime = _cache[oldestKey]!.lastAccess;
|
var oldestTime = _cache[oldestKey]!.lastAccess;
|
||||||
|
|
||||||
for (final entry in _cache.entries) {
|
for (final entry in _cache.entries) {
|
||||||
if (entry.value.lastAccess.isBefore(oldestTime)) {
|
if (entry.value.lastAccess.isBefore(oldestTime)) {
|
||||||
oldestKey = entry.key;
|
oldestKey = entry.key;
|
||||||
oldestTime = entry.value.lastAccess;
|
oldestTime = entry.value.lastAccess;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_cache.remove(oldestKey);
|
_cache.remove(oldestKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 이미지 캐시 최적화
|
/// 이미지 캐시 최적화
|
||||||
static void optimizeImageCache() {
|
static void optimizeImageCache() {
|
||||||
PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount;
|
PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount;
|
||||||
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
|
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 이미지 캐시 상태 확인
|
/// 이미지 캐시 상태 확인
|
||||||
static ImageCacheStatus getImageCacheStatus() {
|
static ImageCacheStatus getImageCacheStatus() {
|
||||||
final cache = PaintingBinding.instance.imageCache;
|
final cache = PaintingBinding.instance.imageCache;
|
||||||
@@ -117,33 +117,31 @@ class MemoryManager {
|
|||||||
maximumSizeBytes: cache.maximumSizeBytes,
|
maximumSizeBytes: cache.maximumSizeBytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 이미지 캐시 비우기
|
/// 이미지 캐시 비우기
|
||||||
static void clearImageCache() {
|
static void clearImageCache() {
|
||||||
PaintingBinding.instance.imageCache.clear();
|
PaintingBinding.instance.imageCache.clear();
|
||||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('🖼️ 이미지 캐시가 비워졌습니다.');
|
Log.d('🖼️ 이미지 캐시가 비워졌습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 위젯 참조 추적
|
/// 위젯 참조 추적
|
||||||
void trackWidget(String key, State widget) {
|
void trackWidget(String key, State widget) {
|
||||||
_widgetReferences[key] = WeakReference(widget);
|
_widgetReferences[key] = WeakReference(widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 위젯 참조 제거
|
/// 위젯 참조 제거
|
||||||
void untrackWidget(String key) {
|
void untrackWidget(String key) {
|
||||||
_widgetReferences.remove(key);
|
_widgetReferences.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 살아있는 위젯 수 확인
|
/// 살아있는 위젯 수 확인
|
||||||
int getAliveWidgetCount() {
|
int getAliveWidgetCount() {
|
||||||
return _widgetReferences.values
|
return _widgetReferences.values.where((ref) => ref.target != null).length;
|
||||||
.where((ref) => ref.target != null)
|
|
||||||
.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 메모리 압박 시 대응
|
/// 메모리 압박 시 대응
|
||||||
void handleMemoryPressure() {
|
void handleMemoryPressure() {
|
||||||
// 캐시 50% 제거
|
// 캐시 50% 제거
|
||||||
@@ -151,43 +149,43 @@ class MemoryManager {
|
|||||||
for (final key in keysToRemove) {
|
for (final key in keysToRemove) {
|
||||||
_cache.remove(key);
|
_cache.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 캐시 축소
|
// 이미지 캐시 축소
|
||||||
final imageCache = PaintingBinding.instance.imageCache;
|
final imageCache = PaintingBinding.instance.imageCache;
|
||||||
imageCache.maximumSize = maxImageCacheCount ~/ 2;
|
imageCache.maximumSize = maxImageCacheCount ~/ 2;
|
||||||
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
|
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('⚠️ 메모리 압박 대응: 캐시 크기 감소');
|
Log.w('메모리 압박 대응: 캐시 크기 감소');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 자동 메모리 정리 시작
|
/// 자동 메모리 정리 시작
|
||||||
Timer? _cleanupTimer;
|
Timer? _cleanupTimer;
|
||||||
|
|
||||||
void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) {
|
void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) {
|
||||||
_cleanupTimer?.cancel();
|
_cleanupTimer?.cancel();
|
||||||
_cleanupTimer = Timer.periodic(interval, (_) {
|
_cleanupTimer = Timer.periodic(interval, (_) {
|
||||||
_cleanupExpiredCache();
|
_cleanupExpiredCache();
|
||||||
|
|
||||||
// 죽은 위젯 참조 제거
|
// 죽은 위젯 참조 제거
|
||||||
final deadKeys = _widgetReferences.entries
|
final deadKeys = _widgetReferences.entries
|
||||||
.where((entry) => entry.value.target == null)
|
.where((entry) => entry.value.target == null)
|
||||||
.map((entry) => entry.key)
|
.map((entry) => entry.key)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
for (final key in deadKeys) {
|
for (final key in deadKeys) {
|
||||||
_widgetReferences.remove(key);
|
_widgetReferences.remove(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 자동 메모리 정리 중지
|
/// 자동 메모리 정리 중지
|
||||||
void stopAutoCleanup() {
|
void stopAutoCleanup() {
|
||||||
_cleanupTimer?.cancel();
|
_cleanupTimer?.cancel();
|
||||||
_cleanupTimer = null;
|
_cleanupTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 메모리 사용량 리포트
|
/// 메모리 사용량 리포트
|
||||||
Map<String, dynamic> getMemoryReport() {
|
Map<String, dynamic> getMemoryReport() {
|
||||||
return {
|
return {
|
||||||
@@ -206,13 +204,13 @@ class _CacheEntry {
|
|||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
final Duration ttl;
|
final Duration ttl;
|
||||||
DateTime lastAccess;
|
DateTime lastAccess;
|
||||||
|
|
||||||
_CacheEntry({
|
_CacheEntry({
|
||||||
required this.data,
|
required this.data,
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
required this.ttl,
|
required this.ttl,
|
||||||
}) : lastAccess = timestamp;
|
}) : lastAccess = timestamp;
|
||||||
|
|
||||||
bool get isExpired => DateTime.now().difference(timestamp) > ttl;
|
bool get isExpired => DateTime.now().difference(timestamp) > ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,25 +220,26 @@ class ImageCacheStatus {
|
|||||||
final int maximumSize;
|
final int maximumSize;
|
||||||
final int currentSizeBytes;
|
final int currentSizeBytes;
|
||||||
final int maximumSizeBytes;
|
final int maximumSizeBytes;
|
||||||
|
|
||||||
ImageCacheStatus({
|
ImageCacheStatus({
|
||||||
required this.currentSize,
|
required this.currentSize,
|
||||||
required this.maximumSize,
|
required this.maximumSize,
|
||||||
required this.currentSizeBytes,
|
required this.currentSizeBytes,
|
||||||
required this.maximumSizeBytes,
|
required this.maximumSizeBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
double get sizeUsagePercentage => (currentSize / maximumSize) * 100;
|
double get sizeUsagePercentage => (currentSize / maximumSize) * 100;
|
||||||
double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100;
|
double get bytesUsagePercentage =>
|
||||||
|
(currentSizeBytes / maximumSizeBytes) * 100;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'currentSize': currentSize,
|
'currentSize': currentSize,
|
||||||
'maximumSize': maximumSize,
|
'maximumSize': maximumSize,
|
||||||
'currentSizeBytes': currentSizeBytes,
|
'currentSizeBytes': currentSizeBytes,
|
||||||
'maximumSizeBytes': maximumSizeBytes,
|
'maximumSizeBytes': maximumSizeBytes,
|
||||||
'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2),
|
'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2),
|
||||||
'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2),
|
'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 메모리 효율적인 리스트 뷰
|
/// 메모리 효율적인 리스트 뷰
|
||||||
@@ -249,7 +248,7 @@ class MemoryEfficientListView<T> extends StatefulWidget {
|
|||||||
final Widget Function(BuildContext, T) itemBuilder;
|
final Widget Function(BuildContext, T) itemBuilder;
|
||||||
final int cacheExtent;
|
final int cacheExtent;
|
||||||
final ScrollPhysics? physics;
|
final ScrollPhysics? physics;
|
||||||
|
|
||||||
const MemoryEfficientListView({
|
const MemoryEfficientListView({
|
||||||
super.key,
|
super.key,
|
||||||
required this.items,
|
required this.items,
|
||||||
@@ -257,23 +256,21 @@ class MemoryEfficientListView<T> extends StatefulWidget {
|
|||||||
this.cacheExtent = 250,
|
this.cacheExtent = 250,
|
||||||
this.physics,
|
this.physics,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MemoryEfficientListView<T>> createState() =>
|
State<MemoryEfficientListView<T>> createState() =>
|
||||||
_MemoryEfficientListViewState<T>();
|
_MemoryEfficientListViewState<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MemoryEfficientListViewState<T>
|
class _MemoryEfficientListViewState<T> extends State<MemoryEfficientListView<T>>
|
||||||
extends State<MemoryEfficientListView<T>>
|
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => false;
|
bool get wantKeepAlive => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: widget.items.length,
|
itemCount: widget.items.length,
|
||||||
cacheExtent: widget.cacheExtent.toDouble(),
|
cacheExtent: widget.cacheExtent.toDouble(),
|
||||||
@@ -283,4 +280,4 @@ class _MemoryEfficientListViewState<T>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'logger.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
/// 성능 최적화를 위한 유틸리티 클래스
|
/// 성능 최적화를 위한 유틸리티 클래스
|
||||||
class PerformanceOptimizer {
|
class PerformanceOptimizer {
|
||||||
static final PerformanceOptimizer _instance = PerformanceOptimizer._internal();
|
static final PerformanceOptimizer _instance =
|
||||||
|
PerformanceOptimizer._internal();
|
||||||
factory PerformanceOptimizer() => _instance;
|
factory PerformanceOptimizer() => _instance;
|
||||||
PerformanceOptimizer._internal();
|
PerformanceOptimizer._internal();
|
||||||
|
|
||||||
// 프레임 타이밍 정보
|
// 프레임 타이밍 정보
|
||||||
final List<FrameTiming> _frameTimings = [];
|
final List<FrameTiming> _frameTimings = [];
|
||||||
bool _isMonitoring = false;
|
bool _isMonitoring = false;
|
||||||
|
|
||||||
/// 프레임 성능 모니터링 시작
|
/// 프레임 성능 모니터링 시작
|
||||||
void startFrameMonitoring() {
|
void startFrameMonitoring() {
|
||||||
if (_isMonitoring) return;
|
if (_isMonitoring) return;
|
||||||
_isMonitoring = true;
|
_isMonitoring = true;
|
||||||
|
|
||||||
SchedulerBinding.instance.addTimingsCallback((timings) {
|
SchedulerBinding.instance.addTimingsCallback((timings) {
|
||||||
_frameTimings.addAll(timings);
|
_frameTimings.addAll(timings);
|
||||||
// 최근 100개 프레임만 유지
|
// 최근 100개 프레임만 유지
|
||||||
@@ -26,27 +28,27 @@ class PerformanceOptimizer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 프레임 성능 모니터링 중지
|
/// 프레임 성능 모니터링 중지
|
||||||
void stopFrameMonitoring() {
|
void stopFrameMonitoring() {
|
||||||
if (!_isMonitoring) return;
|
if (!_isMonitoring) return;
|
||||||
_isMonitoring = false;
|
_isMonitoring = false;
|
||||||
SchedulerBinding.instance.addTimingsCallback((_) {});
|
SchedulerBinding.instance.addTimingsCallback((_) {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 평균 FPS 계산
|
/// 평균 FPS 계산
|
||||||
double getAverageFPS() {
|
double getAverageFPS() {
|
||||||
if (_frameTimings.isEmpty) return 0.0;
|
if (_frameTimings.isEmpty) return 0.0;
|
||||||
|
|
||||||
double totalDuration = 0;
|
double totalDuration = 0;
|
||||||
for (final timing in _frameTimings) {
|
for (final timing in _frameTimings) {
|
||||||
totalDuration += timing.totalSpan.inMicroseconds;
|
totalDuration += timing.totalSpan.inMicroseconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
final averageDuration = totalDuration / _frameTimings.length;
|
final averageDuration = totalDuration / _frameTimings.length;
|
||||||
return 1000000 / averageDuration; // microseconds to FPS
|
return 1000000 / averageDuration; // microseconds to FPS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 메모리 사용량 모니터링
|
/// 메모리 사용량 모니터링
|
||||||
static Future<MemoryInfo> getMemoryInfo() async {
|
static Future<MemoryInfo> getMemoryInfo() async {
|
||||||
// Flutter에서는 직접적인 메모리 사용량 측정이 제한적이므로
|
// Flutter에서는 직접적인 메모리 사용량 측정이 제한적이므로
|
||||||
@@ -57,7 +59,7 @@ class PerformanceOptimizer {
|
|||||||
capacity: imageCache.maximumSizeBytes,
|
capacity: imageCache.maximumSizeBytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 위젯 재빌드 최적화를 위한 데바운서
|
/// 위젯 재빌드 최적화를 위한 데바운서
|
||||||
static Timer? _debounceTimer;
|
static Timer? _debounceTimer;
|
||||||
static void debounce(
|
static void debounce(
|
||||||
@@ -67,7 +69,7 @@ class PerformanceOptimizer {
|
|||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
_debounceTimer = Timer(delay, callback);
|
_debounceTimer = Timer(delay, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 스로틀링 - 지정된 시간 간격으로만 실행
|
/// 스로틀링 - 지정된 시간 간격으로만 실행
|
||||||
static DateTime? _lastThrottleTime;
|
static DateTime? _lastThrottleTime;
|
||||||
static void throttle(
|
static void throttle(
|
||||||
@@ -81,7 +83,7 @@ class PerformanceOptimizer {
|
|||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 무거운 연산을 별도 Isolate에서 실행
|
/// 무거운 연산을 별도 Isolate에서 실행
|
||||||
static Future<T> runInIsolate<T>(
|
static Future<T> runInIsolate<T>(
|
||||||
ComputeCallback<dynamic, T> callback,
|
ComputeCallback<dynamic, T> callback,
|
||||||
@@ -89,7 +91,7 @@ class PerformanceOptimizer {
|
|||||||
) async {
|
) async {
|
||||||
return await compute(callback, parameter);
|
return await compute(callback, parameter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 레이지 로딩을 위한 페이지네이션 헬퍼
|
/// 레이지 로딩을 위한 페이지네이션 헬퍼
|
||||||
static List<T> paginate<T>({
|
static List<T> paginate<T>({
|
||||||
required List<T> items,
|
required List<T> items,
|
||||||
@@ -98,13 +100,14 @@ class PerformanceOptimizer {
|
|||||||
}) {
|
}) {
|
||||||
final startIndex = page * pageSize;
|
final startIndex = page * pageSize;
|
||||||
final endIndex = (startIndex + pageSize).clamp(0, items.length);
|
final endIndex = (startIndex + pageSize).clamp(0, items.length);
|
||||||
|
|
||||||
if (startIndex >= items.length) return [];
|
if (startIndex >= items.length) return [];
|
||||||
return items.sublist(startIndex, endIndex);
|
return items.sublist(startIndex, endIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 이미지 최적화 - 메모리 효율적인 크기로 조정
|
/// 이미지 최적화 - 메모리 효율적인 크기로 조정
|
||||||
static double getOptimalImageSize(BuildContext context, {
|
static double getOptimalImageSize(
|
||||||
|
BuildContext context, {
|
||||||
required double originalSize,
|
required double originalSize,
|
||||||
double maxSize = 1000,
|
double maxSize = 1000,
|
||||||
}) {
|
}) {
|
||||||
@@ -113,53 +116,53 @@ class PerformanceOptimizer {
|
|||||||
final maxDimension = screenSize.width > screenSize.height
|
final maxDimension = screenSize.width > screenSize.height
|
||||||
? screenSize.width
|
? screenSize.width
|
||||||
: screenSize.height;
|
: screenSize.height;
|
||||||
|
|
||||||
final optimalSize = (maxDimension * devicePixelRatio).clamp(100.0, maxSize);
|
final optimalSize = (maxDimension * devicePixelRatio).clamp(100.0, maxSize);
|
||||||
return optimalSize < originalSize ? optimalSize : originalSize;
|
return optimalSize < originalSize ? optimalSize : originalSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 위젯 키 최적화
|
/// 위젯 키 최적화
|
||||||
static Key generateOptimizedKey(String prefix, dynamic identifier) {
|
static Key generateOptimizedKey(String prefix, dynamic identifier) {
|
||||||
return ValueKey('${prefix}_$identifier');
|
return ValueKey('${prefix}_$identifier');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 애니메이션 최적화 - 보이지 않는 애니메이션 중지
|
/// 애니메이션 최적화 - 보이지 않는 애니메이션 중지
|
||||||
static bool shouldAnimateWidget(BuildContext context) {
|
static bool shouldAnimateWidget(BuildContext context) {
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
return !mediaQuery.disableAnimations && mediaQuery.accessibleNavigation;
|
return !mediaQuery.disableAnimations && mediaQuery.accessibleNavigation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 스크롤 성능 최적화
|
/// 스크롤 성능 최적화
|
||||||
static ScrollPhysics getOptimizedScrollPhysics() {
|
static ScrollPhysics getOptimizedScrollPhysics() {
|
||||||
return const BouncingScrollPhysics(
|
return const BouncingScrollPhysics(
|
||||||
parent: AlwaysScrollableScrollPhysics(),
|
parent: AlwaysScrollableScrollPhysics(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 빌드 최적화를 위한 const 위젯 권장사항 체크
|
/// 빌드 최적화를 위한 const 위젯 권장사항 체크
|
||||||
static void checkConstOptimization() {
|
static void checkConstOptimization() {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('💡 성능 최적화 팁:');
|
Log.i('💡 성능 최적화 팁:\n'
|
||||||
print('1. 가능한 모든 위젯에 const 사용');
|
'1. 가능한 모든 위젯에 const 사용\n'
|
||||||
print('2. StatelessWidget 대신 const 생성자 사용');
|
'2. StatelessWidget 대신 const 생성자 사용\n'
|
||||||
print('3. 큰 리스트는 ListView.builder 사용');
|
'3. 큰 리스트는 ListView.builder 사용\n'
|
||||||
print('4. 이미지는 캐싱과 함께 적절한 크기로 로드');
|
'4. 이미지는 캐싱과 함께 적절한 크기로 로드\n'
|
||||||
print('5. 애니메이션은 AnimatedBuilder 사용');
|
'5. 애니메이션은 AnimatedBuilder 사용');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 메모리 누수 감지 헬퍼
|
/// 메모리 누수 감지 헬퍼
|
||||||
static final Map<String, int> _widgetCounts = {};
|
static final Map<String, int> _widgetCounts = {};
|
||||||
|
|
||||||
static void trackWidget(String widgetName, bool isCreated) {
|
static void trackWidget(String widgetName, bool isCreated) {
|
||||||
if (!kDebugMode) return;
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
_widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) +
|
_widgetCounts[widgetName] =
|
||||||
(isCreated ? 1 : -1);
|
(_widgetCounts[widgetName] ?? 0) + (isCreated ? 1 : -1);
|
||||||
|
|
||||||
// 위젯이 비정상적으로 많이 생성되면 경고
|
// 위젯이 비정상적으로 많이 생성되면 경고
|
||||||
if ((_widgetCounts[widgetName] ?? 0) > 100) {
|
if ((_widgetCounts[widgetName] ?? 0) > 100) {
|
||||||
print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
|
Log.w('경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,16 +171,18 @@ class PerformanceOptimizer {
|
|||||||
class MemoryInfo {
|
class MemoryInfo {
|
||||||
final int currentUsage;
|
final int currentUsage;
|
||||||
final int capacity;
|
final int capacity;
|
||||||
|
|
||||||
MemoryInfo({
|
MemoryInfo({
|
||||||
required this.currentUsage,
|
required this.currentUsage,
|
||||||
required this.capacity,
|
required this.capacity,
|
||||||
});
|
});
|
||||||
|
|
||||||
double get usagePercentage => (currentUsage / capacity) * 100;
|
double get usagePercentage => (currentUsage / capacity) * 100;
|
||||||
|
|
||||||
String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
|
String get formattedUsage =>
|
||||||
String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
|
'${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
|
||||||
|
String get formattedCapacity =>
|
||||||
|
'${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 성능 측정 데코레이터
|
/// 성능 측정 데코레이터
|
||||||
@@ -187,17 +192,17 @@ class PerformanceMeasure {
|
|||||||
required Future<T> Function() operation,
|
required Future<T> Function() operation,
|
||||||
}) async {
|
}) async {
|
||||||
if (!kDebugMode) return await operation();
|
if (!kDebugMode) return await operation();
|
||||||
|
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
final result = await operation();
|
final result = await operation();
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
print('✅ $name 완료: ${stopwatch.elapsedMilliseconds}ms');
|
Log.d('✅ $name 완료: ${stopwatch.elapsedMilliseconds}ms');
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
print('❌ $name 실패: ${stopwatch.elapsedMilliseconds}ms - $e');
|
Log.e('❌ $name 실패: ${stopwatch.elapsedMilliseconds}ms', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,23 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
class PlatformHelper {
|
class PlatformHelper {
|
||||||
static bool get isWeb => kIsWeb;
|
static bool get isWeb => kIsWeb;
|
||||||
|
|
||||||
static bool get isIOS {
|
static bool get isIOS {
|
||||||
if (kIsWeb) return false;
|
if (kIsWeb) return false;
|
||||||
return defaultTargetPlatform == TargetPlatform.iOS;
|
return defaultTargetPlatform == TargetPlatform.iOS;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool get isAndroid {
|
static bool get isAndroid {
|
||||||
if (kIsWeb) return false;
|
if (kIsWeb) return false;
|
||||||
return defaultTargetPlatform == TargetPlatform.android;
|
return defaultTargetPlatform == TargetPlatform.android;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool get isMobile => isIOS || isAndroid;
|
static bool get isMobile => isIOS || isAndroid;
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
lib/utils/reduce_motion.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,4 +50,4 @@ class CategoryIconMapper {
|
|||||||
return 16.0;
|
return 16.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class SmsDateFormatter {
|
|||||||
) {
|
) {
|
||||||
// 주기에 따라 다음 결제일 예측
|
// 주기에 따라 다음 결제일 예측
|
||||||
DateTime? predictedDate = _predictNextBillingDate(date, billingCycle, now);
|
DateTime? predictedDate = _predictNextBillingDate(date, billingCycle, now);
|
||||||
|
|
||||||
if (predictedDate != null) {
|
if (predictedDate != null) {
|
||||||
final daysUntil = predictedDate.difference(now).inDays;
|
final daysUntil = predictedDate.difference(now).inDays;
|
||||||
return AppLocalizations.of(context).nextBillingDateEstimated(
|
return AppLocalizations.of(context).nextBillingDateEstimated(
|
||||||
@@ -34,7 +34,7 @@ class SmsDateFormatter {
|
|||||||
daysUntil,
|
daysUntil,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return '다음 결제일 확인 필요 (과거 날짜)';
|
return '다음 결제일 확인 필요 (과거 날짜)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class SmsDateFormatter {
|
|||||||
// 다음 월간 결제일 계산
|
// 다음 월간 결제일 계산
|
||||||
static DateTime _getNextMonthlyDate(DateTime lastDate, DateTime now) {
|
static DateTime _getNextMonthlyDate(DateTime lastDate, DateTime now) {
|
||||||
int day = lastDate.day;
|
int day = lastDate.day;
|
||||||
|
|
||||||
// 현재 월의 마지막 날을 초과하는 경우 조정
|
// 현재 월의 마지막 날을 초과하는 경우 조정
|
||||||
final lastDay = DateTime(now.year, now.month + 1, 0).day;
|
final lastDay = DateTime(now.year, now.month + 1, 0).day;
|
||||||
if (day > lastDay) {
|
if (day > lastDay) {
|
||||||
@@ -101,7 +101,7 @@ class SmsDateFormatter {
|
|||||||
// 다음 연간 결제일 계산
|
// 다음 연간 결제일 계산
|
||||||
static DateTime _getNextYearlyDate(DateTime lastDate, DateTime now) {
|
static DateTime _getNextYearlyDate(DateTime lastDate, DateTime now) {
|
||||||
int day = lastDate.day;
|
int day = lastDate.day;
|
||||||
|
|
||||||
// 해당 월의 마지막 날을 초과하는 경우 조정
|
// 해당 월의 마지막 날을 초과하는 경우 조정
|
||||||
final lastDay = DateTime(now.year, lastDate.month + 1, 0).day;
|
final lastDay = DateTime(now.year, lastDate.month + 1, 0).day;
|
||||||
if (day > lastDay) {
|
if (day > lastDay) {
|
||||||
@@ -162,4 +162,4 @@ class SmsDateFormatter {
|
|||||||
static String getRepeatCountText(BuildContext context, int count) {
|
static String getRepeatCountText(BuildContext context, int count) {
|
||||||
return AppLocalizations.of(context).repeatCountDetected(count);
|
return AppLocalizations.of(context).repeatCountDetected(count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'] = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import '../../controllers/add_subscription_controller.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// 구독 추가 화면의 App Bar
|
/// 구독 추가 화면의 App Bar
|
||||||
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget {
|
class AddSubscriptionAppBar extends StatelessWidget
|
||||||
|
implements PreferredSizeWidget {
|
||||||
final AddSubscriptionController controller;
|
final AddSubscriptionController controller;
|
||||||
final double scrollOffset;
|
final double scrollOffset;
|
||||||
final VoidCallback onScanSMS;
|
final VoidCallback onScanSMS;
|
||||||
@@ -101,4 +102,4 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import '../../controllers/add_subscription_controller.dart';
|
|||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
import '../../theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
/// 구독 추가 화면의 이벤트/할인 섹션
|
/// 구독 추가 화면의 이벤트/할인 섹션
|
||||||
class AddSubscriptionEventSection extends StatelessWidget {
|
class AddSubscriptionEventSection extends StatelessWidget {
|
||||||
@@ -47,11 +46,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.shadowBlack,
|
color: AppColors.shadowBlack,
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 4),
|
offset: Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -66,7 +65,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: controller.gradientColors[0].withValues(alpha: 0.1),
|
color: controller.gradientColors[0]
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -122,7 +122,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// 이벤트 활성화 시 추가 필드 표시
|
// 이벤트 활성화 시 추가 필드 표시
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
@@ -146,7 +146,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.info_outline_rounded,
|
Icons.info_outline_rounded,
|
||||||
color: AppColors.infoColor,
|
color: AppColors.infoColor,
|
||||||
size: 20,
|
size: 20,
|
||||||
@@ -155,7 +155,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final locale = Localizations.localeOf(context);
|
final locale =
|
||||||
|
Localizations.localeOf(context);
|
||||||
String infoText;
|
String infoText;
|
||||||
switch (locale.languageCode) {
|
switch (locale.languageCode) {
|
||||||
case 'ko':
|
case 'ko':
|
||||||
@@ -168,11 +169,12 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
infoText = '设置折扣或促销价格';
|
infoText = '设置折扣或促销价格';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
infoText = 'Set up discount or promotion price';
|
infoText =
|
||||||
|
'Set up discount or promotion price';
|
||||||
}
|
}
|
||||||
return Text(
|
return Text(
|
||||||
infoText,
|
infoText,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.darkNavy,
|
color: AppColors.darkNavy,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -185,7 +187,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 이벤트 기간
|
// 이벤트 기간
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
@@ -216,8 +218,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
setState(() {
|
setState(() {
|
||||||
controller.eventStartDate = date;
|
controller.eventStartDate = date;
|
||||||
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
||||||
if (date != null && controller.eventEndDate == null) {
|
if (date != null &&
|
||||||
controller.eventEndDate = date.add(const Duration(days: 30));
|
controller.eventEndDate == null) {
|
||||||
|
controller.eventEndDate =
|
||||||
|
date.add(const Duration(days: 30));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -233,17 +237,18 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 이벤트 가격
|
// 이벤트 가격
|
||||||
Builder(
|
Builder(
|
||||||
builder: (BuildContext innerContext) {
|
builder: (BuildContext innerContext) {
|
||||||
// 현재 로케일 확인
|
// 현재 로케일 확인
|
||||||
final currentLocale = Localizations.localeOf(innerContext);
|
final currentLocale =
|
||||||
|
Localizations.localeOf(innerContext);
|
||||||
|
|
||||||
// 로케일에 따라 직접 텍스트 설정
|
// 로케일에 따라 직접 텍스트 설정
|
||||||
String eventPriceLabel;
|
String eventPriceLabel;
|
||||||
String eventPriceHint;
|
String eventPriceHint;
|
||||||
|
|
||||||
switch (currentLocale.languageCode) {
|
switch (currentLocale.languageCode) {
|
||||||
case 'ko':
|
case 'ko':
|
||||||
eventPriceLabel = '이벤트 가격';
|
eventPriceLabel = '이벤트 가격';
|
||||||
@@ -261,7 +266,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
eventPriceLabel = 'Event Price';
|
eventPriceLabel = 'Event Price';
|
||||||
eventPriceHint = 'Enter discounted price';
|
eventPriceHint = 'Enter discounted price';
|
||||||
}
|
}
|
||||||
|
|
||||||
return CurrencyInputField(
|
return CurrencyInputField(
|
||||||
controller: controller.eventPriceController,
|
controller: controller.eventPriceController,
|
||||||
currency: controller.currency,
|
currency: controller.currency,
|
||||||
@@ -280,4 +285,4 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,4 +86,4 @@ class AddSubscriptionHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ class AddSubscriptionSaveButton extends StatelessWidget {
|
|||||||
child: PrimaryButton(
|
child: PrimaryButton(
|
||||||
text: AppLocalizations.of(context).addSubscriptionButton,
|
text: AppLocalizations.of(context).addSubscriptionButton,
|
||||||
icon: Icons.add_circle_outline,
|
icon: Icons.add_circle_outline,
|
||||||
onPressed: controller.isLoading
|
onPressed: controller.isLoading
|
||||||
? null
|
? null
|
||||||
: () => controller.saveSubscription(setState: setState),
|
: () => controller.saveSubscription(setState: setState),
|
||||||
isLoading: controller.isLoading,
|
isLoading: controller.isLoading,
|
||||||
backgroundColor: const Color(0xFF3B82F6),
|
backgroundColor: const Color(0xFF3B82F6),
|
||||||
@@ -50,4 +50,4 @@ class AddSubscriptionSaveButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import '../../models/subscription_model.dart';
|
|||||||
import '../../services/currency_util.dart';
|
import '../../services/currency_util.dart';
|
||||||
import '../../providers/locale_provider.dart';
|
import '../../providers/locale_provider.dart';
|
||||||
import '../../theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
|
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
|
||||||
class AnalysisBadge extends StatelessWidget {
|
class AnalysisBadge extends StatelessWidget {
|
||||||
@@ -33,7 +32,7 @@ class AnalysisBadge extends StatelessWidget {
|
|||||||
color: borderColor,
|
color: borderColor,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.shadowBlack,
|
color: AppColors.shadowBlack,
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
@@ -69,13 +68,17 @@ class AnalysisBadge extends StatelessWidget {
|
|||||||
String displayText = amountText;
|
String displayText = amountText;
|
||||||
if (amountText.length > 12) {
|
if (amountText.length > 12) {
|
||||||
// 괄호 안의 내용 제거
|
// 괄호 안의 내용 제거
|
||||||
displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
|
displayText =
|
||||||
|
amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
|
||||||
}
|
}
|
||||||
if (displayText.length > 10) {
|
if (displayText.length > 10) {
|
||||||
// 통화 기호만 남기고 숫자만 표시
|
// 통화 기호만 남기고 숫자만 표시
|
||||||
final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency);
|
final currencySymbol =
|
||||||
displayText = displayText.replaceAll(currencySymbol, '').trim();
|
CurrencyUtil.getCurrencySymbol(subscription.currency);
|
||||||
displayText = '$currencySymbol${displayText.substring(0, 6)}...';
|
displayText =
|
||||||
|
displayText.replaceAll(currencySymbol, '').trim();
|
||||||
|
displayText =
|
||||||
|
'$currencySymbol${displayText.substring(0, 6)}...';
|
||||||
}
|
}
|
||||||
return Text(
|
return Text(
|
||||||
displayText,
|
displayText,
|
||||||
@@ -93,4 +96,4 @@ class AnalysisBadge extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
/// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리
|
/// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리
|
||||||
class AnalysisScreenSpacer extends StatelessWidget {
|
class AnalysisScreenSpacer extends StatelessWidget {
|
||||||
final double height;
|
final double height;
|
||||||
|
|
||||||
const AnalysisScreenSpacer({
|
const AnalysisScreenSpacer({
|
||||||
super.key,
|
super.key,
|
||||||
this.height = 24,
|
this.height = 24,
|
||||||
@@ -16,4 +16,4 @@ class AnalysisScreenSpacer extends StatelessWidget {
|
|||||||
child: SizedBox(height: height),
|
child: SizedBox(height: height),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
ThemedText.headline(
|
ThemedText.headline(
|
||||||
text: AppLocalizations.of(context).eventDiscountStatus,
|
text: AppLocalizations.of(context)
|
||||||
|
.eventDiscountStatus,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
),
|
),
|
||||||
@@ -79,7 +81,10 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length),
|
AppLocalizations.of(context)
|
||||||
|
.servicesInProgress(provider
|
||||||
|
.activeEventSubscriptions
|
||||||
|
.length),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -97,15 +102,18 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
const Color(0xFFFF6B6B).withValues(alpha: 0.1),
|
const Color(0xFFFF6B6B)
|
||||||
const Color(0xFFFF8787).withValues(alpha: 0.1),
|
.withValues(alpha: 0.1),
|
||||||
|
const Color(0xFFFF8787)
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
],
|
],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0xFFFF6B6B).withValues(alpha: 0.3),
|
color: const Color(0xFFFF6B6B)
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -118,10 +126,12 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ThemedText(
|
ThemedText(
|
||||||
AppLocalizations.of(context).monthlySavingAmount,
|
AppLocalizations.of(context)
|
||||||
|
.monthlySavingAmount,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -154,24 +164,29 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
...provider.activeEventSubscriptions.map((sub) {
|
...provider.activeEventSubscriptions.map((sub) {
|
||||||
final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice);
|
final savings = sub.originalPrice -
|
||||||
final discountRate =
|
(sub.eventPrice ?? sub.originalPrice);
|
||||||
((savings / sub.originalPrice) * 100).round();
|
final discountRate =
|
||||||
|
((savings / sub.originalPrice) * 100)
|
||||||
|
.round();
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.darkNavy.withValues(alpha: 0.05),
|
color: AppColors.darkNavy
|
||||||
|
.withValues(alpha: 0.05),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.darkNavy.withValues(alpha: 0.1),
|
color: AppColors.darkNavy
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ThemedText(
|
ThemedText(
|
||||||
sub.serviceName,
|
sub.serviceName,
|
||||||
@@ -184,8 +199,8 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
FutureBuilder<String>(
|
FutureBuilder<String>(
|
||||||
future: CurrencyUtil
|
future:
|
||||||
.formatAmount(
|
CurrencyUtil.formatAmount(
|
||||||
sub.originalPrice,
|
sub.originalPrice,
|
||||||
sub.currency),
|
sub.currency),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -194,9 +209,11 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
snapshot.data!,
|
snapshot.data!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
decoration: TextDecoration
|
decoration:
|
||||||
.lineThrough,
|
TextDecoration
|
||||||
color: AppColors.navyGray,
|
.lineThrough,
|
||||||
|
color: AppColors
|
||||||
|
.navyGray,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,9 +228,10 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FutureBuilder<String>(
|
FutureBuilder<String>(
|
||||||
future: CurrencyUtil
|
future:
|
||||||
.formatAmount(
|
CurrencyUtil.formatAmount(
|
||||||
sub.eventPrice ?? sub.originalPrice,
|
sub.eventPrice ??
|
||||||
|
sub.originalPrice,
|
||||||
sub.currency),
|
sub.currency),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
@@ -244,7 +262,8 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFF6B6B)
|
color: const Color(0xFFFF6B6B)
|
||||||
.withValues(alpha: 0.2),
|
.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius:
|
||||||
|
BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$discountRate${AppLocalizations.of(context).discountPercent}',
|
'$discountRate${AppLocalizations.of(context).discountPercent}',
|
||||||
@@ -271,4 +290,4 @@ class EventAnalysisCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../theme/app_colors.dart';
|
|||||||
import '../glassmorphism_card.dart';
|
import '../glassmorphism_card.dart';
|
||||||
import '../themed_text.dart';
|
import '../themed_text.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
|
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
|
||||||
class MonthlyExpenseChartCard extends StatelessWidget {
|
class MonthlyExpenseChartCard extends StatelessWidget {
|
||||||
@@ -23,7 +24,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
/// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰)
|
/// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰)
|
||||||
double _calculateChartMaxY(double maxValue, String locale) {
|
double _calculateChartMaxY(double maxValue, String locale) {
|
||||||
final currency = CurrencyUtil.getDefaultCurrency(locale);
|
final currency = CurrencyUtil.getDefaultCurrency(locale);
|
||||||
|
|
||||||
if (currency == 'KRW' || currency == 'JPY') {
|
if (currency == 'KRW' || currency == 'JPY') {
|
||||||
// 소수점이 없는 통화 (원화, 엔화)
|
// 소수점이 없는 통화 (원화, 엔화)
|
||||||
if (maxValue <= 0) return 100000;
|
if (maxValue <= 0) return 100000;
|
||||||
@@ -33,9 +34,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
if (maxValue <= 200000) return 200000;
|
if (maxValue <= 200000) return 200000;
|
||||||
if (maxValue <= 500000) return 500000;
|
if (maxValue <= 500000) return 500000;
|
||||||
if (maxValue <= 1000000) return 1000000;
|
if (maxValue <= 1000000) return 1000000;
|
||||||
|
|
||||||
// 큰 금액은 자릿수에 맞춰 반올림
|
// 큰 금액은 자릿수에 맞춰 반올림
|
||||||
final magnitude = math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
|
final magnitude =
|
||||||
|
math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
|
||||||
return ((maxValue / magnitude).ceil() * magnitude).toDouble();
|
return ((maxValue / magnitude).ceil() * magnitude).toDouble();
|
||||||
} else {
|
} else {
|
||||||
// 소수점이 있는 통화 (달러, 위안)
|
// 소수점이 있는 통화 (달러, 위안)
|
||||||
@@ -47,7 +49,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
if (maxValue <= 250) return 250.0;
|
if (maxValue <= 250) return 250.0;
|
||||||
if (maxValue <= 500) return 500.0;
|
if (maxValue <= 500) return 500.0;
|
||||||
if (maxValue <= 1000) return 1000.0;
|
if (maxValue <= 1000) return 1000.0;
|
||||||
|
|
||||||
// 큰 금액은 100 단위로 반올림
|
// 큰 금액은 100 단위로 반올림
|
||||||
return ((maxValue / 100).ceil() * 100).toDouble();
|
return ((maxValue / 100).ceil() * 100).toDouble();
|
||||||
}
|
}
|
||||||
@@ -153,108 +155,114 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// 바 차트
|
// 바 차트 (RepaintBoundary로 페인트 분리)
|
||||||
AspectRatio(
|
RepaintBoundary(
|
||||||
aspectRatio: 1.6,
|
child: AspectRatio(
|
||||||
child: BarChart(
|
aspectRatio: 1.6,
|
||||||
BarChartData(
|
child: BarChart(
|
||||||
alignment: BarChartAlignment.spaceAround,
|
BarChartData(
|
||||||
maxY: _calculateChartMaxY(
|
alignment: BarChartAlignment.spaceAround,
|
||||||
monthlyData.fold<double>(
|
maxY: _calculateChartMaxY(
|
||||||
0,
|
monthlyData.fold<double>(
|
||||||
(max, data) => math.max(
|
0,
|
||||||
max, data['totalExpense'] as double)),
|
(max, data) => math.max(
|
||||||
locale
|
max, data['totalExpense'] as double)),
|
||||||
),
|
locale),
|
||||||
barGroups: _getMonthlyBarGroups(locale),
|
barGroups: _getMonthlyBarGroups(locale),
|
||||||
gridData: FlGridData(
|
gridData: FlGridData(
|
||||||
show: true,
|
show: true,
|
||||||
drawVerticalLine: false,
|
drawVerticalLine: false,
|
||||||
horizontalInterval: _calculateGridInterval(
|
horizontalInterval: _calculateGridInterval(
|
||||||
_calculateChartMaxY(
|
_calculateChartMaxY(
|
||||||
monthlyData.fold<double>(
|
monthlyData.fold<double>(
|
||||||
0,
|
0,
|
||||||
(max, data) => math.max(max,
|
(max, data) => math.max(max,
|
||||||
data['totalExpense'] as double)),
|
data['totalExpense'] as double)),
|
||||||
locale
|
locale),
|
||||||
),
|
CurrencyUtil.getDefaultCurrency(locale)),
|
||||||
CurrencyUtil.getDefaultCurrency(locale)
|
getDrawingHorizontalLine: (value) {
|
||||||
|
return FlLine(
|
||||||
|
color:
|
||||||
|
AppColors.navyGray.withValues(alpha: 0.1),
|
||||||
|
strokeWidth: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
getDrawingHorizontalLine: (value) {
|
titlesData: FlTitlesData(
|
||||||
return FlLine(
|
show: true,
|
||||||
color: AppColors.navyGray.withValues(alpha: 0.1),
|
topTitles: const AxisTitles(
|
||||||
strokeWidth: 1,
|
sideTitles: SideTitles(showTitles: false),
|
||||||
);
|
),
|
||||||
},
|
bottomTitles: AxisTitles(
|
||||||
),
|
sideTitles: SideTitles(
|
||||||
titlesData: FlTitlesData(
|
showTitles: true,
|
||||||
show: true,
|
getTitlesWidget: (value, meta) {
|
||||||
topTitles: const AxisTitles(
|
return Padding(
|
||||||
sideTitles: SideTitles(showTitles: false),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
),
|
child: ThemedText.caption(
|
||||||
bottomTitles: AxisTitles(
|
text: monthlyData[value.toInt()]
|
||||||
sideTitles: SideTitles(
|
['monthName'],
|
||||||
showTitles: true,
|
style: const TextStyle(
|
||||||
getTitlesWidget: (value, meta) {
|
fontSize: 12,
|
||||||
return Padding(
|
fontWeight: FontWeight.bold,
|
||||||
padding: const EdgeInsets.only(top: 8),
|
),
|
||||||
child: ThemedText.caption(
|
|
||||||
text: monthlyData[value.toInt()]
|
|
||||||
['monthName'],
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
barTouchData: BarTouchData(
|
||||||
|
enabled: true,
|
||||||
|
touchTooltipData: BarTouchTooltipData(
|
||||||
|
tooltipBgColor: AppColors.darkNavy,
|
||||||
|
tooltipRoundedRadius: 8,
|
||||||
|
getTooltipItem:
|
||||||
|
(group, groupIndex, rod, rodIndex) {
|
||||||
|
return BarTooltipItem(
|
||||||
|
'${monthlyData[group.x]['monthName']}\n',
|
||||||
|
const TextStyle(
|
||||||
|
color: AppColors.pureWhite,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: CurrencyUtil
|
||||||
|
.formatTotalAmountWithLocale(
|
||||||
|
monthlyData[group.x]
|
||||||
|
['totalExpense'] as double,
|
||||||
|
locale),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFFBBF24),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
leftTitles: const AxisTitles(
|
|
||||||
sideTitles: SideTitles(showTitles: false),
|
|
||||||
),
|
|
||||||
rightTitles: const AxisTitles(
|
|
||||||
sideTitles: SideTitles(showTitles: false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
borderData: FlBorderData(show: false),
|
|
||||||
barTouchData: BarTouchData(
|
|
||||||
enabled: true,
|
|
||||||
touchTooltipData: BarTouchTooltipData(
|
|
||||||
tooltipBgColor: AppColors.darkNavy,
|
|
||||||
tooltipRoundedRadius: 8,
|
|
||||||
getTooltipItem:
|
|
||||||
(group, groupIndex, rod, rodIndex) {
|
|
||||||
return BarTooltipItem(
|
|
||||||
'${monthlyData[group.x]['monthName']}\n',
|
|
||||||
const TextStyle(
|
|
||||||
color: AppColors.pureWhite,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
TextSpan(
|
|
||||||
text: CurrencyUtil.formatTotalAmountWithLocale(
|
|
||||||
monthlyData[group.x]['totalExpense']
|
|
||||||
as double,
|
|
||||||
locale),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFFFBBF24),
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
swapAnimationDuration: ReduceMotion.isEnabled(context)
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
|
swapAnimationCurve: Curves.easeOut,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Center(
|
Center(
|
||||||
child: ThemedText.caption(
|
child: ThemedText.caption(
|
||||||
text: AppLocalizations.of(context).monthlySubscriptionExpense,
|
text: AppLocalizations.of(context)
|
||||||
|
.monthlySubscriptionExpense,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -270,4 +278,4 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../themed_text.dart';
|
|||||||
import 'analysis_badge.dart';
|
import 'analysis_badge.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../providers/locale_provider.dart';
|
import '../../providers/locale_provider.dart';
|
||||||
|
import '../../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
||||||
class SubscriptionPieChartCard extends StatefulWidget {
|
class SubscriptionPieChartCard extends StatefulWidget {
|
||||||
@@ -23,14 +24,15 @@ class SubscriptionPieChartCard extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SubscriptionPieChartCard> createState() => _SubscriptionPieChartCardState();
|
State<SubscriptionPieChartCard> createState() =>
|
||||||
|
_SubscriptionPieChartCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||||
int _touchedIndex = -1;
|
int _touchedIndex = -1;
|
||||||
late Future<List<PieChartSectionData>> _pieSectionsFuture;
|
late Future<List<PieChartSectionData>> _pieSectionsFuture;
|
||||||
String? _lastLocale;
|
String? _lastLocale;
|
||||||
|
|
||||||
static const _chartColors = [
|
static const _chartColors = [
|
||||||
Color(0xFF3B82F6),
|
Color(0xFF3B82F6),
|
||||||
Color(0xFF10B981),
|
Color(0xFF10B981),
|
||||||
@@ -52,7 +54,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
// subscriptions나 locale이 변경된 경우만 Future 재생성
|
// subscriptions나 locale이 변경된 경우만 Future 재생성
|
||||||
final currentLocale = context.read<LocaleProvider>().locale.languageCode;
|
final currentLocale = context.read<LocaleProvider>().locale.languageCode;
|
||||||
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
|
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
|
||||||
_lastLocale != currentLocale) {
|
_lastLocale != currentLocale) {
|
||||||
_initializeFuture();
|
_initializeFuture();
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
|
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
|
||||||
if (a.length != b.length) return false;
|
if (a.length != b.length) return false;
|
||||||
for (int i = 0; i < a.length; i++) {
|
for (int i = 0; i < a.length; i++) {
|
||||||
if (a[i].id != b[i].id ||
|
if (a[i].id != b[i].id ||
|
||||||
a[i].currentPrice != b[i].currentPrice ||
|
a[i].currentPrice != b[i].currentPrice ||
|
||||||
a[i].currency != b[i].currency ||
|
a[i].currency != b[i].currency ||
|
||||||
a[i].serviceName != b[i].serviceName) {
|
a[i].serviceName != b[i].serviceName) {
|
||||||
@@ -78,7 +80,6 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
|
|
||||||
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
|
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
|
||||||
Future<List<PieChartSectionData>> _getPieSections() async {
|
Future<List<PieChartSectionData>> _getPieSections() async {
|
||||||
|
|
||||||
if (widget.subscriptions.isEmpty) return [];
|
if (widget.subscriptions.isEmpty) return [];
|
||||||
|
|
||||||
// 현재 locale 가져오기
|
// 현재 locale 가져오기
|
||||||
@@ -91,17 +92,19 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
// 각 구독의 현재 가격을 언어별 기본 통화로 환산
|
// 각 구독의 현재 가격을 언어별 기본 통화로 환산
|
||||||
for (var subscription in widget.subscriptions) {
|
for (var subscription in widget.subscriptions) {
|
||||||
double value = subscription.currentPrice;
|
double value = subscription.currentPrice;
|
||||||
|
|
||||||
if (subscription.currency == defaultCurrency) {
|
if (subscription.currency == defaultCurrency) {
|
||||||
// 이미 기본 통화인 경우 그대로 사용
|
// 이미 기본 통화인 경우 그대로 사용
|
||||||
sectionValues.add(value);
|
sectionValues.add(value);
|
||||||
} else if (subscription.currency == 'USD') {
|
} else if (subscription.currency == 'USD') {
|
||||||
// USD를 기본 통화로 변환
|
// USD를 기본 통화로 변환
|
||||||
final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency);
|
final converted = await ExchangeRateService()
|
||||||
|
.convertUsdToTarget(value, defaultCurrency);
|
||||||
sectionValues.add(converted ?? value);
|
sectionValues.add(converted ?? value);
|
||||||
} else if (defaultCurrency == 'USD') {
|
} else if (defaultCurrency == 'USD') {
|
||||||
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환
|
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환
|
||||||
final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency);
|
final converted = await ExchangeRateService()
|
||||||
|
.convertTargetToUsd(value, subscription.currency);
|
||||||
sectionValues.add(converted ?? value);
|
sectionValues.add(converted ?? value);
|
||||||
} else {
|
} else {
|
||||||
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
|
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
|
||||||
@@ -111,7 +114,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
|
|
||||||
// 총합 계산
|
// 총합 계산
|
||||||
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
|
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
|
||||||
|
|
||||||
// 총합이 0이면 빈 배열 반환
|
// 총합이 0이면 빈 배열 반환
|
||||||
if (sectionsTotal == 0) return [];
|
if (sectionsTotal == 0) return [];
|
||||||
|
|
||||||
@@ -138,17 +141,17 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
badgePositionPercentageOffset: .98,
|
badgePositionPercentageOffset: .98,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배지 위젯 생성
|
// 배지 위젯 생성
|
||||||
Widget _createBadgeWidget(int index) {
|
Widget _createBadgeWidget(int index) {
|
||||||
if (index >= widget.subscriptions.length) return const SizedBox.shrink();
|
if (index >= widget.subscriptions.length) return const SizedBox.shrink();
|
||||||
|
|
||||||
final subscription = widget.subscriptions[index];
|
final subscription = widget.subscriptions[index];
|
||||||
final colorIndex = index % _chartColors.length;
|
final colorIndex = index % _chartColors.length;
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
child: AnalysisBadge(
|
child: AnalysisBadge(
|
||||||
size: 40,
|
size: 40,
|
||||||
@@ -159,24 +162,27 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 터치 상태를 반영한 섹션 데이터 생성
|
// 터치 상태를 반영한 섹션 데이터 생성
|
||||||
List<PieChartSectionData> _applyTouchedState(List<PieChartSectionData> sections) {
|
List<PieChartSectionData> _applyTouchedState(
|
||||||
|
List<PieChartSectionData> sections) {
|
||||||
return List.generate(sections.length, (i) {
|
return List.generate(sections.length, (i) {
|
||||||
final section = sections[i];
|
final section = sections[i];
|
||||||
final isTouched = _touchedIndex == i;
|
final isTouched = _touchedIndex == i;
|
||||||
final fontSize = isTouched ? 16.0 : 12.0;
|
final fontSize = isTouched ? 16.0 : 12.0;
|
||||||
final radius = isTouched ? 105.0 : 100.0;
|
final radius = isTouched ? 105.0 : 100.0;
|
||||||
|
|
||||||
return PieChartSectionData(
|
return PieChartSectionData(
|
||||||
value: section.value,
|
value: section.value,
|
||||||
title: section.title,
|
title: section.title,
|
||||||
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle(
|
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ??
|
||||||
fontSize: fontSize,
|
TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: fontSize,
|
||||||
color: AppColors.pureWhite,
|
fontWeight: FontWeight.bold,
|
||||||
shadows: const [
|
color: AppColors.pureWhite,
|
||||||
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
shadows: const [
|
||||||
],
|
Shadow(
|
||||||
),
|
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
||||||
|
],
|
||||||
|
),
|
||||||
color: section.color,
|
color: section.color,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
titlePositionPercentageOffset: section.titlePositionPercentageOffset,
|
titlePositionPercentageOffset: section.titlePositionPercentageOffset,
|
||||||
@@ -217,18 +223,20 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
ThemedText.headline(
|
ThemedText.headline(
|
||||||
text: AppLocalizations.of(context).subscriptionServiceRatio,
|
text: AppLocalizations.of(context)
|
||||||
|
.subscriptionServiceRatio,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FutureBuilder<String>(
|
FutureBuilder<String>(
|
||||||
future: CurrencyUtil.getExchangeRateInfoForLocale(
|
future: CurrencyUtil.getExchangeRateInfoForLocale(
|
||||||
context.watch<LocaleProvider>().locale.languageCode
|
context
|
||||||
),
|
.watch<LocaleProvider>()
|
||||||
|
.locale
|
||||||
|
.languageCode),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasData &&
|
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
|
||||||
snapshot.data!.isNotEmpty) {
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8,
|
horizontal: 8,
|
||||||
@@ -236,15 +244,15 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFE5F2FF),
|
color: const Color(0xFFE5F2FF),
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(4),
|
||||||
BorderRadius.circular(4),
|
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0xFFBFDBFE),
|
color: const Color(0xFFBFDBFE),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).exchangeRateFormat(snapshot.data!),
|
AppLocalizations.of(context)
|
||||||
|
.exchangeRateFormat(snapshot.data!),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -272,7 +280,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
height: 250,
|
height: 250,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ThemedText(
|
child: ThemedText(
|
||||||
AppLocalizations.of(context).noSubscriptionServices,
|
AppLocalizations.of(context)
|
||||||
|
.noSubscriptionServices,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
@@ -284,70 +293,89 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
child: FutureBuilder<List<PieChartSectionData>>(
|
child: FutureBuilder<List<PieChartSectionData>>(
|
||||||
future: _pieSectionsFuture,
|
future: _pieSectionsFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
if (!snapshot.hasData ||
|
||||||
|
snapshot.data!.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: ThemedText(
|
child: ThemedText(
|
||||||
AppLocalizations.of(context).noSubscriptionServices,
|
AppLocalizations.of(context)
|
||||||
|
.noSubscriptionServices,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PieChart(
|
return RepaintBoundary(
|
||||||
PieChartData(
|
child: PieChart(
|
||||||
borderData: FlBorderData(show: false),
|
PieChartData(
|
||||||
sectionsSpace: 2,
|
borderData: FlBorderData(show: false),
|
||||||
centerSpaceRadius: 60,
|
sectionsSpace: 2,
|
||||||
sections: _applyTouchedState(snapshot.data!),
|
centerSpaceRadius: 60,
|
||||||
pieTouchData: PieTouchData(
|
sections:
|
||||||
enabled: true,
|
_applyTouchedState(snapshot.data!),
|
||||||
touchCallback: (FlTouchEvent event,
|
pieTouchData: PieTouchData(
|
||||||
pieTouchResponse) {
|
enabled: true,
|
||||||
// 터치 응답이 없거나 섹션이 없는 경우
|
touchCallback: (FlTouchEvent event,
|
||||||
if (pieTouchResponse == null ||
|
pieTouchResponse) {
|
||||||
pieTouchResponse.touchedSection == null) {
|
// 터치 응답이 없거나 섹션이 없는 경우
|
||||||
// 차트 밖으로 나갔을 때만 리셋
|
if (pieTouchResponse == null ||
|
||||||
if (_touchedIndex != -1) {
|
pieTouchResponse
|
||||||
setState(() {
|
.touchedSection ==
|
||||||
_touchedIndex = -1;
|
null) {
|
||||||
});
|
// 차트 밖으로 나갔을 때만 리셋
|
||||||
|
if (_touchedIndex != -1) {
|
||||||
|
setState(() {
|
||||||
|
_touchedIndex = -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
final touchedIndex =
|
||||||
|
pieTouchResponse.touchedSection!
|
||||||
final touchedIndex = pieTouchResponse
|
.touchedSectionIndex;
|
||||||
.touchedSection!
|
|
||||||
.touchedSectionIndex;
|
// 탭 이벤트 처리 (토글)
|
||||||
|
if (event is FlTapUpEvent) {
|
||||||
// 탭 이벤트 처리 (토글)
|
|
||||||
if (event is FlTapUpEvent) {
|
|
||||||
setState(() {
|
|
||||||
// 동일 섹션 탭하면 선택 해제, 아니면 선택
|
|
||||||
_touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// hover 이벤트 처리 (단순 표시)
|
|
||||||
if (event is FlPointerHoverEvent ||
|
|
||||||
event is FlPointerEnterEvent) {
|
|
||||||
// 현재 인덱스와 다른 경우만 업데이트
|
|
||||||
if (_touchedIndex != touchedIndex) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_touchedIndex = touchedIndex;
|
// 동일 섹션 탭하면 선택 해제, 아니면 선택
|
||||||
|
_touchedIndex =
|
||||||
|
(_touchedIndex ==
|
||||||
|
touchedIndex)
|
||||||
|
? -1
|
||||||
|
: touchedIndex;
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
// hover 이벤트 처리 (단순 표시)
|
||||||
|
if (event is FlPointerHoverEvent ||
|
||||||
|
event is FlPointerEnterEvent) {
|
||||||
|
// 현재 인덱스와 다른 경우만 업데이트
|
||||||
|
if (_touchedIndex !=
|
||||||
|
touchedIndex) {
|
||||||
|
setState(() {
|
||||||
|
_touchedIndex = touchedIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
swapAnimationDuration:
|
||||||
|
ReduceMotion.isEnabled(context)
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(
|
||||||
|
milliseconds: 300),
|
||||||
|
swapAnimationCurve: Curves.easeOut,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -364,10 +392,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
(index) {
|
(index) {
|
||||||
final subscription =
|
final subscription =
|
||||||
widget.subscriptions[index];
|
widget.subscriptions[index];
|
||||||
final color = _chartColors[index % _chartColors.length];
|
final color =
|
||||||
|
_chartColors[index % _chartColors.length];
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(bottom: 4.0),
|
||||||
bottom: 4.0),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -385,31 +413,31 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
overflow:
|
overflow: TextOverflow.ellipsis,
|
||||||
TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FutureBuilder<String>(
|
FutureBuilder<String>(
|
||||||
future: CurrencyUtil
|
future: CurrencyUtil
|
||||||
.formatSubscriptionAmountWithLocale(
|
.formatSubscriptionAmountWithLocale(
|
||||||
subscription,
|
subscription,
|
||||||
context.read<LocaleProvider>().locale.languageCode),
|
context
|
||||||
|
.read<LocaleProvider>()
|
||||||
|
.locale
|
||||||
|
.languageCode),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return ThemedText(
|
return ThemedText(
|
||||||
snapshot.data!,
|
snapshot.data!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight:
|
fontWeight: FontWeight.bold,
|
||||||
FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox(
|
return const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child:
|
child: CircularProgressIndicator(
|
||||||
CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -430,4 +458,4 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,185 +43,204 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
|||||||
parent: animationController,
|
parent: animationController,
|
||||||
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
||||||
)),
|
)),
|
||||||
child: GlassmorphismCard(
|
child: RepaintBoundary(
|
||||||
blur: 10,
|
child: GlassmorphismCard(
|
||||||
opacity: 0.1,
|
blur: 10,
|
||||||
borderRadius: 16,
|
opacity: 0.1,
|
||||||
child: Padding(
|
borderRadius: 16,
|
||||||
padding: const EdgeInsets.all(16),
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(16),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Row(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
ThemedText.headline(
|
children: [
|
||||||
text: AppLocalizations.of(context).totalExpenseSummary,
|
ThemedText.headline(
|
||||||
style: const TextStyle(
|
text: AppLocalizations.of(context)
|
||||||
fontSize: 18,
|
.totalExpenseSummary,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
IconButton(
|
icon: const Icon(Icons.content_copy),
|
||||||
icon: const Icon(Icons.content_copy),
|
iconSize: 20,
|
||||||
iconSize: 20,
|
padding: EdgeInsets.zero,
|
||||||
padding: EdgeInsets.zero,
|
constraints: const BoxConstraints(),
|
||||||
constraints: const BoxConstraints(),
|
onPressed: () async {
|
||||||
onPressed: () async {
|
final totalExpenseText =
|
||||||
final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale);
|
CurrencyUtil.formatTotalAmountWithLocale(
|
||||||
await Clipboard.setData(
|
totalExpense, locale);
|
||||||
ClipboardData(text: totalExpenseText));
|
await Clipboard.setData(
|
||||||
HapticFeedbackHelper.lightImpact();
|
ClipboardData(text: totalExpenseText));
|
||||||
if (!context.mounted) return;
|
HapticFeedbackHelper.lightImpact();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (!context.mounted) return;
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)),
|
SnackBar(
|
||||||
duration: const Duration(seconds: 2),
|
content: Text(AppLocalizations.of(context)
|
||||||
behavior: SnackBarBehavior.floating,
|
.totalExpenseCopied(totalExpenseText)),
|
||||||
shape: RoundedRectangleBorder(
|
duration: const Duration(seconds: 2),
|
||||||
borderRadius: BorderRadius.circular(8),
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
backgroundColor: AppColors.glassBackground
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3),
|
);
|
||||||
margin: const EdgeInsets.symmetric(
|
},
|
||||||
horizontal: 16,
|
),
|
||||||
vertical: 8,
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ThemedText.subtitle(
|
|
||||||
text: AppLocalizations.of(context).monthlyTotalAmount,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 16),
|
ThemedText.subtitle(
|
||||||
Row(
|
text: AppLocalizations.of(context).monthlyTotalAmount,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: const TextStyle(
|
||||||
children: [
|
fontSize: 14,
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ThemedText.caption(
|
|
||||||
text: AppLocalizations.of(context).totalExpense,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
ThemedText(
|
|
||||||
CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
),
|
||||||
Expanded(
|
const SizedBox(height: 16),
|
||||||
child: Column(
|
Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
children: [
|
||||||
children: [
|
Expanded(
|
||||||
Container(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: AppColors.glassBackground.withValues(alpha: 0.3),
|
ThemedText.caption(
|
||||||
borderRadius: BorderRadius.circular(8),
|
text:
|
||||||
border: Border.all(
|
AppLocalizations.of(context).totalExpense,
|
||||||
color: AppColors.glassBorder.withValues(alpha: 0.2),
|
style: const TextStyle(
|
||||||
),
|
fontSize: 12,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: const FaIcon(
|
|
||||||
FontAwesomeIcons.listCheck,
|
|
||||||
size: 16,
|
|
||||||
color: AppColors.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
Column(
|
const SizedBox(height: 4),
|
||||||
crossAxisAlignment:
|
ThemedText(
|
||||||
CrossAxisAlignment.start,
|
CurrencyUtil.formatTotalAmountWithLocale(
|
||||||
children: [
|
totalExpense, locale),
|
||||||
ThemedText.caption(
|
style: const TextStyle(
|
||||||
text: AppLocalizations.of(context).totalServices,
|
fontSize: 26,
|
||||||
style: const TextStyle(
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 12,
|
letterSpacing: -0.5,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
ThemedText(
|
|
||||||
AppLocalizations.of(context).subscriptionCount(subscriptions.length),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.glassBackground.withValues(alpha: 0.3),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.glassBorder.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const FaIcon(
|
|
||||||
FontAwesomeIcons.chartLine,
|
|
||||||
size: 16,
|
|
||||||
color: AppColors.successColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ThemedText.caption(
|
|
||||||
text: AppLocalizations.of(context).averageCost,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
ThemedText(
|
|
||||||
CurrencyUtil.formatTotalAmountWithLocale(
|
|
||||||
subscriptions.isEmpty
|
|
||||||
? 0
|
|
||||||
: totalExpense / subscriptions.length,
|
|
||||||
locale),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 16),
|
||||||
],
|
Expanded(
|
||||||
),
|
child: Column(
|
||||||
],
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.glassBackground
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const FaIcon(
|
||||||
|
FontAwesomeIcons.listCheck,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ThemedText.caption(
|
||||||
|
text: AppLocalizations.of(context)
|
||||||
|
.totalServices,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
ThemedText(
|
||||||
|
AppLocalizations.of(context)
|
||||||
|
.subscriptionCount(
|
||||||
|
subscriptions.length),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.glassBackground
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const FaIcon(
|
||||||
|
FontAwesomeIcons.chartLine,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ThemedText.caption(
|
||||||
|
text: AppLocalizations.of(context)
|
||||||
|
.averageCost,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
ThemedText(
|
||||||
|
CurrencyUtil
|
||||||
|
.formatTotalAmountWithLocale(
|
||||||
|
subscriptions.isEmpty
|
||||||
|
? 0
|
||||||
|
: totalExpense /
|
||||||
|
subscriptions.length,
|
||||||
|
locale),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -230,4 +249,4 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 슬라이드 + 페이드 전환
|
/// 슬라이드 + 페이드 전환
|
||||||
class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
||||||
final Widget page;
|
final Widget page;
|
||||||
final AxisDirection direction;
|
final AxisDirection direction;
|
||||||
|
|
||||||
SlidePageRoute({
|
SlidePageRoute({
|
||||||
required this.page,
|
required this.page,
|
||||||
this.direction = AxisDirection.right,
|
this.direction = AxisDirection.right,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
Offset begin;
|
Offset begin;
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
@@ -29,20 +34,20 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
begin = const Offset(0.0, -1.0);
|
begin = const Offset(0.0, -1.0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const end = Offset.zero;
|
const end = Offset.zero;
|
||||||
const curve = Curves.easeOutCubic;
|
const curve = Curves.easeOutCubic;
|
||||||
|
|
||||||
var tween = Tween(begin: begin, end: end).chain(
|
var tween = Tween(begin: begin, end: end).chain(
|
||||||
CurveTween(curve: curve),
|
CurveTween(curve: curve),
|
||||||
);
|
);
|
||||||
var offsetAnimation = animation.drive(tween);
|
var offsetAnimation = animation.drive(tween);
|
||||||
|
|
||||||
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
|
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||||
CurveTween(curve: curve),
|
CurveTween(curve: curve),
|
||||||
);
|
);
|
||||||
var fadeAnimation = animation.drive(fadeTween);
|
var fadeAnimation = animation.drive(fadeTween);
|
||||||
|
|
||||||
return SlideTransition(
|
return SlideTransition(
|
||||||
position: offsetAnimation,
|
position: offsetAnimation,
|
||||||
child: FadeTransition(
|
child: FadeTransition(
|
||||||
@@ -58,27 +63,31 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
class ScalePageRoute<T> extends PageRouteBuilder<T> {
|
class ScalePageRoute<T> extends PageRouteBuilder<T> {
|
||||||
final Widget page;
|
final Widget page;
|
||||||
final Alignment alignment;
|
final Alignment alignment;
|
||||||
|
|
||||||
ScalePageRoute({
|
ScalePageRoute({
|
||||||
required this.page,
|
required this.page,
|
||||||
this.alignment = Alignment.center,
|
this.alignment = Alignment.center,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 400),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 400),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 400),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 400),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
const curve = Curves.elasticOut;
|
const curve = Curves.elasticOut;
|
||||||
|
|
||||||
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
|
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||||
CurveTween(curve: curve),
|
CurveTween(curve: curve),
|
||||||
);
|
);
|
||||||
var scaleAnimation = animation.drive(scaleTween);
|
var scaleAnimation = animation.drive(scaleTween);
|
||||||
|
|
||||||
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
|
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||||
CurveTween(curve: Curves.easeIn),
|
CurveTween(curve: Curves.easeIn),
|
||||||
);
|
);
|
||||||
var fadeAnimation = animation.drive(fadeTween);
|
var fadeAnimation = animation.drive(fadeTween);
|
||||||
|
|
||||||
return ScaleTransition(
|
return ScaleTransition(
|
||||||
scale: scaleAnimation,
|
scale: scaleAnimation,
|
||||||
alignment: alignment,
|
alignment: alignment,
|
||||||
@@ -94,25 +103,29 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
/// 회전 + 스케일 전환
|
/// 회전 + 스케일 전환
|
||||||
class RotatePageRoute<T> extends PageRouteBuilder<T> {
|
class RotatePageRoute<T> extends PageRouteBuilder<T> {
|
||||||
final Widget page;
|
final Widget page;
|
||||||
|
|
||||||
RotatePageRoute({required this.page})
|
RotatePageRoute({required this.page})
|
||||||
: super(
|
: super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 500),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 500),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 500),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
const curve = Curves.easeInOut;
|
const curve = Curves.easeInOut;
|
||||||
|
|
||||||
var rotateTween = Tween(begin: -0.5, end: 0.0).chain(
|
var rotateTween = Tween(begin: -0.5, end: 0.0).chain(
|
||||||
CurveTween(curve: curve),
|
CurveTween(curve: curve),
|
||||||
);
|
);
|
||||||
var rotateAnimation = animation.drive(rotateTween);
|
var rotateAnimation = animation.drive(rotateTween);
|
||||||
|
|
||||||
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
|
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||||
CurveTween(curve: curve),
|
CurveTween(curve: curve),
|
||||||
);
|
);
|
||||||
var scaleAnimation = animation.drive(scaleTween);
|
var scaleAnimation = animation.drive(scaleTween);
|
||||||
|
|
||||||
return Transform(
|
return Transform(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
transform: Matrix4.identity()
|
transform: Matrix4.identity()
|
||||||
@@ -129,17 +142,22 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
class FlipPageRoute<T> extends PageRouteBuilder<T> {
|
class FlipPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
final Widget page;
|
final Widget page;
|
||||||
final bool horizontal;
|
final bool horizontal;
|
||||||
|
|
||||||
FlipPageRoute({
|
FlipPageRoute({
|
||||||
required this.page,
|
required this.page,
|
||||||
this.horizontal = true,
|
this.horizontal = true,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 800),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 800),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 800),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 800),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
final isAnimatingForward = animation.status == AnimationStatus.forward;
|
final isAnimatingForward =
|
||||||
|
animation.status == AnimationStatus.forward;
|
||||||
|
|
||||||
final flipAnimation = Tween(
|
final flipAnimation = Tween(
|
||||||
begin: 0.0,
|
begin: 0.0,
|
||||||
end: isAnimatingForward ? -math.pi : math.pi,
|
end: isAnimatingForward ? -math.pi : math.pi,
|
||||||
@@ -147,12 +165,12 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
parent: animation,
|
parent: animation,
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
));
|
));
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: flipAnimation,
|
animation: flipAnimation,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final isShowingFront = flipAnimation.value.abs() < math.pi / 2;
|
final isShowingFront = flipAnimation.value.abs() < math.pi / 2;
|
||||||
|
|
||||||
return Transform(
|
return Transform(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
transform: Matrix4.identity()
|
transform: Matrix4.identity()
|
||||||
@@ -181,15 +199,19 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
final Widget page;
|
final Widget page;
|
||||||
final Widget startWidget;
|
final Widget startWidget;
|
||||||
final BorderRadius? borderRadius;
|
final BorderRadius? borderRadius;
|
||||||
|
|
||||||
ContainerTransformPageRoute({
|
ContainerTransformPageRoute({
|
||||||
required this.page,
|
required this.page,
|
||||||
required this.startWidget,
|
required this.startWidget,
|
||||||
this.borderRadius,
|
this.borderRadius,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 500),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 500),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 500),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -208,7 +230,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
final scale = 0.5 + (0.5 * progress);
|
final scale = 0.5 + (0.5 * progress);
|
||||||
final radius = borderRadius?.topLeft.x ?? 0;
|
final radius = borderRadius?.topLeft.x ?? 0;
|
||||||
final currentRadius = radius * (1 - progress);
|
final currentRadius = radius * (1 - progress);
|
||||||
|
|
||||||
return Transform.scale(
|
return Transform.scale(
|
||||||
scale: scale,
|
scale: scale,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
@@ -229,7 +251,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
|
class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
final Widget page;
|
final Widget page;
|
||||||
final String heroTag;
|
final String heroTag;
|
||||||
|
|
||||||
CustomHeroPageRoute({
|
CustomHeroPageRoute({
|
||||||
required this.page,
|
required this.page,
|
||||||
required this.heroTag,
|
required this.heroTag,
|
||||||
@@ -253,18 +275,22 @@ class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
|
class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
final Widget page;
|
final Widget page;
|
||||||
final SharedAxisTransitionType transitionType;
|
final SharedAxisTransitionType transitionType;
|
||||||
|
|
||||||
SharedAxisPageRoute({
|
SharedAxisPageRoute({
|
||||||
required this.page,
|
required this.page,
|
||||||
required this.transitionType,
|
required this.transitionType,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
late final Offset begin;
|
late final Offset begin;
|
||||||
late final Offset end;
|
late final Offset end;
|
||||||
|
|
||||||
switch (transitionType) {
|
switch (transitionType) {
|
||||||
case SharedAxisTransitionType.horizontal:
|
case SharedAxisTransitionType.horizontal:
|
||||||
begin = const Offset(1.0, 0.0);
|
begin = const Offset(1.0, 0.0);
|
||||||
@@ -279,17 +305,17 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
end = Offset.zero;
|
end = Offset.zero;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
final slideTween = Tween(begin: begin, end: end);
|
final slideTween = Tween(begin: begin, end: end);
|
||||||
final fadeTween = Tween(begin: 0.0, end: 1.0);
|
final fadeTween = Tween(begin: 0.0, end: 1.0);
|
||||||
final scaleTween = transitionType == SharedAxisTransitionType.scaled
|
final scaleTween = transitionType == SharedAxisTransitionType.scaled
|
||||||
? Tween(begin: 0.8, end: 1.0)
|
? Tween(begin: 0.8, end: 1.0)
|
||||||
: Tween(begin: 1.0, end: 1.0);
|
: Tween(begin: 1.0, end: 1.0);
|
||||||
|
|
||||||
final slideAnimation = animation.drive(slideTween);
|
final slideAnimation = animation.drive(slideTween);
|
||||||
final fadeAnimation = animation.drive(fadeTween);
|
final fadeAnimation = animation.drive(fadeTween);
|
||||||
final scaleAnimation = animation.drive(scaleTween);
|
final scaleAnimation = animation.drive(scaleTween);
|
||||||
|
|
||||||
return SlideTransition(
|
return SlideTransition(
|
||||||
position: slideAnimation,
|
position: slideAnimation,
|
||||||
child: FadeTransition(
|
child: FadeTransition(
|
||||||
@@ -308,4 +334,4 @@ enum SharedAxisTransitionType {
|
|||||||
horizontal,
|
horizontal,
|
||||||
vertical,
|
vertical,
|
||||||
scaled,
|
scaled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯
|
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯
|
||||||
///
|
///
|
||||||
@@ -16,6 +17,8 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final reduce = ReduceMotion.isEnabled(context);
|
||||||
|
final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
||||||
@@ -25,15 +28,15 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
||||||
final angle = controller.value * 2 * math.pi;
|
final angle = controller.value * 2 * math.pi;
|
||||||
// 사인 함수를 사용하여 부드러운 움직임 생성
|
// 사인 함수를 사용하여 부드러운 움직임 생성
|
||||||
final xOffset = 20 * math.sin(angle);
|
final xOffset = 20 * amp * math.sin(angle);
|
||||||
final yOffset = 10 * math.cos(angle);
|
final yOffset = 10 * amp * math.cos(angle);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: -40 + xOffset,
|
right: -40 + xOffset,
|
||||||
top: -60 + yOffset,
|
top: -60 + yOffset,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
// 회전도 선형적으로 변화하도록 수정
|
// 회전도 선형적으로 변화하도록 수정
|
||||||
angle: 0.2 * math.sin(angle * 0.5),
|
angle: 0.2 * amp * math.sin(angle * 0.5),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 200,
|
height: 200,
|
||||||
@@ -51,15 +54,15 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
||||||
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
|
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
|
||||||
final xOffset = 20 * math.cos(angle);
|
final xOffset = 20 * amp * math.cos(angle);
|
||||||
final yOffset = 10 * math.sin(angle);
|
final yOffset = 10 * amp * math.sin(angle);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: -80 + xOffset,
|
left: -80 + xOffset,
|
||||||
bottom: -70 + yOffset,
|
bottom: -70 + yOffset,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
// 반대 방향으로 회전하도록 설정
|
// 반대 방향으로 회전하도록 설정
|
||||||
angle: -0.3 * math.sin(angle * 0.5),
|
angle: -0.3 * amp * math.sin(angle * 0.5),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 220,
|
width: 220,
|
||||||
height: 220,
|
height: 220,
|
||||||
@@ -78,14 +81,14 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
||||||
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
|
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
|
||||||
final xOffset = 15 * math.sin(angle * 0.7);
|
final xOffset = 15 * amp * math.sin(angle * 0.7);
|
||||||
final yOffset = 8 * math.cos(angle * 0.7);
|
final yOffset = 8 * amp * math.cos(angle * 0.7);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: 40 + xOffset,
|
right: 40 + xOffset,
|
||||||
bottom: -40 + yOffset,
|
bottom: -40 + yOffset,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: 0.4 * math.cos(angle * 0.5),
|
angle: 0.4 * amp * math.cos(angle * 0.5),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
@@ -109,9 +112,8 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
width: 30,
|
width: 30,
|
||||||
height: 30,
|
height: 30,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha:
|
color: Colors.white.withValues(
|
||||||
0.1 + 0.1 * pulseController.value,
|
alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(15),
|
borderRadius: BorderRadius.circular(15),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../screens/app_lock_screen.dart';
|
|||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
import '../routes/app_routes.dart';
|
import '../routes/app_routes.dart';
|
||||||
|
import '../utils/logger.dart';
|
||||||
import 'animated_page_transitions.dart';
|
import 'animated_page_transitions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class AppNavigator {
|
|||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
final navigationProvider = context.read<NavigationProvider>();
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
navigationProvider.clearHistoryAndGoHome();
|
navigationProvider.clearHistoryAndGoHome();
|
||||||
|
|
||||||
await Navigator.of(context).pushNamedAndRemoveUntil(
|
await Navigator.of(context).pushNamedAndRemoveUntil(
|
||||||
AppRoutes.main,
|
AppRoutes.main,
|
||||||
(route) => false,
|
(route) => false,
|
||||||
@@ -30,30 +31,31 @@ class AppNavigator {
|
|||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
final navigationProvider = context.read<NavigationProvider>();
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
navigationProvider.updateCurrentIndex(1);
|
navigationProvider.updateCurrentIndex(1);
|
||||||
|
|
||||||
await Navigator.of(context).pushNamed(AppRoutes.analysis);
|
await Navigator.of(context).pushNamed(AppRoutes.analysis);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구독 추가 화면으로 네비게이션
|
/// 구독 추가 화면으로 네비게이션
|
||||||
static Future<void> toAddSubscription(BuildContext context) async {
|
static Future<void> toAddSubscription(BuildContext context) async {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
await Navigator.of(context).pushNamed(AppRoutes.addSubscription);
|
await Navigator.of(context).pushNamed(AppRoutes.addSubscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구독 상세 화면으로 네비게이션
|
/// 구독 상세 화면으로 네비게이션
|
||||||
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
|
static Future<void> toDetail(
|
||||||
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
|
BuildContext context, SubscriptionModel subscription) async {
|
||||||
|
Log.d('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Navigator.of(context).pushNamed(
|
await Navigator.of(context).pushNamed(
|
||||||
AppRoutes.subscriptionDetail,
|
AppRoutes.subscriptionDetail,
|
||||||
arguments: subscription,
|
arguments: subscription,
|
||||||
);
|
);
|
||||||
print('DetailScreen 네비게이션 성공');
|
Log.d('DetailScreen 네비게이션 성공');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('DetailScreen 네비게이션 오류: $e');
|
Log.e('DetailScreen 네비게이션 오류', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ class AppNavigator {
|
|||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
final navigationProvider = context.read<NavigationProvider>();
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
navigationProvider.updateCurrentIndex(3);
|
navigationProvider.updateCurrentIndex(3);
|
||||||
|
|
||||||
await Navigator.of(context).pushNamed(AppRoutes.smsScanner);
|
await Navigator.of(context).pushNamed(AppRoutes.smsScanner);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +73,14 @@ class AppNavigator {
|
|||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
final navigationProvider = context.read<NavigationProvider>();
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
navigationProvider.updateCurrentIndex(4);
|
navigationProvider.updateCurrentIndex(4);
|
||||||
|
|
||||||
await Navigator.of(context).pushNamed(AppRoutes.settings);
|
await Navigator.of(context).pushNamed(AppRoutes.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 카테고리 관리 화면으로 네비게이션
|
/// 카테고리 관리 화면으로 네비게이션
|
||||||
static Future<void> toCategoryManagement(BuildContext context) async {
|
static Future<void> toCategoryManagement(BuildContext context) async {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
SlidePageRoute(
|
SlidePageRoute(
|
||||||
page: const CategoryManagementScreen(),
|
page: const CategoryManagementScreen(),
|
||||||
@@ -101,20 +103,20 @@ class AppNavigator {
|
|||||||
static Future<bool> handleBackButton(BuildContext context) async {
|
static Future<bool> handleBackButton(BuildContext context) async {
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
final navigationProvider = context.read<NavigationProvider>();
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
|
|
||||||
// 네비게이션 스택이 있으면 팝
|
// 네비게이션 스택이 있으면 팝
|
||||||
if (navigator.canPop()) {
|
if (navigator.canPop()) {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
|
||||||
// NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원
|
// NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원
|
||||||
if (navigationProvider.canPop()) {
|
if (navigationProvider.canPop()) {
|
||||||
navigationProvider.pop();
|
navigationProvider.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.pop();
|
navigator.pop();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앱 종료 확인
|
// 앱 종료 확인
|
||||||
final shouldExit = await showDialog<bool>(
|
final shouldExit = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -133,7 +135,7 @@ class AppNavigator {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return shouldExit ?? false;
|
return shouldExit ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,17 +143,17 @@ class AppNavigator {
|
|||||||
static void handleFloatingNavTap(BuildContext context, int index) {
|
static void handleFloatingNavTap(BuildContext context, int index) {
|
||||||
final navigationProvider = context.read<NavigationProvider>();
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
final currentIndex = navigationProvider.currentIndex;
|
final currentIndex = navigationProvider.currentIndex;
|
||||||
|
|
||||||
// 같은 탭을 다시 탭하면 아무 동작 안 함
|
// 같은 탭을 다시 탭하면 아무 동작 안 함
|
||||||
if (currentIndex == index) {
|
if (currentIndex == index) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기
|
// 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기
|
||||||
if (Navigator.of(context).canPop()) {
|
if (Navigator.of(context).canPop()) {
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택된 인덱스에 따라 네비게이션
|
// 선택된 인덱스에 따라 네비게이션
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case 0: // 홈
|
case 0: // 홈
|
||||||
@@ -196,6 +198,7 @@ class AppNavigationObserver extends NavigatorObserver {
|
|||||||
@override
|
@override
|
||||||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||||
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||||
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
debugPrint(
|
||||||
|
'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,13 +66,14 @@ class CategoryHeaderWidget extends StatelessWidget {
|
|||||||
/// 통화별 합계를 표시하는 문자열을 생성합니다.
|
/// 통화별 합계를 표시하는 문자열을 생성합니다.
|
||||||
String _buildCostDisplay(BuildContext context) {
|
String _buildCostDisplay(BuildContext context) {
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
|
|
||||||
// 개수는 항상 표시
|
// 개수는 항상 표시
|
||||||
parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
|
parts
|
||||||
|
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
|
||||||
|
|
||||||
// 통화 부분을 별도로 처리
|
// 통화 부분을 별도로 처리
|
||||||
final currencyParts = <String>[];
|
final currencyParts = <String>[];
|
||||||
|
|
||||||
// 달러가 있는 경우
|
// 달러가 있는 경우
|
||||||
if (totalCostUSD > 0) {
|
if (totalCostUSD > 0) {
|
||||||
final formatter = NumberFormat.currency(
|
final formatter = NumberFormat.currency(
|
||||||
@@ -82,7 +83,7 @@ class CategoryHeaderWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
currencyParts.add(formatter.format(totalCostUSD));
|
currencyParts.add(formatter.format(totalCostUSD));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 원화가 있는 경우
|
// 원화가 있는 경우
|
||||||
if (totalCostKRW > 0) {
|
if (totalCostKRW > 0) {
|
||||||
final formatter = NumberFormat.currency(
|
final formatter = NumberFormat.currency(
|
||||||
@@ -92,7 +93,7 @@ class CategoryHeaderWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
currencyParts.add(formatter.format(totalCostKRW));
|
currencyParts.add(formatter.format(totalCostKRW));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 엔화가 있는 경우
|
// 엔화가 있는 경우
|
||||||
if (totalCostJPY > 0) {
|
if (totalCostJPY > 0) {
|
||||||
final formatter = NumberFormat.currency(
|
final formatter = NumberFormat.currency(
|
||||||
@@ -102,7 +103,7 @@ class CategoryHeaderWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
currencyParts.add(formatter.format(totalCostJPY));
|
currencyParts.add(formatter.format(totalCostJPY));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위안화가 있는 경우
|
// 위안화가 있는 경우
|
||||||
if (totalCostCNY > 0) {
|
if (totalCostCNY > 0) {
|
||||||
final formatter = NumberFormat.currency(
|
final formatter = NumberFormat.currency(
|
||||||
@@ -112,14 +113,14 @@ class CategoryHeaderWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
currencyParts.add(formatter.format(totalCostCNY));
|
currencyParts.add(formatter.format(totalCostCNY));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 통화가 하나 이상 있는 경우
|
// 통화가 하나 이상 있는 경우
|
||||||
if (currencyParts.isNotEmpty) {
|
if (currencyParts.isNotEmpty) {
|
||||||
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
|
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
|
||||||
final currencyDisplay = currencyParts.join(' + ');
|
final currencyDisplay = currencyParts.join(' + ');
|
||||||
parts.add(currencyDisplay);
|
parts.add(currencyDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,8 +43,10 @@ class _PrimaryButtonState extends State<PrimaryButton> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor;
|
final effectiveBackgroundColor =
|
||||||
final effectiveForegroundColor = widget.foregroundColor ?? AppColors.pureWhite;
|
widget.backgroundColor ?? theme.primaryColor;
|
||||||
|
final effectiveForegroundColor =
|
||||||
|
widget.foregroundColor ?? AppColors.pureWhite;
|
||||||
|
|
||||||
Widget button = AnimatedContainer(
|
Widget button = AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
@@ -64,7 +66,8 @@ class _PrimaryButtonState extends State<PrimaryButton> {
|
|||||||
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
|
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
|
||||||
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
|
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
|
||||||
shadowColor: Colors.black.withValues(alpha: 0.08),
|
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||||
disabledBackgroundColor: effectiveBackgroundColor.withValues(alpha: 0.6),
|
disabledBackgroundColor:
|
||||||
|
effectiveBackgroundColor.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
child: widget.isLoading
|
child: widget.isLoading
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
@@ -110,4 +113,4 @@ class _PrimaryButtonState extends State<PrimaryButton> {
|
|||||||
|
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class _SecondaryButtonState extends State<SecondaryButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
|
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
|
||||||
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;
|
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;
|
||||||
|
|
||||||
@@ -61,18 +60,18 @@ class _SecondaryButtonState extends State<SecondaryButton> {
|
|||||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||||
),
|
),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: _isHovered
|
color: _isHovered
|
||||||
? effectiveBorderColor.withValues(alpha: 0.4)
|
? effectiveBorderColor.withValues(alpha: 0.4)
|
||||||
: effectiveBorderColor,
|
: effectiveBorderColor,
|
||||||
width: widget.borderWidth,
|
width: widget.borderWidth,
|
||||||
),
|
),
|
||||||
padding: widget.padding ?? const EdgeInsets.symmetric(
|
padding: widget.padding ??
|
||||||
vertical: 12,
|
const EdgeInsets.symmetric(
|
||||||
horizontal: 24,
|
vertical: 12,
|
||||||
),
|
horizontal: 24,
|
||||||
backgroundColor: _isHovered
|
),
|
||||||
? AppColors.glassBackground
|
backgroundColor:
|
||||||
: Colors.transparent,
|
_isHovered ? AppColors.glassBackground : Colors.transparent,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -146,7 +145,7 @@ class _TextLinkButtonState extends State<TextLinkButton> {
|
|||||||
Widget button = AnimatedContainer(
|
Widget button = AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _isHovered
|
color: _isHovered
|
||||||
? theme.colorScheme.onSurface.withValues(alpha: 0.05)
|
? theme.colorScheme.onSurface.withValues(alpha: 0.05)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -179,9 +178,8 @@ class _TextLinkButtonState extends State<TextLinkButton> {
|
|||||||
fontSize: widget.fontSize,
|
fontSize: widget.fontSize,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: effectiveColor,
|
color: effectiveColor,
|
||||||
decoration: _isHovered
|
decoration:
|
||||||
? TextDecoration.underline
|
_isHovered ? TextDecoration.underline : TextDecoration.none,
|
||||||
: TextDecoration.none,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -199,4 +197,4 @@ class _TextLinkButtonState extends State<TextLinkButton> {
|
|||||||
|
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,8 @@ class ConfirmationDialog extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
|
color:
|
||||||
|
(iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -350,4 +351,4 @@ class ErrorDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -59,14 +59,14 @@ class BaseTextField extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (label != null) ...[
|
if (label != null) ...[
|
||||||
Text(
|
Text(
|
||||||
label!,
|
label!,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
@@ -90,13 +90,14 @@ class BaseTextField extends StatelessWidget {
|
|||||||
minLines: minLines,
|
minLines: minLines,
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
cursorColor: cursorColor ?? theme.primaryColor,
|
cursorColor: cursorColor ?? theme.primaryColor,
|
||||||
style: style ?? TextStyle(
|
style: style ??
|
||||||
fontSize: 16,
|
const TextStyle(
|
||||||
color: AppColors.textPrimary,
|
fontSize: 16,
|
||||||
),
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hintText,
|
hintText: hintText,
|
||||||
hintStyle: TextStyle(
|
hintStyle: const TextStyle(
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
prefixIcon: prefixIcon,
|
prefixIcon: prefixIcon,
|
||||||
@@ -146,4 +147,4 @@ class BaseTextField extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ 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 = isGlassmorphism
|
||||||
? [
|
? [
|
||||||
localization.billingCycleMonthly,
|
localization.billingCycleMonthly,
|
||||||
localization.billingCycleQuarterly,
|
localization.billingCycleQuarterly,
|
||||||
@@ -37,7 +37,7 @@ class BillingCycleSelector extends StatelessWidget {
|
|||||||
localization.billingCycleHalfYearly,
|
localization.billingCycleHalfYearly,
|
||||||
localization.yearly,
|
localization.yearly,
|
||||||
];
|
];
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -76,7 +76,7 @@ class BillingCycleSelector extends StatelessWidget {
|
|||||||
|
|
||||||
Color _getBackgroundColor(bool isSelected) {
|
Color _getBackgroundColor(bool isSelected) {
|
||||||
if (!isSelected) {
|
if (!isSelected) {
|
||||||
return isGlassmorphism
|
return isGlassmorphism
|
||||||
? AppColors.backgroundColor
|
? AppColors.backgroundColor
|
||||||
: Colors.grey.withValues(alpha: 0.1);
|
: Colors.grey.withValues(alpha: 0.1);
|
||||||
}
|
}
|
||||||
@@ -84,11 +84,11 @@ class BillingCycleSelector extends StatelessWidget {
|
|||||||
if (baseColor != null) {
|
if (baseColor != null) {
|
||||||
return baseColor!;
|
return baseColor!;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gradientColors != null && gradientColors!.isNotEmpty) {
|
if (gradientColors != null && gradientColors!.isNotEmpty) {
|
||||||
return gradientColors![0];
|
return gradientColors![0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return const Color(0xFF3B82F6);
|
return const Color(0xFF3B82F6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,8 +106,6 @@ class BillingCycleSelector extends StatelessWidget {
|
|||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return Colors.white;
|
return Colors.white;
|
||||||
}
|
}
|
||||||
return isGlassmorphism
|
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
|
||||||
? AppColors.darkNavy
|
|
||||||
: Colors.grey[700]!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||