14 Commits

Author SHA1 Message Date
JiWoong Sul
d111b5dd62 fix(sms-permission): re-request on denial and guide permanent denial to app settings
Summary: Improve SMS permission UX so users can request again after denial and are guided to app settings when permanently denied.\nChanges: handle Permission.sms status in controllers, show settings dialog for permanently denied, use kIsWeb guard, context-safety across async.\nValidation: scripts/check.sh passed (analyze/tests OK).\nRisk & Rollback: low; scoped to permission request flow. Revert two controllers if issues.
2025-09-15 11:37:38 +09:00
JiWoong Sul
b944f6967d docs(ads): add AdMob mediation native networks guide with regional strategy and Gradle adapter examples
Summary: Document networks supporting Native ads via AdMob mediation, with regional prioritization, Gradle adapter examples, and setup checklist.\nChanges: adds doc/ads.md.\nValidation: scripts/check.sh passed.\nRisk & Rollback: low; doc-only change. Revert file if needed.
2025-09-15 11:37:32 +09:00
JiWoong Sul
997c2f53a0 feat(assets): 디지털렌트매니저 아이콘(집+체크·스퀴클) PNG 세트 및 생성 스크립트 추가\n\n- 경로: assets/app_icon/house_check/{32..1024}.png\n- 스크립트: scripts/render_icon.py (무의존 PNG 렌더) / scripts/generate_icons.sh
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:37:34 +09:00
JiWoong Sul
79f9aa3eb0 docs: flutter-shadcn-ui 마이그레이션 상세 계획 추가(doc/plan.md)
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:16:09 +09:00
JiWoong Sul
5b72fa196c merge: 'codex/perf-sms-ui-optimizations' 브랜치를 master에 병합
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:00:47 +09:00
JiWoong Sul
6cd3b9720f chore(macos): Flutter GeneratedPluginRegistrant 업데이트\n\n- 플러그인/플러터 변경으로 생성 파일 갱신\n- 의존성 lockfile 동기화(pubspec.lock) 2025-09-10 05:55:59 +09:00
JiWoong Sul
5a7ef8039e refactor: remove unreferenced widgets/utilities and backup file in lib 2025-09-08 14:33:55 +09:00
JiWoong Sul
10069a1800 perf(ui): enable KeepAlive on subscription list, tune prefetch, and reduce list/gesture animations 2025-09-08 14:32:28 +09:00
JiWoong Sul
b034f60510 feat(cache): add SimpleCacheManager and cache formatted rates/amounts in exchange and currency services 2025-09-08 14:31:44 +09:00
JiWoong Sul
eb6691ce6a feat(accessibility): add reduceMotion scaling and minimize animations; apply RepaintBoundary to heavy widgets 2025-09-08 14:30:28 +09:00
JiWoong Sul
10491af55b feat(perf): offload Android SMS parsing to Isolate and wrap pie chart with RepaintBoundary 2025-09-08 14:30:03 +09:00
JiWoong Sul
4673aed281 chore(agents): add Korean response rule to AGENTS.md 2025-09-08 14:21:59 +09:00
JiWoong Sul
84b3fdd530 perf: 파싱/렌더 최적화 다수 적용
- SmsScanner 키워드/정규식 상수화로 반복 컴파일 제거\n- 리스트에 prototypeItem 추가, 카드 RepaintBoundary 적용\n- 차트 영역 RepaintBoundary로 페인트 분리\n- GlassmorphicScaffold 파티클 수를 disableAnimations에 따라 감소\n- 캐시 초기화 플래그를 --dart-define로 제어(CLEAR_CACHE_ON_STARTUP)
2025-09-07 23:28:18 +09:00
JiWoong Sul
d37f66d526 feat(settings): SMS 읽기 권한 상태/요청 위젯 추가 (Android)
- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지

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

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

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

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

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

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

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

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

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

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

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

i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
2025-09-07 21:32:16 +09:00
90 changed files with 2477 additions and 4248 deletions

View File

@@ -13,6 +13,7 @@ Guardrails
- Safety: avoid destructive actions (file deletions, rewrites, config changes) unless explicitly requested. - 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. - Responses: be concise; code first, short rationale after. If uncertain, prefix with "Uncertain:". If multiple viable solutions, show the top 2 briefly.
- Planning: for multistep tasks, maintain an update_plan with exactly one in_progress step. - Planning: for multistep tasks, maintain an update_plan with exactly one in_progress step.
- Language: 기본적으로 한국어로 응답합니다. (필요 시 코드/로그/명령어는 원문 유지)
Coding Standards Coding Standards
- Language: Dart/Flutter (SDK >= 3.0). Respect `analysis_options.yaml` (flutter_lints baseline). - Language: Dart/Flutter (SDK >= 3.0). Respect `analysis_options.yaml` (flutter_lints baseline).
@@ -66,4 +67,3 @@ References & External Facts
Notes from ~/.claude (adapted) Notes from ~/.claude (adapted)
- Fewshot examples improve accuracy; include small before/after or sample input→output when helpful. - Fewshot examples improve accuracy; include small before/after or sample input→output when helpful.
- Use structured thinking internally; present only concise, actionable outputs here. - Use structured thinking internally; present only concise, actionable outputs here.

View File

@@ -1,6 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_SMS" /> <uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:label="구독 관리" android:label="구독 관리"

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

View File

@@ -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": "デジタル月額管理者",

123
doc/ads.md Normal file
View File

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

113
doc/plan.md Normal file
View File

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

View File

@@ -2,13 +2,15 @@ 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 {
@@ -105,6 +107,26 @@ class AddSubscriptionController {
scrollOffset = scrollController.offset; scrollOffset = scrollController.offset;
}); });
// 언어별 기본 통화 설정
try {
final lang = Localizations.localeOf(context).languageCode;
switch (lang) {
case 'ko':
currency = 'KRW';
break;
case 'ja':
currency = 'JPY';
break;
case 'zh':
currency = 'CNY';
break;
default:
currency = 'USD';
}
} catch (_) {
// Localizations가 아직 준비되지 않은 경우 기본값 유지
}
// 애니메이션 시작 // 애니메이션 시작
animationController!.forward(); animationController!.forward();
} }
@@ -183,6 +205,7 @@ class AddSubscriptionController {
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e'); print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
} }
} }
@@ -284,25 +307,55 @@ class AddSubscriptionController {
setState(() => isLoading = true); setState(() => isLoading = true);
try { try {
final ctx = context;
if (!await SMSService.hasSMSPermission()) { if (!await SMSService.hasSMSPermission()) {
final granted = await SMSService.requestSMSPermission(); final granted = await SMSService.requestSMSPermission();
if (!ctx.mounted) return;
if (!granted) { if (!granted) {
if (context.mounted) { if (ctx.mounted) {
AppSnackBar.showError( // 영구 거부 여부 확인 후 설정 화면 안내
context: context, final status = await permission.Permission.sms.status;
message: AppLocalizations.of(context).smsPermissionRequired, if (!ctx.mounted) return;
if (status.isPermanentlyDenied) {
await showDialog(
context: ctx,
builder: (_) => AlertDialog(
title: Text(AppLocalizations.of(ctx).smsPermissionRequired),
content:
Text(AppLocalizations.of(ctx).permanentlyDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(AppLocalizations.of(ctx).cancel),
),
TextButton(
onPressed: () async {
await permission.openAppSettings();
if (ctx.mounted) Navigator.of(ctx).pop();
},
child: Text(AppLocalizations.of(ctx).openSettings),
),
],
),
); );
} else {
AppSnackBar.showError(
context: ctx,
message: AppLocalizations.of(ctx).smsPermissionRequired,
);
}
} }
return; return;
} }
} }
final subscriptions = await SMSService.scanSubscriptions(); final subscriptions = await SMSService.scanSubscriptions();
if (!ctx.mounted) return;
if (subscriptions.isEmpty) { if (subscriptions.isEmpty) {
if (context.mounted) { if (ctx.mounted) {
AppSnackBar.showWarning( AppSnackBar.showWarning(
context: context, context: ctx,
message: AppLocalizations.of(context).noSubscriptionSmsFound, message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
); );
} }
return; return;
@@ -320,6 +373,7 @@ class AddSubscriptionController {
await SubscriptionUrlMatcher.extractServiceFromSms(smsContent); await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e'); print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
} }
} }
@@ -433,12 +487,22 @@ class AddSubscriptionController {
double.tryParse(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,
@@ -448,6 +512,16 @@ class AddSubscriptionController {
eventPrice: eventPrice, eventPrice: eventPrice,
); );
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
);
}
}
if (context.mounted) { if (context.mounted) {
Navigator.pop(context, true); // 성공 여부 반환 Navigator.pop(context, true); // 성공 여부 반환
} }

View File

@@ -401,7 +401,7 @@ class DetailScreenController extends ChangeNotifier {
debugPrint('[DetailScreenController] 구독 업데이트 시작: ' debugPrint('[DetailScreenController] 구독 업데이트 시작: '
'${subscription.serviceName}${serviceNameController.text}, ' '${subscription.serviceName}${serviceNameController.text}, '
'금액: ${subscription.monthlyCost}$monthlyCost ${_currency}'); '금액: $subscription.monthlyCost → $monthlyCost $_currency');
subscription.serviceName = serviceNameController.text; subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost; subscription.monthlyCost = monthlyCost;
@@ -460,12 +460,14 @@ class DetailScreenController extends ChangeNotifier {
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;
@@ -529,6 +531,7 @@ class DetailScreenController extends ChangeNotifier {
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('DetailScreenController: 해지 페이지 열기 실패 - $e'); print('DetailScreenController: 해지 페이지 열기 실패 - $e');
} }

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/sms_scanner.dart'; import '../services/sms_scanner.dart';
import '../models/subscription.dart'; import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/sms_scan/subscription_converter.dart'; import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart'; import '../services/sms_scan/subscription_filter.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/logger.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -57,21 +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 = final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions(); await _smsScanner.scanForSubscriptions();
print('스캔된 구독: ${scannedSubscriptionModels.length}'); Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) { if (scannedSubscriptionModels.isNotEmpty) {
print( Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); '첫 번째 구독: ${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();
@@ -85,15 +114,15 @@ class SmsScanController extends ChangeNotifier {
// 2회 이상 반복 결제된 구독만 필터링 // 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions = final repeatSubscriptions =
_filter.filterByRepeatCount(scannedSubscriptions, 2); _filter.filterByRepeatCount(scannedSubscriptions, 2);
print('반복 결제된 구독: ${repeatSubscriptions.length}'); Log.d('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) { if (repeatSubscriptions.isNotEmpty) {
print( Log.d(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}'); '첫 번째 반복 구독: ${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();
@@ -104,21 +133,21 @@ class SmsScanController extends ChangeNotifier {
final provider = final provider =
Provider.of<SubscriptionProvider>(context, listen: false); Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions; final existingSubscriptions = provider.subscriptions;
print('기존 구독: ${existingSubscriptions.length}'); Log.d('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링 // 중복 구독 필터링
final filteredSubscriptions = final filteredSubscriptions =
_filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}'); Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) { if (filteredSubscriptions.isNotEmpty) {
print( Log.d(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); '첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
} }
// 중복 제거 후 신규 구독이 없는 경우 // 중복 제거 후 신규 구독이 없는 경우
if (filteredSubscriptions.isEmpty) { if (filteredSubscriptions.isEmpty) {
print('중복 제거 후 신규 구독이 없음'); Log.i('중복 제거 후 신규 구독이 없음');
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
return; return;
@@ -129,7 +158,7 @@ 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 = _errorMessage =
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
@@ -139,6 +168,30 @@ class SmsScanController extends ChangeNotifier {
} }
} }
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;
@@ -159,7 +212,7 @@ class SmsScanController extends ChangeNotifier {
? websiteUrlController.text.trim() ? websiteUrlController.text.trim()
: subscription.websiteUrl; : subscription.websiteUrl;
print( Log.d(
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); '구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
// addSubscription 호출 // addSubscription 호출
@@ -176,19 +229,20 @@ class SmsScanController extends ChangeNotifier {
currency: subscription.currency, currency: subscription.currency,
); );
print('구독 추가 성공: ${subscription.serviceName}'); Log.i('구독 추가 성공: ${subscription.serviceName}');
if (!context.mounted) return;
moveToNextSubscription(context); moveToNextSubscription(context);
} catch (e) { } catch (e) {
print('구독 추가 중 오류 발생: $e'); Log.e('구독 추가 중 오류 발생', e);
// 오류가 있어도 다음 구독으로 이동 // 오류가 있어도 다음 구독으로 이동
if (!context.mounted) return;
moveToNextSubscription(context); moveToNextSubscription(context);
} }
} }
void skipCurrentSubscription(BuildContext context) { void skipCurrentSubscription(BuildContext context) {
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
print('구독 건너뛰기: ${subscription.serviceName}'); Log.i('구독 건너뛰기: ${subscription.serviceName}');
moveToNextSubscription(context); moveToNextSubscription(context);
} }
@@ -224,7 +278,7 @@ class SmsScanController extends ChangeNotifier {
(cat) => cat.name == 'other', (cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first, orElse: () => categoryProvider.categories.first,
); );
print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
return otherCategory.id; return otherCategory.id;
} }

View File

@@ -63,6 +63,28 @@ class AppLocalizations {
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';
@@ -308,11 +330,11 @@ class AppLocalizations {
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();
} }
@@ -444,11 +466,11 @@ class AppLocalizations {
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';
} }

View File

@@ -22,6 +22,7 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:async' show unawaited; import 'dart:async' show unawaited;
import 'utils/memory_manager.dart'; import 'utils/memory_manager.dart';
import 'utils/logger.dart';
import 'utils/performance_optimizer.dart'; import 'utils/performance_optimizer.dart';
import 'navigator_key.dart'; import 'navigator_key.dart';
@@ -44,16 +45,23 @@ Future<void> main() async {
try { try {
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비 // 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지) // 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화
// 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요.
const bool clearCacheOnStartup = bool.fromEnvironment(
'CLEAR_CACHE_ON_STARTUP',
defaultValue: false,
);
if (clearCacheOnStartup) {
await DefaultCacheManager().emptyCache(); await DefaultCacheManager().emptyCache();
}
if (kDebugMode) { if (kDebugMode) {
print('이미지 캐시 관리 초기화 완료'); Log.d('이미지 캐시 관리 초기화 완료');
PerformanceOptimizer.checkConstOptimization(); PerformanceOptimizer.checkConstOptimization();
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('캐시 초기화 오류: $e'); Log.e('캐시 초기화 오류', e);
} }
} }

View File

@@ -28,7 +28,7 @@ class SubscriptionProvider extends ChangeNotifier {
final price = subscription.currentPrice; final price = subscription.currentPrice;
if (subscription.currency == 'USD') { if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$${price} ×$rate = ₩${price * rate}'); '\$$price ×$rate = ₩${price * rate}');
return sum + (price * rate); return sum + (price * rate);
} }
debugPrint( debugPrint(
@@ -264,7 +264,7 @@ class SubscriptionProvider extends ChangeNotifier {
for (final subscription in _subscriptions) { for (final subscription in _subscriptions) {
final currentPrice = subscription.currentPrice; final currentPrice = subscription.currentPrice;
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
final converted = await ExchangeRateService().convertBetweenCurrencies( final converted = await ExchangeRateService().convertBetweenCurrencies(
currentPrice, currentPrice,
@@ -310,7 +310,7 @@ class SubscriptionProvider extends ChangeNotifier {
final cost = subscription.currentPrice; final cost = subscription.currentPrice;
debugPrint( debugPrint(
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' '[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
// 통화 변환 // 통화 변환
final converted = final converted =
@@ -508,7 +508,6 @@ class SubscriptionProvider extends ChangeNotifier {
.id; .id;
} }
if (categoryId != null) {
subscription.categoryId = categoryId; subscription.categoryId = categoryId;
await subscription.save(); await subscription.save();
migratedCount++; migratedCount++;
@@ -517,10 +516,9 @@ class SubscriptionProvider extends ChangeNotifier {
debugPrint('${subscription.serviceName}$categoryName'); debugPrint('${subscription.serviceName}$categoryName');
} }
} }
}
if (migratedCount > 0) { if (migratedCount > 0) {
debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료'); debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료');
await refreshSubscriptions(); await refreshSubscriptions();
} else { } else {
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다'); debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');

View File

@@ -169,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,
@@ -179,7 +179,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 3. 월별 지출 차트 // 3. 월별 지출 차트
MonthlyExpenseChartCard( MonthlyExpenseChartCard(
key: ValueKey('monthly_expense_${_lastDataHash}'), key: ValueKey('monthly_expense_$_lastDataHash'),
monthlyData: _monthlyData, monthlyData: _monthlyData,
animationController: _animationController, animationController: _animationController,
), ),

View File

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

View File

@@ -43,7 +43,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: const Text(
'카테고리 관리', '카테고리 관리',
style: TextStyle( style: TextStyle(
color: AppColors.pureWhite, color: AppColors.pureWhite,
@@ -66,7 +66,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '카테고리 이름', labelText: '카테고리 이름',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
@@ -82,7 +82,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedColor, value: _selectedColor,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '색상 선택', labelText: '색상 선택',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
@@ -93,32 +93,32 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
value: '#1976D2', value: '#1976D2',
child: Text( child: Text(
AppLocalizations.of(context).colorBlue, AppLocalizations.of(context).colorBlue,
style: style: const TextStyle(
TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#4CAF50', value: '#4CAF50',
child: Text( child: Text(
AppLocalizations.of(context).colorGreen, AppLocalizations.of(context).colorGreen,
style: style: const TextStyle(
TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#FF9800', value: '#FF9800',
child: Text( child: Text(
AppLocalizations.of(context).colorOrange, AppLocalizations.of(context).colorOrange,
style: style: const TextStyle(
TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#F44336', value: '#F44336',
child: Text( child: Text(
AppLocalizations.of(context).colorRed, AppLocalizations.of(context).colorRed,
style: style: const TextStyle(
TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#9C27B0', value: '#9C27B0',
child: Text( child: Text(
AppLocalizations.of(context).colorPurple, AppLocalizations.of(context).colorPurple,
style: style: const TextStyle(
TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -129,13 +129,13 @@ 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', value: 'subscriptions',
child: Text('구독', child: Text('구독',
@@ -171,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,
@@ -201,7 +201,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
title: Text( title: Text(
provider.getLocalizedCategoryName( provider.getLocalizedCategoryName(
context, category.name), context, category.name),
style: TextStyle( style: const TextStyle(
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),
), ),

View File

@@ -111,7 +111,7 @@ class _DetailScreenState extends State<DetailScreen>
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.changesAppliedAfterSave, .changesAppliedAfterSave,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),

View File

@@ -5,7 +5,6 @@ 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';
@@ -230,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)
@@ -273,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),
@@ -414,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),
@@ -484,44 +484,73 @@ class SettingsScreen extends StatelessWidget {
margin: margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8), const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: FutureBuilder<bool>( child: FutureBuilder<permission.PermissionStatus>(
future: SMSService.hasSMSPermission(), future: permission.Permission.sms.status,
builder: (context, snapshot) { builder: (context, snapshot) {
final hasPermission = snapshot.data ?? false; final isLoading =
snapshot.connectionState == ConnectionState.waiting;
final status = snapshot.data;
final hasPermission = status?.isGranted ?? false;
final isPermanentlyDenied =
status?.isPermanentlyDenied ?? false;
return ListTile( return ListTile(
leading: const Icon( leading: const Icon(
Icons.sms, Icons.sms,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
title: const Text( title: Text(
'SMS 권한', AppLocalizations.of(context).smsPermissionLabel,
style: TextStyle(color: AppColors.textPrimary), style: const TextStyle(color: AppColors.textPrimary),
), ),
subtitle: Text( subtitle: !hasPermission
AppLocalizations.of(context).smsPermissionRequired, ? Text(
style: isPermanentlyDenied
const TextStyle(color: AppColors.textSecondary), ? AppLocalizations.of(context)
), .permanentlyDeniedMessage
trailing: hasPermission : 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( ? const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0), padding:
EdgeInsets.symmetric(horizontal: 8.0),
child: Icon(Icons.check_circle, child: Icon(Icons.check_circle,
color: Colors.green), color: Colors.green),
) )
: isPermanentlyDenied
? TextButton(
onPressed: () async {
await permission.openAppSettings();
},
child: Text(AppLocalizations.of(context)
.openSettings),
)
: ElevatedButton( : ElevatedButton(
onPressed: () async { onPressed: () async {
final granted = final granted = await SMSService
await SMSService.requestSMSPermission(); .requestSMSPermission();
if (!granted) { if (!granted) {
final status = final newStatus = await permission
await permission.Permission.sms.status; .Permission.sms.status;
if (status.isPermanentlyDenied) { if (newStatus.isPermanentlyDenied) {
await permission.openAppSettings(); await permission
.openAppSettings();
} }
} }
if (context.mounted) { if (context.mounted) {
// 상태 갱신을 위해 다시 build 트리거 (context as Element)
(context as Element).markNeedsBuild(); .markNeedsBuild();
} }
}, },
child: Text(AppLocalizations.of(context) child: Text(AppLocalizations.of(context)

View File

@@ -63,18 +63,18 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
context: context, context: context,
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
title: Text(loc.smsPermissionRequired), title: Text(loc.smsPermissionRequired),
content: const Text('권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.'), content: Text(loc.permanentlyDeniedMessage),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기'), child: Text(AppLocalizations.of(context).cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await permission.openAppSettings(); await permission.openAppSettings();
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
}, },
child: const Text('설정 열기'), child: Text(loc.openSettings),
), ),
], ],
), ),
@@ -95,7 +95,7 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
const Icon(Icons.sms, size: 64, color: AppColors.primaryColor), const Icon(Icons.sms, size: 64, color: AppColors.primaryColor),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'SMS 권한 요청', loc.smsPermissionTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -112,16 +112,16 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: const [ children: [
Text('이유:', Text(loc.smsPermissionReasonTitle,
style: TextStyle(fontWeight: FontWeight.bold)), style: const TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8), const SizedBox(height: 8),
Text('문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다.'), Text(loc.smsPermissionReasonBody),
SizedBox(height: 12), const SizedBox(height: 12),
Text('수집 범위:', Text(loc.smsPermissionScopeTitle,
style: TextStyle(fontWeight: FontWeight.bold)), style: const TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8), const SizedBox(height: 8),
Text('결제 관련 문자 메시지(서비스명/금액/날짜 패턴)를 로컬에서만 처리합니다.'), Text(loc.smsPermissionScopeBody),
], ],
), ),
), ),
@@ -131,15 +131,15 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _requesting ? null : _handleRequest, onPressed: _requesting ? null : _handleRequest,
icon: const Icon(Icons.lock_open), icon: const Icon(Icons.lock_open),
label: label: Text(
Text(_requesting ? '요청 중...' : loc.requestPermission), _requesting ? loc.requesting : loc.requestPermission),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: () => Navigator.of(context) onPressed: () => Navigator.of(context)
.pushReplacementNamed(AppRoutes.main), .pushReplacementNamed(AppRoutes.main),
child: const Text('나중에 하기'), child: Text(loc.later),
) )
], ],
), ),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/sms_scan_controller.dart'; import '../controllers/sms_scan_controller.dart';
import '../widgets/sms_scan/scan_loading_widget.dart'; import '../widgets/sms_scan/scan_loading_widget.dart';
import '../widgets/sms_scan/scan_initial_widget.dart'; import '../widgets/sms_scan/scan_initial_widget.dart';

View File

@@ -6,6 +6,7 @@ import '../services/sms_service.dart';
import '../utils/platform_helper.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});
@@ -65,7 +66,8 @@ class _SplashScreenState extends State<SplashScreen>
)); ));
// 랜덤 파티클 생성 // 랜덤 파티클 생성
_generateParticles(); // 접근성(모션 축소) 고려한 파티클 생성
_generateParticles(reduced: ReduceMotion.platform());
_animationController.forward(); _animationController.forward();
@@ -75,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;
@@ -257,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(
@@ -282,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),
), ),
], ],
@@ -398,7 +413,7 @@ class _SplashScreenState extends State<SplashScreen>
width: 1, width: 1,
), ),
), ),
child: CircularProgressIndicator( child: const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
AppColors.pureWhite), AppColors.pureWhite),
strokeWidth: 3, strokeWidth: 3,

View File

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

View File

@@ -1,10 +1,17 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import 'exchange_rate_service.dart'; import 'exchange_rate_service.dart';
import 'cache_manager.dart';
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스 /// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
class CurrencyUtil { class CurrencyUtil {
static final ExchangeRateService _exchangeRateService = ExchangeRateService(); static final ExchangeRateService _exchangeRateService = ExchangeRateService();
static final SimpleCacheManager<String> _fmtCache =
SimpleCacheManager<String>(
maxEntries: 256,
maxBytes: 256 * 1024,
ttl: const Duration(minutes: 15),
);
/// 언어에 따른 기본 통화 반환 /// 언어에 따른 기본 통화 반환
static String getDefaultCurrency(String locale) { static String getDefaultCurrency(String locale) {
@@ -80,11 +87,19 @@ 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 입력인 경우 - 기본 통화로 변환하여 표시
@@ -95,17 +110,23 @@ class CurrencyUtil {
final primaryFormatted = final primaryFormatted =
_formatSingleCurrency(convertedAmount, defaultCurrency); _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;
} }
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로) /// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
@@ -141,7 +162,20 @@ class CurrencyUtil {
static Future<String> formatSubscriptionAmountWithLocale( static Future<String> formatSubscriptionAmountWithLocale(
SubscriptionModel subscription, String locale) async { SubscriptionModel subscription, String locale) async {
final price = subscription.currentPrice; final price = subscription.currentPrice;
return formatAmountWithLocale(price, subscription.currency, locale); // 구독 단위 캐시 키 (통화/가격/locale + id)
final decimals =
(subscription.currency == 'KRW' || subscription.currency == 'JPY')
? 0
: 2;
final key =
'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}';
final cached = _fmtCache.get(key);
if (cached != null) return cached;
final result =
await formatAmountWithLocale(price, subscription.currency, locale);
_fmtCache.set(key, result, size: result.length);
return result;
} }
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지 /// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지

View File

@@ -1,6 +1,8 @@
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:convert'; import 'dart:convert';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../utils/logger.dart';
import 'cache_manager.dart';
/// 환율 정보 서비스 클래스 /// 환율 정보 서비스 클래스
class ExchangeRateService { class ExchangeRateService {
@@ -15,18 +17,34 @@ class ExchangeRateService {
// 내부 생성자 // 내부 생성자
ExchangeRateService._internal(); ExchangeRateService._internal();
// 포맷된 환율 문자열 캐시 (언어별)
static final SimpleCacheManager<String> _fmtCache =
SimpleCacheManager<String>(
maxEntries: 64,
maxBytes: 64 * 1024,
ttl: const Duration(minutes: 30),
);
// 캐싱된 환율 정보 // 캐싱된 환율 정보
double? _usdToKrwRate; double? _usdToKrwRate;
double? _usdToJpyRate; double? _usdToJpyRate;
double? _usdToCnyRate; double? _usdToCnyRate;
DateTime? _lastUpdated; DateTime? _lastUpdated;
// API 요청 URL (ExchangeRate-API 사용) // API 요청 URL (ExchangeRate-API 등) - 빌드 타임 오버라이드 가능
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD'; static const String _defaultApiUrl =
'https://api.exchangerate-api.com/v4/latest/USD';
final String _apiUrl = const String.fromEnvironment(
'EXCHANGE_RATE_API_URL',
defaultValue: _defaultApiUrl,
);
// 기본 환율 상수 // 기본 환율 상수
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_KRW_RATE = 1350.0; static const double DEFAULT_USD_TO_KRW_RATE = 1350.0;
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_JPY_RATE = 150.0; static const double DEFAULT_USD_TO_JPY_RATE = 150.0;
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_CNY_RATE = 7.2; static const double DEFAULT_USD_TO_CNY_RATE = 7.2;
// 캐싱된 환율 반환 (동기적) // 캐싱된 환율 반환 (동기적)
@@ -44,18 +62,28 @@ class ExchangeRateService {
} }
try { try {
// API 요청 // API 요청 (네트워크 불가 환경에서는 예외 발생 가능)
final response = await http.get(Uri.parse(_apiUrl)); final response = await http.get(Uri.parse(_apiUrl));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body); final data = json.decode(response.body);
_usdToKrwRate = data['rates']['KRW']?.toDouble(); _usdToKrwRate = (data['rates']['KRW'] as num?)?.toDouble();
_usdToJpyRate = data['rates']['JPY']?.toDouble(); _usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
_usdToCnyRate = data['rates']['CNY']?.toDouble(); _usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
_lastUpdated = DateTime.now(); _lastUpdated = DateTime.now();
// 환율 갱신 시 포맷 캐시 무효화
_fmtCache.clear();
Log.d(
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
return;
} else {
Log.w(
'환율 API 응답 코드: ${response.statusCode} (${response.reasonPhrase})');
} }
} catch (e) { } catch (e, st) {
// 오류 발생 시 기본값 사용 // 네트워크 실패 시 캐시/기본값 폴백
Log.w('환율 API 요청 실패. 캐시/기본값 사용');
Log.e('환율 API 에러', e, st);
} }
} }
@@ -160,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로 변환하여 포맷팅된 문자열로 반환합니다.

View File

@@ -1,7 +1,6 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
@@ -10,7 +9,7 @@ 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';
@@ -241,7 +240,7 @@ 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;
@@ -266,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');
@@ -351,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');
@@ -416,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');
@@ -456,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),
@@ -469,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');

View File

@@ -12,9 +12,12 @@ class SubscriptionConverter {
final subscription = _convertSingle(model); final subscription = _convertSingle(model);
result.add(subscription); result.add(subscription);
// 개발 편의를 위한 디버그 로그
// ignore: avoid_print
print( print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); '모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
} catch (e) { } catch (e) {
// ignore: avoid_print
print('모델 변환 중 오류 발생: $e'); print('모델 변환 중 오류 발생: $e');
} }
} }

View File

@@ -1,11 +1,12 @@
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( Log.d(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}'); '_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링 // 중복되지 않은 구독만 필터링
@@ -17,7 +18,7 @@ class SubscriptionFilter {
final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost; final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost;
if (isSameName && isSameCost) { if (isSameName && isSameCost) {
print( Log.d(
'중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)'); '중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
return true; return true;
} }

View File

@@ -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,182 +82,29 @@ class SmsScanner {
Future<List<dynamic>> _scanAndroidSms() async { Future<List<dynamic>> _scanAndroidSms() async {
try { try {
final messages = await _query.getAllSms; final messages = await _query.getAllSms;
final smsList = <Map<String, dynamic>>[];
// SMS 메시지를 분석하여 구독 서비스 감지 // Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가)
final serialized = <Map<String, dynamic>>[];
for (final message in messages) { for (final message in messages) {
final parsedData = _parseRawSms(message); serialized.add({
if (parsedData != null) { 'body': message.body ?? '',
smsList.add(parsedData); 'address': message.address ?? '',
} 'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch,
});
} }
// 대량 파싱은 별도 Isolate로 오프로딩
final List<Map<String, dynamic>> smsList =
await compute(_parseRawSmsBatch, serialized);
return smsList; return smsList;
} catch (e) { } catch (e) {
print('SmsScanner: Android SMS 스캔 실패: $e'); Log.e('SmsScanner: Android SMS 스캔 실패', e);
return []; return [];
} }
} }
// 실제 SMS 메시지싱하여 구독 정보 추출 // (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
Map<String, dynamic>? _parseRawSms(SmsMessage message) {
try {
final body = message.body ?? '';
final sender = message.address ?? '';
final date = message.date ?? DateTime.now();
// 구독 서비스 키워드 매칭
final subscriptionKeywords = [
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'subscription',
'payment',
'billing',
'charge',
'넷플릭스',
'Netflix',
'유튜브',
'YouTube',
'Spotify',
'멜론',
'웨이브',
'Disney+',
'디즈니플러스',
'Apple',
'Microsoft',
'GitHub',
'Adobe',
'Amazon'
];
// 구독 관련 키워드가 있는지 확인
bool isSubscription = subscriptionKeywords.any((keyword) =>
body.toLowerCase().contains(keyword.toLowerCase()) ||
sender.toLowerCase().contains(keyword.toLowerCase()));
if (!isSubscription) {
return null;
}
// 서비스명 추출
String serviceName = _extractServiceName(body, sender);
// 금액 추출
double? amount = _extractAmount(body);
// 결제 주기 추출
String billingCycle = _extractBillingCycle(body);
return {
'serviceName': serviceName,
'monthlyCost': amount ?? 0.0,
'billingCycle': billingCycle,
'message': body,
'nextBillingDate':
_calculateNextBillingFromDate(date, billingCycle).toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
};
} catch (e) {
print('SmsScanner: SMS 파싱 실패: $e');
return null;
}
}
// 서비스명 추출 로직
String _extractServiceName(String body, String sender) {
// 알려진 서비스 매핑
final servicePatterns = {
'netflix': '넷플릭스',
'youtube': '유튜브 프리미엄',
'spotify': 'Spotify',
'disney': '디즈니플러스',
'apple': 'Apple',
'microsoft': 'Microsoft',
'github': 'GitHub',
'adobe': 'Adobe',
'멜론': '멜론',
'웨이브': '웨이브',
};
// 메시지나 발신자에서 서비스명 찾기
final combinedText = '$body $sender'.toLowerCase();
for (final entry in servicePatterns.entries) {
if (combinedText.contains(entry.key)) {
return entry.value;
}
}
// 찾지 못한 경우
return _extractServiceNameFromSender(sender);
}
// 발신자 정보에서 서비스명 추출
String _extractServiceNameFromSender(String sender) {
// 숫자만 있으면 제거
if (RegExp(r'^\d+$').hasMatch(sender)) {
return '알 수 없는 서비스';
}
// 특수문자 제거하고 서비스명으로 사용
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
}
// 금액 추출 로직
double? _extractAmount(String body) {
// 다양한 금액 패턴 매칭
final patterns = [
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
];
for (final pattern in patterns) {
final match = pattern.firstMatch(body);
if (match != null) {
String amountStr = match.group(1) ?? '';
amountStr = amountStr.replaceAll(',', '');
return double.tryParse(amountStr);
}
}
return null;
}
// 결제 주기 추출 로직
String _extractBillingCycle(String body) {
if (body.contains('') || body.contains('monthly') || body.contains('매월')) {
return 'monthly';
} else if (body.contains('') ||
body.contains('yearly') ||
body.contains('annual')) {
return 'yearly';
} else if (body.contains('') || body.contains('weekly')) {
return 'weekly';
}
// 기본값
return 'monthly';
}
// 다음 결제일 계산
DateTime _calculateNextBillingFromDate(
DateTime lastDate, String billingCycle) {
switch (billingCycle) {
case 'monthly':
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
case 'yearly':
return DateTime(lastDate.year + 1, lastDate.month, lastDate.day);
case 'weekly':
return lastDate.add(const Duration(days: 7));
default:
return lastDate.add(const Duration(days: 30));
}
}
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) { SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try { try {
@@ -281,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';
} }
@@ -411,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';
} }
} }
@@ -419,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';
} }
} }
@@ -428,3 +276,148 @@ class SmsScanner {
return 'KRW'; return 'KRW';
} }
} }
// ===== Isolate 오프로딩용 Top-level 파서 =====
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
List<Map<String, dynamic>> _parseRawSmsBatch(
List<Map<String, dynamic>> messages) {
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
const subscriptionKeywords = [
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'subscription',
'payment',
'billing',
'charge',
'넷플릭스',
'Netflix',
'유튜브',
'YouTube',
'Spotify',
'멜론',
'웨이브',
'Disney+',
'디즈니플러스',
'Apple',
'Microsoft',
'GitHub',
'Adobe',
'Amazon'
];
final amountPatterns = <RegExp>[
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
];
final results = <Map<String, dynamic>>[];
for (final m in messages) {
final body = (m['body'] as String?) ?? '';
final sender = (m['address'] as String?) ?? '';
final dateMillis =
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
final lowerBody = body.toLowerCase();
final lowerSender = sender.toLowerCase();
final isSubscription = subscriptionKeywords.any((k) =>
lowerBody.contains(k.toLowerCase()) ||
lowerSender.contains(k.toLowerCase()));
if (!isSubscription) continue;
final serviceName = _isoExtractServiceName(body, sender);
final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0;
final billingCycle = _isoExtractBillingCycle(body);
final nextBillingDate =
_isoCalculateNextBillingFromDate(date, billingCycle);
results.add({
'serviceName': serviceName,
'monthlyCost': amount,
'billingCycle': billingCycle,
'message': body,
'nextBillingDate': nextBillingDate.toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
});
}
return results;
}
String _isoExtractServiceName(String body, String sender) {
final servicePatterns = {
'netflix': '넷플릭스',
'youtube': '유튜브 프리미엄',
'spotify': 'Spotify',
'disney': '디즈니플러스',
'apple': 'Apple',
'microsoft': 'Microsoft',
'github': 'GitHub',
'adobe': 'Adobe',
'멜론': '멜론',
'웨이브': '웨이브',
};
final combined = '$body $sender'.toLowerCase();
for (final e in servicePatterns.entries) {
if (combined.contains(e.key)) return e.value;
}
return _isoExtractServiceNameFromSender(sender);
}
String _isoExtractServiceNameFromSender(String sender) {
if (RegExp(r'^\d+$').hasMatch(sender)) {
return '알 수 없는 서비스';
}
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
}
double? _isoExtractAmount(String body, List<RegExp> patterns) {
for (final pattern in patterns) {
final match = pattern.firstMatch(body);
if (match != null) {
var amountStr = match.group(1) ?? '';
amountStr = amountStr.replaceAll(',', '');
final parsed = double.tryParse(amountStr);
if (parsed != null) return parsed;
}
}
return null;
}
String _isoExtractBillingCycle(String body) {
if (body.contains('') ||
body.toLowerCase().contains('monthly') ||
body.contains('매월')) {
return 'monthly';
} else if (body.contains('') ||
body.toLowerCase().contains('yearly') ||
body.toLowerCase().contains('annual')) {
return 'yearly';
} else if (body.contains('') || body.toLowerCase().contains('weekly')) {
return 'weekly';
}
return 'monthly';
}
DateTime _isoCalculateNextBillingFromDate(
DateTime lastDate, String billingCycle) {
switch (billingCycle) {
case 'monthly':
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
case 'yearly':
return DateTime(lastDate.year + 1, lastDate.month, lastDate.day);
case 'weekly':
return lastDate.add(const Duration(days: 7));
default:
return lastDate.add(const Duration(days: 30));
}
}

View File

@@ -1,941 +0,0 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
/// 서비스 정보를 담는 데이터 클래스
class ServiceInfo {
final String serviceId;
final String serviceName;
final String? serviceUrl;
final String? cancellationUrl;
final String categoryId;
final String categoryNameKr;
final String categoryNameEn;
ServiceInfo({
required this.serviceId,
required this.serviceName,
this.serviceUrl,
this.cancellationUrl,
required this.categoryId,
required this.categoryNameKr,
required this.categoryNameEn,
});
}
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스
class SubscriptionUrlMatcher {
static Map<String, dynamic>? _servicesData;
static bool _isInitialized = false;
// 레거시 데이터 (JSON 로드 실패시 폴백)
// OTT 서비스
static final Map<String, String> ottServices = {
'netflix': 'https://www.netflix.com',
'넷플릭스': 'https://www.netflix.com',
'disney+': 'https://www.disneyplus.com',
'디즈니플러스': 'https://www.disneyplus.com',
'youtube premium': 'https://www.youtube.com/premium',
'유튜브 프리미엄': 'https://www.youtube.com/premium',
'watcha': 'https://watcha.com',
'왓챠': 'https://watcha.com',
'wavve': 'https://www.wavve.com',
'웨이브': 'https://www.wavve.com',
'apple tv+': 'https://tv.apple.com',
'애플 티비플러스': 'https://tv.apple.com',
'tving': 'https://www.tving.com',
'티빙': 'https://www.tving.com',
'prime video': 'https://www.primevideo.com',
'프라임 비디오': 'https://www.primevideo.com',
'amazon prime': 'https://www.amazon.com/prime',
'아마존 프라임': 'https://www.amazon.com/prime',
'coupang play': 'https://play.coupangplay.com',
'쿠팡 플레이': 'https://play.coupangplay.com',
'hulu': 'https://www.hulu.com',
'훌루': 'https://www.hulu.com',
};
// 음악 서비스
static final Map<String, String> musicServices = {
'spotify': 'https://www.spotify.com',
'스포티파이': 'https://www.spotify.com',
'apple music': 'https://music.apple.com',
'애플 뮤직': 'https://music.apple.com',
'melon': 'https://www.melon.com',
'멜론': 'https://www.melon.com',
'genie': 'https://www.genie.co.kr',
'지니': 'https://www.genie.co.kr',
'youtube music': 'https://music.youtube.com',
'유튜브 뮤직': 'https://music.youtube.com',
'bugs': 'https://music.bugs.co.kr',
'벅스': 'https://music.bugs.co.kr',
'flo': 'https://www.music-flo.com',
'플로': 'https://www.music-flo.com',
'vibe': 'https://vibe.naver.com',
'바이브': 'https://vibe.naver.com',
'tidal': 'https://www.tidal.com',
'타이달': 'https://www.tidal.com',
};
// 저장 (클라우드/파일) 서비스
static final Map<String, String> storageServices = {
'google drive': 'https://www.google.com/drive/',
'구글 드라이브': 'https://www.google.com/drive/',
'dropbox': 'https://www.dropbox.com',
'드롭박스': 'https://www.dropbox.com',
'onedrive': 'https://www.onedrive.com',
'원드라이브': 'https://www.onedrive.com',
'icloud': 'https://www.icloud.com',
'아이클라우드': 'https://www.icloud.com',
'box': 'https://www.box.com',
'박스': 'https://www.box.com',
'pcloud': 'https://www.pcloud.com',
'mega': 'https://mega.nz',
'메가': 'https://mega.nz',
'naver mybox': 'https://mybox.naver.com',
'네이버 마이박스': 'https://mybox.naver.com',
};
// 통신 · 인터넷 · TV 서비스
static final Map<String, String> telecomServices = {
'skt': 'https://www.sktelecom.com',
'sk텔레콤': 'https://www.sktelecom.com',
'kt': 'https://www.kt.com',
'lgu+': 'https://www.lguplus.com',
'lg유플러스': 'https://www.lguplus.com',
'olleh tv': 'https://www.kt.com/olleh_tv',
'올레 tv': 'https://www.kt.com/olleh_tv',
'b tv': 'https://www.skbroadband.com',
'비티비': 'https://www.skbroadband.com',
'u+모바일tv': 'https://www.lguplus.com',
'유플러스모바일tv': 'https://www.lguplus.com',
};
// 생활/라이프스타일 서비스
static final Map<String, String> lifestyleServices = {
'네이버 플러스': 'https://plus.naver.com',
'naver plus': 'https://plus.naver.com',
'카카오 구독': 'https://subscribe.kakao.com',
'kakao subscribe': 'https://subscribe.kakao.com',
'쿠팡 와우': 'https://www.coupang.com/np/coupangplus',
'coupang wow': 'https://www.coupang.com/np/coupangplus',
'스타벅스 버디': 'https://www.starbucks.co.kr',
'starbucks buddy': 'https://www.starbucks.co.kr',
'cu 구독': 'https://cu.bgfretail.com',
'gs25 구독': 'https://gs25.gsretail.com',
'현대차 구독': 'https://www.hyundai.com/kr/ko/eco/vehicle-subscription',
'lg전자 구독': 'https://www.lge.co.kr',
'삼성전자 구독': 'https://www.samsung.com/sec',
'다이슨 케어': 'https://www.dyson.co.kr',
'dyson care': 'https://www.dyson.co.kr',
'마켓컬리': 'https://www.kurly.com',
'kurly': 'https://www.kurly.com',
'헬로네이처': 'https://www.hellonature.com',
'hello nature': 'https://www.hellonature.com',
'이마트 트레이더스': 'https://www.emarttraders.co.kr',
'홈플러스': 'https://www.homeplus.co.kr',
'hellofresh': 'https://www.hellofresh.com',
'헬로프레시': 'https://www.hellofresh.com',
'bespoke post': 'https://www.bespokepost.com',
};
// 쇼핑/이커머스 서비스
static final Map<String, String> shoppingServices = {
'amazon prime': 'https://www.amazon.com/prime',
'아마존 프라임': 'https://www.amazon.com/prime',
'walmart+': 'https://www.walmart.com/plus',
'월마트플러스': 'https://www.walmart.com/plus',
'chewy': 'https://www.chewy.com',
'츄이': 'https://www.chewy.com',
'dollar shave club': 'https://www.dollarshaveclub.com',
'달러셰이브클럽': 'https://www.dollarshaveclub.com',
'instacart': 'https://www.instacart.com',
'인스타카트': 'https://www.instacart.com',
'shipt': 'https://www.shipt.com',
'십트': 'https://www.shipt.com',
'grove': 'https://grove.co',
'그로브': 'https://grove.co',
'cratejoy': 'https://www.cratejoy.com',
'shopify': 'https://www.shopify.com',
'쇼피파이': 'https://www.shopify.com',
};
// AI 서비스
static final Map<String, String> aiServices = {
'chatgpt': 'https://chat.openai.com',
'챗GPT': 'https://chat.openai.com',
'openai': 'https://openai.com',
'오픈AI': 'https://openai.com',
'claude': 'https://claude.ai',
'클로드': 'https://claude.ai',
'anthropic': 'https://www.anthropic.com',
'앤트로픽': 'https://www.anthropic.com',
'midjourney': 'https://www.midjourney.com',
'미드저니': 'https://www.midjourney.com',
'perplexity': 'https://www.perplexity.ai',
'퍼플렉시티': 'https://www.perplexity.ai',
'copilot': 'https://copilot.microsoft.com',
'코파일럿': 'https://copilot.microsoft.com',
'gemini': 'https://gemini.google.com',
'제미니': 'https://gemini.google.com',
'google ai': 'https://ai.google',
'구글 AI': 'https://ai.google',
'bard': 'https://bard.google.com',
'바드': 'https://bard.google.com',
'dall-e': 'https://openai.com/dall-e',
'달리': 'https://openai.com/dall-e',
'stable diffusion': 'https://stability.ai',
'스테이블 디퓨전': 'https://stability.ai',
};
// 프로그래밍 / 개발 서비스
static final Map<String, String> programmingServices = {
'github': 'https://github.com',
'깃허브': 'https://github.com',
'cursor': 'https://cursor.com',
'커서': 'https://cursor.com',
'jetbrains': 'https://www.jetbrains.com',
'제트브레인스': 'https://www.jetbrains.com',
'intellij': 'https://www.jetbrains.com/idea',
'인텔리제이': 'https://www.jetbrains.com/idea',
'visual studio': 'https://visualstudio.microsoft.com',
'비주얼 스튜디오': 'https://visualstudio.microsoft.com',
'aws': 'https://aws.amazon.com',
'아마존 웹서비스': 'https://aws.amazon.com',
'azure': 'https://azure.microsoft.com',
'애저': 'https://azure.microsoft.com',
'google cloud': 'https://cloud.google.com',
'구글 클라우드': 'https://cloud.google.com',
'digitalocean': 'https://www.digitalocean.com',
'디지털오션': 'https://www.digitalocean.com',
'heroku': 'https://www.heroku.com',
'헤로쿠': 'https://www.heroku.com',
'codecademy': 'https://www.codecademy.com',
'코드아카데미': 'https://www.codecademy.com',
'udemy': 'https://www.udemy.com',
'유데미': 'https://www.udemy.com',
'coursera': 'https://www.coursera.org',
'코세라': 'https://www.coursera.org',
};
// 오피스 및 협업 툴
static final Map<String, String> officeTools = {
'microsoft 365': 'https://www.microsoft.com/microsoft-365',
'마이크로소프트 365': 'https://www.microsoft.com/microsoft-365',
'office 365': 'https://www.microsoft.com/microsoft-365',
'오피스 365': 'https://www.microsoft.com/microsoft-365',
'google workspace': 'https://workspace.google.com',
'구글 워크스페이스': 'https://workspace.google.com',
'slack': 'https://slack.com',
'슬랙': 'https://slack.com',
'notion': 'https://www.notion.so',
'노션': 'https://www.notion.so',
'trello': 'https://trello.com',
'트렐로': 'https://trello.com',
'asana': 'https://asana.com',
'아사나': 'https://asana.com',
'dropbox': 'https://www.dropbox.com',
'드롭박스': 'https://www.dropbox.com',
'figma': 'https://www.figma.com',
'피그마': 'https://www.figma.com',
'adobe creative cloud': 'https://www.adobe.com/creativecloud.html',
'어도비 크리에이티브 클라우드': 'https://www.adobe.com/creativecloud.html',
};
// 기타 유명 서비스
static final Map<String, String> otherServices = {
'google one': 'https://one.google.com',
'구글 원': 'https://one.google.com',
'icloud': 'https://www.icloud.com',
'아이클라우드': 'https://www.icloud.com',
'nintendo switch online': 'https://www.nintendo.com/switch/online-service',
'닌텐도 스위치 온라인': 'https://www.nintendo.com/switch/online-service',
'playstation plus': 'https://www.playstation.com/ps-plus',
'플레이스테이션 플러스': 'https://www.playstation.com/ps-plus',
'xbox game pass': 'https://www.xbox.com/xbox-game-pass',
'엑스박스 게임 패스': 'https://www.xbox.com/xbox-game-pass',
'ea play': 'https://www.ea.com/ea-play',
'EA 플레이': 'https://www.ea.com/ea-play',
'ubisoft+': 'https://ubisoft.com/plus',
'유비소프트+': 'https://ubisoft.com/plus',
'epic games': 'https://www.epicgames.com',
'에픽 게임즈': 'https://www.epicgames.com',
'steam': 'https://store.steampowered.com',
'스팀': 'https://store.steampowered.com',
};
// 해지 안내 페이지 URL 목록 (공식 해지 안내 페이지가 있는 서비스들)
static final Map<String, String> cancellationUrls = {
// OTT 서비스 해지 안내 페이지
'netflix': 'https://help.netflix.com/ko/node/407',
'넷플릭스': 'https://help.netflix.com/ko/node/407',
'disney+':
'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979',
'디즈니플러스':
'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979',
'youtube premium': 'https://support.google.com/youtube/answer/6308278',
'유튜브 프리미엄': 'https://support.google.com/youtube/answer/6308278',
'watcha': 'https://watcha.com/settings/payment',
'왓챠': 'https://watcha.com/settings/payment',
'wavve': 'https://www.wavve.com/my',
'웨이브': 'https://www.wavve.com/my',
'apple tv+': 'https://support.apple.com/ko-kr/HT202039',
'애플 티비플러스': 'https://support.apple.com/ko-kr/HT202039',
'tving': 'https://www.tving.com/my/cancelMembership',
'티빙': 'https://www.tving.com/my/cancelMembership',
'amazon prime': 'https://www.amazon.com/gp/primecentral/managemembership',
'아마존 프라임': 'https://www.amazon.com/gp/primecentral/managemembership',
// 음악 서비스 해지 안내 페이지
'spotify': 'https://support.spotify.com/us/article/cancel-premium/',
'스포티파이': 'https://support.spotify.com/us/article/cancel-premium/',
'apple music': 'https://support.apple.com/ko-kr/HT202039',
'애플 뮤직': 'https://support.apple.com/ko-kr/HT202039',
'melon':
'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021',
'멜론':
'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021',
'youtube music': 'https://support.google.com/youtubemusic/answer/6308278',
'유튜브 뮤직': 'https://support.google.com/youtubemusic/answer/6308278',
// AI 서비스 해지 안내 페이지
'chatgpt':
'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription',
'챗GPT':
'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription',
'claude':
'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription',
'클로드':
'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription',
'midjourney': 'https://docs.midjourney.com/docs/manage-subscription',
'미드저니': 'https://docs.midjourney.com/docs/manage-subscription',
// 프로그래밍 / 개발 서비스 해지 안내 페이지
'github':
'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription',
'깃허브':
'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription',
'jetbrains':
'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-',
'제트브레인스':
'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-',
// 오피스 및 협업 툴 해지 안내 페이지
'microsoft 365':
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
'마이크로소프트 365':
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
'office 365':
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
'오피스 365':
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
'slack':
'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription',
'슬랙':
'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription',
'notion':
'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription',
'노션':
'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription',
'dropbox': 'https://help.dropbox.com/accounts-billing/cancellation',
'드롭박스': 'https://help.dropbox.com/accounts-billing/cancellation',
'adobe creative cloud':
'https://helpx.adobe.com/manage-account/using/cancel-subscription.html',
'어도비 크리에이티브 클라우드':
'https://helpx.adobe.com/manage-account/using/cancel-subscription.html',
// 기타 유명 서비스 해지 안내 페이지
'google one': 'https://support.google.com/googleone/answer/9140429',
'구글 원': 'https://support.google.com/googleone/answer/9140429',
'icloud': 'https://support.apple.com/ko-kr/HT207594',
'아이클라우드': 'https://support.apple.com/ko-kr/HT207594',
'nintendo switch online':
'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership',
'닌텐도 스위치 온라인':
'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership',
'playstation plus':
'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/',
'플레이스테이션 플러스':
'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/',
'xbox game pass':
'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel',
'엑스박스 게임 패스':
'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel',
};
// 모든 서비스 매핑을 합친 맵
static final Map<String, String> allServices = {
...ottServices,
...musicServices,
...storageServices,
...aiServices,
...programmingServices,
...officeTools,
...lifestyleServices,
...shoppingServices,
...telecomServices,
...otherServices,
};
/// JSON 데이터 초기화
static Future<void> initialize() async {
if (_isInitialized) return;
try {
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
_servicesData = json.decode(jsonString);
_isInitialized = true;
print('SubscriptionUrlMatcher: JSON 데이터 로드 완료');
} catch (e) {
print('SubscriptionUrlMatcher: JSON 로드 실패 - $e');
// 로드 실패시 기존 하드코딩 데이터 사용
_isInitialized = true;
}
}
/// 도메인 추출 (www와 TLD 제외)
static String? extractDomain(String url) {
try {
final uri = Uri.parse(url);
final host = uri.host.toLowerCase();
// 도메인 부분 추출
var parts = host.split('.');
// www 제거
if (parts.isNotEmpty && parts[0] == 'www') {
parts = parts.sublist(1);
}
// 서브도메인 처리 (예: music.youtube.com)
if (parts.length >= 3) {
// 서브도메인 포함 전체 도메인 반환
return parts.sublist(0, parts.length - 1).join('.');
} else if (parts.length >= 2) {
// 메인 도메인만 반환
return parts[0];
}
return null;
} catch (e) {
print('SubscriptionUrlMatcher: 도메인 추출 실패 - $e');
return null;
}
}
/// URL로 서비스 찾기
static Future<ServiceInfo?> findServiceByUrl(String url) async {
await initialize();
final domain = extractDomain(url);
if (domain == null) return null;
// JSON 데이터가 있으면 JSON에서 찾기
if (_servicesData != null) {
final categories = _servicesData!['categories'] as Map<String, dynamic>;
for (final categoryEntry in categories.entries) {
final categoryId = categoryEntry.key;
final categoryData = categoryEntry.value as Map<String, dynamic>;
final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceEntry in services.entries) {
final serviceId = serviceEntry.key;
final serviceData = serviceEntry.value as Map<String, dynamic>;
final domains = List<String>.from(serviceData['domains'] ?? []);
// 도메인이 일치하는지 확인
for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
final names = List<String>.from(serviceData['names'] ?? []);
final urls = serviceData['urls'] as Map<String, dynamic>?;
return ServiceInfo(
serviceId: serviceId,
serviceName: names.isNotEmpty ? names[0] : serviceId,
serviceUrl: urls?['kr'] ?? urls?['en'],
cancellationUrl: null,
categoryId: _getCategoryIdByKey(categoryId),
categoryNameKr: categoryData['nameKr'] ?? '',
categoryNameEn: categoryData['nameEn'] ?? '',
);
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
for (final entry in allServices.entries) {
final serviceUrl = entry.value;
final serviceDomain = extractDomain(serviceUrl);
if (serviceDomain != null &&
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
return ServiceInfo(
serviceId: entry.key,
serviceName: entry.key,
serviceUrl: serviceUrl,
cancellationUrl: null,
categoryId: _getCategoryForLegacyService(entry.key),
categoryNameKr: '',
categoryNameEn: '',
);
}
}
return null;
}
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
static String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) {
print('SubscriptionUrlMatcher: 빈 serviceName');
return null;
}
// 소문자로 변환하여 비교
final lowerName = serviceName.toLowerCase().trim();
try {
// 정확한 매칭을 먼저 시도
for (final entry in allServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print('SubscriptionUrlMatcher: 정확한 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// OTT 서비스 검사
for (final entry in ottServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 음악 서비스 검사
for (final entry in musicServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// AI 서비스 검사
for (final entry in aiServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: AI 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 개발 서비스 검사
for (final entry in programmingServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: 개발 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 오피스 툴 검사
for (final entry in officeTools.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 기타 서비스 검사
for (final entry in otherServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) {
print(
'SubscriptionUrlMatcher: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value;
}
}
// 유사한 이름 검사 (퍼지 매칭) - 단어 기반으로 검색
for (final entry in allServices.entries) {
final serviceWords = lowerName.split(' ');
final keyWords = entry.key.toLowerCase().split(' ');
// 단어 단위로 일치하는지 확인
for (final word in serviceWords) {
if (word.length > 2 &&
keyWords.any((keyWord) => keyWord.contains(word))) {
print(
'SubscriptionUrlMatcher: 단어 기반 매칭 - $word (in $lowerName) -> ${entry.key}');
return entry.value;
}
}
}
// 추출 가능한 도메인이 있는지 확인
final domainMatch = RegExp(r'(\w+)').firstMatch(lowerName);
if (domainMatch != null && domainMatch.group(1)!.length > 2) {
final domain = domainMatch.group(1)!.trim();
if (domain.length > 2 &&
!['the', 'and', 'for', 'www'].contains(domain)) {
final url = 'https://www.$domain.com';
print('SubscriptionUrlMatcher: 도메인 추출 - $lowerName -> $url');
return url;
}
}
print('SubscriptionUrlMatcher: 매칭 실패 - $lowerName');
return null;
} catch (e) {
print('SubscriptionUrlMatcher: URL 매칭 중 오류 발생: $e');
return null;
}
}
/// 해지 안내 URL 찾기 (개선된 버전)
static Future<String?> findCancellationUrl({
String? serviceName,
String? websiteUrl,
String locale = 'kr',
}) async {
await initialize();
// JSON 데이터가 있으면 JSON에서 찾기
if (_servicesData != null) {
final categories = _servicesData!['categories'] as Map<String, dynamic>;
// 1. 서비스명으로 찾기
if (serviceName != null && serviceName.isNotEmpty) {
final lowerName = serviceName.toLowerCase().trim();
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) {
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
}
}
}
}
}
}
// 2. URL로 찾기
if (websiteUrl != null && websiteUrl.isNotEmpty) {
final domain = extractDomain(websiteUrl);
if (domain != null) {
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) {
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
}
}
}
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
}
/// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시)
static String? _findCancellationUrlLegacy(String serviceNameOrUrl) {
if (serviceNameOrUrl.isEmpty) {
return null;
}
// 소문자로 변환하여 처리
final String lowerText = serviceNameOrUrl.toLowerCase().trim();
// 직접 서비스명으로 찾기
if (cancellationUrls.containsKey(lowerText)) {
return cancellationUrls[lowerText];
}
// 서비스명에 부분 포함으로 찾기
for (var entry in cancellationUrls.entries) {
final String key = entry.key.toLowerCase();
if (lowerText.contains(key) || key.contains(lowerText)) {
return entry.value;
}
}
// URL을 통해 서비스명 추출 후 찾기
if (lowerText.startsWith('http')) {
// URL 도메인 추출 (https://www.netflix.com 에서 netflix 추출)
final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)');
final match = domainRegex.firstMatch(lowerText);
if (match != null && match.groupCount >= 1) {
final domain = match.group(1)?.toLowerCase() ?? '';
// 도메인으로 서비스명 찾기
for (var entry in cancellationUrls.entries) {
if (entry.key.toLowerCase().contains(domain)) {
return entry.value;
}
}
}
}
// 해지 안내 페이지를 찾지 못함
return null;
}
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
// 새로운 JSON 기반 방식으로 확인
final cancellationUrl = await findCancellationUrl(
serviceName: serviceNameOrUrl,
websiteUrl: serviceNameOrUrl,
);
return cancellationUrl != null;
}
/// 서비스명으로 카테고리 찾기
static Future<String?> findCategoryByServiceName(String serviceName) async {
await initialize();
if (serviceName.isEmpty) return null;
final lowerName = serviceName.toLowerCase().trim();
// JSON 데이터가 있으면 JSON에서 찾기
if (_servicesData != null) {
final categories = _servicesData!['categories'] as Map<String, dynamic>;
for (final categoryEntry in categories.entries) {
final categoryId = categoryEntry.key;
final categoryData = categoryEntry.value as Map<String, dynamic>;
final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
return _getCategoryIdByKey(categoryId);
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
return _getCategoryForLegacyService(serviceName);
}
/// 현재 로케일에 따라 서비스 표시명 가져오기
static Future<String> getServiceDisplayName({
required String serviceName,
required String locale,
}) async {
await initialize();
if (_servicesData == null) {
return serviceName;
}
final lowerName = serviceName.toLowerCase().trim();
final categories = _servicesData!['categories'] as Map<String, dynamic>;
// JSON에서 서비스 찾기
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final data = serviceData as Map<String, dynamic>;
final names = List<String>.from(data['names'] ?? []);
// names 배열에 있는지 확인
for (final name in names) {
if (lowerName == name.toLowerCase() ||
lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
// 로케일에 따라 적절한 이름 반환
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
} else {
return data['nameEn'] ?? serviceName;
}
}
}
// nameKr/nameEn에 직접 매칭 확인
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
if (lowerName == nameKr || lowerName == nameEn) {
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
} else {
return data['nameEn'] ?? serviceName;
}
}
}
}
// 찾지 못한 경우 원래 이름 반환
return serviceName;
}
/// 카테고리 키를 실제 카테고리 ID로 매핑
static String _getCategoryIdByKey(String key) {
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
// 임시로 카테고리명 기반 매핑
switch (key) {
case 'music':
return 'music_streaming';
case 'ott':
return 'ott_services';
case 'storage':
return 'cloud_storage';
case 'ai':
return 'ai_services';
case 'programming':
return 'dev_tools';
case 'office':
return 'office_tools';
case 'lifestyle':
return 'lifestyle';
case 'shopping':
return 'shopping';
case 'gaming':
return 'gaming';
case 'telecom':
return 'telecom';
default:
return 'other';
}
}
/// 레거시 서비스명으로 카테고리 추측
static String _getCategoryForLegacyService(String serviceName) {
final lowerName = serviceName.toLowerCase();
if (ottServices.containsKey(lowerName)) return 'ott_services';
if (musicServices.containsKey(lowerName)) return 'music_streaming';
if (storageServices.containsKey(lowerName)) return 'cloud_storage';
if (aiServices.containsKey(lowerName)) return 'ai_services';
if (programmingServices.containsKey(lowerName)) return 'dev_tools';
if (officeTools.containsKey(lowerName)) return 'office_tools';
if (lifestyleServices.containsKey(lowerName)) return 'lifestyle';
if (shoppingServices.containsKey(lowerName)) return 'shopping';
if (telecomServices.containsKey(lowerName)) return 'telecom';
return 'other';
}
/// SMS에서 URL과 서비스 정보 추출
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
await initialize();
// URL 패턴 찾기
final urlPattern = RegExp(
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
caseSensitive: false,
);
final matches = urlPattern.allMatches(smsText);
for (final match in matches) {
final url = match.group(0);
if (url != null) {
final serviceInfo = await findServiceByUrl(url);
if (serviceInfo != null) {
return serviceInfo;
}
}
}
// URL로 못 찾았으면 서비스명으로 시도
final lowerSms = smsText.toLowerCase();
// 모든 서비스명 검사
for (final entry in allServices.entries) {
if (lowerSms.contains(entry.key.toLowerCase())) {
final categoryId = await findCategoryByServiceName(entry.key) ?? 'other';
return ServiceInfo(
serviceId: entry.key,
serviceName: entry.key,
serviceUrl: entry.value,
cancellationUrl: null,
categoryId: categoryId,
categoryNameKr: '',
categoryNameEn: '',
);
}
}
return null;
}
/// URL이 알려진 서비스 URL인지 확인
static Future<bool> isKnownServiceUrl(String url) async {
final serviceInfo = await findServiceByUrl(url);
return serviceInfo != null;
}
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성)
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
// 입력 텍스트가 비어있거나 null인 경우
if (text.isEmpty) {
return null;
}
// 소문자로 변환하여 처리
final String lowerText = text.toLowerCase().trim();
// 정확히 일치하는 경우
if (allServices.containsKey(lowerText)) {
return allServices[lowerText];
}
// 부분 일치 검색이 활성화된 경우
if (usePartialMatch) {
// 가장 긴 부분 매칭 찾기
String? bestMatch;
int maxLength = 0;
for (var entry in allServices.entries) {
final String key = entry.key;
// 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우
if (lowerText.contains(key) || key.contains(lowerText)) {
// 더 긴 매칭을 우선시
if (key.length > maxLength) {
maxLength = key.length;
bestMatch = entry.value;
}
}
}
return bestMatch;
}
return null;
}
}

View File

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

View File

@@ -75,24 +75,33 @@ class CategoryMapperService {
String getCategoryForLegacyService(String serviceName) { String getCategoryForLegacyService(String serviceName) {
final lowerName = serviceName.toLowerCase(); final lowerName = serviceName.toLowerCase();
if (LegacyServiceData.ottServices.containsKey(lowerName)) if (LegacyServiceData.ottServices.containsKey(lowerName)) {
return 'ott_services'; return 'ott_services';
if (LegacyServiceData.musicServices.containsKey(lowerName)) }
if (LegacyServiceData.musicServices.containsKey(lowerName)) {
return 'music_streaming'; return 'music_streaming';
if (LegacyServiceData.storageServices.containsKey(lowerName)) }
if (LegacyServiceData.storageServices.containsKey(lowerName)) {
return 'cloud_storage'; return 'cloud_storage';
if (LegacyServiceData.aiServices.containsKey(lowerName)) }
if (LegacyServiceData.aiServices.containsKey(lowerName)) {
return 'ai_services'; return 'ai_services';
if (LegacyServiceData.programmingServices.containsKey(lowerName)) }
if (LegacyServiceData.programmingServices.containsKey(lowerName)) {
return 'dev_tools'; return 'dev_tools';
if (LegacyServiceData.officeTools.containsKey(lowerName)) }
if (LegacyServiceData.officeTools.containsKey(lowerName)) {
return 'office_tools'; return 'office_tools';
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) }
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) {
return 'lifestyle'; return 'lifestyle';
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) }
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) {
return 'shopping'; return 'shopping';
if (LegacyServiceData.telecomServices.containsKey(lowerName)) }
if (LegacyServiceData.telecomServices.containsKey(lowerName)) {
return 'telecom'; return 'telecom';
}
return 'other'; return 'other';
} }

View File

@@ -2,6 +2,7 @@ import '../models/service_info.dart';
import '../data/service_data_repository.dart'; import '../data/service_data_repository.dart';
import '../data/legacy_service_data.dart'; import '../data/legacy_service_data.dart';
import 'category_mapper_service.dart'; import 'category_mapper_service.dart';
import '../../../utils/logger.dart';
/// URL 매칭 관련 기능을 제공하는 서비스 클래스 /// URL 매칭 관련 기능을 제공하는 서비스 클래스
class UrlMatcherService { class UrlMatcherService {
@@ -35,7 +36,7 @@ class UrlMatcherService {
return null; return null;
} catch (e) { } catch (e) {
print('UrlMatcherService: 도메인 추출 실패 - $e'); Log.e('UrlMatcherService: 도메인 추출 실패', e);
return null; return null;
} }
} }
@@ -107,7 +108,7 @@ class UrlMatcherService {
/// 서비스명으로 URL 찾기 /// 서비스명으로 URL 찾기
String? suggestUrl(String serviceName) { String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) { if (serviceName.isEmpty) {
print('UrlMatcherService: 빈 serviceName'); Log.w('UrlMatcherService: 빈 serviceName');
return null; return null;
} }
@@ -118,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;
} }
} }
@@ -126,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;
} }
} }
@@ -134,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;
} }
} }
@@ -142,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;
} }
} }
@@ -150,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;
} }
} }
@@ -158,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;
} }
} }
@@ -166,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;
} }
} }
@@ -175,15 +176,15 @@ class UrlMatcherService {
for (final entry in LegacyServiceData.allServices.entries) { for (final entry in LegacyServiceData.allServices.entries) {
final key = entry.key.toLowerCase(); final key = entry.key.toLowerCase();
if (key.contains(lowerName) || lowerName.contains(key)) { if (key.contains(lowerName) || lowerName.contains(key)) {
print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
print('UrlMatcherService: 매칭 실패 - $lowerName'); Log.d('UrlMatcherService: 매칭 실패 - $lowerName');
return null; return null;
} catch (e) { } catch (e) {
print('UrlMatcherService: suggestUrl 에러 - $e'); Log.e('UrlMatcherService: suggestUrl 에러', e);
return null; return null;
} }
} }

View File

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

View File

@@ -19,8 +19,7 @@ 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: CardThemeData(
@@ -175,7 +174,6 @@ class AdaptiveTheme {
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(
@@ -200,7 +198,6 @@ 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(

View File

@@ -10,7 +10,6 @@ class AppTheme {
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor, tertiary: AppColors.infoColor,
error: AppColors.dangerColor, error: AppColors.dangerColor,
background: AppColors.backgroundColor,
surface: AppColors.surfaceColor, surface: AppColors.surfaceColor,
), ),
@@ -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,21 +50,21 @@ 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,
@@ -74,7 +73,7 @@ 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,
@@ -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;
@@ -311,12 +310,12 @@ class AppTheme {
labelColor: AppColors.primaryColor, labelColor: AppColors.primaryColor,
unselectedLabelColor: AppColors.textSecondary, unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.primaryColor, indicatorColor: AppColors.primaryColor,
labelStyle: const TextStyle( labelStyle: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
unselectedLabelStyle: const TextStyle( unselectedLabelStyle: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.1, letterSpacing: 0.1,

View File

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

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

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

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'logger.dart';
import 'dart:async'; import 'dart:async';
/// 메모리 관리를 위한 헬퍼 클래스 /// 메모리 관리를 위한 헬퍼 클래스
@@ -57,7 +58,7 @@ class MemoryManager {
void clearCache() { void clearCache() {
_cache.clear(); _cache.clear();
if (kDebugMode) { if (kDebugMode) {
print('🧹 메모리 캐시가 비워졌습니다.'); Log.d('🧹 메모리 캐시가 비워졌습니다.');
} }
} }
@@ -122,7 +123,7 @@ class MemoryManager {
PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages(); PaintingBinding.instance.imageCache.clearLiveImages();
if (kDebugMode) { if (kDebugMode) {
print('🖼️ 이미지 캐시가 비워졌습니다.'); Log.d('🖼️ 이미지 캐시가 비워졌습니다.');
} }
} }
@@ -155,7 +156,7 @@ class MemoryManager {
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2; imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
if (kDebugMode) { if (kDebugMode) {
print('⚠️ 메모리 압박 대응: 캐시 크기 감소'); Log.w('메모리 압박 대응: 캐시 크기 감소');
} }
} }

View File

@@ -1,4 +1,5 @@
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';
@@ -141,12 +142,12 @@ class PerformanceOptimizer {
/// 빌드 최적화를 위한 const 위젯 권장사항 체크 /// 빌드 최적화를 위한 const 위젯 권장사항 체크
static void checkConstOptimization() { static void checkConstOptimization() {
if (kDebugMode) { if (kDebugMode) {
print('💡 성능 최적화 팁:'); Log.i('💡 성능 최적화 팁:\n'
print('1. 가능한 모든 위젯에 const 사용'); '1. 가능한 모든 위젯에 const 사용\n'
print('2. StatelessWidget 대신 const 생성자 사용'); '2. StatelessWidget 대신 const 생성자 사용\n'
print('3. 큰 리스트는 ListView.builder 사용'); '3. 큰 리스트는 ListView.builder 사용\n'
print('4. 이미지는 캐싱과 함께 적절한 크기로 로드'); '4. 이미지는 캐싱과 함께 적절한 크기로 로드\n'
print('5. 애니메이션은 AnimatedBuilder 사용'); '5. 애니메이션은 AnimatedBuilder 사용');
} }
} }
@@ -161,7 +162,7 @@ class PerformanceOptimizer {
// 위젯이 비정상적으로 많이 생성되면 경고 // 위젯이 비정상적으로 많이 생성되면 경고
if ((_widgetCounts[widgetName] ?? 0) > 100) { if ((_widgetCounts[widgetName] ?? 0) > 100) {
print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!'); Log.w('경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
} }
} }
} }
@@ -196,11 +197,11 @@ class PerformanceMeasure {
try { try {
final result = await operation(); final result = await operation();
stopwatch.stop(); stopwatch.stop();
print('$name 완료: ${stopwatch.elapsedMilliseconds}ms'); Log.d('$name 완료: ${stopwatch.elapsedMilliseconds}ms');
return result; return result;
} catch (e) { } catch (e) {
stopwatch.stop(); stopwatch.stop();
print('$name 실패: ${stopwatch.elapsedMilliseconds}ms - $e'); Log.e('$name 실패: ${stopwatch.elapsedMilliseconds}ms', e);
rethrow; rethrow;
} }
} }

View File

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

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/url_matcher/data/legacy_service_data.dart'; import '../services/url_matcher/data/legacy_service_data.dart';
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스 /// 구독 서비스를 카테고리별로 구분하는 도우미 클래스

View File

@@ -3,7 +3,6 @@ import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/date_picker_field.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 이벤트/할인 섹션 /// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget { class AddSubscriptionEventSection extends StatelessWidget {
@@ -47,11 +46,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@@ -147,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,
@@ -175,7 +174,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
} }
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,

View File

@@ -5,7 +5,6 @@ import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget { class AnalysisBadge extends StatelessWidget {
@@ -33,7 +32,7 @@ class AnalysisBadge extends StatelessWidget {
color: borderColor, color: borderColor,
width: 2, width: 2,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,

View File

@@ -8,6 +8,7 @@ import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/reduce_motion.dart';
/// 월별 지출 현황을 차트로 보여주는 카드 위젯 /// 월별 지출 현황을 차트로 보여주는 카드 위젯
class MonthlyExpenseChartCard extends StatelessWidget { class MonthlyExpenseChartCard extends StatelessWidget {
@@ -154,8 +155,9 @@ class MonthlyExpenseChartCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 바 차트 // 바 차트 (RepaintBoundary로 페인트 분리)
AspectRatio( RepaintBoundary(
child: AspectRatio(
aspectRatio: 1.6, aspectRatio: 1.6,
child: BarChart( child: BarChart(
BarChartData( BarChartData(
@@ -249,6 +251,11 @@ class MonthlyExpenseChartCard extends StatelessWidget {
), ),
), ),
), ),
swapAnimationDuration: ReduceMotion.isEnabled(context)
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
swapAnimationCurve: Curves.easeOut,
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -10,6 +10,7 @@ import '../themed_text.dart';
import 'analysis_badge.dart'; import 'analysis_badge.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../utils/reduce_motion.dart';
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯 /// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
class SubscriptionPieChartCard extends StatefulWidget { class SubscriptionPieChartCard extends StatefulWidget {
@@ -312,7 +313,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
); );
} }
return PieChart( return RepaintBoundary(
child: PieChart(
PieChartData( PieChartData(
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
sectionsSpace: 2, sectionsSpace: 2,
@@ -325,7 +327,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
pieTouchResponse) { pieTouchResponse) {
// 터치 응답이 없거나 섹션이 없는 경우 // 터치 응답이 없거나 섹션이 없는 경우
if (pieTouchResponse == null || if (pieTouchResponse == null ||
pieTouchResponse.touchedSection == pieTouchResponse
.touchedSection ==
null) { null) {
// 차트 밖으로 나갔을 때만 리셋 // 차트 밖으로 나갔을 때만 리셋
if (_touchedIndex != -1) { if (_touchedIndex != -1) {
@@ -336,15 +339,16 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
return; return;
} }
final touchedIndex = pieTouchResponse final touchedIndex =
.touchedSection! pieTouchResponse.touchedSection!
.touchedSectionIndex; .touchedSectionIndex;
// 탭 이벤트 처리 (토글) // 탭 이벤트 처리 (토글)
if (event is FlTapUpEvent) { if (event is FlTapUpEvent) {
setState(() { setState(() {
// 동일 섹션 탭하면 선택 해제, 아니면 선택 // 동일 섹션 탭하면 선택 해제, 아니면 선택
_touchedIndex = (_touchedIndex == _touchedIndex =
(_touchedIndex ==
touchedIndex) touchedIndex)
? -1 ? -1
: touchedIndex; : touchedIndex;
@@ -356,7 +360,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
if (event is FlPointerHoverEvent || if (event is FlPointerHoverEvent ||
event is FlPointerEnterEvent) { event is FlPointerEnterEvent) {
// 현재 인덱스와 다른 경우만 업데이트 // 현재 인덱스와 다른 경우만 업데이트
if (_touchedIndex != touchedIndex) { if (_touchedIndex !=
touchedIndex) {
setState(() { setState(() {
_touchedIndex = touchedIndex; _touchedIndex = touchedIndex;
}); });
@@ -365,6 +370,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
}, },
), ),
), ),
swapAnimationDuration:
ReduceMotion.isEnabled(context)
? const Duration(milliseconds: 0)
: const Duration(
milliseconds: 300),
swapAnimationCurve: Curves.easeOut,
),
); );
}, },
), ),

View File

@@ -43,6 +43,7 @@ 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: RepaintBoundary(
child: GlassmorphismCard( child: GlassmorphismCard(
blur: 10, blur: 10,
opacity: 0.1, opacity: 0.1,
@@ -56,8 +57,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: text: AppLocalizations.of(context)
AppLocalizations.of(context).totalExpenseSummary, .totalExpenseSummary,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
@@ -112,7 +113,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ThemedText.caption( ThemedText.caption(
text: AppLocalizations.of(context).totalExpense, text:
AppLocalizations.of(context).totalExpense,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -244,6 +246,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:math' as math; import 'dart:math' as math;
import '../utils/reduce_motion.dart';
/// 슬라이드 + 페이드 전환 /// 슬라이드 + 페이드 전환
class SlidePageRoute<T> extends PageRouteBuilder<T> { class SlidePageRoute<T> extends PageRouteBuilder<T> {
@@ -11,8 +12,12 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
this.direction = AxisDirection.right, this.direction = AxisDirection.right,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 300), ? Duration.zero
: const Duration(milliseconds: 300),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
Offset begin; Offset begin;
switch (direction) { switch (direction) {
@@ -64,8 +69,12 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
this.alignment = Alignment.center, this.alignment = Alignment.center,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 400), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 400), ? Duration.zero
: const Duration(milliseconds: 400),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.elasticOut; const curve = Curves.elasticOut;
@@ -98,8 +107,12 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
RotatePageRoute({required this.page}) RotatePageRoute({required this.page})
: super( : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 500), ? Duration.zero
: const Duration(milliseconds: 500),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.easeInOut; const curve = Curves.easeInOut;
@@ -135,8 +148,12 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
this.horizontal = true, this.horizontal = true,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 800), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 800), ? Duration.zero
: const Duration(milliseconds: 800),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 800),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
final isAnimatingForward = final isAnimatingForward =
animation.status == AnimationStatus.forward; animation.status == AnimationStatus.forward;
@@ -189,8 +206,12 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
this.borderRadius, this.borderRadius,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 500), ? Duration.zero
: const Duration(milliseconds: 500),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
return Stack( return Stack(
children: [ children: [
@@ -260,8 +281,12 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
required this.transitionType, required this.transitionType,
}) : super( }) : super(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300), transitionDuration: ReduceMotion.platform()
reverseTransitionDuration: const Duration(milliseconds: 300), ? Duration.zero
: const Duration(milliseconds: 300),
reverseTransitionDuration: ReduceMotion.platform()
? Duration.zero
: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
late final Offset begin; late final Offset begin;
late final Offset end; late final Offset end;

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:math' as math; import 'dart:math' as math;
import '../utils/reduce_motion.dart';
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯 /// 웨이브 애니메이션 배경 효과를 제공하는 위젯
/// ///
@@ -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,
@@ -110,8 +113,7 @@ class AnimatedWaveBackground extends StatelessWidget {
height: 30, height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues( color: Colors.white.withValues(
alpha: 0.1 + 0.1 * pulseController.value, alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
),
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
), ),
), ),

View File

@@ -6,6 +6,7 @@ import '../screens/app_lock_screen.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../utils/logger.dart';
import 'animated_page_transitions.dart'; import 'animated_page_transitions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -44,7 +45,7 @@ class AppNavigator {
/// 구독 상세 화면으로 네비게이션 /// 구독 상세 화면으로 네비게이션
static Future<void> toDetail( static Future<void> toDetail(
BuildContext context, SubscriptionModel subscription) async { BuildContext context, SubscriptionModel subscription) async {
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); Log.d('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
try { try {
@@ -52,9 +53,9 @@ class AppNavigator {
AppRoutes.subscriptionDetail, AppRoutes.subscriptionDetail,
arguments: subscription, arguments: subscription,
); );
print('DetailScreen 네비게이션 성공'); Log.d('DetailScreen 네비게이션 성공');
} catch (e) { } catch (e) {
print('DetailScreen 네비게이션 오류: $e'); Log.e('DetailScreen 네비게이션 오류', e);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,7 +66,7 @@ class BaseTextField extends StatelessWidget {
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,
@@ -91,13 +91,13 @@ class BaseTextField extends StatelessWidget {
readOnly: readOnly, readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor, cursorColor: cursorColor ?? theme.primaryColor,
style: style ?? style: style ??
TextStyle( const TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.textPrimary, 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,

View File

@@ -48,7 +48,7 @@ class DatePickerField extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -249,7 +249,7 @@ class _DateRangeItem extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),

View File

@@ -200,7 +200,7 @@ class AppSnackBar {
width: 24, width: 24,
height: 24, height: 24,
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
child: CircularProgressIndicator( child: const CircularProgressIndicator(
strokeWidth: 2.5, strokeWidth: 2.5,
color: AppColors.pureWhite, color: AppColors.pureWhite,
), ),

View File

@@ -44,11 +44,11 @@ class DetailEventSection 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),
), ),
], ],
), ),
@@ -118,7 +118,7 @@ class DetailEventSection 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,
@@ -127,7 +127,7 @@ class DetailEventSection extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context).eventPriceHint, AppLocalizations.of(context).eventPriceHint,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -253,8 +253,8 @@ class _DiscountBadge extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
_getLocalizedDiscountAmount(context, currency, discountAmount), _getLocalizedDiscountAmount(context, currency, discountAmount),
style: TextStyle( style: const TextStyle(
color: const Color(0xFF15803D), color: Color(0xFF15803D),
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),

View File

@@ -49,11 +49,11 @@ class DetailFormSection 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),
), ),
], ],
), ),

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';

View File

@@ -41,11 +41,11 @@ class DetailUrlSection 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),
), ),
], ],
), ),
@@ -89,7 +89,7 @@ class DetailUrlSection extends StatelessWidget {
label: AppLocalizations.of(context).websiteUrl, label: AppLocalizations.of(context).websiteUrl,
hintText: AppLocalizations.of(context).urlExample, hintText: AppLocalizations.of(context).urlExample,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
prefixIcon: Icon( prefixIcon: const Icon(
Icons.link_rounded, Icons.link_rounded,
color: AppColors.navyGray, color: AppColors.navyGray,
), ),
@@ -114,7 +114,7 @@ class DetailUrlSection extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Icon( const Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.warningColor, color: AppColors.warningColor,
size: 20, size: 20,
@@ -122,7 +122,7 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
AppLocalizations.of(context).cancelGuide, AppLocalizations.of(context).cancelGuide,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -133,7 +133,7 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
AppLocalizations.of(context).cancelServiceGuide, AppLocalizations.of(context).cancelServiceGuide,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -167,7 +167,7 @@ class DetailUrlSection extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Icon(
Icons.auto_fix_high_rounded, Icons.auto_fix_high_rounded,
color: AppColors.infoColor, color: AppColors.infoColor,
size: 20, size: 20,
@@ -176,7 +176,7 @@ class DetailUrlSection extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context).urlAutoMatchInfo, AppLocalizations.of(context).urlAutoMatchInfo,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:ui'; import 'dart:ui';
import '../../utils/reduce_motion.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../common/buttons/primary_button.dart'; import '../common/buttons/primary_button.dart';
import '../common/buttons/secondary_button.dart'; import '../common/buttons/secondary_button.dart';
@@ -27,7 +28,10 @@ class DeleteConfirmationDialog extends StatelessWidget {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), filter: ImageFilter.blur(
sigmaX: ReduceMotion.scale(context, normal: 10, reduced: 4),
sigmaY: ReduceMotion.scale(context, normal: 10, reduced: 4),
),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassCard.withValues(alpha: 0.8), color: AppColors.glassCard.withValues(alpha: 0.8),

View File

@@ -5,6 +5,7 @@ import 'glassmorphism_card.dart';
import 'themed_text.dart'; import 'themed_text.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/reduce_motion.dart';
/// 구독이 없을 때 표시되는 빈 화면 위젯 /// 구독이 없을 때 표시되는 빈 화면 위젯
/// ///
@@ -25,16 +26,20 @@ class EmptyStateWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final beginOffset = ReduceMotion.isEnabled(context)
? const Offset(0, 0.05)
: const Offset(0, 0.2);
return FadeTransition( return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate( opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)), CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
child: Center( child: Center(
child: SlideTransition( child: SlideTransition(
position: Tween<Offset>( position: Tween<Offset>(
begin: const Offset(0, 0.2), begin: beginOffset,
end: Offset.zero, end: Offset.zero,
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutBack)), parent: slideController, curve: Curves.easeOutBack)),
child: RepaintBoundary(
child: GlassmorphismCard( child: GlassmorphismCard(
width: null, width: null,
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
@@ -45,8 +50,11 @@ class EmptyStateWidget extends StatelessWidget {
AnimatedBuilder( AnimatedBuilder(
animation: rotateController, animation: rotateController,
builder: (context, child) { builder: (context, child) {
final angleScale =
ReduceMotion.isEnabled(context) ? 0.2 : 1.0;
return Transform.rotate( return Transform.rotate(
angle: rotateController.value * 2 * math.pi, angle:
angleScale * rotateController.value * 2 * math.pi,
child: Container( child: Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -58,8 +66,8 @@ class EmptyStateWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: color: AppColors.primaryColor
AppColors.primaryColor.withValues(alpha: 0.3), .withValues(alpha: 0.3),
spreadRadius: 0, spreadRadius: 0,
blurRadius: 16, blurRadius: 16,
offset: const Offset(0, 8), offset: const Offset(0, 8),
@@ -111,7 +119,7 @@ class EmptyStateWidget extends StatelessWidget {
}, },
child: Text( child: Text(
AppLocalizations.of(context).addSubscription, AppLocalizations.of(context).addSubscription,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.5, letterSpacing: 0.5,
@@ -125,6 +133,7 @@ class EmptyStateWidget extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -1,153 +0,0 @@
import 'package:flutter/material.dart';
import '../services/exchange_rate_service.dart';
/// 환율 정보를 표시하는 위젯
/// 달러 금액을 입력받아 원화 금액으로 변환하여 표시합니다.
class ExchangeRateWidget extends StatefulWidget {
/// 달러 금액 변화 감지용 TextEditingController
final TextEditingController costController;
/// 환율 정보를 보여줄지 여부 (통화가 달러일 때만 true)
final bool showExchangeRate;
const ExchangeRateWidget({
Key? key,
required this.costController,
required this.showExchangeRate,
}) : super(key: key);
@override
State<ExchangeRateWidget> createState() => _ExchangeRateWidgetState();
}
class _ExchangeRateWidgetState extends State<ExchangeRateWidget> {
final ExchangeRateService _exchangeRateService = ExchangeRateService();
String _exchangeRateInfo = '';
String _convertedAmount = '';
@override
void initState() {
super.initState();
_loadExchangeRate();
widget.costController.addListener(_updateConvertedAmount);
}
@override
void dispose() {
widget.costController.removeListener(_updateConvertedAmount);
super.dispose();
}
@override
void didUpdateWidget(ExchangeRateWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// 통화 변경 감지(달러->원화 또는 원화->달러)되면 리스너 해제 및 재등록
if (oldWidget.showExchangeRate != widget.showExchangeRate) {
oldWidget.costController.removeListener(_updateConvertedAmount);
if (widget.showExchangeRate) {
widget.costController.addListener(_updateConvertedAmount);
_loadExchangeRate();
_updateConvertedAmount();
} else {
setState(() {
_exchangeRateInfo = '';
_convertedAmount = '';
});
}
}
}
/// 환율 정보 로드
Future<void> _loadExchangeRate() async {
if (!widget.showExchangeRate) return;
final rateInfo = await _exchangeRateService.getFormattedExchangeRateInfo();
if (mounted) {
setState(() {
_exchangeRateInfo = rateInfo;
});
}
}
/// 달러 금액이 변경될 때 원화 금액 업데이트
Future<void> _updateConvertedAmount() async {
if (!widget.showExchangeRate) return;
try {
// 금액 입력값에서 콤마 제거 후 숫자로 변환
final text = widget.costController.text.replaceAll(',', '');
if (text.isEmpty) {
setState(() {
_convertedAmount = '';
});
return;
}
final amount = double.tryParse(text);
if (amount != null) {
final converted =
await _exchangeRateService.getFormattedKrwAmount(amount);
if (mounted) {
setState(() {
_convertedAmount = converted;
});
}
}
} catch (e) {
// 오류 발생 시 빈 문자열 표시
setState(() {
_convertedAmount = '';
});
}
}
/// 환율 정보 텍스트 위젯 생성
Widget buildExchangeRateInfo() {
if (_exchangeRateInfo.isEmpty) return const SizedBox.shrink();
return Text(
_exchangeRateInfo,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
);
}
/// 환산 금액 텍스트 위젯 생성
Widget buildConvertedAmount() {
if (_convertedAmount.isEmpty) return const SizedBox.shrink();
return Text(
_convertedAmount,
style: const TextStyle(
fontSize: 14,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
);
}
@override
Widget build(BuildContext context) {
if (!widget.showExchangeRate) {
return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환
}
return const Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성
],
);
}
// 익스포즈드 메서드: 환율 정보 문자열 가져오기
String get exchangeRateInfo => _exchangeRateInfo;
// 익스포즈드 메서드: 변환된 금액 문자열 가져오기
String get convertedAmount => _convertedAmount;
}

View File

@@ -1,269 +0,0 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
import '../theme/app_colors.dart';
import '../utils/haptic_feedback_helper.dart';
import 'glassmorphism_card.dart';
class ExpandableFab extends StatefulWidget {
final List<FabAction> actions;
final double distance;
const ExpandableFab({
super.key,
required this.actions,
this.distance = 100.0,
});
@override
State<ExpandableFab> createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _expandAnimation;
late Animation<double> _rotateAnimation;
bool _isExpanded = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_expandAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeInBack,
);
_rotateAnimation = Tween<double>(
begin: 0.0,
end: math.pi / 4,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_isExpanded = !_isExpanded;
});
if (_isExpanded) {
HapticFeedbackHelper.mediumImpact();
_controller.forward();
} else {
HapticFeedbackHelper.lightImpact();
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.bottomRight,
children: [
// 배경 오버레이 (확장 시)
if (_isExpanded)
GestureDetector(
onTap: _toggle,
child: AnimatedBuilder(
animation: _expandAnimation,
builder: (context, child) {
return Container(
color: AppColors.shadowBlack
.withValues(alpha: 3.75 * _expandAnimation.value),
);
},
),
),
// 액션 버튼들
...widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
return AnimatedBuilder(
animation: _expandAnimation,
builder: (context, child) {
final distance = widget.distance * _expandAnimation.value;
final x = distance * math.cos(angle);
final y = distance * math.sin(angle);
return Transform.translate(
offset: Offset(-x, -y),
child: ScaleTransition(
scale: _expandAnimation,
child: FloatingActionButton.small(
heroTag: 'fab_action_$index',
onPressed: _isExpanded
? () {
HapticFeedbackHelper.lightImpact();
_toggle();
action.onPressed();
}
: null,
backgroundColor: action.color ?? AppColors.primaryColor,
child: Icon(
action.icon,
size: 20,
color: AppColors.pureWhite,
),
),
),
);
},
);
}),
// 메인 FAB
AnimatedBuilder(
animation: _rotateAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _rotateAnimation.value,
child: FloatingActionButton(
onPressed: _toggle,
backgroundColor: AppColors.primaryColor,
child: Icon(
_isExpanded ? Icons.close : Icons.add,
size: 28,
color: Colors.white,
),
),
);
},
),
// 라벨 표시
if (_isExpanded)
...widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
return AnimatedBuilder(
animation: _expandAnimation,
builder: (context, child) {
final distance = widget.distance * _expandAnimation.value;
final x = distance * math.cos(angle);
final y = distance * math.sin(angle);
return Transform.translate(
offset: Offset(-x - 80, -y),
child: FadeTransition(
opacity: _expandAnimation,
child: GlassmorphismCard(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
borderRadius: 8,
blur: 10,
child: Text(
action.label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
),
),
);
},
);
}),
],
);
}
}
class FabAction {
final IconData icon;
final String label;
final VoidCallback onPressed;
final Color? color;
const FabAction({
required this.icon,
required this.label,
required this.onPressed,
this.color,
});
}
// 드래그 가능한 FAB
class DraggableFab extends StatefulWidget {
final Widget child;
final EdgeInsets? padding;
const DraggableFab({
super.key,
required this.child,
this.padding,
});
@override
State<DraggableFab> createState() => _DraggableFabState();
}
class _DraggableFabState extends State<DraggableFab> {
Offset _position = const Offset(20, 20);
bool _isDragging = false;
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final padding = widget.padding ?? const EdgeInsets.all(20);
return Stack(
children: [
Positioned(
right: _position.dx,
bottom: _position.dy,
child: GestureDetector(
onPanStart: (_) {
setState(() => _isDragging = true);
HapticFeedbackHelper.lightImpact();
},
onPanUpdate: (details) {
setState(() {
_position = Offset(
(_position.dx - details.delta.dx).clamp(
padding.right,
screenSize.width - 100 - padding.left,
),
(_position.dy - details.delta.dy).clamp(
padding.bottom,
screenSize.height - 200 - padding.top,
),
);
});
},
onPanEnd: (_) {
setState(() => _isDragging = false);
HapticFeedbackHelper.lightImpact();
},
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _isDragging ? 0.9 : 1.0,
child: widget.child,
),
),
),
],
);
}
}

View File

@@ -4,6 +4,7 @@ import '../theme/app_colors.dart';
import 'glassmorphism_card.dart'; import 'glassmorphism_card.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../utils/reduce_motion.dart';
class FloatingNavigationBar extends StatefulWidget { class FloatingNavigationBar extends StatefulWidget {
final int selectedIndex; final int selectedIndex;
@@ -30,7 +31,9 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
void initState() { void initState() {
super.initState(); super.initState();
_controller = AnimationController( _controller = AnimationController(
duration: const Duration(milliseconds: 300), duration: ReduceMotion.platform()
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
vsync: this, vsync: this,
); );
_animation = CurvedAnimation( _animation = CurvedAnimation(
@@ -72,9 +75,13 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
right: 16, right: 16,
height: 88, height: 88,
child: Transform.translate( child: Transform.translate(
offset: Offset(0, 100 * (1 - _animation.value)), offset: Offset(
0,
ReduceMotion.isEnabled(context)
? 0
: 100 * (1 - _animation.value)),
child: Opacity( child: Opacity(
opacity: _animation.value, opacity: ReduceMotion.isEnabled(context) ? 1 : _animation.value,
child: Container( child: Container(
margin: const EdgeInsets.all(4), // 그림자 공간 확보 margin: const EdgeInsets.all(4), // 그림자 공간 확보
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -220,12 +227,14 @@ class _AddButtonState extends State<_AddButton>
void initState() { void initState() {
super.initState(); super.initState();
_controller = AnimationController( _controller = AnimationController(
duration: const Duration(milliseconds: 150), duration: ReduceMotion.platform()
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 150),
vsync: this, vsync: this,
); );
_scaleAnimation = Tween<double>( _scaleAnimation = Tween<double>(
begin: 1.0, begin: 1.0,
end: 0.9, end: ReduceMotion.platform() ? 1.0 : 0.9,
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: _controller, parent: _controller,
curve: Curves.easeInOut, curve: Curves.easeInOut,

View File

@@ -1,320 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:ui';
import '../theme/app_colors.dart';
import 'themed_text.dart';
import '../l10n/app_localizations.dart';
/// 글래스모피즘 효과가 적용된 통일된 앱바
class GlassmorphicAppBar extends StatelessWidget
implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
final Widget? leading;
final bool automaticallyImplyLeading;
final double elevation;
final Color? backgroundColor;
final double blur;
final double opacity;
final PreferredSizeWidget? bottom;
final bool centerTitle;
final double? titleSpacing;
final VoidCallback? onBackPressed;
const GlassmorphicAppBar({
super.key,
required this.title,
this.actions,
this.leading,
this.automaticallyImplyLeading = true,
this.elevation = 0,
this.backgroundColor,
this.blur = 20,
this.opacity = 0.1,
this.bottom,
this.centerTitle = false,
this.titleSpacing,
this.onBackPressed,
});
@override
Size get preferredSize => Size.fromHeight(
kToolbarHeight + (bottom?.preferredSize.height ?? 0.0) + 0.5);
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final canPop = Navigator.of(context).canPop();
return ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
(backgroundColor ??
(isDarkMode
? AppColors.glassBackgroundDark
: AppColors.glassBackground))
.withValues(alpha: opacity),
(backgroundColor ??
(isDarkMode
? AppColors.glassSurfaceDark
: AppColors.glassSurface))
.withValues(alpha: opacity * 0.8),
],
),
border: Border(
bottom: BorderSide(
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.5),
width: 0.5,
),
),
),
child: SafeArea(
bottom: false,
child: ClipRect(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: SizedBox(
height: kToolbarHeight,
child: NavigationToolbar(
leading: leading ??
(automaticallyImplyLeading &&
(canPop || onBackPressed != null)
? _buildBackButton(context)
: null),
middle: _buildTitle(context),
trailing: actions != null
? Row(
mainAxisSize: MainAxisSize.min,
children: actions!,
)
: null,
centerMiddle: centerTitle,
middleSpacing:
titleSpacing ?? NavigationToolbar.kMiddleSpacing,
),
),
),
if (bottom != null) bottom!,
],
),
),
),
),
),
);
}
Widget _buildBackButton(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ??
() {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
splashRadius: 24,
tooltip: AppLocalizations.of(context).back,
color: ThemedText.getContrastColor(context),
);
}
Widget _buildTitle(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ThemedText.headline(
text: title,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
),
);
}
/// 투명 스타일 팩토리
static GlassmorphicAppBar transparent({
required String title,
List<Widget>? actions,
Widget? leading,
VoidCallback? onBackPressed,
}) {
return GlassmorphicAppBar(
title: title,
actions: actions,
leading: leading,
blur: 30,
opacity: 0.05,
onBackPressed: onBackPressed,
);
}
/// 반투명 스타일 팩토리
static GlassmorphicAppBar translucent({
required String title,
List<Widget>? actions,
Widget? leading,
VoidCallback? onBackPressed,
}) {
return GlassmorphicAppBar(
title: title,
actions: actions,
leading: leading,
blur: 20,
opacity: 0.15,
onBackPressed: onBackPressed,
);
}
}
/// 확장된 글래스모피즘 앱바 (이미지나 추가 콘텐츠 포함)
class GlassmorphicSliverAppBar extends StatelessWidget {
final String title;
final List<Widget>? actions;
final Widget? leading;
final double expandedHeight;
final bool floating;
final bool pinned;
final bool snap;
final Widget? flexibleSpace;
final double blur;
final double opacity;
final bool automaticallyImplyLeading;
final VoidCallback? onBackPressed;
final bool centerTitle;
const GlassmorphicSliverAppBar({
super.key,
required this.title,
this.actions,
this.leading,
this.expandedHeight = kToolbarHeight,
this.floating = false,
this.pinned = true,
this.snap = false,
this.flexibleSpace,
this.blur = 20,
this.opacity = 0.1,
this.automaticallyImplyLeading = true,
this.onBackPressed,
this.centerTitle = false,
});
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final canPop = Navigator.of(context).canPop();
return SliverAppBar(
expandedHeight: expandedHeight,
floating: floating,
pinned: pinned,
snap: snap,
backgroundColor: Colors.transparent,
elevation: 0,
automaticallyImplyLeading: false,
leading: leading ??
(automaticallyImplyLeading && (canPop || onBackPressed != null)
? IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ??
() {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
splashRadius: 24,
tooltip: AppLocalizations.of(context).back,
)
: null),
actions: actions,
centerTitle: centerTitle,
flexibleSpace: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final top = constraints.biggest.height;
final isCollapsed =
top <= kToolbarHeight + MediaQuery.of(context).padding.top;
return FlexibleSpaceBar(
title: isCollapsed
? ThemedText.headline(
text: title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
)
: null,
centerTitle: centerTitle,
titlePadding:
const EdgeInsets.only(left: 16, bottom: 16, right: 16),
background: Stack(
fit: StackFit.expand,
children: [
// 글래스모피즘 배경
ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
(isDarkMode
? AppColors.glassBackgroundDark
: AppColors.glassBackground)
.withValues(alpha: opacity),
(isDarkMode
? AppColors.glassSurfaceDark
: AppColors.glassSurface)
.withValues(alpha: opacity * 0.8),
],
),
border: Border(
bottom: BorderSide(
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.5),
width: 0.5,
),
),
),
),
),
),
// 확장 상태에서만 보이는 타이틀
if (!isCollapsed)
Positioned(
left: 16,
right: 16,
bottom: 16,
child: ThemedText.headline(
text: title,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
),
// 커스텀 flexibleSpace가 있으면 추가
if (flexibleSpace != null) flexibleSpace!,
],
),
);
},
),
);
}
}

View File

@@ -163,7 +163,7 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: gradientColors colors: gradientColors
.map((color) => color.withOpacity(0.3)) .map((color) => color.withValues(alpha: 0.3))
.toList(), .toList(),
), ),
), ),
@@ -177,10 +177,13 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _particleController, animation: _particleController,
builder: (context, child) { builder: (context, child) {
final media = MediaQuery.maybeOf(context);
final reduce = media?.disableAnimations ?? false;
final count = reduce ? 10 : 30;
return CustomPaint( return CustomPaint(
painter: ParticlePainter( painter: ParticlePainter(
animation: _particleController, animation: _particleController,
particleCount: 30, particleCount: count,
), ),
); );
}, },

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../utils/logger.dart';
import 'dart:ui'; import 'dart:ui';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../utils/reduce_motion.dart';
import 'themed_text.dart'; import 'themed_text.dart';
class GlassmorphismCard extends StatelessWidget { class GlassmorphismCard extends StatelessWidget {
@@ -51,7 +53,12 @@ class GlassmorphismCard extends StatelessWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), filter: ImageFilter.blur(
sigmaX: ReduceMotion.scale(context,
normal: blur, reduced: blur * 0.4),
sigmaY: ReduceMotion.scale(context,
normal: blur, reduced: blur * 0.4),
),
child: Container( child: Container(
padding: padding, padding: padding,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -76,8 +83,9 @@ class GlassmorphismCard extends StatelessWidget {
[ [
BoxShadow( BoxShadow(
color: AppColors color: AppColors
.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08) .shadowBlack, // color.md: rgba(0,0,0,0.08)
blurRadius: 20, blurRadius: ReduceMotion.scale(context,
normal: 20, reduced: 10),
spreadRadius: -5, spreadRadius: -5,
offset: const Offset(0, 10), offset: const Offset(0, 10),
), ),
@@ -200,7 +208,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
_handleTapUp(details); _handleTapUp(details);
// onTap 콜백 실행 // onTap 콜백 실행
if (widget.onTap != null) { if (widget.onTap != null) {
print('[AnimatedGlassmorphismCard] onTap 콜백 실행'); Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행');
widget.onTap!(); widget.onTap!();
} }
}, },
@@ -208,15 +216,18 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _controller, animation: _controller,
builder: (context, child) { builder: (context, child) {
final scaleValue = ReduceMotion.scale(context,
normal: _scaleAnimation.value, reduced: 1.0);
return Transform.scale( return Transform.scale(
scale: _scaleAnimation.value, scale: scaleValue,
child: GlassmorphismCard( child: GlassmorphismCard(
padding: widget.padding, padding: widget.padding,
margin: widget.margin, margin: widget.margin,
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
borderRadius: widget.borderRadius, borderRadius: widget.borderRadius,
blur: _blurAnimation.value, blur: ReduceMotion.scale(context,
normal: _blurAnimation.value, reduced: widget.blur),
opacity: widget.opacity, opacity: widget.opacity,
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음 onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
child: widget.child, child: widget.child,

View File

@@ -42,6 +42,7 @@ class MainScreenSummaryCard extends StatelessWidget {
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)), CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 23, 16, 12), padding: const EdgeInsets.fromLTRB(16, 23, 16, 12),
child: RepaintBoundary(
child: GlassmorphismCard( child: GlassmorphismCard(
borderRadius: 16, borderRadius: 16,
blur: 15, blur: 15,
@@ -90,7 +91,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.monthlyTotalSubscriptionCost, .monthlyTotalSubscriptionCost,
style: TextStyle( style: const TextStyle(
color: AppColors color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 15, fontSize: 15,
@@ -113,7 +114,8 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFE5F2FF), color: const Color(0xFFE5F2FF),
borderRadius: BorderRadius.circular(4), borderRadius:
BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: const Color(0xFFBFDBFE), color: const Color(0xFFBFDBFE),
width: 1, width: 1,
@@ -215,7 +217,7 @@ class MainScreenSummaryCard extends StatelessWidget {
context, context,
title: AppLocalizations.of(context) title: AppLocalizations.of(context)
.estimatedAnnualCost, .estimatedAnnualCost,
value: '${NumberFormat.currency( value: NumberFormat.currency(
locale: defaultCurrency == 'KRW' locale: defaultCurrency == 'KRW'
? 'ko_KR' ? 'ko_KR'
: defaultCurrency == 'JPY' : defaultCurrency == 'JPY'
@@ -225,7 +227,7 @@ class MainScreenSummaryCard extends StatelessWidget {
: 'en_US', : 'en_US',
symbol: currencySymbol, symbol: currencySymbol,
decimalDigits: decimals, decimalDigits: decimals,
).format(yearlyCost)}', ).format(yearlyCost),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildInfoBox( _buildInfoBox(
@@ -265,7 +267,8 @@ class MainScreenSummaryCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.25), color:
Colors.white.withValues(alpha: 0.25),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
@@ -277,12 +280,13 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.eventDiscountActive, .eventDiscountActive,
style: TextStyle( style: const TextStyle(
color: AppColors color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 11, fontSize: 11,
@@ -312,7 +316,8 @@ class MainScreenSummaryCard extends StatelessWidget {
children: [ children: [
Text( Text(
NumberFormat.currency( NumberFormat.currency(
locale: defaultCurrency == 'KRW' locale: defaultCurrency ==
'KRW'
? 'ko_KR' ? 'ko_KR'
: defaultCurrency == 'JPY' : defaultCurrency == 'JPY'
? 'ja_JP' ? 'ja_JP'
@@ -356,6 +361,7 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
@@ -373,7 +379,7 @@ class MainScreenSummaryCard extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
style: TextStyle( style: const TextStyle(
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -1,159 +0,0 @@
import 'package:flutter/material.dart';
import 'glassmorphism_card.dart';
class SkeletonLoading extends StatelessWidget {
final double? width;
final double? height;
final double borderRadius;
const SkeletonLoading({
Key? key,
this.width,
this.height,
this.borderRadius = 8.0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// 단일 스켈레톤 아이템이 요청된 경우
if (width != null || height != null) {
return _buildSingleSkeleton();
}
// 기본 전체 화면 스켈레톤
return Column(
children: [
// 요약 카드 스켈레톤
GlassmorphismCard(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16.0),
blur: 10,
opacity: 0.1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 24,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildSkeletonColumn(),
_buildSkeletonColumn(),
],
),
],
),
),
// 구독 목록 스켈레톤
Expanded(
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) {
return GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
blur: 10,
opacity: 0.1,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 200,
height: 24,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
width: 150,
height: 16,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
width: 180,
height: 16,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
);
},
),
),
],
);
}
Widget _buildSingleSkeleton() {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(borderRadius),
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 1500),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.grey[300]!,
Colors.grey[100]!,
Colors.grey[300]!,
],
),
),
),
);
}
Widget _buildSkeletonColumn() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 80,
height: 16,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
width: 100,
height: 24,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
],
);
}
}

View File

@@ -14,7 +14,7 @@ class ScanLoadingWidget extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CircularProgressIndicator( const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor), valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -1,340 +0,0 @@
import 'package:flutter/material.dart';
/// 물리 기반 스프링 애니메이션을 적용하는 위젯
class SpringAnimationWidget extends StatefulWidget {
final Widget child;
final Duration delay;
final SpringDescription spring;
final Offset? initialOffset;
final double? initialScale;
final double? initialRotation;
const SpringAnimationWidget({
super.key,
required this.child,
this.delay = Duration.zero,
this.spring = const SpringDescription(
mass: 1,
stiffness: 100,
damping: 10,
),
this.initialOffset,
this.initialScale,
this.initialRotation,
});
@override
State<SpringAnimationWidget> createState() => _SpringAnimationWidgetState();
}
class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _scaleAnimation;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
// 오프셋 애니메이션
_offsetAnimation = Tween<Offset>(
begin: widget.initialOffset ?? const Offset(0, 50),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
));
// 스케일 애니메이션
_scaleAnimation = Tween<double>(
begin: widget.initialScale ?? 0.5,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
));
// 회전 애니메이션
_rotationAnimation = Tween<double>(
begin: widget.initialRotation ?? 0.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
));
// 지연 후 애니메이션 시작
Future.delayed(widget.delay, () {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.translate(
offset: _offsetAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotationAnimation.value,
child: child,
),
),
);
},
child: widget.child,
);
}
}
/// 바운스 효과가 있는 버튼
class BouncyButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final EdgeInsetsGeometry? padding;
final BoxDecoration? decoration;
const BouncyButton({
super.key,
required this.child,
this.onPressed,
this.padding,
this.decoration,
});
@override
State<BouncyButton> createState() => _BouncyButtonState();
}
class _BouncyButtonState extends State<BouncyButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
_controller.forward();
}
void _handleTapUp(TapUpDetails details) {
_controller.reverse();
widget.onPressed?.call();
}
void _handleTapCancel() {
_controller.reverse();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
padding: widget.padding,
decoration: widget.decoration,
child: widget.child,
),
);
},
),
);
}
}
/// 중력 효과 애니메이션
class GravityAnimation extends StatefulWidget {
final Widget child;
final double gravity;
final double bounceFactor;
final double initialVelocity;
const GravityAnimation({
super.key,
required this.child,
this.gravity = 9.8,
this.bounceFactor = 0.8,
this.initialVelocity = 0,
});
@override
State<GravityAnimation> createState() => _GravityAnimationState();
}
class _GravityAnimationState extends State<GravityAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
double _position = 0;
double _velocity = 0;
final double _floor = 300;
@override
void initState() {
super.initState();
_velocity = widget.initialVelocity;
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 10),
)..addListener(_updatePhysics);
_controller.repeat();
}
void _updatePhysics() {
setState(() {
// 속도 업데이트 (중력 적용)
_velocity += widget.gravity * 0.016; // 60fps 가정
// 위치 업데이트
_position += _velocity;
// 바닥 충돌 감지
if (_position >= _floor) {
_position = _floor;
_velocity = -_velocity * widget.bounceFactor;
// 너무 작은 바운스는 멈춤
if (_velocity.abs() < 1) {
_velocity = 0;
}
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: Offset(0, _position),
child: widget.child,
);
}
}
/// 물결 효과 애니메이션
class RippleAnimation extends StatefulWidget {
final Widget child;
final Color rippleColor;
final Duration duration;
const RippleAnimation({
super.key,
required this.child,
this.rippleColor = Colors.blue,
this.duration = const Duration(milliseconds: 600),
});
@override
State<RippleAnimation> createState() => _RippleAnimationState();
}
class _RippleAnimationState extends State<RippleAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTap() {
_controller.forward(from: 0.0);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _handleTap,
child: Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: 100 + 200 * _animation.value,
height: 100 + 200 * _animation.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.rippleColor.withValues(
alpha: (1 - _animation.value) * 0.3,
),
),
);
},
),
widget.child,
],
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:math' as math; import 'dart:math' as math;
import '../utils/reduce_motion.dart';
/// 스태거 애니메이션이 적용된 리스트 위젯 /// 스태거 애니메이션이 적용된 리스트 위젯
class StaggeredListAnimation extends StatefulWidget { class StaggeredListAnimation extends StatefulWidget {
@@ -95,13 +96,14 @@ class _StaggeredListAnimationState extends State<StaggeredListAnimation>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (ReduceMotion.platform()) {
return widget.direction == Axis.vertical return widget.direction == Axis.vertical
? Column( ? Column(children: widget.children)
children: _buildAnimatedChildren(), : Row(children: widget.children);
) }
: Row( return widget.direction == Axis.vertical
children: _buildAnimatedChildren(), ? Column(children: _buildAnimatedChildren())
); : Row(children: _buildAnimatedChildren());
} }
List<Widget> _buildAnimatedChildren() { List<Widget> _buildAnimatedChildren() {
@@ -156,8 +158,9 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final reduced = ReduceMotion.platform();
_controller = AnimationController( _controller = AnimationController(
duration: widget.duration, duration: reduced ? Duration.zero : widget.duration,
vsync: this, vsync: this,
); );
@@ -170,7 +173,9 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
)); ));
_slideAnimation = Tween<Offset>( _slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3), begin: ReduceMotion.platform()
? const Offset(0, 0.05)
: const Offset(0, 0.3),
end: Offset.zero, end: Offset.zero,
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: _controller, parent: _controller,
@@ -185,11 +190,11 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
curve: widget.curve, curve: widget.curve,
)); ));
// 지연 후 애니메이션 시작 // 지연 후 애니메이션 시작 (모션 축소 시 지연 없음)
Future.delayed(widget.delay * widget.index, () { final startDelay =
if (mounted) { ReduceMotion.platform() ? Duration.zero : widget.delay * widget.index;
_controller.forward(); Future.delayed(startDelay, () {
} if (mounted) _controller.forward();
}); });
} }
@@ -201,6 +206,7 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (ReduceMotion.platform()) return widget.child;
return AnimatedBuilder( return AnimatedBuilder(
animation: _controller, animation: _controller,
builder: (context, child) { builder: (context, child) {

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
@@ -202,8 +201,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
daysUntilNext = 7; // 다음 주 같은 요일 daysUntilNext = 7; // 다음 주 같은 요일
} }
if (daysUntilNext == 0) if (daysUntilNext == 0) {
return AppLocalizations.of(context).paymentDueToday; return AppLocalizations.of(context).paymentDueToday;
}
return AppLocalizations.of(context).paymentDueInDays(daysUntilNext); return AppLocalizations.of(context).paymentDueInDays(daysUntilNext);
} }
@@ -303,8 +303,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
width: double.infinity, // 전체 너비를 차지하도록 설정 width: double.infinity, // 전체 너비를 차지하도록 설정
onTap: widget.onTap ?? onTap: widget.onTap ??
() async { () async {
print( // ignore: use_build_context_synchronously
'[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}');
await AppNavigator.toDetail(context, widget.subscription); await AppNavigator.toDetail(context, widget.subscription);
}, },
child: Column( child: Column(

View File

@@ -12,6 +12,7 @@ import '../services/subscription_url_matcher.dart';
import './dialogs/delete_confirmation_dialog.dart'; import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart'; import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯 /// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget { class SubscriptionListWidget extends StatelessWidget {
@@ -70,6 +71,8 @@ class SubscriptionListWidget extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true, shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
cacheExtent: 500,
prototypeItem: const SizedBox(height: 156),
itemCount: subscriptions.length, itemCount: subscriptions.length,
itemBuilder: (context, subIndex) { itemBuilder: (context, subIndex) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록) // 각 구독의 지연값 계산 (순차적으로 나타나도록)
@@ -97,10 +100,12 @@ class SubscriptionListWidget extends StatelessWidget {
child: StaggeredAnimationItem( child: StaggeredAnimationItem(
index: subIndex, index: subIndex,
delay: const Duration(milliseconds: 50), delay: const Duration(milliseconds: 50),
child: RepaintBoundary(
child: SwipeableSubscriptionCard( child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex], subscription: subscriptions[subIndex],
keepAlive: true,
onTap: () { onTap: () {
print( Log.d(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail( AppNavigator.toDetail(
context, subscriptions[subIndex]); context, subscriptions[subIndex]);
@@ -114,7 +119,8 @@ class SubscriptionListWidget extends StatelessWidget {
); );
final locale = final locale =
localeProvider.locale.languageCode; localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher final displayName =
await SubscriptionUrlMatcher
.getServiceDisplayName( .getServiceDisplayName(
serviceName: serviceName:
subscriptions[subIndex].serviceName, subscriptions[subIndex].serviceName,
@@ -122,13 +128,15 @@ class SubscriptionListWidget extends StatelessWidget {
); );
// 삭제 확인 다이얼로그 표시 // 삭제 확인 다이얼로그 표시
if (!context.mounted) return;
final shouldDelete = final shouldDelete =
await DeleteConfirmationDialog.show( await DeleteConfirmationDialog.show(
context: context, context: context,
serviceName: displayName, serviceName: displayName,
); );
if (!context.mounted) return;
if (shouldDelete && context.mounted) { if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행 // 사용자가 확인한 경우에만 삭제 진행
final provider = final provider =
Provider.of<SubscriptionProvider>( Provider.of<SubscriptionProvider>(
@@ -152,6 +160,7 @@ class SubscriptionListWidget extends StatelessWidget {
), ),
), ),
), ),
),
); );
}, },
), ),

View File

@@ -1,14 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../utils/haptic_feedback_helper.dart'; import '../utils/haptic_feedback_helper.dart';
import 'subscription_card.dart'; import 'subscription_card.dart';
import '../utils/reduce_motion.dart';
class SwipeableSubscriptionCard extends StatefulWidget { class SwipeableSubscriptionCard extends StatefulWidget {
final SubscriptionModel subscription; final SubscriptionModel subscription;
final VoidCallback? onEdit; final VoidCallback? onEdit;
final Future<void> Function()? onDelete; final Future<void> Function()? onDelete;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool keepAlive;
const SwipeableSubscriptionCard({ const SwipeableSubscriptionCard({
super.key, super.key,
@@ -16,6 +17,7 @@ class SwipeableSubscriptionCard extends StatefulWidget {
this.onEdit, this.onEdit,
this.onDelete, this.onDelete,
this.onTap, this.onTap,
this.keepAlive = false,
}); });
@override @override
@@ -24,12 +26,11 @@ class SwipeableSubscriptionCard extends StatefulWidget {
} }
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard> class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
// 상수 정의 // 상수 정의
static const double _tapTolerance = 20.0; // 탭 허용 범위 static const double _tapTolerance = 20.0; // 탭 허용 범위
static const double _actionThresholdPercent = 0.15; static const double _actionThresholdPercent = 0.15;
static const double _deleteThresholdPercent = 0.40; static const double _deleteThresholdPercent = 0.40;
static const int _tapDurationMs = 500;
static const double _velocityThreshold = 800.0; static const double _velocityThreshold = 800.0;
// static const double _animationDuration = 300.0; // static const double _animationDuration = 300.0;
@@ -39,8 +40,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
// 제스처 추적 // 제스처 추적
Offset? _startPosition; Offset? _startPosition;
DateTime? _startTime; // 제스처 관련 보조 변수(간소화)
bool _isValidTap = true;
// 상태 관리 // 상태 관리
double _currentOffset = 0; double _currentOffset = 0;
@@ -52,7 +52,9 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
void initState() { void initState() {
super.initState(); super.initState();
_controller = AnimationController( _controller = AnimationController(
duration: const Duration(milliseconds: 300), duration: ReduceMotion.platform()
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
vsync: this, vsync: this,
); );
_animation = Tween<double>( _animation = Tween<double>(
@@ -95,8 +97,6 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
// 제스처 핸들러 // 제스처 핸들러
void _handlePanStart(DragStartDetails details) { void _handlePanStart(DragStartDetails details) {
_startPosition = details.localPosition; _startPosition = details.localPosition;
_startTime = DateTime.now();
_isValidTap = true;
_hapticTriggered = false; _hapticTriggered = false;
_controller.stop(); _controller.stop();
} }
@@ -104,12 +104,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
void _handlePanUpdate(DragUpdateDetails details) { void _handlePanUpdate(DragUpdateDetails details) {
final currentPosition = details.localPosition; final currentPosition = details.localPosition;
final delta = currentPosition.dx - _startPosition!.dx; final delta = currentPosition.dx - _startPosition!.dx;
final distance = (currentPosition - _startPosition!).distance; // 탭/스와이프 판별 거리는 외부에서 사용하지 않아 제거
// 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주
if (distance > _tapTolerance) {
_isValidTap = false;
}
// 카드 이동 // 카드 이동
setState(() { setState(() {
@@ -129,14 +124,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
} }
// 헬퍼 메서드 // 헬퍼 메서드
void _processTap() { // 탭 처리는 SubscriptionCard에서 수행
print('[SwipeableSubscriptionCard] _processTap 호출됨');
if (widget.onTap != null) {
print('[SwipeableSubscriptionCard] onTap 콜백 실행');
widget.onTap!();
}
_animateToOffset(0);
}
void _processSwipe(double velocity) { void _processSwipe(double velocity) {
final extent = _currentOffset.abs(); final extent = _currentOffset.abs();
@@ -232,10 +220,14 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
right: isLeft ? 0 : 24, right: isLeft ? 0 : 24,
), ),
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: ReduceMotion.platform()
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 200),
opacity: showIcon ? 1.0 : 0.0, opacity: showIcon ? 1.0 : 0.0,
child: AnimatedScale( child: AnimatedScale(
duration: const Duration(milliseconds: 200), duration: ReduceMotion.platform()
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 200),
scale: showIcon ? 1.0 : 0.5, scale: showIcon ? 1.0 : 0.5,
child: Icon( child: Icon(
isDeleteThreshold isDeleteThreshold
@@ -253,9 +245,10 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
return Transform.translate( return Transform.translate(
offset: Offset(_currentOffset, 0), offset: Offset(_currentOffset, 0),
child: Transform.scale( child: Transform.scale(
scale: 1.0 - (_currentOffset.abs() / 2000), scale:
ReduceMotion.platform() ? 1.0 : 1.0 - (_currentOffset.abs() / 2000),
child: Transform.rotate( child: Transform.rotate(
angle: _currentOffset / 2000, angle: ReduceMotion.platform() ? 0.0 : _currentOffset / 2000,
child: SubscriptionCard( child: SubscriptionCard(
subscription: widget.subscription, subscription: widget.subscription,
onTap: widget onTap: widget
@@ -268,6 +261,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
// 웹과 모바일 모두 동일한 스와이프 기능 제공 // 웹과 모바일 모두 동일한 스와이프 기능 제공
return Stack( return Stack(
children: [ children: [
@@ -283,4 +277,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
], ],
); );
} }
@override
bool get wantKeepAlive => widget.keepAlive;
} }

View File

@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import '../utils/reduce_motion.dart';
// 파비콘 캐시 관리 클래스 // 파비콘 캐시 관리 클래스
class FaviconCache { class FaviconCache {
@@ -190,11 +191,14 @@ class _WebsiteIconState extends State<WebsiteIcon>
// 애니메이션 컨트롤러 초기화 // 애니메이션 컨트롤러 초기화
_animationController = AnimationController( _animationController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 300), duration: ReduceMotion.platform()
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
); );
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.08).animate( _scaleAnimation =
CurvedAnimation( Tween<double>(begin: 1.0, end: ReduceMotion.platform() ? 1.0 : 1.08)
.animate(CurvedAnimation(
parent: _animationController, curve: Curves.easeOutCubic)); parent: _animationController, curve: Curves.easeOutCubic));
// 초기 _previousServiceKey 설정 // 초기 _previousServiceKey 설정
@@ -548,11 +552,14 @@ class _WebsiteIconState extends State<WebsiteIcon>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return RepaintBoundary(
child: AnimatedBuilder(
animation: _animationController, animation: _animationController,
builder: (context, child) { builder: (context, child) {
final scale =
ReduceMotion.isEnabled(context) ? 1.0 : _scaleAnimation.value;
return Transform.scale( return Transform.scale(
scale: _scaleAnimation.value, scale: scale,
child: child, child: child,
); );
}, },
@@ -578,12 +585,25 @@ class _WebsiteIconState extends State<WebsiteIcon>
), ),
child: _buildIconContent(), child: _buildIconContent(),
), ),
); ));
} }
Widget _buildIconContent() { Widget _buildIconContent() {
// 로딩 중 표시 // 로딩 중 표시
if (_isLoading) { if (_isLoading) {
if (ReduceMotion.isEnabled(context)) {
return Container(
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(widget.size * 0.2),
border: Border.all(
color: AppColors.borderColor,
width: 0.5,
),
),
);
}
return Container( return Container(
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'), key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -633,7 +653,17 @@ class _WebsiteIconState extends State<WebsiteIcon>
width: widget.size, width: widget.size,
height: widget.size, height: widget.size,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => Container( fadeInDuration: ReduceMotion.isEnabled(context)
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
fadeOutDuration: ReduceMotion.isEnabled(context)
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
placeholder: (context, url) {
if (ReduceMotion.isEnabled(context)) {
return Container(color: AppColors.surfaceColorAlt);
}
return Container(
color: AppColors.surfaceColorAlt, color: AppColors.surfaceColorAlt,
child: Center( child: Center(
child: SizedBox( child: SizedBox(
@@ -646,7 +676,8 @@ class _WebsiteIconState extends State<WebsiteIcon>
), ),
), ),
), ),
), );
},
errorWidget: (context, url, error) => _buildFallbackIcon(), errorWidget: (context, url, error) => _buildFallbackIcon(),
), ),
); );

View File

@@ -18,7 +18,7 @@ import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -5,23 +5,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "76.0.0" version: "67.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.3"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.11.0" version: "6.4.1"
ansicolor: ansicolor:
dependency: transitive dependency: transitive
description: description:
@@ -66,10 +61,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build name: build
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.1"
build_config: build_config:
dependency: transitive dependency: transitive
description: description:
@@ -90,26 +85,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build_resolvers name: build_resolvers
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.4" version: "2.4.2"
build_runner: build_runner:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: build_runner name: build_runner
sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.15" version: "2.4.13"
build_runner_core: build_runner_core:
dependency: transitive dependency: transitive
description: description:
name: build_runner_core name: build_runner_core
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.0" version: "7.3.2"
built_collection: built_collection:
dependency: transitive dependency: transitive
description: description:
@@ -122,10 +117,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: built_value name: built_value
sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.9.5" version: "8.12.0"
cached_network_image: cached_network_image:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -162,10 +157,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: checked_yaml name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "2.0.4"
cli_util: cli_util:
dependency: transitive dependency: transitive
description: description:
@@ -242,10 +237,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: dart_style
sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.8" version: "2.3.6"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@@ -380,10 +375,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_plugin_android_lifecycle name: flutter_plugin_android_lifecycle
sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.28" version: "2.0.30"
flutter_secure_storage: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -444,10 +439,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_svg name: flutter_svg
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.2.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -462,10 +457,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: font_awesome_flutter name: font_awesome_flutter
sha256: d3a89184101baec7f4600d58840a764d2ef760fe1c5a20ef9e6b0e9b24a07a3a sha256: "27af5982e6c510dec1ba038eff634fa284676ee84e3fd807225c80c4ad869177"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.8.0" version: "10.10.0"
frontend_server_client: frontend_server_client:
dependency: transitive dependency: transitive
description: description:
@@ -534,10 +529,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: http name: http
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.5.0"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -598,26 +593,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.9" version: "11.0.1"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.9" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -638,18 +633,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: local_auth_android name: local_auth_android
sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e" sha256: "1ee0e63fb8b5c6fa286796b5fb1570d256857c2f4a262127e728b36b80a570cf"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.49" version: "1.0.53"
local_auth_darwin: local_auth_darwin:
dependency: transitive dependency: transitive
description: description:
name: local_auth_darwin name: local_auth_darwin
sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2" sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.3" version: "1.6.0"
local_auth_platform_interface: local_auth_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -674,14 +669,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
macros:
dependency: transitive
description:
name: macros
sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
url: "https://pub.dev"
source: hosted
version: "0.1.3-main.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -766,18 +753,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.17" version: "2.2.18"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.2"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -854,10 +841,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "7.0.1"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -886,18 +873,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: posix name: posix
sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.2" version: "6.0.3"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
name: provider name: provider
sha256: "489024f942069c2920c844ee18bb3d467c69e48955a4f32d1677f71be103e310" sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.4" version: "6.1.5+1"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -966,10 +953,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.10" version: "2.4.12"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
@@ -1022,10 +1009,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shelf_web_socket name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "2.0.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -1075,18 +1062,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqflite_android name: sqflite_android
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.2+2"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.5" version: "2.5.6"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@@ -1139,10 +1126,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: synchronized name: synchronized
sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.1" version: "3.4.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -1155,10 +1142,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.6"
timezone: timezone:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1195,26 +1182,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: url_launcher name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.1" version: "6.3.2"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" sha256: dbdd48a5b6a6fe9c7d75099bd2d03f9da9393f8d51a0d250301debbcecd552d2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.16" version: "6.3.19"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.3" version: "6.3.4"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
@@ -1227,10 +1214,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.2" version: "3.2.3"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1267,10 +1254,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_graphics name: vector_graphics
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.18" version: "1.1.19"
vector_graphics_codec: vector_graphics_codec:
dependency: transitive dependency: transitive
description: description:
@@ -1283,34 +1270,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_graphics_compiler name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.16" version: "1.1.19"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.0" version: "15.0.2"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:
name: watcher name: watcher
sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.3"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -1323,10 +1310,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: web_socket name: web_socket
sha256: bfe6f435f6ec49cb6c01da1e275ae4228719e59a6b067048c51e72d9d63bcc4b sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.1"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: transitive
description: description:
@@ -1355,26 +1342,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_platform_interface name: webview_flutter_platform_interface
sha256: "7cb32b21825bd65569665c32bb00a34ded5779786d6201f5350979d2d529940d" sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.14.0"
webview_flutter_wkwebview: webview_flutter_wkwebview:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_wkwebview name: webview_flutter_wkwebview
sha256: a3d461fe3467014e05f3ac4962e5fdde2a4bf44c561cb53e9ae5c586600fdbc3 sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.22.0" version: "3.23.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.12.0" version: "5.14.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -1387,10 +1374,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: xml name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@@ -1400,5 +1387,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.7.0 <4.0.0" dart: ">=3.9.0 <4.0.0"
flutter: ">=3.27.0" flutter: ">=3.35.0"

10
scripts/generate_icons.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
OUT_DIR="assets/app_icon/house_check"
SIZES=(1024 512 256 192 128 96 64 48 32)
mkdir -p "$OUT_DIR"
python3 scripts/render_icon.py "$OUT_DIR" "${SIZES[@]}"
echo "\n아이콘 생성 완료: $OUT_DIR"

236
scripts/render_icon.py Normal file
View File

@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""
Generate squircle app icons with a house + check mark glyph.
No external dependencies. Writes PNGs using zlib and CRC via stdlib.
Usage:
python3 scripts/render_icon.py assets/app_icon/house_check 1024 512 256 192 128 96 64 48 32
"""
import os
import sys
import math
import struct
import zlib
from typing import List, Tuple
NAVY = (0x0F, 0x17, 0x2A, 255) # #0F172A
WHITE = (255, 255, 255, 255)
GREEN = (0x10, 0xB9, 0x81, 255) # #10B981
def srgb_to_lin(c: float) -> float:
c = c / 255.0
if c <= 0.04045:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
def lin_to_srgb(c: float) -> int:
if c <= 0.0031308:
v = 12.92 * c
else:
v = 1.055 * (c ** (1.0 / 2.4)) - 0.055
return max(0, min(255, int(round(v * 255.0))))
def over(dst: Tuple[float, float, float, float], src: Tuple[float, float, float, float]):
# dst, src in linear space RGBA (0..1)
dr, dg, db, da = dst
sr, sg, sb, sa = src
out_a = sa + da * (1.0 - sa)
if out_a == 0.0:
return (0.0, 0.0, 0.0, 0.0)
out_r = (sr * sa + dr * da * (1.0 - sa)) / out_a
out_g = (sg * sa + dg * da * (1.0 - sa)) / out_a
out_b = (sb * sa + db * da * (1.0 - sa)) / out_a
return (out_r, out_g, out_b, out_a)
def to_lin_rgba(color: Tuple[int, int, int, int], alpha_scale: float = 1.0):
r, g, b, a = color
return (
srgb_to_lin(r),
srgb_to_lin(g),
srgb_to_lin(b),
(a / 255.0) * alpha_scale,
)
def point_in_triangle(px, py, ax, ay, bx, by, cx, cy) -> bool:
# Barycentric technique
v0x, v0y = cx - ax, cy - ay
v1x, v1y = bx - ax, by - ay
v2x, v2y = px - ax, py - ay
dot00 = v0x * v0x + v0y * v0y
dot01 = v0x * v1x + v0y * v1y
dot02 = v0x * v2x + v0y * v2y
dot11 = v1x * v1x + v1y * v1y
dot12 = v1x * v2x + v1y * v2y
denom = dot00 * dot11 - dot01 * dot01
if denom == 0:
return False
inv = 1.0 / denom
u = (dot11 * dot02 - dot01 * dot12) * inv
v = (dot00 * dot12 - dot01 * dot02) * inv
return (u >= 0) and (v >= 0) and (u + v <= 1)
def point_in_rect(px, py, x0, y0, x1, y1) -> bool:
return (x0 <= px <= x1) and (y0 <= py <= y1)
def dist_point_to_segment(px, py, ax, ay, bx, by) -> float:
abx, aby = bx - ax, by - ay
apx, apy = px - ax, py - ay
ab2 = abx * abx + aby * aby
if ab2 == 0:
dx, dy = px - ax, py - ay
return math.hypot(dx, dy)
t = (apx * abx + apy * aby) / ab2
if t < 0:
t = 0
elif t > 1:
t = 1
hx, hy = ax + t * abx, ay + t * aby
dx, dy = px - hx, py - hy
return math.hypot(dx, dy)
def inside_superellipse(u: float, v: float, n: float = 4.5) -> bool:
# map [0,1] -> [-1,1]
x = 2.0 * u - 1.0
y = 2.0 * v - 1.0
return (abs(x) ** n + abs(y) ** n) <= 1.0
def render_icon(size: int) -> bytes:
# 2x2 supersampling
samples = [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]
# House geometry (normalized 0..1)
roof = (0.28, 0.46, 0.50, 0.30, 0.72, 0.46) # (ax,ay,bx,by,cx,cy)
body = (0.32, 0.46, 0.68, 0.76) # (x0,y0,x1,y1)
# Check path
chk_a = (0.33, 0.60)
chk_b = (0.46, 0.74)
chk_c = (0.72, 0.48)
thickness = 0.08 # relative to width
lin_bg = to_lin_rgba(NAVY)
lin_white = to_lin_rgba(WHITE)
lin_green = to_lin_rgba(GREEN)
out = bytearray(size * size * 4)
idx = 0
for j in range(size):
for i in range(size):
# Accumulate in linear
dst = (0.0, 0.0, 0.0, 0.0)
cov_bg = 0.0
cov_house = 0.0
cov_check = 0.0
for (ox, oy) in samples:
u = (i + ox) / size
v = (j + oy) / size
if inside_superellipse(u, v):
cov_bg += 1.0
hx = 0
# house coverage (triangle or rect)
if point_in_triangle(u, v, *roof):
hx = 1
elif point_in_rect(u, v, *body):
hx = 1
cov_house += hx
# check coverage by distance to segments
d1 = dist_point_to_segment(u, v, *chk_a, *chk_b)
d2 = dist_point_to_segment(u, v, *chk_b, *chk_c)
if min(d1, d2) <= (thickness * 0.5):
cov_check += 1.0
ss = float(len(samples))
if cov_bg > 0.0:
dst = over(dst, (lin_bg[0], lin_bg[1], lin_bg[2], lin_bg[3] * (cov_bg / ss)))
if cov_house > 0.0:
dst = over(dst, (lin_white[0], lin_white[1], lin_white[2], lin_white[3] * (cov_house / ss)))
if cov_check > 0.0:
dst = over(dst, (lin_green[0], lin_green[1], lin_green[2], lin_green[3] * (cov_check / ss)))
r = lin_to_srgb(dst[0])
g = lin_to_srgb(dst[1])
b = lin_to_srgb(dst[2])
a = max(0, min(255, int(round(dst[3] * 255.0))))
out[idx + 0] = r
out[idx + 1] = g
out[idx + 2] = b
out[idx + 3] = a
idx += 4
return png_encode(size, size, bytes(out))
def png_chunk(typ: bytes, data: bytes) -> bytes:
return struct.pack(
">I", len(data)
) + typ + data + struct.pack(
">I", (zlib.crc32(typ + data) & 0xFFFFFFFF)
)
def png_encode(width: int, height: int, rgba: bytes) -> bytes:
# Build raw scanlines with filter 0
stride = width * 4
raw = bytearray()
for y in range(height):
raw.append(0) # filter type 0
start = y * stride
raw.extend(rgba[start : start + stride])
comp = zlib.compress(bytes(raw), level=9)
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(
">IIBBBBB",
width,
height,
8, # bit depth
6, # color type RGBA
0, # compression
0, # filter
0, # interlace
)
out = bytearray()
out.extend(sig)
out.extend(png_chunk(b"IHDR", ihdr))
out.extend(png_chunk(b"IDAT", comp))
out.extend(png_chunk(b"IEND", b""))
return bytes(out)
def main():
if len(sys.argv) < 3:
print(
"Usage: python3 scripts/render_icon.py <out_dir> <sizes...>",
file=sys.stderr,
)
sys.exit(2)
out_dir = sys.argv[1]
sizes = [int(s) for s in sys.argv[2:]]
os.makedirs(out_dir, exist_ok=True)
for s in sizes:
data = render_icon(s)
path = os.path.join(out_dir, f"{s}.png")
with open(path, "wb") as f:
f.write(data)
print(f"wrote {path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:submanager/services/exchange_rate_service.dart';
void main() {
test('USD -> KRW conversion returns non-null using defaults when offline',
() async {
final service = ExchangeRateService();
final krw = await service.convertUsdToTarget(1.0, 'KRW');
expect(krw, isNotNull);
expect(krw, greaterThan(0));
});
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:submanager/services/subscription_url_matcher.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('extractDomain parses host correctly', () async {
await SubscriptionUrlMatcher.initialize();
final domain =
SubscriptionUrlMatcher.extractDomain('https://www.netflix.com/kr');
expect(domain, 'netflix');
});
test('findMatchingUrl finds known service', () async {
await SubscriptionUrlMatcher.initialize();
final url = SubscriptionUrlMatcher.findMatchingUrl('넷플릭스');
expect(url, isNotNull);
});
}