46 Commits

Author SHA1 Message Date
JiWoong Sul
7125a4745a feat(settings): 앱 버전 자동 표시 기능 추가
- package_info_plus 패키지 추가
- settings_screen에서 pubspec.yaml 버전을 자동으로 표시
2026-01-17 00:31:41 +09:00
JiWoong Sul
8d6b24ed6f chore: 버전 1.0.6+8 업데이트 2026-01-17 00:15:52 +09:00
JiWoong Sul
0db1f12b40 feat: Android 15 edge-to-edge 모드 지원
- immersiveSticky → edgeToEdge 모드 변경
- deprecated된 네비게이션바 색상 API 제거
- 시스템이 네비게이션바 색상 자동 처리
2026-01-14 19:12:35 +09:00
JiWoong Sul
595513b2e6 refactor: MainActivity 불필요한 주석 제거 2026-01-14 19:12:28 +09:00
JiWoong Sul
98488dbcd5 chore: 버전 1.0.5+7 업데이트 2026-01-14 00:18:43 +09:00
JiWoong Sul
18a0004d57 feat(ui): 결제 금액 UI 표시 적용 2026-01-14 00:18:37 +09:00
JiWoong Sul
6e7a7d2477 feat: 컨트롤러에 결제 금액 표시 로직 추가 2026-01-14 00:18:30 +09:00
JiWoong Sul
a0b24f9a75 feat: SubscriptionProvider 결제 금액 계산 로직 추가 2026-01-14 00:18:25 +09:00
JiWoong Sul
58c00443c1 feat(i18n): 결제 금액 다국어 키 추가 2026-01-14 00:18:19 +09:00
JiWoong Sul
da530a99b7 feat: 결제 금액 계산 유틸리티 추가 2026-01-14 00:18:12 +09:00
JiWoong Sul
0f92206833 chore: 버전 1.0.3+5 업데이트 2026-01-06 15:53:51 +09:00
JiWoong Sul
db93c14105 fix: 광고 후 UI 복구 시 몰입형 모드 유지 2026-01-06 15:53:45 +09:00
JiWoong Sul
c8c4746f52 feat: 시스템 네비게이션 바 몰입형 모드 적용 2026-01-06 15:53:38 +09:00
JiWoong Sul
48b2063499 chore: 버전 1.0.2+4 업데이트 2025-12-22 17:08:07 +09:00
JiWoong Sul
843fa0601a chore: 불필요한 코드 및 빈 디렉토리 제거
- 미사용 파일 삭제: confirmation_dialog.dart
- 빈 디렉토리 삭제: shadcn, mappers, common/dialogs
- app_lock_provider: BuildContext async gap 경고 수정
2025-12-22 16:50:03 +09:00
JiWoong Sul
83c43fb61f feat: SMS 스캔 전면광고 및 Isolate 버그 수정
## 전면 광고 (AdService)
- AdService 클래스 신규 생성 (lunchpick 패턴 참조)
- Completer 패턴으로 광고 완료 대기 구현
- 로딩 오버레이로 앱 foreground 상태 유지
- 몰입형 모드 (immersiveSticky) 적용
- iOS 테스트 광고 ID 설정

## SMS 스캔 버그 수정
- Isolate 내 Flutter 바인딩 접근 오류 해결
- _isoExtractServiceNameFromSender()에서 하드코딩 사용
- 로딩 위젯 화면 정중앙 배치 수정

## 문서 및 설정
- CLAUDE.md 최적화 (글로벌 규칙 중복 제거)
- Claude Code Skills 5개 추가
  - flutter-build: 빌드/분석
  - hive-model: Hive 모델 관리
  - release-deploy: 릴리즈 배포
  - sms-scanner: SMS 스캔 디버깅
  - admob: 광고 구현

## 버전
- 1.0.1+2 → 1.0.1+3
2025-12-08 18:14:52 +09:00
JiWoong Sul
bac4acf9a3 i8n과 광고 수정 2025-12-07 21:14:54 +09:00
JiWoong Sul
64da0c5fd3 스토어등록용 이미지 및 앱아이콘 2025-11-17 19:28:51 +09:00
JiWoong Sul
d9435bbee5 앱 키 설정 및 버전업 처리 2025-11-17 19:28:33 +09:00
JiWoong Sul
b018e5eb2f 옵션창 정보팝업 단절 처리 2025-11-17 19:26:46 +09:00
JiWoong Sul
b22df5daf3 i8n누락 사항 추가 적용 2025-11-17 19:26:14 +09:00
JiWoong Sul
2cd46a303e feat: improve sms scan review and detail layouts 2025-11-14 19:33:32 +09:00
JiWoong Sul
a9f42f6f01 fix: adjust subscription card layout 2025-11-14 17:14:16 +09:00
JiWoong Sul
132ae758de feat: add payment card grouping and analysis 2025-11-14 16:53:41 +09:00
JiWoong Sul
cba7d082bd docs: outline payment card grouping plan 2025-11-14 14:29:36 +09:00
JiWoong Sul
8cec03f181 feat: enhance sms scanner repeat detection 2025-11-14 14:29:32 +09:00
JiWoong Sul
7ace3afaf3 Merge branch 'codex/fix-notification-reliability'
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-19 18:15:36 +09:00
JiWoong Sul
87f82546a4 feat: 알림 재예약 개선과 패키지 업그레이드 2025-09-19 18:10:47 +09:00
JiWoong Sul
e909ba59a4 fix: allow weekend billing dates and restore full-screen alerts 2025-09-19 01:08:09 +09:00
JiWoong Sul
3af9a1f839 fix: ensure notifications use correct channels and dates 2025-09-19 01:06:36 +09:00
JiWoong Sul
44850a53cc feat: adopt material 3 theme and billing adjustments 2025-09-16 14:30:14 +09:00
JiWoong Sul
a01d9092ba docs(pr): summarize notification reliability changes (branch codex/fix-notification-reliability) 2025-09-15 15:38:49 +09:00
JiWoong Sul
3d86316a2b feat(android): add exact alarms permission request entry in Settings\n\n- UI: Settings card shows request when exact alarms not allowed\n- Service: wrap canScheduleExactAlarms/requestExactAlarmsPermission via FLN plugin\n- Keeps changes minimal; no new deps\n\nValidation: scripts/check.sh passed 2025-09-15 15:21:44 +09:00
JiWoong Sul
55e3f67279 fix(notification): improve local notification reliability on iOS/Android\n\n- iOS: set UNUserNotificationCenter delegate and present [.banner,.sound,.badge]\n- Android: create channels on init; use exactAllowWhileIdle; add RECEIVE_BOOT_COMPLETED and SCHEDULE_EXACT_ALARM\n- Dart: ensure iOS present options enabled; fix title variable shadowing\n\nValidation: scripts/check.sh passed (format/analyze/tests)\nRisk: exact alarms require user to allow 'Alarms & reminders' on Android 12+\nRollback: revert manifest perms and switch schedule mode back to inexact 2025-09-15 15:18:45 +09:00
JiWoong Sul
d111b5dd62 fix(sms-permission): re-request on denial and guide permanent denial to app settings
Summary: Improve SMS permission UX so users can request again after denial and are guided to app settings when permanently denied.\nChanges: handle Permission.sms status in controllers, show settings dialog for permanently denied, use kIsWeb guard, context-safety across async.\nValidation: scripts/check.sh passed (analyze/tests OK).\nRisk & Rollback: low; scoped to permission request flow. Revert two controllers if issues.
2025-09-15 11:37:38 +09:00
JiWoong Sul
b944f6967d docs(ads): add AdMob mediation native networks guide with regional strategy and Gradle adapter examples
Summary: Document networks supporting Native ads via AdMob mediation, with regional prioritization, Gradle adapter examples, and setup checklist.\nChanges: adds doc/ads.md.\nValidation: scripts/check.sh passed.\nRisk & Rollback: low; doc-only change. Revert file if needed.
2025-09-15 11:37:32 +09:00
JiWoong Sul
997c2f53a0 feat(assets): 디지털렌트매니저 아이콘(집+체크·스퀴클) PNG 세트 및 생성 스크립트 추가\n\n- 경로: assets/app_icon/house_check/{32..1024}.png\n- 스크립트: scripts/render_icon.py (무의존 PNG 렌더) / scripts/generate_icons.sh
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:37:34 +09:00
JiWoong Sul
79f9aa3eb0 docs: flutter-shadcn-ui 마이그레이션 상세 계획 추가(doc/plan.md)
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:16:09 +09:00
JiWoong Sul
5b72fa196c merge: 'codex/perf-sms-ui-optimizations' 브랜치를 master에 병합
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:00:47 +09:00
JiWoong Sul
6cd3b9720f chore(macos): Flutter GeneratedPluginRegistrant 업데이트\n\n- 플러그인/플러터 변경으로 생성 파일 갱신\n- 의존성 lockfile 동기화(pubspec.lock) 2025-09-10 05:55:59 +09:00
JiWoong Sul
5a7ef8039e refactor: remove unreferenced widgets/utilities and backup file in lib 2025-09-08 14:33:55 +09:00
JiWoong Sul
10069a1800 perf(ui): enable KeepAlive on subscription list, tune prefetch, and reduce list/gesture animations 2025-09-08 14:32:28 +09:00
JiWoong Sul
b034f60510 feat(cache): add SimpleCacheManager and cache formatted rates/amounts in exchange and currency services 2025-09-08 14:31:44 +09:00
JiWoong Sul
eb6691ce6a feat(accessibility): add reduceMotion scaling and minimize animations; apply RepaintBoundary to heavy widgets 2025-09-08 14:30:28 +09:00
JiWoong Sul
10491af55b feat(perf): offload Android SMS parsing to Isolate and wrap pie chart with RepaintBoundary 2025-09-08 14:30:03 +09:00
JiWoong Sul
4673aed281 chore(agents): add Korean response rule to AGENTS.md 2025-09-08 14:21:59 +09:00
176 changed files with 11509 additions and 8801 deletions

View File

@@ -0,0 +1,89 @@
---
name: admob
description: AdMob 전면 광고 구현 및 디버깅. 광고 표시, 로드 실패, foreground 이슈 시 사용.
allowed-tools: Read, Edit, Grep
---
# AdMob Integration
## 핵심 파일
| 파일 | 역할 |
|------|------|
| `lib/services/ad_service.dart` | 전면 광고 서비스 |
## 광고 ID
| 플랫폼 | ID | 비고 |
|--------|-----|------|
| Android | `ca-app-pub-6691216385521068/5281562472` | 프로덕션 |
| iOS | `ca-app-pub-3940256099942544/1033173712` | 테스트 |
## Completer 패턴 (필수)
광고 완료를 기다리려면 Completer 사용:
```dart
Future<bool> showInterstitialAd(BuildContext context) async {
final completer = Completer<bool>();
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
completer.complete(true);
},
onAdFailedToShowFullScreenContent: (ad, error) {
ad.dispose();
completer.complete(false);
},
);
ad.show();
return completer.future;
}
```
## "App not in foreground" 해결
광고 로드 중 앱이 백그라운드로 가면 오류 발생.
**해결책: 로딩 오버레이**
```dart
// 광고 로드 전 다이얼로그 표시 → 앱이 foreground 유지
final closeLoading = _showLoadingOverlay(context);
await _enterImmersiveMode();
final loaded = await _ensureAdLoaded();
closeLoading();
```
## 몰입형 모드
```dart
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
overlays: [],
);
```
## 흐름도
```
startScan()
showLoadingOverlay() ← 앱 foreground 유지
enterImmersiveMode()
loadAd()
closeOverlay()
ad.show()
await Completer.future ← 광고 완료 대기
restoreSystemUI()
continueWithScan()
```

View File

@@ -0,0 +1,39 @@
---
name: flutter-build
description: Flutter 빌드 및 분석 수행. 앱 빌드, 릴리즈 APK/AAB 생성, 코드 분석 시 사용.
allowed-tools: Bash, Read
---
# Flutter Build
## 빌드 명령어
```bash
# 코드 분석
flutter analyze
# 디버그 빌드
flutter run
# 릴리즈 APK (디바이스 테스트용)
flutter build apk --release
# 릴리즈 AAB (Play Store용)
flutter build appbundle --release
# 디바이스 설치
flutter install --release
```
## 빌드 전 체크리스트
1. `flutter analyze` 통과 확인
2. pubspec.yaml 버전 확인
3. 필요시 `flutter clean` 실행
## 출력 위치
| 타입 | 경로 |
|------|------|
| APK | `build/app/outputs/flutter-apk/app-release.apk` |
| AAB | `build/app/outputs/bundle/release/app-release.aab` |

View File

@@ -0,0 +1,55 @@
---
name: hive-model
description: Hive 데이터 모델 생성 및 수정. 새 모델 추가, 필드 변경, typeId 관리 시 사용.
allowed-tools: Bash, Read, Write, Edit, Glob
---
# Hive Model Management
## 현재 모델 (typeId)
| Model | typeId | 파일 |
|-------|--------|------|
| SubscriptionModel | 0 | `lib/models/subscription_model.dart` |
| CategoryModel | 1 | `lib/models/category_model.dart` |
| PaymentCardModel | 2 | `lib/models/payment_card_model.dart` |
## 새 모델 생성 시
```dart
import 'package:hive/hive.dart';
part 'new_model.g.dart';
@HiveType(typeId: 3) // 다음 사용 가능한 typeId
class NewModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
NewModel({required this.id, required this.name});
}
```
## 코드 생성
모델 변경 후 반드시 실행:
```bash
dart run build_runner build --delete-conflicting-outputs
```
## 주의사항
1. **typeId 충돌 금지**: 기존 typeId 재사용 불가
2. **HiveField 순서**: 기존 필드 인덱스 변경 금지 (데이터 손실)
3. **마이그레이션**: 필드 추가는 안전, 삭제/변경은 마이그레이션 필요
## main.dart 등록
```dart
Hive.registerAdapter(NewModelAdapter());
await Hive.openBox<NewModel>('newModelBox');
```

View File

@@ -0,0 +1,64 @@
---
name: release-deploy
description: 앱 릴리즈 및 배포 프로세스. 버전업, 빌드, 디바이스 설치, Play Store AAB 생성 시 사용.
allowed-tools: Bash, Read, Edit
---
# Release & Deploy
## 버전 관리
pubspec.yaml 버전 형식: `major.minor.patch+buildNumber`
```yaml
version: 1.0.1+3
# │ │ │ └─ 빌드번호 (내부 버전, 매 빌드마다 증가)
# │ │ └─── 패치 (버그 수정)
# │ └───── 마이너 (기능 추가)
# └─────── 메이저 (대규모 변경)
```
## 릴리즈 프로세스
### 1. 버전 업데이트
```bash
# pubspec.yaml에서 버전 수정
# 예: 1.0.1+3 → 1.0.1+4
```
### 2. 릴리즈 빌드
```bash
# APK (테스트용)
flutter build apk --release
# AAB (Play Store용)
flutter build appbundle --release
```
### 3. 디바이스 설치
```bash
flutter install --release
```
## 전체 자동화
```bash
# 버전업 + APK 빌드 + 설치 + AAB 빌드
flutter build apk --release && flutter install --release && flutter build appbundle --release
```
## Play Store 업로드
1. AAB 파일: `build/app/outputs/bundle/release/app-release.aab`
2. Google Play Console에서 업로드
3. 릴리즈 노트 작성 (한국어/영어/일본어/중국어)
## 체크리스트
- [ ] 버전 번호 증가
- [ ] `flutter analyze` 통과
- [ ] 테스트 디바이스에서 확인
- [ ] AAB 생성 완료

View File

@@ -0,0 +1,60 @@
---
name: sms-scanner
description: SMS 스캔 기능 개발 및 디버깅. SMS 파싱, 구독 감지, Isolate 관련 이슈 시 사용.
allowed-tools: Read, Edit, Grep, Glob
---
# SMS Scanner
## 핵심 파일
| 파일 | 역할 |
|------|------|
| `lib/services/sms_scanner.dart` | SMS 파싱 로직 (Isolate) |
| `lib/controllers/sms_scan_controller.dart` | 스캔 플로우 제어 |
| `lib/screens/sms_scan_screen.dart` | UI |
| `lib/services/sms_scan/` | 보조 클래스 |
## Isolate 주의사항
`compute()` 내부에서 실행되는 함수는 별도 Isolate에서 실행됨.
**접근 불가 항목:**
- Flutter 바인딩 (`WidgetsBinding`)
- `BuildContext`
- `Provider`
- `navigatorKey`
- `AppLocalizations`
**올바른 패턴:**
```dart
// Isolate 내부 함수 (접두사: _iso)
String _isoExtractServiceName(String sender) {
if (RegExp(r'^\d+$').hasMatch(sender)) {
return 'Unknown service'; // 하드코딩 사용
}
return sender;
}
```
**잘못된 패턴:**
```dart
String _isoExtractServiceName(String sender) {
// 오류: Isolate에서 Context 접근 불가
return AppLocalizations.of(context).unknownService;
}
```
## 디버깅
```bash
# SMS 스캔 로그 확인
flutter logs | grep -i "sms\|scan\|isolate"
```
## 테스트 데이터
`lib/temp/test_sms_data.dart`에 테스트용 SMS 데이터 정의됨.

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).
@@ -35,6 +36,7 @@ Sensitive Areas (require explicit approval)
Operational Conventions Operational Conventions
- Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-url-matcher`). - Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-url-matcher`).
- Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`). - Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`).
- Git push 후 공유하는 설명/보고는 반드시 한국어로 작성합니다.
- PR description template: - PR description template:
- Summary: what/why - Summary: what/why
- Changes: key files and decisions - Changes: key files and decisions
@@ -66,4 +68,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.

377
CLAUDE.md
View File

@@ -1,314 +1,111 @@
# Claude 프로젝트 컨텍스트 # CLAUDE.md
## 언어 설정 프로젝트별 가이드. 일반 규칙은 `~/.claude/CLAUDE.md` 참조.
- 모든 답변은 한국어로 제공
- 기술 용어는 영어와 한국어 병기 가능
## 프로젝트 정보 ## Project Overview
- Flutter 기반 구독 관리 앱 (SubManager)
## 현재 작업 **SubManager** - 구독 관리 앱 (Flutter 3.x)
- 구독카드가 클릭이 되지 않아서 문제를 찾는 중.
## 🎯 Mandatory Response Format | 항목 | 기술 |
|------|------|
| DB | Hive (로컬 전용) |
| 상태관리 | Provider + ChangeNotifier |
| 디자인 | Material 3 |
| 광고 | google_mobile_ads |
Before starting any task, you MUST respond in the following format: **버전**: 1.0.1+3
``` ## Quick Commands
[Model Name]. I have reviewed all the following rules: [rule file list or categories]. Proceeding with the task. Master!
```bash
# Hive 모델 생성
dart run build_runner build --delete-conflicting-outputs
# 빌드
flutter build apk --release # APK
flutter build appbundle --release # AAB (Play Store)
# 버전업 후 디바이스 설치
flutter install --release
``` ```
**Examples:** ## Architecture
- `Claude Sonnet 4. I have reviewed all the following rules: development guidelines, class structure, testing rules. Proceeding with the task. Master!` ```text
- For extensive rules: `coding style, class design, exception handling, testing rules` (categorized summary) lib/
├── controllers/ # 비즈니스 로직 (3개)
## 🚀 Mandatory 3-Phase Task Process │ ├── add_subscription_controller.dart
│ ├── detail_screen_controller.dart
### Phase 1: Codebase Exploration & Analysis │ └── sms_scan_controller.dart
├── models/ # Hive 모델 (@HiveType)
**Required Actions:** │ ├── subscription_model.dart (typeId: 0)
│ ├── category_model.dart (typeId: 1)
- Systematically discover ALL relevant files, directories, modules │ └── payment_card_model.dart (typeId: 2)
- Search for related keywords, functions, classes, patterns ├── providers/ # ChangeNotifier 상태관리
- Thoroughly examine each identified file ├── screens/ # 화면 위젯
- Document coding conventions and style guidelines ├── services/ # 외부 서비스 연동
- Identify framework/library usage patterns ├── widgets/ # 재사용 컴포넌트
├── utils/ # 유틸리티 헬퍼
### Phase 2: Implementation Planning ├── routes/ # 라우팅 정의
├── theme/ # 테마/색상
**Required Actions:** └── l10n/ # 다국어 (ko/en/ja/zh)
- Create detailed implementation roadmap based on Phase 1 findings
- Define specific task lists and acceptance criteria per module
- Specify performance/quality requirements
### Phase 3: Implementation Execution
**Required Actions:**
- Implement each module following Phase 2 plan
- Verify ALL acceptance criteria before proceeding
- Ensure adherence to conventions identified in Phase 1
## ✅ Core Development Principles
### Language & Type Rules
- **Write ALL code, variables, and names in English**
- **Write ALL comments, documentation, prompts, and responses in Korean**
- **Always declare types explicitly** for variables, parameters, and return values
- Avoid `any`, `dynamic`, or loosely typed declarations (except when strictly necessary)
- Define **custom types** when needed
- Extract magic numbers and literals into named constants or enums
### Naming Conventions
|Element|Style|Example|
|---|---|---|
|Classes|`PascalCase`|`UserService`, `DataRepository`|
|Variables/Methods|`camelCase`|`userName`, `calculateTotal`|
|Files/Folders|`under_score_case`|`user_service.dart`, `data_models/`|
|Environment Variables|`UPPERCASE`|`API_URL`, `DATABASE_PASSWORD`|
**Critical Rules:**
- **Boolean variables must be verb-based**: `isReady`, `hasError`, `canDelete`
- **Function/method names start with verbs**: `executeLogin`, `saveUser`
- Use meaningful, descriptive names
- Avoid abbreviations unless widely accepted: `i`, `j`, `err`, `ctx`, `API`, `URL`
## 🔧 Function & Method Design
### Function Structure Principles
- **Keep functions short and focused** (≤20 lines recommended)
- **Avoid blank lines inside functions**
- **Follow Single Responsibility Principle**
- **Use verb + object format** for naming:
- Boolean return: `isValid`, `canRetry`, `hasPermission`
- Void return: `executeLogin`, `saveUser`, `startAnimation`
### Function Optimization Techniques
- Use **early returns** to avoid nested logic
- Extract logic into helper functions
- Prefer **arrow functions** for short expressions (≤3 lines)
- Use **named functions** for complex logic
- Minimize null checks by using **default values**
- Minimize parameters using **RO-RO pattern** (Receive Object Return Object)
## 📦 Data & Class Design
### Class Design Principles
- **Strictly follow Single Responsibility Principle (SRP)**
- **Favor composition over inheritance**
- **Define interfaces/abstract classes** to establish contracts
- **Prefer immutable data structures** (use `readonly`, `const`)
### File Size Management
- **Split by responsibility when exceeding 200 lines** (responsibility-based, not line-based)
-**May remain as-is if**:
- Has **single clear responsibility**
- Is **easy to maintain**
- 🔁 **Must split when**:
- Contains **multiple concerns**
- Requires **excessive scrolling**
- Patterns repeat across files
- Difficult for new developer onboarding
### Class Recommendations
- ≤ 200 lines (not mandatory)
- ≤ 10 public methods
- ≤ 10 properties
### Data Model Design
- Avoid excessive use of primitives — use **composite types or classes**
- Move **validation logic inside data models** (not in business logic)
## ❗ Exception Handling
### Exception Usage Principles
- Use exceptions only for **truly unexpected or critical issues**
- **Catch exceptions only to**:
- Handle known failure scenarios
- Add useful context
- Otherwise, let global handlers manage them
## 🧪 Testing
### Test Structure
- Follow **ArrangeActAssert** pattern
- Clear test variable naming: `inputX`, `mockX`, `actualX`, `expectedX`
- **Write unit tests for every public method**
### Test Doubles Usage
- Use **test doubles** (mock/fake/stub) for dependencies
- Exception: allow real use of **lightweight third-party libraries**
### Integration Testing
- Write **integration tests per module**
- Follow **GivenWhenThen** structure
- Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures
## 📝 Git Commit Guidelines
### Commit Message Format
- **Use clear, descriptive commit messages in Korean**
- **Follow conventional commit format**: `type: description`
- **Keep commit messages concise and focused**
- **DO NOT include Claude Code attribution or co-author tags**
### Commit Message Structure
```
type: brief description in Korean
Optional detailed explanation if needed
``` ```
### Commit Types ## Key Services
- `feat`: 새로운 기능 추가 | Service | 역할 |
- `fix`: 버그 수정 |---------|------|
- `refactor`: 코드 리팩토링 | `AdService` | 전면 광고 (Completer 패턴) |
- `style`: 코드 스타일 변경 (formatting, missing semi-colons, etc) | `SmsScanner` | SMS 파싱 → 구독 자동 감지 (Isolate 사용) |
- `docs`: 문서 변경 | `NotificationService` | 로컬 알림 |
- `test`: 테스트 코드 추가 또는 수정 | `ExchangeRateService` | 환율 조회 |
- `chore`: 빌드 프로세스 또는 보조 도구 변경 | `url_matcher/` | 서비스명 → URL 매칭 |
### Examples ## Routes
**Good Examples:** | Path | Screen |
- `feat: 월별 차트 다국어 지원 추가` |------|--------|
- `fix: 분석화면 총지출 금액 불일치 문제 해결` | `/` | MainScreen |
- `refactor: 통화 변환 로직 모듈화` | `/add-subscription` | AddSubscriptionScreen |
| `/subscription-detail` | DetailScreen (requires SubscriptionModel) |
| `/sms-scanner` | SmsScanScreen |
| `/analysis` | AnalysisScreen |
| `/settings` | SettingsScreen |
| `/payment-card-management` | PaymentCardManagementScreen |
**Avoid These:** ## Project Rules
- Including "🤖 Generated with [Claude Code](https://claude.ai/code)"
- Including "Co-Authored-By: Claude <noreply@anthropic.com>"
- Vague messages like "update code" or "fix stuff"
- English commit messages (use Korean)
### Critical Rules 1. **로컬 전용**: 서버/Firebase/외부 DB 금지
2. **권한 거부 시**: 수동 입력 폴백 (SMS), 기능 비활성화 (알림)
3. **외부 API**: Clearbit Logo API만 허용
4. **Isolate 주의**: `compute()` 내부에서 Flutter 바인딩/Context 접근 불가
- **NEVER include AI tool attribution in commit messages** ## Known Patterns
- **Focus on what was changed and why**
- **Use present tense and imperative mood**
- **Keep the first line under 50 characters when possible**
## 🧠 Error Analysis & Rule Documentation ### 전면 광고 (AdService)
### Mandatory Process When Errors Occur ```dart
// Completer 패턴으로 광고 완료 대기
1. **Analyze root cause in detail** final completer = Completer<bool>();
2. **Document preventive rule in `.cursor/rules/error_analysis.mdc`** ad.fullScreenContentCallback = FullScreenContentCallback(
3. **Write in English including**: onAdDismissedFullScreenContent: (ad) {
- Error description and context completer.complete(true);
- Cause and reproducibility steps },
- Resolution approach );
- Rule for preventing future recurrences ad.show();
- Sample code and references to related rules return completer.future;
### Rule Writing Standards
```markdown
---
description: Clear, one-line description of what the rule enforces
globs: path/to/files/*.ext, other/path/**/*
alwaysApply: boolean
---
**Main Points in Bold**
- Sub-points with details
- Examples and explanations
``` ```
## 🏗️ Architectural Guidelines ### SMS 스캔 (Isolate)
### Clean Architecture Compliance ```dart
// Isolate 내부에서는 하드코딩 사용
// Flutter 바인딩, Context, Provider 접근 불가
return 'Unknown service'; // AppLocalizations 사용 불가
```
- **Layered structure**: `modules`, `controllers`, `services`, `repositories`, `entities` ## Response Format
- Apply **Repository Pattern** for data abstraction
- Use **Dependency Injection** (`getIt`, `inject`, etc.)
- Controllers handle business logic (not view processing)
### Code Structuring ```text
[모델명]. I have reviewed all the following rules: [규칙]. Proceeding with the task. Master!
- **One export or public declaration per file** ```
- Centralize constants, error messages, and configuration
- Make **all shared logic reusable** and place in dedicated helper modules
## 🌲 UI Structure & Component Design
### UI Optimization Principles
- **Avoid deeply nested widget/component trees**:
- Flatten hierarchy for **better performance and readability**
- Easier **state management and testability**
- **Split large components into small, focused widgets/components**
- Use `const` constructors (or equivalents) for performance optimization
- Apply clear **naming and separation** between view, logic, and data layers
## 📈 Continuous Rule Improvement
### Rule Improvement Triggers
- New code patterns not covered by existing rules
- Repeated similar implementations across files
- Common error patterns that could be prevented
- New libraries or tools being used consistently
- Emerging best practices in the codebase
### Rule Update Criteria
**Add New Rules When:**
- A new technology/pattern is used in 3+ files
- Common bugs could be prevented by a rule
- Code reviews repeatedly mention the same feedback
**Modify Existing Rules When:**
- Better examples exist in the codebase
- Additional edge cases are discovered
- Related rules have been updated
## ✅ Quality Validation Checklist
Before completing any task, confirm:
- ✅ All three phases completed sequentially
- ✅ Each phase output meets specified format requirements
- ✅ Implementation satisfies all acceptance criteria
- ✅ Code quality meets professional standards
- ✅ Started with mandatory response format
- ✅ All naming conventions followed
- ✅ Type safety ensured
- ✅ Single Responsibility Principle adhered to
## 🎯 Success Validation Framework
### Expert-Level Standards Verification
- **Minimalistic Approach**: High-quality, clean solutions without unnecessary complexity
- **Professional Standards**: Every output meets industry-standard software engineering practices
- **Concrete Results**: Specific, actionable details at each step
### Final Quality Gates
- [ ] All acceptance criteria validated
- [ ] Code follows established conventions
- [ ] Minimalistic approach maintained
- [ ] Expert-level implementation standards met
- [ ] Korean comments and documentation provided
- [ ] English code and variable names used consistently

View File

@@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
load(FileInputStream(keystorePropertiesFile))
}
}
android { android {
namespace = "com.naturebridgeai.digitalrentmanager" namespace = "com.naturebridgeai.digitalrentmanager"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -31,11 +41,22 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
if (keystoreProperties.isNotEmpty()) {
create("release") {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. if (signingConfigs.findByName("release") != null) {
// Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("release")
signingConfig = signingConfigs.getByName("debug") }
} }
} }
} }
@@ -45,5 +66,5 @@ flutter {
} }
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
} }

View File

@@ -1,8 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_SMS" /> <uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 재부팅 후 예약 복구를 위해 필요 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 정확 알람(결제/캘린더 등 정확 시각 필요시) 사용 -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application <application
android:label="구독 관리" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -13,7 +19,9 @@
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize"
android:showWhenLocked="true"
android:turnScreenOn="true">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues
@@ -36,6 +44,20 @@
<meta-data <meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID" android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-6691216385521068~6638409932" /> android:value="ca-app-pub-6691216385521068~6638409932" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View File

@@ -11,6 +11,9 @@
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"delete": "Delete", "delete": "Delete",
"deleteSubscriptionTitle": "Delete Subscription",
"deleteSubscriptionMessage": "Are you sure you want to delete @ subscription?",
"deleteIrreversibleWarning": "This action cannot be undone",
"edit": "Edit", "edit": "Edit",
"totalSubscriptions": "Total Subscriptions", "totalSubscriptions": "Total Subscriptions",
"totalMonthlyExpense": "Total Monthly Expense", "totalMonthlyExpense": "Total Monthly Expense",
@@ -25,10 +28,35 @@
"selectIcon": "Select Icon", "selectIcon": "Select Icon",
"addCategory": "Add Category", "addCategory": "Add Category",
"settings": "Settings", "settings": "Settings",
"theme": "Theme",
"darkMode": "Dark Mode", "darkMode": "Dark Mode",
"language": "Language", "language": "Language",
"notifications": "Notifications", "notifications": "Notifications",
"appLock": "App Lock", "appLock": "App Lock",
"appLocked": "App is locked",
"paymentCard": "Payment Card",
"paymentCardManagement": "Payment Card Management",
"paymentCardManagementDescription": "Manage saved cards for subscriptions",
"addPaymentCard": "Add Payment Card",
"editPaymentCard": "Edit Payment Card",
"paymentCardIssuer": "Card Name / Issuer",
"paymentCardLast4": "Last 4 Digits",
"paymentCardColor": "Card Color",
"paymentCardIcon": "Card Icon",
"setAsDefaultCard": "Set as default card",
"paymentCardUnassigned": "Unassigned",
"addNewCard": "Add New Card",
"managePaymentCards": "Manage Cards",
"choosePaymentCard": "Choose Payment Card",
"analysisCardFilterLabel": "Filter by payment card",
"analysisCardFilterAll": "All cards",
"cardDefaultBadge": "Default",
"noPaymentCards": "No payment cards saved yet.",
"detectedPaymentCard": "Card Detected",
"detectedPaymentCardDescription": "@ was detected from SMS.",
"addDetectedPaymentCard": "Add Card",
"paymentCardUnassignedWarning": "Without a card selection this subscription will be saved as \"Unassigned\".",
"areYouSure": "Are you sure?",
"notificationPermission": "Notification Permission", "notificationPermission": "Notification Permission",
"notificationPermissionDesc": "Permission is required to receive notifications", "notificationPermissionDesc": "Permission is required to receive notifications",
"requestPermission": "Request Permission", "requestPermission": "Request Permission",
@@ -41,6 +69,7 @@
"dailyReminderEnabled": "Receive daily notifications until payment date", "dailyReminderEnabled": "Receive daily notifications until payment date",
"dailyReminderDisabled": "Receive notification @ day(s) before payment", "dailyReminderDisabled": "Receive notification @ day(s) before payment",
"notificationPermissionDenied": "Notification permission denied", "notificationPermissionDenied": "Notification permission denied",
"permissionGranted": "Permission granted.",
"appInfo": "App Info", "appInfo": "App Info",
"version": "Version", "version": "Version",
"appDescription": "Digital Rent Management App", "appDescription": "Digital Rent Management App",
@@ -60,6 +89,7 @@
"twoDaysBefore": "2 days before", "twoDaysBefore": "2 days before",
"threeDaysBefore": "3 days before", "threeDaysBefore": "3 days before",
"requiredFieldsError": "Please fill in all required fields", "requiredFieldsError": "Please fill in all required fields",
"categoryNameRequired": "Please enter category name",
"subscriptionUpdated": "Subscription information has been updated", "subscriptionUpdated": "Subscription information has been updated",
"subscriptionDeleted": "@ subscription has been deleted", "subscriptionDeleted": "@ subscription has been deleted",
"officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.", "officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.",
@@ -69,6 +99,7 @@
"changesAppliedAfterSave": "Changes will be applied after saving", "changesAppliedAfterSave": "Changes will be applied after saving",
"saveChanges": "Save Changes", "saveChanges": "Save Changes",
"monthlyExpense": "Monthly Expense", "monthlyExpense": "Monthly Expense",
"billingAmount": "Billing Amount",
"websiteUrl": "Website URL", "websiteUrl": "Website URL",
"websiteUrlOptional": "Website URL (Optional)", "websiteUrlOptional": "Website URL (Optional)",
"eventPrice": "Event Price", "eventPrice": "Event Price",
@@ -88,6 +119,7 @@
"appLockDesc": "App lock with biometric authentication", "appLockDesc": "App lock with biometric authentication",
"unlockWithBiometric": "Unlock with biometric authentication", "unlockWithBiometric": "Unlock with biometric authentication",
"authenticationFailed": "Authentication failed. Please try again.", "authenticationFailed": "Authentication failed. Please try again.",
"nextBillingDateAdjusted": "Saved as the next billing date",
"totalExpenseCopied": "Total expense copied: @", "totalExpenseCopied": "Total expense copied: @",
"smsPermissionRequired": "SMS permission required", "smsPermissionRequired": "SMS permission required",
"noSubscriptionSmsFound": "No subscription related SMS found", "noSubscriptionSmsFound": "No subscription related SMS found",
@@ -126,10 +158,13 @@
"repeatSubscriptionNotFound": "No repeated subscription information found.", "repeatSubscriptionNotFound": "No repeated subscription information found.",
"newSubscriptionNotFound": "No new subscription SMS found", "newSubscriptionNotFound": "No new subscription SMS found",
"findRepeatSubscriptions": "Find subscriptions paid 2+ times", "findRepeatSubscriptions": "Find subscriptions paid 2+ times",
"scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.", "scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions.\nService names and amounts can be extracted for easy subscription addition.\nThis auto-detection feature is still under development, and might miss or misidentify some subscriptions.\nPlease review the detected results and add or edit subscriptions manually if needed.",
"startScanning": "Start Scanning", "startScanning": "Start Scanning",
"foundSubscription": "Found subscription", "foundSubscription": "Found subscription",
"latestSmsMessage": "Latest SMS message",
"smsDetectedDate": "Detected on @",
"serviceName": "Service Name", "serviceName": "Service Name",
"unknownService": "Unknown service",
"nextBillingDateLabel": "Next Billing Date", "nextBillingDateLabel": "Next Billing Date",
"category": "Category", "category": "Category",
"websiteUrlAuto": "Website URL (Auto-extracted)", "websiteUrlAuto": "Website URL (Auto-extracted)",
@@ -147,6 +182,7 @@
"estimatedAnnualCost": "Estimated Annual Cost", "estimatedAnnualCost": "Estimated Annual Cost",
"totalSubscriptionServices": "Total Subscription Services", "totalSubscriptionServices": "Total Subscription Services",
"eventDiscountActive": "Event Discount Active", "eventDiscountActive": "Event Discount Active",
"eventDiscountEndsBeforeBilling": "Event discount ends before billing date",
"saving": "Saving", "saving": "Saving",
"paymentDueToday": "Payment Due Today", "paymentDueToday": "Payment Due Today",
"paymentDueInDays": "Payment due in @ days", "paymentDueInDays": "Payment due in @ days",
@@ -199,7 +235,7 @@
"cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.", "cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.",
"goToCancelPage": "Go to Cancellation Page", "goToCancelPage": "Go to Cancellation Page",
"urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name", "urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name",
"discountPercent": "@% discount", "discountPercent": "% discount",
"discountAmountWon": "Save ₩@", "discountAmountWon": "Save ₩@",
"discountAmountDollar": "Save $@", "discountAmountDollar": "Save $@",
"discountAmountYen": "Save ¥@", "discountAmountYen": "Save ¥@",
@@ -216,8 +252,12 @@
"subscriptionDetail": "Subscription Detail", "subscriptionDetail": "Subscription Detail",
"enterAmount": "Enter amount", "enterAmount": "Enter amount",
"invalidAmount": "Please enter a valid amount", "invalidAmount": "Please enter a valid amount",
"featureComingSoon": "This feature is coming soon" "featureComingSoon": "This feature is coming soon",
, "exactAlarmPermission": "Exact alarm permission (Alarms & Reminders)",
"exactAlarmPermissionDesc": "We need permission to guarantee precise alarms.",
"allowAlarmsInSettings": "Please allow \"Alarms & reminders\" in Settings.",
"testNotification": "Test notification",
"testSubscriptionBody": "Test subscription • @",
"smsPermissionTitle": "Request SMS Permission", "smsPermissionTitle": "Request SMS Permission",
"smsPermissionReasonTitle": "Why", "smsPermissionReasonTitle": "Why",
"smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.", "smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.",
@@ -227,7 +267,11 @@
"openSettings": "Open Settings", "openSettings": "Open Settings",
"later": "Later", "later": "Later",
"requesting": "Requesting...", "requesting": "Requesting...",
"smsPermissionLabel": "SMS Permission" "smsPermissionLabel": "SMS Permission",
"expirationReminderBody": "@ subscription expires in # days.",
"eventEndNotificationTitle": "Event end notification",
"eventEndNotificationBody": "@'s discount event has ended.",
"paymentChargeNotification": "@ subscription charge @ was completed."
}, },
"ko": { "ko": {
"appTitle": "디지털 월세 관리자", "appTitle": "디지털 월세 관리자",
@@ -241,6 +285,9 @@
"save": "저장", "save": "저장",
"cancel": "취소", "cancel": "취소",
"delete": "삭제", "delete": "삭제",
"deleteSubscriptionTitle": "구독 삭제",
"deleteSubscriptionMessage": "정말로 @ 구독을 삭제하시겠습니까?",
"deleteIrreversibleWarning": "이 작업은 되돌릴 수 없습니다",
"edit": "수정", "edit": "수정",
"totalSubscriptions": "총 구독", "totalSubscriptions": "총 구독",
"totalMonthlyExpense": "이번 달 총 지출", "totalMonthlyExpense": "이번 달 총 지출",
@@ -255,10 +302,35 @@
"selectIcon": "아이콘 선택", "selectIcon": "아이콘 선택",
"addCategory": "카테고리 추가", "addCategory": "카테고리 추가",
"settings": "설정", "settings": "설정",
"theme": "테마",
"darkMode": "다크 모드", "darkMode": "다크 모드",
"language": "언어", "language": "언어",
"notifications": "알림", "notifications": "알림",
"appLock": "앱 잠금", "appLock": "앱 잠금",
"appLocked": "앱이 잠겨 있습니다",
"paymentCard": "결제수단",
"paymentCardManagement": "결제수단 관리",
"paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다",
"addPaymentCard": "결제수단 추가",
"editPaymentCard": "결제수단 수정",
"paymentCardIssuer": "카드 이름 / 발급사",
"paymentCardLast4": "마지막 4자리",
"paymentCardColor": "카드 색상",
"paymentCardIcon": "아이콘",
"setAsDefaultCard": "기본 결제수단으로 설정",
"paymentCardUnassigned": "미지정",
"addNewCard": "새 카드 추가",
"managePaymentCards": "결제수단 관리",
"choosePaymentCard": "결제수단 선택",
"analysisCardFilterLabel": "결제수단별 보기",
"analysisCardFilterAll": "모든 결제수단",
"cardDefaultBadge": "기본",
"noPaymentCards": "등록된 결제수단이 없습니다.",
"detectedPaymentCard": "감지된 결제수단",
"detectedPaymentCardDescription": "SMS에서 @ 이(가) 감지되었습니다.",
"addDetectedPaymentCard": "카드 추가",
"paymentCardUnassignedWarning": "결제수단을 선택하지 않으면 '미지정'으로 저장됩니다.",
"areYouSure": "정말 진행하시겠어요?",
"notificationPermission": "알림 권한", "notificationPermission": "알림 권한",
"notificationPermissionDesc": "알림을 받으려면 권한이 필요합니다", "notificationPermissionDesc": "알림을 받으려면 권한이 필요합니다",
"requestPermission": "권한 요청", "requestPermission": "권한 요청",
@@ -271,6 +343,7 @@
"dailyReminderEnabled": "결제일까지 매일 알림을 받습니다", "dailyReminderEnabled": "결제일까지 매일 알림을 받습니다",
"dailyReminderDisabled": "결제 @일 전에 알림을 받습니다", "dailyReminderDisabled": "결제 @일 전에 알림을 받습니다",
"notificationPermissionDenied": "알림 권한이 거부되었습니다", "notificationPermissionDenied": "알림 권한이 거부되었습니다",
"permissionGranted": "권한이 허용되었습니다.",
"appInfo": "앱 정보", "appInfo": "앱 정보",
"version": "버전", "version": "버전",
"appDescription": "디지털 월세 관리 앱", "appDescription": "디지털 월세 관리 앱",
@@ -290,6 +363,7 @@
"twoDaysBefore": "2일 전", "twoDaysBefore": "2일 전",
"threeDaysBefore": "3일 전", "threeDaysBefore": "3일 전",
"requiredFieldsError": "필수 항목을 모두 입력해주세요", "requiredFieldsError": "필수 항목을 모두 입력해주세요",
"categoryNameRequired": "카테고리 이름을 입력하세요",
"subscriptionUpdated": "구독 정보가 업데이트되었습니다.", "subscriptionUpdated": "구독 정보가 업데이트되었습니다.",
"subscriptionDeleted": "@ 구독이 삭제되었습니다.", "subscriptionDeleted": "@ 구독이 삭제되었습니다.",
"officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.", "officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.",
@@ -299,6 +373,7 @@
"changesAppliedAfterSave": "변경사항은 저장 후 적용됩니다", "changesAppliedAfterSave": "변경사항은 저장 후 적용됩니다",
"saveChanges": "변경사항 저장", "saveChanges": "변경사항 저장",
"monthlyExpense": "월 지출", "monthlyExpense": "월 지출",
"billingAmount": "결제 금액",
"websiteUrl": "웹사이트 URL", "websiteUrl": "웹사이트 URL",
"websiteUrlOptional": "웹사이트 URL (선택)", "websiteUrlOptional": "웹사이트 URL (선택)",
"eventPrice": "이벤트 가격", "eventPrice": "이벤트 가격",
@@ -318,6 +393,7 @@
"appLockDesc": "생체 인증으로 앱 잠금", "appLockDesc": "생체 인증으로 앱 잠금",
"unlockWithBiometric": "생체 인증으로 잠금 해제", "unlockWithBiometric": "생체 인증으로 잠금 해제",
"authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.", "authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.",
"nextBillingDateAdjusted": "다음 결제 예정일로 저장됨",
"totalExpenseCopied": "총 지출액이 복사되었습니다: @", "totalExpenseCopied": "총 지출액이 복사되었습니다: @",
"smsPermissionRequired": "SMS 권한이 필요합니다.", "smsPermissionRequired": "SMS 권한이 필요합니다.",
"noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.", "noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.",
@@ -356,10 +432,13 @@
"repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.", "repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.",
"newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다", "newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다",
"findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기", "findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기",
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.", "scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.",
"startScanning": "스캔 시작하기", "startScanning": "스캔 시작하기",
"foundSubscription": "다음 구독을 찾았습니다", "foundSubscription": "다음 구독을 찾았습니다",
"latestSmsMessage": "최신 SMS 메시지",
"smsDetectedDate": "SMS 수신일: @",
"serviceName": "서비스명", "serviceName": "서비스명",
"unknownService": "알 수 없는 서비스",
"nextBillingDateLabel": "다음 결제일", "nextBillingDateLabel": "다음 결제일",
"category": "카테고리", "category": "카테고리",
"websiteUrlAuto": "웹사이트 URL (자동 추출됨)", "websiteUrlAuto": "웹사이트 URL (자동 추출됨)",
@@ -377,6 +456,7 @@
"estimatedAnnualCost": "예상 연간 구독 비용", "estimatedAnnualCost": "예상 연간 구독 비용",
"totalSubscriptionServices": "총 구독 서비스", "totalSubscriptionServices": "총 구독 서비스",
"eventDiscountActive": "이벤트 할인 중", "eventDiscountActive": "이벤트 할인 중",
"eventDiscountEndsBeforeBilling": "이벤트 할인이 결제일 전에 종료됩니다",
"saving": "절약", "saving": "절약",
"paymentDueToday": "오늘 결제 예정", "paymentDueToday": "오늘 결제 예정",
"paymentDueInDays": "@일 후 결제 예정", "paymentDueInDays": "@일 후 결제 예정",
@@ -429,7 +509,7 @@
"cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.", "cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.",
"goToCancelPage": "해지 페이지로 이동", "goToCancelPage": "해지 페이지로 이동",
"urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다", "urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다",
"discountPercent": "@% 할인", "discountPercent": "% 할인",
"discountAmountWon": "₩@원 절약", "discountAmountWon": "₩@원 절약",
"discountAmountDollar": "$@ 절약", "discountAmountDollar": "$@ 절약",
"discountAmountYen": "¥@ 절약", "discountAmountYen": "¥@ 절약",
@@ -446,8 +526,12 @@
"subscriptionDetail": "구독 상세", "subscriptionDetail": "구독 상세",
"enterAmount": "금액을 입력하세요", "enterAmount": "금액을 입력하세요",
"invalidAmount": "올바른 금액을 입력해주세요", "invalidAmount": "올바른 금액을 입력해주세요",
"featureComingSoon": "이 기능은 곧 출시됩니다" "featureComingSoon": "이 기능은 곧 출시됩니다",
, "exactAlarmPermission": "정확 알람 권한(알람 및 리마인더)",
"exactAlarmPermissionDesc": "정확한 시각에 알림을 보장하려면 권한이 필요합니다.",
"allowAlarmsInSettings": "설정에서 \"알람 및 리마인더\"를 허용해 주세요.",
"testNotification": "테스트 알림",
"testSubscriptionBody": "테스트 구독 • @",
"smsPermissionTitle": "SMS 권한 요청", "smsPermissionTitle": "SMS 권한 요청",
"smsPermissionReasonTitle": "이유", "smsPermissionReasonTitle": "이유",
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.", "smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
@@ -457,7 +541,11 @@
"openSettings": "설정 열기", "openSettings": "설정 열기",
"later": "나중에 하기", "later": "나중에 하기",
"requesting": "요청 중...", "requesting": "요청 중...",
"smsPermissionLabel": "SMS 권한" "smsPermissionLabel": "SMS 권한",
"expirationReminderBody": "@ 구독이 #일 후 만료됩니다.",
"eventEndNotificationTitle": "이벤트 종료 알림",
"eventEndNotificationBody": "@의 할인 이벤트가 종료되었습니다.",
"paymentChargeNotification": "@ 구독료 @이 결제되었습니다."
}, },
"ja": { "ja": {
"appTitle": "デジタル月額管理者", "appTitle": "デジタル月額管理者",
@@ -471,6 +559,9 @@
"save": "保存", "save": "保存",
"cancel": "キャンセル", "cancel": "キャンセル",
"delete": "削除", "delete": "削除",
"deleteSubscriptionTitle": "サブスクリプション削除",
"deleteSubscriptionMessage": "本当に@のサブスクリプションを削除しますか?",
"deleteIrreversibleWarning": "この操作は取り消せません",
"edit": "編集", "edit": "編集",
"totalSubscriptions": "総サブスクリプション", "totalSubscriptions": "総サブスクリプション",
"totalMonthlyExpense": "今月の総支出", "totalMonthlyExpense": "今月の総支出",
@@ -485,10 +576,35 @@
"selectIcon": "アイコンを選択", "selectIcon": "アイコンを選択",
"addCategory": "カテゴリー追加", "addCategory": "カテゴリー追加",
"settings": "設定", "settings": "設定",
"theme": "テーマ",
"darkMode": "ダークモード", "darkMode": "ダークモード",
"language": "言語", "language": "言語",
"notifications": "通知", "notifications": "通知",
"appLock": "アプリロック", "appLock": "アプリロック",
"appLocked": "アプリがロックされています",
"paymentCard": "支払いカード",
"paymentCardManagement": "支払いカード管理",
"paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します",
"addPaymentCard": "カードを追加",
"editPaymentCard": "カードを編集",
"paymentCardIssuer": "カード名 / 発行会社",
"paymentCardLast4": "下4桁",
"paymentCardColor": "カードカラー",
"paymentCardIcon": "アイコン",
"setAsDefaultCard": "既定のカードとして設定",
"paymentCardUnassigned": "未設定",
"addNewCard": "新しいカードを追加",
"managePaymentCards": "カードを管理",
"choosePaymentCard": "支払いカードを選択",
"analysisCardFilterLabel": "支払いカード別に表示",
"analysisCardFilterAll": "すべてのカード",
"cardDefaultBadge": "既定",
"noPaymentCards": "登録されたカードがありません。",
"detectedPaymentCard": "検出されたカード",
"detectedPaymentCardDescription": "SMS から @ が検出されました。",
"addDetectedPaymentCard": "カードを追加",
"paymentCardUnassignedWarning": "カードを選択しない場合は「未設定」として保存されます。",
"areYouSure": "よろしいですか?",
"notificationPermission": "通知権限", "notificationPermission": "通知権限",
"notificationPermissionDesc": "通知を受け取るには権限が必要です", "notificationPermissionDesc": "通知を受け取るには権限が必要です",
"requestPermission": "権限をリクエスト", "requestPermission": "権限をリクエスト",
@@ -501,6 +617,7 @@
"dailyReminderEnabled": "支払い日まで毎日通知を受け取ります", "dailyReminderEnabled": "支払い日まで毎日通知を受け取ります",
"dailyReminderDisabled": "支払い@日前に通知を受け取ります", "dailyReminderDisabled": "支払い@日前に通知を受け取ります",
"notificationPermissionDenied": "通知権限が拒否されました", "notificationPermissionDenied": "通知権限が拒否されました",
"permissionGranted": "権限が許可されました。",
"appInfo": "アプリ情報", "appInfo": "アプリ情報",
"version": "バージョン", "version": "バージョン",
"appDescription": "デジタル月額管理アプリ", "appDescription": "デジタル月額管理アプリ",
@@ -520,6 +637,7 @@
"twoDaysBefore": "2日前", "twoDaysBefore": "2日前",
"threeDaysBefore": "3日前", "threeDaysBefore": "3日前",
"requiredFieldsError": "すべての必須項目を入力してください", "requiredFieldsError": "すべての必須項目を入力してください",
"categoryNameRequired": "カテゴリ名を入力してください",
"subscriptionUpdated": "サブスクリプション情報が更新されました", "subscriptionUpdated": "サブスクリプション情報が更新されました",
"subscriptionDeleted": "@サブスクリプションが削除されました", "subscriptionDeleted": "@サブスクリプションが削除されました",
"officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。", "officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。",
@@ -529,6 +647,7 @@
"changesAppliedAfterSave": "変更は保存後に適用されます", "changesAppliedAfterSave": "変更は保存後に適用されます",
"saveChanges": "変更を保存", "saveChanges": "変更を保存",
"monthlyExpense": "月額支出", "monthlyExpense": "月額支出",
"billingAmount": "請求金額",
"websiteUrl": "ウェブサイトURL", "websiteUrl": "ウェブサイトURL",
"websiteUrlOptional": "ウェブサイトURLオプション", "websiteUrlOptional": "ウェブサイトURLオプション",
"eventPrice": "イベント価格", "eventPrice": "イベント価格",
@@ -548,6 +667,7 @@
"appLockDesc": "生体認証でアプリをロック", "appLockDesc": "生体認証でアプリをロック",
"unlockWithBiometric": "生体認証でロック解除", "unlockWithBiometric": "生体認証でロック解除",
"authenticationFailed": "認証に失敗しました。もう一度お試しください。", "authenticationFailed": "認証に失敗しました。もう一度お試しください。",
"nextBillingDateAdjusted": "次回請求日に保存しました",
"totalExpenseCopied": "総支出がコピーされました:@", "totalExpenseCopied": "総支出がコピーされました:@",
"smsPermissionRequired": "SMS権限が必要です", "smsPermissionRequired": "SMS権限が必要です",
"noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません", "noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません",
@@ -586,10 +706,13 @@
"repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。", "repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。",
"newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません", "newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません",
"findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索", "findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索",
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。サービス名と金額を抽出して簡単にサブスクリプションを追加できます。", "scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。",
"startScanning": "スキャン開始", "startScanning": "スキャン開始",
"foundSubscription": "サブスクリプションが見つかりました", "foundSubscription": "サブスクリプションが見つかりました",
"latestSmsMessage": "最新のSMSメッセージ",
"smsDetectedDate": "SMS受信日: @",
"serviceName": "サービス名", "serviceName": "サービス名",
"unknownService": "不明なサービス",
"nextBillingDateLabel": "次回請求日", "nextBillingDateLabel": "次回請求日",
"category": "カテゴリー", "category": "カテゴリー",
"websiteUrlAuto": "ウェブサイトURL自動抽出", "websiteUrlAuto": "ウェブサイトURL自動抽出",
@@ -607,6 +730,7 @@
"estimatedAnnualCost": "予想年間サブスクリプション費用", "estimatedAnnualCost": "予想年間サブスクリプション費用",
"totalSubscriptionServices": "総サブスクリプションサービス", "totalSubscriptionServices": "総サブスクリプションサービス",
"eventDiscountActive": "イベント割引中", "eventDiscountActive": "イベント割引中",
"eventDiscountEndsBeforeBilling": "請求日前にイベント割引が終了します",
"saving": "節約", "saving": "節約",
"paymentDueToday": "本日支払い予定", "paymentDueToday": "本日支払い予定",
"paymentDueInDays": "@日後に支払い予定", "paymentDueInDays": "@日後に支払い予定",
@@ -659,7 +783,7 @@
"cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。", "cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。",
"goToCancelPage": "解約ページへ移動", "goToCancelPage": "解約ページへ移動",
"urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます", "urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます",
"discountPercent": "@%割引", "discountPercent": "%割引",
"discountAmountWon": "₩@節約", "discountAmountWon": "₩@節約",
"discountAmountDollar": "$@節約", "discountAmountDollar": "$@節約",
"discountAmountYen": "¥@節約", "discountAmountYen": "¥@節約",
@@ -676,7 +800,16 @@
"subscriptionDetail": "サブスクリプション詳細", "subscriptionDetail": "サブスクリプション詳細",
"enterAmount": "金額を入力してください", "enterAmount": "金額を入力してください",
"invalidAmount": "正しい金額を入力してください", "invalidAmount": "正しい金額を入力してください",
"featureComingSoon": "この機能は近日公開予定です" "featureComingSoon": "この機能は近日公開予定です",
"exactAlarmPermission": "正確なアラーム権限(アラームとリマインダー)",
"exactAlarmPermissionDesc": "正確な時刻に通知するには権限が必要です。",
"allowAlarmsInSettings": "設定で「アラームとリマインダー」を許可してください。",
"testNotification": "テスト通知",
"testSubscriptionBody": "テストサブスクリプション • @",
"expirationReminderBody": "@ のサブスクリプションは #日後に期限切れになります。",
"eventEndNotificationTitle": "イベント終了通知",
"eventEndNotificationBody": "@ の割引イベントが終了しました。",
"paymentChargeNotification": "@ の購読料 @ が請求されました。"
}, },
"zh": { "zh": {
"appTitle": "数字月租管理器", "appTitle": "数字月租管理器",
@@ -690,6 +823,9 @@
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"delete": "删除", "delete": "删除",
"deleteSubscriptionTitle": "删除订阅",
"deleteSubscriptionMessage": "确定要删除@订阅吗?",
"deleteIrreversibleWarning": "此操作无法撤销",
"edit": "编辑", "edit": "编辑",
"totalSubscriptions": "订阅总数", "totalSubscriptions": "订阅总数",
"totalMonthlyExpense": "本月总支出", "totalMonthlyExpense": "本月总支出",
@@ -704,10 +840,35 @@
"selectIcon": "选择图标", "selectIcon": "选择图标",
"addCategory": "添加分类", "addCategory": "添加分类",
"settings": "设置", "settings": "设置",
"theme": "主题",
"darkMode": "深色模式", "darkMode": "深色模式",
"language": "语言", "language": "语言",
"notifications": "通知", "notifications": "通知",
"appLock": "应用锁定", "appLock": "应用锁定",
"appLocked": "应用已锁定",
"paymentCard": "支付卡",
"paymentCardManagement": "支付卡管理",
"paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)",
"addPaymentCard": "添加支付卡",
"editPaymentCard": "编辑支付卡",
"paymentCardIssuer": "卡名称/发卡行",
"paymentCardLast4": "后四位",
"paymentCardColor": "卡片颜色",
"paymentCardIcon": "图标",
"setAsDefaultCard": "设为默认卡",
"paymentCardUnassigned": "未指定",
"addNewCard": "新增卡片",
"managePaymentCards": "管理卡片",
"choosePaymentCard": "选择支付卡",
"analysisCardFilterLabel": "按支付卡筛选",
"analysisCardFilterAll": "所有支付卡",
"cardDefaultBadge": "默认",
"noPaymentCards": "尚未保存任何支付卡。",
"detectedPaymentCard": "检测到的支付卡",
"detectedPaymentCardDescription": "短信检测到 @。",
"addDetectedPaymentCard": "添加卡片",
"paymentCardUnassignedWarning": "未选择支付卡时将以\"未指定\"保存。",
"areYouSure": "确定要继续吗?",
"notificationPermission": "通知权限", "notificationPermission": "通知权限",
"notificationPermissionDesc": "需要权限才能接收通知", "notificationPermissionDesc": "需要权限才能接收通知",
"requestPermission": "请求权限", "requestPermission": "请求权限",
@@ -720,6 +881,7 @@
"dailyReminderEnabled": "直到付款日期每天接收通知", "dailyReminderEnabled": "直到付款日期每天接收通知",
"dailyReminderDisabled": "在付款@天前接收通知", "dailyReminderDisabled": "在付款@天前接收通知",
"notificationPermissionDenied": "通知权限被拒绝", "notificationPermissionDenied": "通知权限被拒绝",
"permissionGranted": "已获得权限。",
"appInfo": "应用信息", "appInfo": "应用信息",
"version": "版本", "version": "版本",
"appDescription": "数字月租管理应用", "appDescription": "数字月租管理应用",
@@ -739,6 +901,7 @@
"twoDaysBefore": "2天前", "twoDaysBefore": "2天前",
"threeDaysBefore": "3天前", "threeDaysBefore": "3天前",
"requiredFieldsError": "请填写所有必填项", "requiredFieldsError": "请填写所有必填项",
"categoryNameRequired": "请输入分类名称",
"subscriptionUpdated": "订阅信息已更新", "subscriptionUpdated": "订阅信息已更新",
"subscriptionDeleted": "@订阅已删除", "subscriptionDeleted": "@订阅已删除",
"officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。", "officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。",
@@ -748,6 +911,7 @@
"changesAppliedAfterSave": "更改将在保存后应用", "changesAppliedAfterSave": "更改将在保存后应用",
"saveChanges": "保存更改", "saveChanges": "保存更改",
"monthlyExpense": "每月支出", "monthlyExpense": "每月支出",
"billingAmount": "账单金额",
"websiteUrl": "网站URL", "websiteUrl": "网站URL",
"websiteUrlOptional": "网站URL可选", "websiteUrlOptional": "网站URL可选",
"eventPrice": "活动价格", "eventPrice": "活动价格",
@@ -767,6 +931,7 @@
"appLockDesc": "使用生物识别锁定应用", "appLockDesc": "使用生物识别锁定应用",
"unlockWithBiometric": "使用生物识别解锁", "unlockWithBiometric": "使用生物识别解锁",
"authenticationFailed": "认证失败。请重试。", "authenticationFailed": "认证失败。请重试。",
"nextBillingDateAdjusted": "已保存为下一次账单日",
"totalExpenseCopied": "总支出已复制:@", "totalExpenseCopied": "总支出已复制:@",
"smsPermissionRequired": "需要短信权限", "smsPermissionRequired": "需要短信权限",
"noSubscriptionSmsFound": "未找到订阅相关的短信", "noSubscriptionSmsFound": "未找到订阅相关的短信",
@@ -805,10 +970,13 @@
"repeatSubscriptionNotFound": "未找到重复付款的订阅信息。", "repeatSubscriptionNotFound": "未找到重复付款的订阅信息。",
"newSubscriptionNotFound": "未找到新订阅短信", "newSubscriptionNotFound": "未找到新订阅短信",
"findRepeatSubscriptions": "查找支付2次以上的订阅", "findRepeatSubscriptions": "查找支付2次以上的订阅",
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。可以提取服务名称和金额,轻松添加订阅。", "scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额,轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果并在需要时手动添加或修改。",
"startScanning": "开始扫描", "startScanning": "开始扫描",
"foundSubscription": "找到订阅", "foundSubscription": "找到订阅",
"latestSmsMessage": "最新短信内容",
"smsDetectedDate": "短信接收日期:@",
"serviceName": "服务名称", "serviceName": "服务名称",
"unknownService": "未知服务",
"nextBillingDateLabel": "下次付款日期", "nextBillingDateLabel": "下次付款日期",
"category": "类别", "category": "类别",
"websiteUrlAuto": "网站URL自动提取", "websiteUrlAuto": "网站URL自动提取",
@@ -826,6 +994,7 @@
"estimatedAnnualCost": "预计年度订阅费用", "estimatedAnnualCost": "预计年度订阅费用",
"totalSubscriptionServices": "总订阅服务", "totalSubscriptionServices": "总订阅服务",
"eventDiscountActive": "活动折扣中", "eventDiscountActive": "活动折扣中",
"eventDiscountEndsBeforeBilling": "活动折扣将在账单日之前结束",
"saving": "节省", "saving": "节省",
"paymentDueToday": "今日付款到期", "paymentDueToday": "今日付款到期",
"paymentDueInDays": "@天后付款到期", "paymentDueInDays": "@天后付款到期",
@@ -878,7 +1047,7 @@
"cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。", "cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。",
"goToCancelPage": "前往取消页面", "goToCancelPage": "前往取消页面",
"urlAutoMatchInfo": "如果URL为空将根据服务名称自动匹配", "urlAutoMatchInfo": "如果URL为空将根据服务名称自动匹配",
"discountPercent": "@%折扣", "discountPercent": "%折扣",
"discountAmountWon": "节省₩@", "discountAmountWon": "节省₩@",
"discountAmountDollar": "节省$@", "discountAmountDollar": "节省$@",
"discountAmountYen": "节省¥@", "discountAmountYen": "节省¥@",
@@ -895,6 +1064,15 @@
"subscriptionDetail": "订阅详情", "subscriptionDetail": "订阅详情",
"enterAmount": "请输入金额", "enterAmount": "请输入金额",
"invalidAmount": "请输入有效的金额", "invalidAmount": "请输入有效的金额",
"featureComingSoon": "此功能即将推出" "featureComingSoon": "此功能即将推出",
"exactAlarmPermission": "精确闹钟权限(闹钟和提醒)",
"exactAlarmPermissionDesc": "需要权限以确保在准确时间发送提醒。",
"allowAlarmsInSettings": "请在设置中允许“闹钟和提醒”。",
"testNotification": "测试通知",
"testSubscriptionBody": "测试订阅 • @",
"expirationReminderBody": "@ 订阅将在 # 天后到期。",
"eventEndNotificationTitle": "活动结束通知",
"eventEndNotificationBody": "@ 的优惠活动已结束。",
"paymentChargeNotification": "@ 订阅费用 @ 已扣款。"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,83 @@
아하, 그런 구조라면 “로컬 저장 + 광고 SDK만 있음” 패턴으로 쓰면 되겠네요.
---
디지털 월세 관리자 개인정보 처리방
디지털 월세 관리자(이하 “앱”)는 사용자의 개인정보 보호를 최우선으로 합니다.
이 앱은 사용자의 데이터를 외부 서버로 전송하지 않으며, 모든 사용 데이터는 사용자의 기기 내에만 저장됩니다.
본 개인정보 처리방침은 앱이 어떤 정보를 어떻게 처리하는지 설명합니다.
---
1. 수집하는 개인정보
이 앱은 다음과 같은 의미에서 사용자를 식별할 수 있는 개인정보(이름, 이메일, 전화번호 등)를 직접 수집하지 않습니다
* 회원가입, 로그인 기능이 없습니다.
* 이메일, 전화번호, 주소 등의 정보를 요구하지 않습니다.
* 개발자가 운영하는 서버로 어떠한 사용자 정보도 전송하지 않습니다.
다만, 사용자가 앱 내에서 입력한 구독·월세·지출 관련 정보는 오직 사용자의 기기 내에만 저 되며, 개발자는 해당 내용에 접근할 수 없습니다.
---
2. 데이터 저장 및 처리 방식
* 사용자가 입력한 모든 데이터는 로컬 저장소(기기 내 저장소, 데이터베이스 등 에만 보관됩니다.
* 앱은 사용자의 데이터를 클라우드 서버나 외부 서비스로 자동 전송하지 않습니다.
* 앱을 삭제하면, 기기에 저장된 데이터도 함께 삭제됩니다.
---
3. 광고 및 제3자 서비스
이 앱은 무료 제공을 위해 광고를 표 할 수 있으며, 이 과정에서 제3자 광고 네트워크(예: Google AdMob 등 가 참여할 수 있습니다.
개발자는 직접 사용자의 개인정보를 수집하지 않지만, 광고 네트워크는 다음과 같은 정 를 수집·처리할 수 있습니다.
* 광고 식별자(예: Android 광고 ID)
* 기기 정보(단말기 모델, OS 버전 등)
* 대략적인 위치 정보(국가/지역 수준)
* 앱 사용 정보(광고 조회/클릭 여부 등)
제3자 광고 파트너의 데이터 처리 방식과 수집 항목은 각 서비스의 개인정보 처리방침을 따릅니다.
자세한 내용은 사용 중인 광고 네트워크(예: Google AdMob)의 정책을 참고하시기 바랍니다.
---
4. 권한 사용
앱은 기능 제공을 위해 다음과 같은 권한을 사용할 수 있습니다.
* 알림 권한: 결제 예정 알림 등 앱 내 알림 기능 제공을 위해 사용
* SMS 읽기 권한: SMS를 읽어서 구독정보를 찾기 위해 사용
* 네트워크 권한: 광고 로딩 및 앱 업데이트를 위해 사용
이 권한들은 앱 기능 및 광고 표시를 위한 용도 외에는 사용되지 않습니다
---
5. 아동의 개인정보
이 앱은 성인을 주요 대상으로 설계되었으며, 만 14세 미만(또는 각 국가의 관련 기준 미만)의 아동을 대상으로 개인정보를 수집하려는 의도가 없습니다
만약 아동의 개인정보가 부주의로 수집된 사실을 인지하게 될 경우, 가능한 한 신속히 해당 정보를 삭제하기 위한 조치를 취하겠습니다.
---
6. 개인정보 처리방침의 변경
본 개인정보 처리방침은 서비스 개선이나 관련 법령 변경 등에 따라 수정될 수 있습니다.
중요한 내용이 변경되는 경우, 앱 내 공지 또는 스토어 설명 등을 통해 변경 내용을 안내하겠습니다.
* 시행일자: 2025.11.17
---
7. 문의처
앱의 개인정보 처리방침에 대한 문의, 의견 또는 오류 신고가 필요하신 경우 아래 연락처로 문의해 주세요.
* 담당자: 네이처브릿지AI 앱개발팀
* 이메일: naturebridgeai@gmail.com
---

123
doc/ads.md Normal file
View File

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

View File

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

13
doc/key/readme.md Normal file
View File

@@ -0,0 +1,13 @@
doc/key/submanager-release.keystore를 생성했습니다. 명령에 사용된 정보: alias submanager, 키 알고리즘 RSA 2048bit, 유효기간 10,000일, 인증서 DN CN=SubManager, OU=Engineering, O=SubManager, L=Seoul, ST=Seoul, C=KR입니다. 저장/키 비밀번호는 모두 submanager로 설정했습니다.
이 키스토어를 android/key.properties 등에서 참조하도록 설정하면 release 빌드를 실제 서명 키로 빌드할 수 있습니다. (예: storeFile=../doc/key/submanager-release.keystore, storePassword=submanager, keyAlias=submanager, keyPassword=submanager).
이후 buildTypes.release에서 signingConfig = signingConfigs.getByName("release")로 연결하면 됩니다.
android/key.properties을 새로 만들어 storeFile=../../doc/key/submanager-release.keystore 등 키 정보를 등록했습니다. (storePassword, keyPassword, keyAlias 모두 submanager로 설정)
android/app/build.gradle.kts (lines 1-52)에 Properties/FileInputStream을 import하고 keystoreProperties 로드 후 signingConfigs.release를 추가했습니다. 이제 buildTypes.release가 디버그 키 대신 새 릴리스 키로 서명합니다.
doc/key/submanager-release.keystore가 키스토어 위치이며, flutter build apk --release로 서명된 릴리스 APK(build/app/outputs/flutter-apk/app-release.apk)가 정상 생성되는 것을 확인했습니다.
다음 단계로는 이 keystore와 key.properties를 안전한 곳에 보관하고, 필요 시 .gitignore 등으로 민감 파일이 VCS에 노출되지 않도록 관리하는 것을 권장합니다.

Binary file not shown.

122
doc/payment_card_plan.md Normal file
View File

@@ -0,0 +1,122 @@
# 결제수단 구분 확장 계획
## 배경
- 현재 홈 화면은 카테고리별 구독 목록만 제공하며, 결제 카드 기준으로 필터링하거나 시각적으로 구분할 수 없음.
- 사용자 요청: 카드 회사명과 마지막 4자리로 구독을 분류해 데이터/UX 양쪽 모두에서 카드별 인사이트를 제공.
## 목표
- 구독 데이터를 카드 단위로 매핑할 수 있는 스키마 확장.
- 카드 정보를 한 번만 등록하도록 관리 화면을 제공해 재사용성 확보.
- 홈 화면에서 카테고리/카드 뷰를 토글하며, 헤더·리스트·분석 카드가 카드 정보를 시각적으로 노출.
- 모든 변경은 기존 카테고리 UX를 유지하면서 점진적 도입이 가능해야 함.
## 작업 체크리스트
1. [x] `SubscriptionModel``paymentCardId` 추가, `PaymentCardModel`/`PaymentCardProvider` 구현, Hive 등록 및 마이그레이션 수행.
2. [x] 결제수단 관리 화면과 구독 추가/편집/SMS 시트에서 사용할 `PaymentCardSelector` + “새 카드 추가” 플로우 구현.
3. [x] 홈·리스트 UI에 카테고리/카드 토글, 그룹 헤더, 카드 Chip 표시, `SubscriptionGroupingHelper` 도입.
4. [x] 구독 상세 화면(헤더, 결제 정보 섹션, 편집 폼)에서 카드 정보 노출 및 수정 기능 연결.
5. [x] SMS 스캔 컨트롤러/리뷰 UI에 카드 추정·선택·저장 로직 추가, 기본값/자동 생성 전략 반영.
6. [x] 분석 화면(파이 차트, 합계 카드, 월별 차트 등)이 카드 필터/데이터에 대응하도록 확장.
7. [x] 설정/내비게이션/로컬라이제이션/접근성 업데이트 및 새 문자열 번역 반영.
8. [ ] QA 플로우: `flutter pub run build_runner build`, `scripts/check.sh`, 다국어·다크모드·태블릿·알림/백업 테스트 완료.
## 데이터 모델 및 저장소
- `SubscriptionModel``paymentCardId`(필요 시 `displayName`/닉네임) 필드 추가, Hive 어댑터 재생성. 기존 데이터는 `null` 기본값으로 역호환.
-`PaymentCardModel` 작성: `id`, `issuerName`(회사명 자유 입력), `last4`, `colorHex`, `iconName` 등을 저장. Hive typeId는 미사용 값을 배정.
- `PaymentCardProvider`에서 Hive box(`payment_cards`)를 관리하고 CRUD, 정렬, 기본값 선택 기능 제공.
- `main.dart` 초기화 시 카드 어댑터 등록 → Provider 주입.
- 구독 저장 로직(`SubscriptionProvider.add/update`)과 SMS/수동 추가 컨트롤러에서 `paymentCardId`를 인자로 전달.
## 카드 정보 입력 UX
- 전용 관리 화면: 설정 > “결제수단 관리” 또는 독립 `PaymentCardManagementScreen`.
- 필수 입력: 회사명(자유 텍스트), 마지막 4자리(숫자 4자리), 선택형 색상/아이콘.
- 리스트 정렬, 편집, 삭제, 기본 카드 지정, 구독 수 연동 배지 표시.
- 컨텍스트 내 빠른 등록: 구독 추가/수정 폼, SMS 스캔 리뷰 화면 등에서 “+ 새 카드” 버튼을 눌렀을 때 시트/모달로 간단 등록 가능.
- 구독 추가/수정 폼에 `PaymentCardSelector`를 추가:
- 드롭다운/검색형 목록에 등록된 카드를 노출하고, 최근 사용 카드가 상단에 정렬되도록 UX 최적화.
- 카드 ID가 비어 있으면 “미지정” 상태로 저장해 기존 UX 유지.
- UX 권장안: **설정 화면**에서 카드 풀을 미리 관리하되, **컨텍스트 모달**로도 등록할 수 있게 하여 흐름을 끊지 않음. 단순한 “옵션” 스위치에 카드 정보를 묻는 것보다 입력 목적이 명확하고 재사용성이 높음.
## 홈 화면 및 리스트 UI
- `HomeContent`를 상태형으로 전환하고 `enum SubscriptionGrouping { category, paymentCard }`를 유지. 선택 상태는 `SharedPreferences` 등으로 로컬 저장.
- “내 구독” 헤더 오른쪽에 SegmentedButton/ChoiceChip으로 카테고리↔카드 토글을 제공.
- `SubscriptionListWidget`을 범용 그룹 리스트로 확장:
- 그룹 메타데이터(타이틀, 통화 합계, 색상, 서브텍스트)를 받아 헤더 구성.
- 카드 모드에서는 회사명 + `****1234`, 카드 색상 배지, 카드별 통화 합계를 노출.
- 개별 구독 카드(`SubscriptionCard`) 상단에 결제수단 Chip을 추가해 어떤 카드에 속했는지 즉시 파악 가능.
## 구독 상세 화면 반영
- `DetailScreen` 상단 요약 카드에 결제수단 Chip/배지와 카드 색상을 노출.
- “결제 정보” 섹션에 “결제수단” 행을 추가해 회사명 + `****1234`, 카드별 메모 등을 보여줌.
- 상세 화면의 편집 아이콘 → 편집 시트로 진입 시 현재 `paymentCardId`를 기본 선택하여 사용자가 쉽게 변경할 수 있게 함.
- 카드 Chip을 탭하면 카드 관리 화면으로 이동하거나 빠른 편집 시트를 띄워 카드 명칭/색상 수정이 가능하도록 연동.
## SMS 스캔 흐름 적용
- `SmsScanController`가 생성한 임시 구독 모델에도 `paymentCardId` 필드를 포함.
- 스캔 결과 리뷰 리스트에서 각 구독 옆에 카드 선택 드롭다운을 노출:
- 기본값은 (1) 동일 발급사를 과거에 사용한 기록이 있으면 해당 카드, (2) 지정된 기본 카드, (3) “미지정” 순으로 결정.
- 다중 선택을 빠르게 하기 위해 스와이프/컨텍스트 메뉴 대신 인라인 세그먼트나 바텀 시트를 사용.
- “모두 저장” 시 선택된 카드 ID를 `SubscriptionProvider.addSubscription` 호출에 전달.
- SMS 패턴으로 카드사를 추정할 수 있는 경우(문구에 “KB국민카드 ****1234” 등)라면 자동으로 새 카드 템플릿을 제안하고, 사용자 확인 후 생성하도록 선택지를 제공.
## 화면/플로우별 변경 영향 (릴리스 전 점검)
### 홈/목록/위젯
- `HomeContent`, `SubscriptionListWidget`, `CategoryHeaderWidget`, `SubscriptionCard`, `NativeAdWidget` 인접 간격 등 모든 위젯이 새로운 그룹 메타데이터를 받아도 레이아웃이 깨지지 않는지 확인.
- 카드 모드에서 스켈레톤/EmptyState/애니메이션이 그대로 작동하는지, 그리고 `RefreshIndicator`·무한 스크롤이 정상인지 검증.
- 다국어(`en/ko/ja/zh`)에서 카드명/`****1234` 조합이 줄바꿈되지 않도록 최소/최대 길이 처리.
### 구독 추가/편집/상세
- `AddSubscriptionController`, `DetailScreenController`의 상태/검증 로직에 `paymentCardId`가 포함되었는지 확인.
- 저장/취소/변경 이벤트에서 카드 ID가 누락될 경우 기본값 처리.
- 이벤트/할인 섹션, URL 섹션 등 기존 위젯과 상호작용 시 포커스 이동·폼 검증이 동일하게 작동하는지 QA.
- 상세 화면 헤더/폼/아코디언 등 모든 서브 위젯(`detail_*`)이 카드 배지를 수용하도록 패딩 보정.
### SMS 스캔 및 자동 감지
- `SmsScanController`, `SmsScanner`, `SubscriptionConverter` 등 데이터 파이프라인에 카드 메타 추가.
- 스캔 결과 UI(선택 리스트, 확정 다이얼로그, Snackbar)에서 카드가 선택되지 않았을 때 경고/기본값 표시를 명확히 함.
- 자동 감지 카드 생성 로직은 사용자 최종 확인 후만 저장되도록 하고, 잘못된 카드 추론 시 수정 경로를 안내.
### 분석/대시보드
- `AnalysisScreen`, `SubscriptionPieChartCard`, `TotalExpenseSummaryCard`, `MonthlyExpenseChartCard`, `EventAnalysisCard`가 카드 모드 전환에 따른 필터/데이터세트 변경을 감지하는지 확인.
- 향후 카드별 하이라이트를 추가할 경우를 대비해 `SubscriptionGroupingHelper` 출력 구조가 확장 가능한지 검토.
### 설정/관리/내비게이션
- `SettingsScreen` 내 새 “결제수단 관리” 항목 및 `PaymentCardManagementScreen`이 탐색 스택/앱 잠금 흐름에 맞게 라우팅되는지 확인.
- `NavigationProvider``FloatingNavigationBar` 상태와 충돌하지 않는지 QA.
### 데이터/싱크/백업
- Hive 박스 버전이 증가한 뒤에도 기존 사용자 데이터(베타/QA) 로딩에 문제가 없는지 실제 마이그레이션 테스트.
- `SubscriptionProvider.refreshSubscriptions`, `notificationProvider`, `ExchangeRateService` 등 구독 컬렉션을 사용하는 모든 클래스에서 `paymentCardId`를 읽고 무시해도 예외가 발생하지 않는지 확인.
- 테스트 데이터(`lib/temp/test_sms_data.dart`, demo seed)에도 카드 필드가 포함되었는지 점검.
### 로컬라이제이션/접근성
- `AppLocalizations`, `intl` 메시지에 결제수단 관련 텍스트(“결제수단”, “카드 관리”, 오류 메시지 등)를 추가하고 4개 언어 번역을 준비.
- 스크린리더(VoiceOver/TalkBack)에서 카드 정보가 올바른 순서로 읽히는지, Chip 탭 시 라벨이 명확한지 확인.
- 컬러 배지 대비가 Material 3 접근성 가이드라인(대비 3:1 이상)을 만족하도록 색상 선택 UI/프리셋을 검토.
### QA 체크리스트
1. 새 카드 생성 → 구독 추가/편집/상세/SMS 스캔 → 삭제까지 전 과정에서 데이터 일관성 확인.
2. 카드 토글이 유지되는지(앱 재시작 포함) 확인.
3. `scripts/check.sh` + `flutter pub run build_runner build --delete-conflicting-outputs` 실행 후 경고 없는지 확인.
4. 다국어·다크모드·태블릿 해상도에서 UI 붕괴 여부 점검.
5. 알림/위젯/백그라운드 서비스(예: 결제 알림)에서 카드 필드 추가로 인한 크래시가 없는지 Crashlytics/디버그 로그 확인.
#### QA 실행 현황 (2025-11-14)
-`flutter pub run build_runner build --delete-conflicting-outputs`
-`scripts/check.sh`
-`flutter analyze`
-`flutter test`
## 분석 및 향후 확장
- 공통 `SubscriptionGroupingHelper`를 만들어 카드/카테고리 그룹 데이터를 모두 생성하게 설계하면 `MonthlyExpenseChartCard`, 파이 차트, 이벤트 카드 등도 카드 필터를 쉽게 지원.
- 초기에는 홈 리스트에만 카드 모드를 적용하고, 이후 분석 탭에 “카드별 지출” 섹션을 추가해 순차 배포.
## 검증/운영
- 모든 변경 후 `scripts/check.sh`로 포맷(`dart format`), 정적 분석(`flutter analyze`), 테스트(`flutter test`)를 실행.
- Hive 스키마가 증가하므로 `flutter pub run build_runner build --delete-conflicting-outputs`를 통해 어댑터 재생성.
- UI 변경 시 기본/카드 모드 스크린샷을 확보해 QA 공유.
## 리스크 및 완화
- **Hive 마이그레이션**: 새 필드는 optional로 두고 기본값을 유지해 앱 크래시를 방지. 배포 전 베타 빌드로 데이터 검증.
- **사용자 혼란**: 토글 기본값을 기존 “카테고리”로 유지하고, 첫 진입 시 간단한 스낵바/tooltip으로 카드 뷰를 안내.
- **데이터 입력 번거로움**: 관리 화면에서 최소 필드만 요구하고, 구독 폼에서 바로 생성할 수 있게 동선 축소.

113
doc/plan.md Normal file
View File

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

70
doc/plan_color.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,14 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../providers/payment_card_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/billing_cost_util.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController { class AddSubscriptionController {
@@ -29,6 +33,7 @@ class AddSubscriptionController {
DateTime? nextBillingDate; DateTime? nextBillingDate;
bool isLoading = false; bool isLoading = false;
String? selectedCategoryId; String? selectedCategoryId;
String? selectedPaymentCardId;
// Event State // Event State
bool isEventActive = false; bool isEventActive = false;
@@ -104,6 +109,33 @@ 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가 아직 준비되지 않은 경우 기본값 유지
}
// 기본 결제수단 설정
try {
final paymentCardProvider =
Provider.of<PaymentCardProvider>(context, listen: false);
selectedPaymentCardId = paymentCardProvider.defaultCard?.id;
} catch (_) {}
// 애니메이션 시작 // 애니메이션 시작
animationController!.forward(); animationController!.forward();
} }
@@ -284,25 +316,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;
@@ -424,24 +486,42 @@ class AddSubscriptionController {
try { try {
// 콤마 제거하고 숫자만 추출 // 콤마 제거하고 숫자만 추출
final monthlyCost = final inputCost =
double.parse(monthlyCostController.text.replaceAll(',', '')); double.parse(monthlyCostController.text.replaceAll(',', ''));
// 이벤트 가격 파싱 // 결제 주기에 따라 월 비용으로 변환
final monthlyCost =
BillingCostUtil.convertToMonthlyCost(inputCost, billingCycle);
// 이벤트 가격 파싱 및 월 비용 변환
double? eventPrice; double? eventPrice;
if (isEventActive && eventPriceController.text.isNotEmpty) { if (isEventActive && eventPriceController.text.isNotEmpty) {
eventPrice = final inputEventPrice =
double.tryParse(eventPriceController.text.replaceAll(',', '')); double.tryParse(eventPriceController.text.replaceAll(',', ''));
if (inputEventPrice != null) {
eventPrice =
BillingCostUtil.convertToMonthlyCost(inputEventPrice, billingCycle);
}
} }
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
final originalDateOnly = DateTime(
nextBillingDate!.year,
nextBillingDate!.month,
nextBillingDate!.day,
);
var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
await Provider.of<SubscriptionProvider>(context, listen: false) await Provider.of<SubscriptionProvider>(context, listen: false)
.addSubscription( .addSubscription(
serviceName: serviceNameController.text.trim(), serviceName: serviceNameController.text.trim(),
monthlyCost: monthlyCost, monthlyCost: monthlyCost,
billingCycle: billingCycle, billingCycle: billingCycle,
nextBillingDate: nextBillingDate!, nextBillingDate: adjustedNext,
websiteUrl: websiteUrlController.text.trim(), websiteUrl: websiteUrlController.text.trim(),
categoryId: selectedCategoryId, categoryId: selectedCategoryId,
paymentCardId: selectedPaymentCardId,
currency: currency, currency: currency,
isEventActive: isEventActive, isEventActive: isEventActive,
eventStartDate: eventStartDate, eventStartDate: eventStartDate,
@@ -449,6 +529,16 @@ class AddSubscriptionController {
eventPrice: eventPrice, eventPrice: eventPrice,
); );
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: AppLocalizations.of(context).nextBillingDateAdjusted,
);
}
}
if (context.mounted) { if (context.mounted) {
Navigator.pop(context, true); // 성공 여부 반환 Navigator.pop(context, true); // 성공 여부 반환
} }

View File

@@ -12,6 +12,8 @@ import 'package:intl/intl.dart';
import '../widgets/dialogs/delete_confirmation_dialog.dart'; import '../widgets/dialogs/delete_confirmation_dialog.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart';
import '../utils/billing_cost_util.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller /// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController extends ChangeNotifier { class DetailScreenController extends ChangeNotifier {
@@ -33,6 +35,7 @@ class DetailScreenController extends ChangeNotifier {
late String _billingCycle; late String _billingCycle;
late DateTime _nextBillingDate; late DateTime _nextBillingDate;
String? _selectedCategoryId; String? _selectedCategoryId;
String? _selectedPaymentCardId;
late String _currency; late String _currency;
bool _isLoading = false; bool _isLoading = false;
@@ -45,6 +48,7 @@ class DetailScreenController extends ChangeNotifier {
String get billingCycle => _billingCycle; String get billingCycle => _billingCycle;
DateTime get nextBillingDate => _nextBillingDate; DateTime get nextBillingDate => _nextBillingDate;
String? get selectedCategoryId => _selectedCategoryId; String? get selectedCategoryId => _selectedCategoryId;
String? get selectedPaymentCardId => _selectedPaymentCardId;
String get currency => _currency; String get currency => _currency;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool get isEventActive => _isEventActive; bool get isEventActive => _isEventActive;
@@ -55,6 +59,8 @@ class DetailScreenController extends ChangeNotifier {
set billingCycle(String value) { set billingCycle(String value) {
if (_billingCycle != value) { if (_billingCycle != value) {
_billingCycle = value; _billingCycle = value;
// 결제 주기 변경 시 금액 표시 업데이트
_updateMonthlyCostFormat();
notifyListeners(); notifyListeners();
} }
} }
@@ -73,6 +79,13 @@ class DetailScreenController extends ChangeNotifier {
} }
} }
set selectedPaymentCardId(String? value) {
if (_selectedPaymentCardId != value) {
_selectedPaymentCardId = value;
notifyListeners();
}
}
set currency(String value) { set currency(String value) {
if (_currency != value) { if (_currency != value) {
_currency = value; _currency = value;
@@ -152,6 +165,7 @@ class DetailScreenController extends ChangeNotifier {
_billingCycle = subscription.billingCycle; _billingCycle = subscription.billingCycle;
_nextBillingDate = subscription.nextBillingDate; _nextBillingDate = subscription.nextBillingDate;
_selectedCategoryId = subscription.categoryId; _selectedCategoryId = subscription.categoryId;
_selectedPaymentCardId = subscription.paymentCardId;
_currency = subscription.currency; _currency = subscription.currency;
// Event State 초기화 // Event State 초기화
@@ -159,14 +173,18 @@ class DetailScreenController extends ChangeNotifier {
_eventStartDate = subscription.eventStartDate; _eventStartDate = subscription.eventStartDate;
_eventEndDate = subscription.eventEndDate; _eventEndDate = subscription.eventEndDate;
// 이벤트 가격 초기화 // 이벤트 가격 초기화 (월 비용을 결제 주기별 실제 금액으로 변환)
if (subscription.eventPrice != null) { if (subscription.eventPrice != null) {
final actualEventPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.eventPrice!,
_billingCycle,
);
if (currency == 'KRW') { if (currency == 'KRW') {
eventPriceController.text = NumberFormat.decimalPattern() eventPriceController.text = NumberFormat.decimalPattern()
.format(subscription.eventPrice!.toInt()); .format(actualEventPrice.toInt());
} else { } else {
eventPriceController.text = eventPriceController.text =
NumberFormat('#,##0.00').format(subscription.eventPrice!); NumberFormat('#,##0.00').format(actualEventPrice);
} }
} }
@@ -260,16 +278,23 @@ class DetailScreenController extends ChangeNotifier {
} }
/// 통화 단위에 따른 금액 표시 형식 업데이트 /// 통화 단위에 따른 금액 표시 형식 업데이트
/// 월 비용을 결제 주기에 맞는 실제 금액으로 변환하여 표시
void _updateMonthlyCostFormat() { void _updateMonthlyCostFormat() {
// 월 비용을 결제 주기별 실제 금액으로 변환
final actualCost = BillingCostUtil.convertFromMonthlyCost(
subscription.monthlyCost,
_billingCycle,
);
if (_currency == 'KRW') { if (_currency == 'KRW') {
// 원화는 소수점 없이 표시 // 원화는 소수점 없이 표시
final intValue = subscription.monthlyCost.toInt(); final intValue = actualCost.toInt();
monthlyCostController.text = monthlyCostController.text =
NumberFormat.decimalPattern().format(intValue); NumberFormat.decimalPattern().format(intValue);
} else { } else {
// 달러는 소수점 2자리까지 표시 // 달러는 소수점 2자리까지 표시
monthlyCostController.text = monthlyCostController.text =
NumberFormat('#,##0.00').format(subscription.monthlyCost); NumberFormat('#,##0.00').format(actualCost);
} }
} }
@@ -389,11 +414,14 @@ class DetailScreenController extends ChangeNotifier {
// 구독 정보 업데이트 // 구독 정보 업데이트
// 콤마 제거하고 숫자만 추출 // 콤마 제거하고 숫자만 추출 후 월 비용으로 변환
double monthlyCost = 0.0; double monthlyCost = 0.0;
try { try {
monthlyCost = final inputCost =
double.parse(monthlyCostController.text.replaceAll(',', '')); double.parse(monthlyCostController.text.replaceAll(',', ''));
// 결제 주기에 따라 월 비용으로 변환
monthlyCost =
BillingCostUtil.convertToMonthlyCost(inputCost, _billingCycle);
} catch (e) { } catch (e) {
// 파싱 오류 발생 시 기본값 사용 // 파싱 오류 발생 시 기본값 사용
monthlyCost = subscription.monthlyCost; monthlyCost = subscription.monthlyCost;
@@ -401,14 +429,20 @@ class DetailScreenController extends ChangeNotifier {
debugPrint('[DetailScreenController] 구독 업데이트 시작: ' debugPrint('[DetailScreenController] 구독 업데이트 시작: '
'${subscription.serviceName}${serviceNameController.text}, ' '${subscription.serviceName}${serviceNameController.text}, '
'금액: $subscription.monthlyCost → $monthlyCost $_currency'); '금액: ${subscription.monthlyCost}$monthlyCost $_currency');
subscription.serviceName = serviceNameController.text; subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost; subscription.monthlyCost = monthlyCost;
subscription.websiteUrl = websiteUrl; subscription.websiteUrl = websiteUrl;
subscription.billingCycle = _billingCycle; subscription.billingCycle = _billingCycle;
subscription.nextBillingDate = _nextBillingDate; // 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장
final originalDateOnly = DateTime(
_nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day);
var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
subscription.nextBillingDate = adjustedNext;
subscription.categoryId = _selectedCategoryId; subscription.categoryId = _selectedCategoryId;
subscription.paymentCardId = _selectedPaymentCardId;
subscription.currency = _currency; subscription.currency = _currency;
// 이벤트 정보 업데이트 // 이벤트 정보 업데이트
@@ -416,11 +450,13 @@ class DetailScreenController extends ChangeNotifier {
subscription.eventStartDate = _eventStartDate; subscription.eventStartDate = _eventStartDate;
subscription.eventEndDate = _eventEndDate; subscription.eventEndDate = _eventEndDate;
// 이벤트 가격 파싱 // 이벤트 가격 파싱 및 월 비용 변환
if (_isEventActive && eventPriceController.text.isNotEmpty) { if (_isEventActive && eventPriceController.text.isNotEmpty) {
try { try {
subscription.eventPrice = final inputEventPrice =
double.parse(eventPriceController.text.replaceAll(',', '')); double.parse(eventPriceController.text.replaceAll(',', ''));
subscription.eventPrice =
BillingCostUtil.convertToMonthlyCost(inputEventPrice, _billingCycle);
} catch (e) { } catch (e) {
subscription.eventPrice = null; subscription.eventPrice = null;
} }
@@ -433,6 +469,14 @@ class DetailScreenController extends ChangeNotifier {
'이벤트활성=${subscription.isEventActive}'); '이벤트활성=${subscription.isEventActive}');
// 구독 업데이트 // 구독 업데이트
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
AppSnackBar.showInfo(
context: context,
message: AppLocalizations.of(context).nextBillingDateAdjusted,
);
}
await provider.updateSubscription(subscription); await provider.updateSubscription(subscription);
if (context.mounted) { if (context.mounted) {
@@ -575,15 +619,5 @@ class DetailScreenController extends ChangeNotifier {
return colors[hash % colors.length]; return colors[hash % colors.length];
} }
/// 그라데이션 가져오기 // getGradient 제거됨 (그라데이션 미사용)
LinearGradient getGradient(Color baseColor) {
return LinearGradient(
colors: [
baseColor,
baseColor.withValues(alpha: 0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
} }

View File

@@ -1,14 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import 'dart:io' show Platform;
import '../services/sms_scanner.dart'; import '../services/sms_scanner.dart';
import '../models/subscription.dart'; import '../services/ad_service.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 '../services/sms_scan/sms_scan_result.dart';
import '../models/subscription.dart';
import '../models/payment_card_suggestion.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart';
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 '../providers/payment_card_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
class SmsScanController extends ChangeNotifier { class SmsScanController extends ChangeNotifier {
// 상태 관리 // 상태 관리
@@ -20,22 +28,33 @@ class SmsScanController extends ChangeNotifier {
List<Subscription> _scannedSubscriptions = []; List<Subscription> _scannedSubscriptions = [];
List<Subscription> get scannedSubscriptions => _scannedSubscriptions; List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
PaymentCardSuggestion? _currentSuggestion;
PaymentCardSuggestion? get currentSuggestion => _currentSuggestion;
bool _shouldSuggestCardCreation = false;
bool get shouldSuggestCardCreation => _shouldSuggestCardCreation;
int _currentIndex = 0; int _currentIndex = 0;
int get currentIndex => _currentIndex; int get currentIndex => _currentIndex;
String? _selectedCategoryId; String? _selectedCategoryId;
String? get selectedCategoryId => _selectedCategoryId; String? get selectedCategoryId => _selectedCategoryId;
String? _selectedPaymentCardId;
String? get selectedPaymentCardId => _selectedPaymentCardId;
final TextEditingController websiteUrlController = TextEditingController(); final TextEditingController websiteUrlController = TextEditingController();
final TextEditingController serviceNameController = TextEditingController();
// 의존성 // 의존성
final SmsScanner _smsScanner = SmsScanner(); final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter(); final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter(); final SubscriptionFilter _filter = SubscriptionFilter();
final AdService _adService = AdService();
bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing;
@override @override
void dispose() { void dispose() {
serviceNameController.dispose();
websiteUrlController.dispose(); websiteUrlController.dispose();
super.dispose(); super.dispose();
} }
@@ -45,8 +64,47 @@ class SmsScanController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setSelectedPaymentCardId(String? paymentCardId) {
_selectedPaymentCardId = paymentCardId;
if (paymentCardId != null) {
_shouldSuggestCardCreation = false;
}
notifyListeners();
}
void resetWebsiteUrl() { void resetWebsiteUrl() {
websiteUrlController.text = ''; websiteUrlController.text = '';
serviceNameController.text = '';
}
void updateCurrentServiceName(BuildContext context, String value) {
if (_currentIndex >= _scannedSubscriptions.length) return;
final trimmed = value.trim();
final unknownLabel = _unknownServiceLabel(context);
final updated = _scannedSubscriptions[_currentIndex]
.copyWith(serviceName: trimmed.isEmpty ? unknownLabel : trimmed);
_scannedSubscriptions[_currentIndex] = updated;
notifyListeners();
}
/// SMS 스캔 시작 (전면 광고 표시 후 스캔 진행)
Future<void> startScan(BuildContext context) async {
if (_isLoading) return;
// 웹/비지원 플랫폼은 바로 스캔
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
await scanSms(context);
return;
}
// 광고 표시 (완료까지 대기)
// 광고 실패해도 스캔 진행 (사용자 경험 우선)
await _adService.showInterstitialAd(context);
if (!context.mounted) return;
// 광고 완료 후 SMS 스캔 실행
await scanSms(context);
} }
Future<void> scanSms(BuildContext context) async { Future<void> scanSms(BuildContext context) async {
@@ -56,21 +114,53 @@ class SmsScanController extends ChangeNotifier {
_currentIndex = 0; _currentIndex = 0;
notifyListeners(); notifyListeners();
await _performSmsScan(context);
}
/// 실제 SMS 스캔 수행 (로딩 상태는 이미 설정되어 있음)
Future<void> _performSmsScan(BuildContext context) async {
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 스캔 실행
Log.i('SMS 스캔 시작'); Log.i('SMS 스캔 시작');
final scannedSubscriptionModels = final List<SmsScanResult> scanResults =
await _smsScanner.scanForSubscriptions(); await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}'); Log.d('스캔된 구독: ${scanResults.length}');
if (scannedSubscriptionModels.isNotEmpty) { if (scanResults.isNotEmpty) {
Log.d( Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); '첫 번째 구독: ${scanResults[0].model.serviceName}, 반복 횟수: ${scanResults[0].model.repeatCount}');
} }
if (!context.mounted) return; if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) { if (scanResults.isEmpty) {
Log.i('스캔된 구독이 없음'); Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound; _errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false; _isLoading = false;
@@ -80,7 +170,7 @@ class SmsScanController extends ChangeNotifier {
// SubscriptionModel을 Subscription으로 변환 // SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions = final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels); _converter.convertResultsToSubscriptions(scanResults);
// 2회 이상 반복 결제된 구독만 필터링 // 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions = final repeatSubscriptions =
@@ -126,7 +216,9 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = filteredSubscriptions; _scannedSubscriptions = filteredSubscriptions;
_isLoading = false; _isLoading = false;
websiteUrlController.text = ''; // URL 입력 필드 초기화 websiteUrlController.text = '';
_currentSuggestion = null;
_prepareCurrentSelection(context);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
Log.e('SMS 스캔 중 오류 발생', e); Log.e('SMS 스캔 중 오류 발생', e);
@@ -139,20 +231,51 @@ 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;
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
final inputName = serviceNameController.text.trim();
final resolvedServiceName =
inputName.isNotEmpty ? inputName : subscription.serviceName;
try { try {
final provider = final provider =
Provider.of<SubscriptionProvider>(context, listen: false); Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider = final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false); Provider.of<CategoryProvider>(context, listen: false);
final paymentCardProvider =
Provider.of<PaymentCardProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ?? final finalCategoryId = _selectedCategoryId ??
subscription.category ?? subscription.category ??
getDefaultCategoryId(categoryProvider); getDefaultCategoryId(categoryProvider);
final finalPaymentCardId =
_selectedPaymentCardId ?? paymentCardProvider.defaultCard?.id;
// websiteUrl 처리 // websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty final websiteUrl = websiteUrlController.text.trim().isNotEmpty
@@ -164,7 +287,7 @@ class SmsScanController extends ChangeNotifier {
// addSubscription 호출 // addSubscription 호출
await provider.addSubscription( await provider.addSubscription(
serviceName: subscription.serviceName, serviceName: resolvedServiceName,
monthlyCost: subscription.monthlyCost, monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle, billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate, nextBillingDate: subscription.nextBillingDate,
@@ -173,6 +296,7 @@ class SmsScanController extends ChangeNotifier {
repeatCount: subscription.repeatCount, repeatCount: subscription.repeatCount,
lastPaymentDate: subscription.lastPaymentDate, lastPaymentDate: subscription.lastPaymentDate,
categoryId: finalCategoryId, categoryId: finalCategoryId,
paymentCardId: finalPaymentCardId,
currency: subscription.currency, currency: subscription.currency,
); );
@@ -195,8 +319,11 @@ class SmsScanController extends ChangeNotifier {
void moveToNextSubscription(BuildContext context) { void moveToNextSubscription(BuildContext context) {
_currentIndex++; _currentIndex++;
websiteUrlController.text = ''; // URL 입력 필드 초기화 websiteUrlController.text = '';
_selectedCategoryId = null; // 카테고리 선택 초기화 serviceNameController.text = '';
_selectedCategoryId = null;
_forceServiceNameEditing = false;
_prepareCurrentSelection(context);
// 모든 구독을 처리했으면 홈 화면으로 이동 // 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) { if (_currentIndex >= _scannedSubscriptions.length) {
@@ -217,6 +344,11 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = []; _scannedSubscriptions = [];
_currentIndex = 0; _currentIndex = 0;
_errorMessage = null; _errorMessage = null;
_selectedPaymentCardId = null;
_currentSuggestion = null;
_shouldSuggestCardCreation = false;
serviceNameController.clear();
_forceServiceNameEditing = false;
notifyListeners(); notifyListeners();
} }
@@ -229,12 +361,114 @@ class SmsScanController extends ChangeNotifier {
return otherCategory.id; return otherCategory.id;
} }
void initializeWebsiteUrl() { void initializeWebsiteUrl(BuildContext context) {
if (_currentIndex < _scannedSubscriptions.length) { if (_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex]; final currentSub = _scannedSubscriptions[_currentIndex];
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!; websiteUrlController.text = currentSub.websiteUrl!;
} }
final unknownLabel = _unknownServiceLabel(context);
if (_shouldEnableServiceNameEditing(currentSub, unknownLabel)) {
if (serviceNameController.text != currentSub.serviceName) {
serviceNameController.clear();
}
} else {
serviceNameController.text = currentSub.serviceName;
}
} }
} }
String? _getDefaultPaymentCardId(BuildContext context) {
try {
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
return provider.defaultCard?.id;
} catch (_) {
return null;
}
}
void _prepareCurrentSelection(BuildContext context) {
if (_currentIndex >= _scannedSubscriptions.length) {
_selectedPaymentCardId = null;
_currentSuggestion = null;
_forceServiceNameEditing = false;
serviceNameController.clear();
return;
}
final current = _scannedSubscriptions[_currentIndex];
final unknownLabel = _unknownServiceLabel(context);
_forceServiceNameEditing =
_shouldEnableServiceNameEditing(current, unknownLabel);
if (_forceServiceNameEditing && current.serviceName == unknownLabel) {
serviceNameController.clear();
} else {
serviceNameController.text = current.serviceName;
}
// URL 기본값
if (current.websiteUrl != null && current.websiteUrl!.isNotEmpty) {
websiteUrlController.text = current.websiteUrl!;
} else {
websiteUrlController.clear();
}
_currentSuggestion = current.paymentCardSuggestion;
final matchedCardId = _matchCardWithSuggestion(context, _currentSuggestion);
_shouldSuggestCardCreation =
_currentSuggestion != null && matchedCardId == null;
if (matchedCardId != null) {
_selectedPaymentCardId = matchedCardId;
return;
}
// 모델에 직접 카드 정보가 존재하면 우선 사용
if (current.paymentCardId != null) {
_selectedPaymentCardId = current.paymentCardId;
return;
}
_selectedPaymentCardId = _getDefaultPaymentCardId(context);
}
String? _matchCardWithSuggestion(
BuildContext context, PaymentCardSuggestion? suggestion) {
if (suggestion == null) return null;
try {
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
final cards = provider.cards;
if (cards.isEmpty) return null;
if (suggestion.hasLast4) {
for (final card in cards) {
if (card.last4 == suggestion.last4) {
return card.id;
}
}
}
final normalizedIssuer = suggestion.issuerName.toLowerCase();
for (final card in cards) {
final issuer = card.issuerName.toLowerCase();
if (issuer.contains(normalizedIssuer) ||
normalizedIssuer.contains(issuer)) {
return card.id;
}
}
} catch (_) {
return null;
}
return null;
}
bool _shouldEnableServiceNameEditing(
Subscription subscription, String unknownLabel) {
final name = subscription.serviceName.trim();
return name.isEmpty || name == unknownLabel;
}
String _unknownServiceLabel(BuildContext context) {
return AppLocalizations.of(context).unknownService;
}
} }

View File

@@ -36,6 +36,16 @@ class AppLocalizations {
String get save => _localizedStrings['save'] ?? 'Save'; String get save => _localizedStrings['save'] ?? 'Save';
String get cancel => _localizedStrings['cancel'] ?? 'Cancel'; String get cancel => _localizedStrings['cancel'] ?? 'Cancel';
String get delete => _localizedStrings['delete'] ?? 'Delete'; String get delete => _localizedStrings['delete'] ?? 'Delete';
String get deleteSubscriptionTitle =>
_localizedStrings['deleteSubscriptionTitle'] ?? 'Delete Subscription';
String get deleteSubscriptionMessageTemplate =>
_localizedStrings['deleteSubscriptionMessage'] ??
'Are you sure you want to delete @ subscription?';
String deleteSubscriptionMessage(String serviceName) =>
deleteSubscriptionMessageTemplate.replaceAll('@', serviceName);
String get deleteIrreversibleWarning =>
_localizedStrings['deleteIrreversibleWarning'] ??
'This action cannot be undone';
String get edit => _localizedStrings['edit'] ?? 'Edit'; String get edit => _localizedStrings['edit'] ?? 'Edit';
String get totalSubscriptions => String get totalSubscriptions =>
_localizedStrings['totalSubscriptions'] ?? 'Total Subscriptions'; _localizedStrings['totalSubscriptions'] ?? 'Total Subscriptions';
@@ -58,11 +68,63 @@ class AppLocalizations {
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon'; String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category'; String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
String get settings => _localizedStrings['settings'] ?? 'Settings'; String get settings => _localizedStrings['settings'] ?? 'Settings';
String get theme => _localizedStrings['theme'] ?? 'Theme';
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode'; String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
String get language => _localizedStrings['language'] ?? 'Language'; String get language => _localizedStrings['language'] ?? 'Language';
String get notifications => String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications'; _localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock'; String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
String get appLocked => _localizedStrings['appLocked'] ?? 'App is locked';
String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card';
String get paymentCardManagement =>
_localizedStrings['paymentCardManagement'] ?? 'Payment Card Management';
String get paymentCardManagementDescription =>
_localizedStrings['paymentCardManagementDescription'] ??
'Manage saved cards for subscriptions';
String get addPaymentCard =>
_localizedStrings['addPaymentCard'] ?? 'Add Payment Card';
String get editPaymentCard =>
_localizedStrings['editPaymentCard'] ?? 'Edit Payment Card';
String get paymentCardIssuer =>
_localizedStrings['paymentCardIssuer'] ?? 'Card Name / Issuer';
String get paymentCardLast4 =>
_localizedStrings['paymentCardLast4'] ?? 'Last 4 Digits';
String get paymentCardColor =>
_localizedStrings['paymentCardColor'] ?? 'Card Color';
String get paymentCardIcon =>
_localizedStrings['paymentCardIcon'] ?? 'Card Icon';
String get setAsDefaultCard =>
_localizedStrings['setAsDefaultCard'] ?? 'Set as default card';
String get paymentCardUnassigned =>
_localizedStrings['paymentCardUnassigned'] ?? 'Unassigned';
String get detectedPaymentCard =>
_localizedStrings['detectedPaymentCard'] ?? 'Card detected';
String detectedPaymentCardDescription(String issuer, String last4) {
final template = _localizedStrings['detectedPaymentCardDescription'] ??
'@ was detected from SMS.';
final label = last4.isNotEmpty ? '$issuer · ****$last4' : issuer;
return template.replaceAll('@', label);
}
String get addDetectedPaymentCard =>
_localizedStrings['addDetectedPaymentCard'] ?? 'Add card';
String get paymentCardUnassignedWarning =>
_localizedStrings['paymentCardUnassignedWarning'] ??
'Without a card selection this subscription will be saved as "Unassigned".';
String get addNewCard => _localizedStrings['addNewCard'] ?? 'Add New Card';
String get managePaymentCards =>
_localizedStrings['managePaymentCards'] ?? 'Manage Cards';
String get choosePaymentCard =>
_localizedStrings['choosePaymentCard'] ?? 'Choose Payment Card';
String get analysisCardFilterLabel =>
_localizedStrings['analysisCardFilterLabel'] ?? 'Filter by payment card';
String get analysisCardFilterAll =>
_localizedStrings['analysisCardFilterAll'] ?? 'All cards';
String get cardDefaultBadge =>
_localizedStrings['cardDefaultBadge'] ?? 'Default';
String get noPaymentCards =>
_localizedStrings['noPaymentCards'] ?? 'No payment cards saved yet.';
String get areYouSure => _localizedStrings['areYouSure'] ?? 'Are you sure?';
// SMS 권한 온보딩/설정 // SMS 권한 온보딩/설정
String get smsPermissionTitle => String get smsPermissionTitle =>
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission'; _localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
@@ -113,9 +175,12 @@ class AppLocalizations {
String get notificationPermissionDenied => String get notificationPermissionDenied =>
_localizedStrings['notificationPermissionDenied'] ?? _localizedStrings['notificationPermissionDenied'] ??
'Notification permission denied'; 'Notification permission denied';
String get permissionGranted =>
_localizedStrings['permissionGranted'] ?? 'Permission granted.';
// 앱 정보 // 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info'; String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version'; String get version => _localizedStrings['version'] ?? 'Version';
String get openStore => _localizedStrings['openStore'] ?? 'Open Store';
String get appDescription => String get appDescription =>
_localizedStrings['appDescription'] ?? 'Subscription Management App'; _localizedStrings['appDescription'] ?? 'Subscription Management App';
String get developer => _localizedStrings['developer'] ?? 'Developer'; String get developer => _localizedStrings['developer'] ?? 'Developer';
@@ -146,6 +211,8 @@ class AppLocalizations {
String get requiredFieldsError => String get requiredFieldsError =>
_localizedStrings['requiredFieldsError'] ?? _localizedStrings['requiredFieldsError'] ??
'Please fill in all required fields'; 'Please fill in all required fields';
String get categoryNameRequired =>
_localizedStrings['categoryNameRequired'] ?? 'Please enter category name';
String get subscriptionUpdated => String get subscriptionUpdated =>
_localizedStrings['subscriptionUpdated'] ?? _localizedStrings['subscriptionUpdated'] ??
'Subscription information has been updated'; 'Subscription information has been updated';
@@ -164,6 +231,8 @@ class AppLocalizations {
String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes'; String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes';
String get monthlyExpense => String get monthlyExpense =>
_localizedStrings['monthlyExpense'] ?? 'Monthly Expense'; _localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
String get billingAmount =>
_localizedStrings['billingAmount'] ?? 'Billing Amount';
String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL'; String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL';
String get websiteUrlOptional => String get websiteUrlOptional =>
_localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)'; _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
@@ -198,6 +267,9 @@ class AppLocalizations {
String get authenticationFailed => String get authenticationFailed =>
_localizedStrings['authenticationFailed'] ?? _localizedStrings['authenticationFailed'] ??
'Authentication failed. Please try again.'; 'Authentication failed. Please try again.';
String get nextBillingDateAdjusted =>
_localizedStrings['nextBillingDateAdjusted'] ??
'Saved as the next billing date';
String get smsPermissionRequired => String get smsPermissionRequired =>
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required'; _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
String get noSubscriptionSmsFound => String get noSubscriptionSmsFound =>
@@ -367,6 +439,9 @@ class AppLocalizations {
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost'; String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
String get eventDiscountStatus => String get eventDiscountStatus =>
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status'; _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get eventDiscountEndsBeforeBilling =>
_localizedStrings['eventDiscountEndsBeforeBilling'] ??
'Event discount ends before billing date';
String get inProgressUnit => String get inProgressUnit =>
_localizedStrings['inProgressUnit'] ?? 'in progress'; _localizedStrings['inProgressUnit'] ?? 'in progress';
String get monthlySavingAmount => String get monthlySavingAmount =>
@@ -403,6 +478,15 @@ class AppLocalizations {
String get foundSubscription => String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription'; _localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name'; String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get unknownService =>
_localizedStrings['unknownService'] ?? 'Unknown service';
String get latestSmsMessage =>
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
String smsDetectedDate(String date) {
final template = _localizedStrings['smsDetectedDate'] ?? 'Detected on @';
return template.replaceAll('@', date);
}
String get nextBillingDateLabel => String get nextBillingDateLabel =>
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date'; _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get category => _localizedStrings['category'] ?? 'Category'; String get category => _localizedStrings['category'] ?? 'Category';
@@ -598,6 +682,49 @@ class AppLocalizations {
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon => String get featureComingSoon =>
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
String get exactAlarmPermission =>
_localizedStrings['exactAlarmPermission'] ??
'Exact alarm permission (Alarms & Reminders)';
String get exactAlarmPermissionDesc =>
_localizedStrings['exactAlarmPermissionDesc'] ??
'We need permission to guarantee precise alarms.';
String get allowAlarmsInSettings =>
_localizedStrings['allowAlarmsInSettings'] ??
'Please allow "Alarms & reminders" in Settings.';
String get testNotification =>
_localizedStrings['testNotification'] ?? 'Test notification';
String testSubscriptionBody(String amountText) {
final template =
_localizedStrings['testSubscriptionBody'] ?? 'Test subscription • @';
return template.replaceAll('@', amountText);
}
String expirationReminderBody(String serviceName, int days) {
final template = _localizedStrings['expirationReminderBody'] ??
'@ subscription expires in # days.';
return template
.replaceAll('@', serviceName)
.replaceAll('#', days.toString());
}
String get eventEndNotificationTitle =>
_localizedStrings['eventEndNotificationTitle'] ??
'Event end notification';
String eventEndNotificationBody(String serviceName) {
final template = _localizedStrings['eventEndNotificationBody'] ??
"@'s discount event has ended.";
return template.replaceAll('@', serviceName);
}
String paymentChargeNotification(String serviceName, String amountText) {
final template = _localizedStrings['paymentChargeNotification'] ??
'@ subscription charge @ was completed.';
return template
.replaceFirst('@', serviceName)
.replaceFirst('@', amountText);
}
// 결제 주기를 키값으로 변환하여 번역된 이름 반환 // 결제 주기를 키값으로 변환하여 번역된 이름 반환
String getBillingCycleName(String billingCycleKey) { String getBillingCycleName(String billingCycleKey) {

View File

@@ -1,14 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'models/subscription_model.dart'; import 'models/subscription_model.dart';
import 'models/category_model.dart'; import 'models/category_model.dart';
import 'models/payment_card_model.dart';
import 'providers/subscription_provider.dart'; import 'providers/subscription_provider.dart';
import 'providers/app_lock_provider.dart'; import 'providers/app_lock_provider.dart';
import 'providers/notification_provider.dart'; import 'providers/notification_provider.dart';
import 'providers/navigation_provider.dart'; import 'providers/navigation_provider.dart';
import 'providers/payment_card_provider.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'providers/category_provider.dart'; import 'providers/category_provider.dart';
import 'providers/locale_provider.dart'; import 'providers/locale_provider.dart';
@@ -32,6 +35,10 @@ const bool enableAdMob = true;
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Android 15 edge-to-edge 모드 활성화
// 콘텐츠가 시스템 바 영역까지 확장됨
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
// 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만) // 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만)
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && enableAdMob) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && enableAdMob) {
unawaited(MobileAds.instance.initialize()); unawaited(MobileAds.instance.initialize());
@@ -69,14 +76,17 @@ Future<void> main() async {
await Hive.initFlutter(); await Hive.initFlutter();
Hive.registerAdapter(SubscriptionModelAdapter()); Hive.registerAdapter(SubscriptionModelAdapter());
Hive.registerAdapter(CategoryModelAdapter()); Hive.registerAdapter(CategoryModelAdapter());
Hive.registerAdapter(PaymentCardModelAdapter());
await Hive.openBox<SubscriptionModel>('subscriptions'); await Hive.openBox<SubscriptionModel>('subscriptions');
await Hive.openBox<CategoryModel>('categories'); await Hive.openBox<CategoryModel>('categories');
await Hive.openBox<PaymentCardModel>('payment_cards');
final appLockBox = await Hive.openBox<bool>('app_lock'); final appLockBox = await Hive.openBox<bool>('app_lock');
// 알림 서비스를 가장 먼저 초기화 // 알림 서비스를 가장 먼저 초기화
await NotificationService.init(); await NotificationService.init();
final subscriptionProvider = SubscriptionProvider(); final subscriptionProvider = SubscriptionProvider();
final categoryProvider = CategoryProvider(); final categoryProvider = CategoryProvider();
final paymentCardProvider = PaymentCardProvider();
final localeProvider = LocaleProvider(); final localeProvider = LocaleProvider();
final notificationProvider = NotificationProvider(); final notificationProvider = NotificationProvider();
final themeProvider = ThemeProvider(); final themeProvider = ThemeProvider();
@@ -84,6 +94,7 @@ Future<void> main() async {
await subscriptionProvider.init(); await subscriptionProvider.init();
await categoryProvider.init(); await categoryProvider.init();
await paymentCardProvider.init();
await localeProvider.init(); await localeProvider.init();
await notificationProvider.init(); await notificationProvider.init();
await themeProvider.initialize(); await themeProvider.initialize();
@@ -110,6 +121,7 @@ Future<void> main() async {
providers: [ providers: [
ChangeNotifierProvider(create: (_) => subscriptionProvider), ChangeNotifierProvider(create: (_) => subscriptionProvider),
ChangeNotifierProvider(create: (_) => categoryProvider), ChangeNotifierProvider(create: (_) => categoryProvider),
ChangeNotifierProvider(create: (_) => paymentCardProvider),
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)), ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
ChangeNotifierProvider(create: (_) => notificationProvider), ChangeNotifierProvider(create: (_) => notificationProvider),
ChangeNotifierProvider(create: (_) => localeProvider), ChangeNotifierProvider(create: (_) => localeProvider),
@@ -133,7 +145,9 @@ class SubManagerApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
key: ValueKey(localeProvider.locale), key: ValueKey(localeProvider.locale),
title: 'Digital Rent Manager', // Localizations는 MaterialApp 내부에서 초기화되므로
// onGenerateTitle을 사용해 로딩 이후 로컬라이즈된 타이틀을 설정합니다.
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: themeProvider.getTheme(context), theme: themeProvider.getTheme(context),
locale: localeProvider.locale, locale: localeProvider.locale,

View File

@@ -0,0 +1,33 @@
import 'package:hive/hive.dart';
part 'payment_card_model.g.dart';
@HiveType(typeId: 2)
class PaymentCardModel extends HiveObject {
@HiveField(0)
String id;
@HiveField(1)
String issuerName;
@HiveField(2)
String last4;
@HiveField(3)
String colorHex;
@HiveField(4)
String iconName;
@HiveField(5)
bool isDefault;
PaymentCardModel({
required this.id,
required this.issuerName,
required this.last4,
required this.colorHex,
required this.iconName,
this.isDefault = false,
});
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'payment_card_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PaymentCardModelAdapter extends TypeAdapter<PaymentCardModel> {
@override
final int typeId = 2;
@override
PaymentCardModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return PaymentCardModel(
id: fields[0] as String,
issuerName: fields[1] as String,
last4: fields[2] as String,
colorHex: fields[3] as String,
iconName: fields[4] as String,
isDefault: fields[5] as bool,
);
}
@override
void write(BinaryWriter writer, PaymentCardModel obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.issuerName)
..writeByte(2)
..write(obj.last4)
..writeByte(3)
..write(obj.colorHex)
..writeByte(4)
..write(obj.iconName)
..writeByte(5)
..write(obj.isDefault);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PaymentCardModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,14 @@
/// SMS 스캔 등에서 추출한 결제수단 정보 제안
class PaymentCardSuggestion {
final String issuerName;
final String? last4;
final String? source; // 예: SMS, OCR 등
const PaymentCardSuggestion({
required this.issuerName,
this.last4,
this.source,
});
bool get hasLast4 => last4 != null && last4!.length == 4;
}

View File

@@ -1,3 +1,5 @@
import 'payment_card_suggestion.dart';
class Subscription { class Subscription {
final String id; final String id;
final String serviceName; final String serviceName;
@@ -10,6 +12,9 @@ class Subscription {
final DateTime? lastPaymentDate; final DateTime? lastPaymentDate;
final String? websiteUrl; final String? websiteUrl;
final String currency; final String currency;
final String? paymentCardId;
final PaymentCardSuggestion? paymentCardSuggestion;
final String? rawMessage;
Subscription({ Subscription({
required this.id, required this.id,
@@ -23,8 +28,52 @@ class Subscription {
this.lastPaymentDate, this.lastPaymentDate,
this.websiteUrl, this.websiteUrl,
this.currency = 'KRW', this.currency = 'KRW',
this.paymentCardId,
this.paymentCardSuggestion,
this.rawMessage,
}); });
Subscription copyWith({
String? id,
String? serviceName,
double? monthlyCost,
String? billingCycle,
DateTime? nextBillingDate,
String? category,
String? notes,
int? repeatCount,
DateTime? lastPaymentDate,
String? websiteUrl,
String? currency,
String? paymentCardId,
PaymentCardSuggestion? paymentCardSuggestion,
String? rawMessage,
}) {
return Subscription(
id: id ?? this.id,
serviceName: serviceName ?? this.serviceName,
monthlyCost: monthlyCost ?? this.monthlyCost,
billingCycle: billingCycle ?? this.billingCycle,
nextBillingDate: nextBillingDate ?? this.nextBillingDate,
category: category ?? this.category,
notes: notes ?? this.notes,
repeatCount: repeatCount ?? this.repeatCount,
lastPaymentDate: lastPaymentDate ?? this.lastPaymentDate,
websiteUrl: websiteUrl ?? this.websiteUrl,
currency: currency ?? this.currency,
paymentCardId: paymentCardId ?? this.paymentCardId,
paymentCardSuggestion: paymentCardSuggestion ??
(this.paymentCardSuggestion != null
? PaymentCardSuggestion(
issuerName: this.paymentCardSuggestion!.issuerName,
last4: this.paymentCardSuggestion!.last4,
source: this.paymentCardSuggestion!.source,
)
: null),
rawMessage: rawMessage ?? this.rawMessage,
);
}
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
@@ -38,6 +87,11 @@ class Subscription {
'lastPaymentDate': lastPaymentDate?.toIso8601String(), 'lastPaymentDate': lastPaymentDate?.toIso8601String(),
'websiteUrl': websiteUrl, 'websiteUrl': websiteUrl,
'currency': currency, 'currency': currency,
'paymentCardId': paymentCardId,
'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName,
'paymentCardSuggestionLast4': paymentCardSuggestion?.last4,
'paymentCardSuggestionSource': paymentCardSuggestion?.source,
'rawMessage': rawMessage,
}; };
} }
@@ -56,6 +110,15 @@ class Subscription {
: null, : null,
websiteUrl: map['websiteUrl'] as String?, websiteUrl: map['websiteUrl'] as String?,
currency: map['currency'] as String? ?? 'KRW', currency: map['currency'] as String? ?? 'KRW',
paymentCardId: map['paymentCardId'] as String?,
paymentCardSuggestion: map['paymentCardSuggestionIssuer'] != null
? PaymentCardSuggestion(
issuerName: map['paymentCardSuggestionIssuer'] as String,
last4: map['paymentCardSuggestionLast4'] as String?,
source: map['paymentCardSuggestionSource'] as String?,
)
: null,
rawMessage: map['rawMessage'] as String?,
); );
} }

View File

@@ -49,6 +49,9 @@ class SubscriptionModel extends HiveObject {
@HiveField(14) @HiveField(14)
double? eventPrice; // 이벤트 기간 중 가격 double? eventPrice; // 이벤트 기간 중 가격
@HiveField(15)
String? paymentCardId; // 연결된 결제수단의 ID
SubscriptionModel({ SubscriptionModel({
required this.id, required this.id,
required this.serviceName, required this.serviceName,
@@ -65,6 +68,7 @@ class SubscriptionModel extends HiveObject {
this.eventStartDate, this.eventStartDate,
this.eventEndDate, this.eventEndDate,
this.eventPrice, this.eventPrice,
this.paymentCardId,
}); });
// 주기적 결제 여부 확인 // 주기적 결제 여부 확인

View File

@@ -32,13 +32,14 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
eventStartDate: fields[12] as DateTime?, eventStartDate: fields[12] as DateTime?,
eventEndDate: fields[13] as DateTime?, eventEndDate: fields[13] as DateTime?,
eventPrice: fields[14] as double?, eventPrice: fields[14] as double?,
paymentCardId: fields[15] as String?,
); );
} }
@override @override
void write(BinaryWriter writer, SubscriptionModel obj) { void write(BinaryWriter writer, SubscriptionModel obj) {
writer writer
..writeByte(15) ..writeByte(16)
..writeByte(0) ..writeByte(0)
..write(obj.id) ..write(obj.id)
..writeByte(1) ..writeByte(1)
@@ -68,7 +69,9 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
..writeByte(13) ..writeByte(13)
..write(obj.eventEndDate) ..write(obj.eventEndDate)
..writeByte(14) ..writeByte(14)
..write(obj.eventPrice); ..write(obj.eventPrice)
..writeByte(15)
..write(obj.paymentCardId);
} }
@override @override

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart';
class AppNavigationObserver extends NavigatorObserver { class AppNavigationObserver extends NavigatorObserver {
@override @override
@@ -47,6 +48,12 @@ class AppNavigationObserver extends NavigatorObserver {
final routeName = route.settings.name; final routeName = route.settings.name;
if (routeName == null) return; if (routeName == null) return;
// 메인 화면('/')은 하단 탭으로 상태를 관리하므로
// 모달 닫힘 등으로 인해 홈 탭으로 강제 전환하지 않도록 무시한다.
if (routeName == AppRoutes.main || routeName == '/') {
return;
}
// build 완료 후 업데이트하도록 변경 // build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return; if (navigator?.context == null) return;

View File

@@ -6,6 +6,8 @@ import '../services/notification_service.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class AppLockProvider extends ChangeNotifier { class AppLockProvider extends ChangeNotifier {
final Box<bool> _appLockBox; final Box<bool> _appLockBox;
@@ -65,6 +67,12 @@ class AppLockProvider extends ChangeNotifier {
} }
try { try {
// async 전에 context 기반 데이터 미리 획득
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
final localizedReason =
loc?.unlockWithBiometric ?? 'Unlock with biometric authentication.';
final canCheck = await _checkBiometrics(); final canCheck = await _checkBiometrics();
if (!canCheck) { if (!canCheck) {
_isLocked = false; _isLocked = false;
@@ -73,7 +81,7 @@ class AppLockProvider extends ChangeNotifier {
} }
final authenticated = await _localAuth.authenticate( final authenticated = await _localAuth.authenticate(
localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.', localizedReason: localizedReason,
options: const AuthenticationOptions( options: const AuthenticationOptions(
stickyAuth: true, stickyAuth: true,
biometricOnly: true, biometricOnly: true,

View File

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

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:uuid/uuid.dart';
import '../models/payment_card_model.dart';
class PaymentCardProvider extends ChangeNotifier {
late Box<PaymentCardModel> _cardBox;
final List<PaymentCardModel> _cards = [];
List<PaymentCardModel> get cards => List.unmodifiable(_cards);
PaymentCardModel? get defaultCard {
try {
return _cards.firstWhere((card) => card.isDefault);
} catch (_) {
return _cards.isNotEmpty ? _cards.first : null;
}
}
Future<void> init() async {
_cardBox = await Hive.openBox<PaymentCardModel>('payment_cards');
_cards
..clear()
..addAll(_cardBox.values);
_sortCards();
notifyListeners();
}
Future<PaymentCardModel> addCard({
required String issuerName,
required String last4,
required String colorHex,
required String iconName,
bool isDefault = false,
}) async {
if (isDefault) {
await _unsetDefaultCard();
}
final card = PaymentCardModel(
id: const Uuid().v4(),
issuerName: issuerName,
last4: last4,
colorHex: colorHex,
iconName: iconName,
isDefault: isDefault,
);
await _cardBox.put(card.id, card);
_cards.add(card);
_sortCards();
notifyListeners();
return card;
}
Future<void> updateCard(PaymentCardModel updated) async {
final index = _cards.indexWhere((card) => card.id == updated.id);
if (index == -1) return;
if (updated.isDefault) {
await _unsetDefaultCard(exceptId: updated.id);
}
_cards[index] = updated;
await _cardBox.put(updated.id, updated);
_sortCards();
notifyListeners();
}
Future<void> deleteCard(String id) async {
await _cardBox.delete(id);
_cards.removeWhere((card) => card.id == id);
if (!_cards.any((card) => card.isDefault) && _cards.isNotEmpty) {
_cards.first.isDefault = true;
await _cardBox.put(_cards.first.id, _cards.first);
}
_sortCards();
notifyListeners();
}
Future<void> setDefaultCard(String id) async {
final index = _cards.indexWhere((card) => card.id == id);
if (index == -1) return;
await _unsetDefaultCard(exceptId: id);
_cards[index].isDefault = true;
await _cardBox.put(id, _cards[index]);
_sortCards();
notifyListeners();
}
PaymentCardModel? getCardById(String? id) {
if (id == null) return null;
try {
return _cards.firstWhere((card) => card.id == id);
} catch (_) {
return null;
}
}
void _sortCards() {
_cards.sort((a, b) {
if (a.isDefault != b.isDefault) {
return a.isDefault ? -1 : 1;
}
final issuerCompare =
a.issuerName.toLowerCase().compareTo(b.issuerName.toLowerCase());
if (issuerCompare != 0) return issuerCompare;
return a.last4.compareTo(b.last4);
});
}
Future<void> _unsetDefaultCard({String? exceptId}) async {
for (final card in _cards) {
if (card.isDefault && card.id != exceptId) {
card.isDefault = false;
await _cardBox.put(card.id, card);
}
}
}
}

View File

@@ -8,6 +8,9 @@ import '../services/notification_service.dart';
import '../services/exchange_rate_service.dart'; import '../services/exchange_rate_service.dart';
import '../services/currency_util.dart'; import '../services/currency_util.dart';
import 'category_provider.dart'; import 'category_provider.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
import '../utils/billing_cost_util.dart';
class SubscriptionProvider extends ChangeNotifier { class SubscriptionProvider extends ChangeNotifier {
late Box<SubscriptionModel> _subscriptionBox; late Box<SubscriptionModel> _subscriptionBox;
@@ -22,18 +25,40 @@ class SubscriptionProvider extends ChangeNotifier {
final rate = exchangeRateService.cachedUsdToKrwRate ?? final rate = exchangeRateService.cachedUsdToKrwRate ??
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
final now = DateTime.now();
final currentYear = now.year;
final currentMonth = now.month;
final total = _subscriptions.fold( final total = _subscriptions.fold(
0.0, 0.0,
(sum, subscription) { (sum, subscription) {
final price = subscription.currentPrice; // 이번 달에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
currentYear,
currentMonth,
);
if (!hasBilling) {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'이번 달 결제 없음, 제외');
return sum;
}
// 실제 결제 금액으로 역변환
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
if (subscription.currency == 'USD') { if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$$price ×$rate = ₩${price * rate}'); '\$$actualPrice ×$rate = ₩${actualPrice * rate}');
return sum + (price * rate); return sum + (actualPrice * rate);
} }
debugPrint( debugPrint(
'[SubscriptionProvider] ${subscription.serviceName}: ₩$price'); '[SubscriptionProvider] ${subscription.serviceName}: ₩$actualPrice');
return sum + price; return sum + actualPrice;
}, },
); );
@@ -74,6 +99,9 @@ class SubscriptionProvider extends ChangeNotifier {
// categoryId 마이그레이션 // categoryId 마이그레이션
await _migrateCategoryIds(); await _migrateCategoryIds();
// billingCycle별 비용 마이그레이션 (연간/분기별 구독 월 비용 변환)
await _migrateBillingCosts();
// 앱 시작 시 이벤트 상태 확인 // 앱 시작 시 이벤트 상태 확인
await checkAndUpdateEventStatus(); await checkAndUpdateEventStatus();
@@ -103,6 +131,14 @@ class SubscriptionProvider extends ChangeNotifier {
} }
} }
Future<void> _reschedulePaymentNotifications() async {
try {
await NotificationService.reschedulAllNotifications(_subscriptions);
} catch (e) {
debugPrint('결제 알림 재예약 중 오류 발생: $e');
}
}
Future<void> addSubscription({ Future<void> addSubscription({
required String serviceName, required String serviceName,
required double monthlyCost, required double monthlyCost,
@@ -110,6 +146,7 @@ class SubscriptionProvider extends ChangeNotifier {
required DateTime nextBillingDate, required DateTime nextBillingDate,
String? websiteUrl, String? websiteUrl,
String? categoryId, String? categoryId,
String? paymentCardId,
bool isAutoDetected = false, bool isAutoDetected = false,
int repeatCount = 1, int repeatCount = 1,
DateTime? lastPaymentDate, DateTime? lastPaymentDate,
@@ -128,6 +165,7 @@ class SubscriptionProvider extends ChangeNotifier {
nextBillingDate: nextBillingDate, nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl, websiteUrl: websiteUrl,
categoryId: categoryId, categoryId: categoryId,
paymentCardId: paymentCardId,
isAutoDetected: isAutoDetected, isAutoDetected: isAutoDetected,
repeatCount: repeatCount, repeatCount: repeatCount,
lastPaymentDate: lastPaymentDate, lastPaymentDate: lastPaymentDate,
@@ -145,6 +183,8 @@ class SubscriptionProvider extends ChangeNotifier {
if (isEventActive && eventEndDate != null) { if (isEventActive && eventEndDate != null) {
await _scheduleEventEndNotification(subscription); await _scheduleEventEndNotification(subscription);
} }
await _reschedulePaymentNotifications();
} catch (e) { } catch (e) {
debugPrint('구독 추가 중 오류 발생: $e'); debugPrint('구독 추가 중 오류 발생: $e');
rethrow; rethrow;
@@ -176,6 +216,8 @@ class SubscriptionProvider extends ChangeNotifier {
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, ' debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}'); '현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners(); notifyListeners();
await _reschedulePaymentNotifications();
} catch (e) { } catch (e) {
debugPrint('구독 업데이트 중 오류 발생: $e'); debugPrint('구독 업데이트 중 오류 발생: $e');
rethrow; rethrow;
@@ -186,6 +228,8 @@ class SubscriptionProvider extends ChangeNotifier {
try { try {
await _subscriptionBox.delete(id); await _subscriptionBox.delete(id);
await refreshSubscriptions(); await refreshSubscriptions();
await _reschedulePaymentNotifications();
} catch (e) { } catch (e) {
debugPrint('구독 삭제 중 오류 발생: $e'); debugPrint('구독 삭제 중 오류 발생: $e');
rethrow; rethrow;
@@ -213,6 +257,8 @@ class SubscriptionProvider extends ChangeNotifier {
} finally { } finally {
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
await _reschedulePaymentNotifications();
} }
} }
@@ -221,11 +267,15 @@ class SubscriptionProvider extends ChangeNotifier {
SubscriptionModel subscription) async { SubscriptionModel subscription) async {
if (subscription.eventEndDate != null && if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) { subscription.eventEndDate!.isAfter(DateTime.now())) {
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
await NotificationService.scheduleNotification( await NotificationService.scheduleNotification(
id: '${subscription.id}_event_end'.hashCode, id: '${subscription.id}_event_end'.hashCode,
title: '이벤트 종료 알림', title: loc?.eventEndNotificationTitle ?? 'Event end notification',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.', body: loc?.eventEndNotificationBody(subscription.serviceName) ??
"${subscription.serviceName}'s discount event has ended.",
scheduledDate: subscription.eventEndDate!, scheduledDate: subscription.eventEndDate!,
channelId: NotificationService.expirationChannelId,
); );
} }
} }
@@ -250,40 +300,77 @@ class SubscriptionProvider extends ChangeNotifier {
} }
} }
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산) /// 이번 달 총 지출을 계산합니다. (로케일별 기본 통화로 환산)
Future<double> calculateTotalExpense({String? locale}) async { /// - 이번 달에 결제가 발생하는 구독만 포함
if (_subscriptions.isEmpty) return 0.0; /// - 실제 결제 금액으로 계산 (연간이면 연간 금액)
Future<double> calculateTotalExpense({
String? locale,
List<SubscriptionModel>? subset,
}) async {
final targetSubscriptions = subset ?? _subscriptions;
if (targetSubscriptions.isEmpty) return 0.0;
final now = DateTime.now();
final currentYear = now.year;
final currentMonth = now.month;
// locale이 제공되지 않으면 현재 로케일 사용 // locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency'); debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, '
'대상 구독: ${targetSubscriptions.length}개, 현재 월: $currentYear-$currentMonth');
double total = 0.0; double total = 0.0;
for (final subscription in _subscriptions) { for (final subscription in targetSubscriptions) {
final currentPrice = subscription.currentPrice; // 이번 달에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
currentYear,
currentMonth,
);
if (!hasBilling) {
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'이번 달 결제 없음 - 제외');
continue;
}
// 실제 결제 금액으로 변환 (월 비용 → 실제 결제 금액)
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '실제 결제 금액 $actualPrice ${subscription.currency} '
'(월 비용: ${subscription.currentPrice}, 주기: ${subscription.billingCycle})');
final converted = await ExchangeRateService().convertBetweenCurrencies( final converted = await ExchangeRateService().convertBetweenCurrencies(
currentPrice, actualPrice,
subscription.currency, subscription.currency,
targetCurrency, targetCurrency,
); );
total += converted ?? currentPrice; total += converted ?? actualPrice;
} }
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency'); debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total '
'$targetCurrency (대상 ${targetSubscriptions.length}개)');
return total; return total;
} }
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData( /// - 각 월에 결제가 발생하는 구독만 포함
{String? locale}) async { /// - 실제 결제 금액으로 계산 (연간이면 연간 금액)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({
String? locale,
List<SubscriptionModel>? subset,
}) async {
final now = DateTime.now(); final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = []; final List<Map<String, dynamic>> monthlyData = [];
final targetSubscriptions = subset ?? _subscriptions;
// locale이 제공되지 않으면 현재 로케일 사용 // locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = final targetCurrency =
@@ -303,60 +390,63 @@ class SubscriptionProvider extends ChangeNotifier {
'[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...'); '[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
} }
// 해당 월에 활성화된 구독 계산 // 해당 월에 결제가 발생하는 구독 계산
for (final subscription in _subscriptions) { for (final subscription in targetSubscriptions) {
// 해당 월에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
month.year,
month.month,
);
if (!hasBilling) {
continue; // 해당 월에 결제가 없으면 제외
}
// 실제 결제 금액으로 변환 (월 비용 → 실제 결제 금액)
double actualCost;
if (isCurrentMonth) {
// 현재 월: 이벤트 가격 반영
actualCost = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
} else {
// 과거 월: 이벤트 기간 확인 후 적용
double monthlyCost;
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
subscription.eventStartDate!
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.eventEndDate!.isAfter(month)) {
monthlyCost = subscription.eventPrice ?? subscription.monthlyCost;
} else {
monthlyCost = subscription.monthlyCost;
}
actualCost = BillingCostUtil.convertFromMonthlyCost(
monthlyCost,
subscription.billingCycle,
);
}
if (isCurrentMonth) { if (isCurrentMonth) {
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
final cost = subscription.currentPrice;
debugPrint( debugPrint(
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' '[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '실제 결제 금액 $actualCost ${subscription.currency}');
// 통화 변환
final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
} else {
// 과거 월인 경우: 기존 로직 유지
// 구독이 해당 월에 활성화되어 있었는지 확인
final subscriptionStartDate = subscription.nextBillingDate.subtract(
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
);
if (subscriptionStartDate
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.nextBillingDate.isAfter(month)) {
// 해당 월의 비용 계산 (이벤트 가격 고려)
double cost;
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
// 이벤트 기간과 해당 월이 겹치는지 확인
subscription.eventStartDate!
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.eventEndDate!.isAfter(month)) {
cost = subscription.eventPrice ?? subscription.monthlyCost;
} else {
cost = subscription.monthlyCost;
}
// 통화 변환
final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
}
} }
// 통화 변환
final converted =
await ExchangeRateService().convertBetweenCurrencies(
actualCost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? actualCost;
} }
if (isCurrentMonth) { if (isCurrentMonth) {
@@ -380,22 +470,6 @@ class SubscriptionProvider extends ChangeNotifier {
return totalEventSavings; return totalEventSavings;
} }
/// 결제 주기를 일 단위로 변환합니다.
int _getBillingCycleDays(String billingCycle) {
switch (billingCycle) {
case 'monthly':
return 30;
case 'yearly':
return 365;
case 'weekly':
return 7;
case 'quarterly':
return 90;
default:
return 30;
}
}
/// 월 라벨을 생성합니다. /// 월 라벨을 생성합니다.
String _getMonthLabel(DateTime month, String locale) { String _getMonthLabel(DateTime month, String locale) {
if (locale == 'ko') { if (locale == 'ko') {
@@ -524,4 +598,59 @@ class SubscriptionProvider extends ChangeNotifier {
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다'); debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');
} }
} }
/// billingCycle별 비용 마이그레이션
/// 기존 연간/분기별 구독의 monthlyCost를 월 환산 비용으로 변환
Future<void> _migrateBillingCosts() async {
debugPrint('💰 BillingCost 마이그레이션 시작...');
int migratedCount = 0;
for (var subscription in _subscriptions) {
final cycle = subscription.billingCycle.toLowerCase();
// 월간 구독이 아닌 경우에만 변환 필요
if (cycle != 'monthly' && cycle != '월간' && cycle != '매월') {
// 현재 monthlyCost가 실제 월 비용인지 확인
// 연간 구독인데 monthlyCost가 12배 이상 크면 변환 안됨 상태로 판단
final multiplier = BillingCostUtil.getBillingCycleMultiplier(cycle);
// 변환이 필요한 경우: monthlyCost가 비정상적으로 큰 경우
// (예: 연간 129,000원이 monthlyCost에 그대로 저장된 경우)
if (multiplier > 1.5) {
// 원래 monthlyCost를 백업
final originalCost = subscription.monthlyCost;
// 월 비용으로 변환
final convertedCost = BillingCostUtil.convertToMonthlyCost(
originalCost,
cycle,
);
// 이벤트 가격도 있다면 변환
if (subscription.eventPrice != null) {
final convertedEventPrice = BillingCostUtil.convertToMonthlyCost(
subscription.eventPrice!,
cycle,
);
subscription.eventPrice = convertedEventPrice;
}
subscription.monthlyCost = convertedCost;
await subscription.save();
migratedCount++;
debugPrint('${subscription.serviceName} ($cycle): '
'${originalCost.toInt()} → ₩${convertedCost.toInt()}/월');
}
}
}
if (migratedCount > 0) {
debugPrint('💰 총 $migratedCount개의 구독 비용 변환 완료');
await refreshSubscriptions();
} else {
debugPrint('💰 모든 구독이 이미 월 비용으로 저장되어 있습니다');
}
}
} }

View File

@@ -8,6 +8,8 @@ import 'package:submanager/screens/settings_screen.dart';
import 'package:submanager/screens/splash_screen.dart'; import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/screens/sms_permission_screen.dart'; import 'package:submanager/screens/sms_permission_screen.dart';
import 'package:submanager/models/subscription_model.dart'; import 'package:submanager/models/subscription_model.dart';
import 'package:submanager/screens/payment_card_management_screen.dart';
import '../l10n/app_localizations.dart';
class AppRoutes { class AppRoutes {
static const String splash = '/splash'; static const String splash = '/splash';
@@ -18,6 +20,7 @@ class AppRoutes {
static const String analysis = '/analysis'; static const String analysis = '/analysis';
static const String settings = '/settings'; static const String settings = '/settings';
static const String smsPermission = '/sms-permission'; static const String smsPermission = '/sms-permission';
static const String paymentCardManagement = '/payment-card-management';
static Map<String, WidgetBuilder> getRoutes() { static Map<String, WidgetBuilder> getRoutes() {
return { return {
@@ -28,6 +31,7 @@ class AppRoutes {
analysis: (context) => const AnalysisScreen(), analysis: (context) => const AnalysisScreen(),
settings: (context) => const SettingsScreen(), settings: (context) => const SettingsScreen(),
smsPermission: (context) => const SmsPermissionScreen(), smsPermission: (context) => const SmsPermissionScreen(),
paymentCardManagement: (context) => const PaymentCardManagementScreen(),
}; };
} }
@@ -61,6 +65,8 @@ class AppRoutes {
case smsPermission: case smsPermission:
return _buildRoute(const SmsPermissionScreen(), routeSettings); return _buildRoute(const SmsPermissionScreen(), routeSettings);
case paymentCardManagement:
return _buildRoute(const PaymentCardManagementScreen(), routeSettings);
default: default:
return _errorRoute(); return _errorRoute();
@@ -76,9 +82,9 @@ class AppRoutes {
static Route<dynamic> _errorRoute() { static Route<dynamic> _errorRoute() {
return MaterialPageRoute( return MaterialPageRoute(
builder: (_) => const Scaffold( builder: (context) => Scaffold(
body: Center( body: Center(
child: Text('페이지를 찾을 수 없습니다'), child: Text(AppLocalizations.of(context).pageNotFound),
), ),
), ),
); );

View File

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

View File

@@ -1,13 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/payment_card_model.dart';
import '../models/subscription_model.dart';
import '../providers/payment_card_provider.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import '../utils/payment_card_utils.dart';
import '../widgets/native_ad_widget.dart'; import '../widgets/native_ad_widget.dart';
import '../widgets/analysis/analysis_screen_spacer.dart'; import '../widgets/analysis/analysis_screen_spacer.dart';
import '../widgets/analysis/subscription_pie_chart_card.dart'; import '../widgets/analysis/subscription_pie_chart_card.dart';
import '../widgets/analysis/total_expense_summary_card.dart'; import '../widgets/analysis/total_expense_summary_card.dart';
import '../widgets/analysis/monthly_expense_chart_card.dart'; import '../widgets/analysis/monthly_expense_chart_card.dart';
import '../widgets/analysis/event_analysis_card.dart'; import '../widgets/analysis/event_analysis_card.dart';
import '../theme/ui_constants.dart';
enum AnalysisCardFilterType { all, unassigned, card }
class AnalysisScreen extends StatefulWidget { class AnalysisScreen extends StatefulWidget {
const AnalysisScreen({super.key}); const AnalysisScreen({super.key});
@@ -25,6 +33,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
List<Map<String, dynamic>> _monthlyData = []; List<Map<String, dynamic>> _monthlyData = [];
bool _isLoading = true; bool _isLoading = true;
String _lastDataHash = ''; String _lastDataHash = '';
AnalysisCardFilterType _filterType = AnalysisCardFilterType.all;
String? _selectedCardId;
@override @override
void initState() { void initState() {
@@ -42,7 +52,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
super.didChangeDependencies(); super.didChangeDependencies();
// Provider 변경 감지 // Provider 변경 감지
final provider = Provider.of<SubscriptionProvider>(context); final provider = Provider.of<SubscriptionProvider>(context);
final currentHash = _calculateDataHash(provider); final filtered = _filterSubscriptions(provider.subscriptions);
final currentHash = _calculateDataHash(provider, filtered: filtered);
debugPrint('[AnalysisScreen] didChangeDependencies: ' debugPrint('[AnalysisScreen] didChangeDependencies: '
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading'); '현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
@@ -64,13 +75,16 @@ class _AnalysisScreenState extends State<AnalysisScreen>
} }
/// 구독 데이터의 해시값을 계산하여 변경 감지 /// 구독 데이터의 해시값을 계산하여 변경 감지
String _calculateDataHash(SubscriptionProvider provider) { String _calculateDataHash(
final subscriptions = provider.subscriptions; SubscriptionProvider provider, {
final buffer = StringBuffer(); List<SubscriptionModel>? filtered,
}) {
buffer.write(subscriptions.length); final subscriptions =
buffer.write('_'); filtered ?? _filterSubscriptions(provider.subscriptions);
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2)); final buffer = StringBuffer()
..write(_filterType.name)
..write('_${_selectedCardId ?? 'all'}')
..write('_${subscriptions.length}');
for (final sub in subscriptions) { for (final sub in subscriptions) {
buffer.write( buffer.write(
@@ -80,6 +94,38 @@ class _AnalysisScreenState extends State<AnalysisScreen>
return buffer.toString(); return buffer.toString();
} }
List<SubscriptionModel> _filterSubscriptions(
List<SubscriptionModel> subscriptions) {
switch (_filterType) {
case AnalysisCardFilterType.all:
return subscriptions;
case AnalysisCardFilterType.unassigned:
return subscriptions.where((sub) => sub.paymentCardId == null).toList();
case AnalysisCardFilterType.card:
final cardId = _selectedCardId;
if (cardId == null) return subscriptions;
return subscriptions
.where((sub) => sub.paymentCardId == cardId)
.toList();
}
}
Future<void> _onFilterChanged(AnalysisCardFilterType type,
{String? cardId}) async {
if (_filterType == type) {
if (type != AnalysisCardFilterType.card || _selectedCardId == cardId) {
return;
}
}
setState(() {
_filterType = type;
_selectedCardId = type == AnalysisCardFilterType.card ? cardId : null;
});
await _loadData();
}
Future<void> _loadData() async { Future<void> _loadData() async {
debugPrint('[AnalysisScreen] _loadData 호출됨'); debugPrint('[AnalysisScreen] _loadData 호출됨');
setState(() { setState(() {
@@ -89,17 +135,25 @@ class _AnalysisScreenState extends State<AnalysisScreen>
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final localeProvider = Provider.of<LocaleProvider>(context, listen: false); final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode; final locale = localeProvider.locale.languageCode;
final filteredSubscriptions = _filterSubscriptions(provider.subscriptions);
// 총 지출 계산 (로케일별 기본 통화로 환산) // 총 지출 계산 (로케일별 기본 통화로 환산)
_totalExpense = await provider.calculateTotalExpense(locale: locale); _totalExpense = await provider.calculateTotalExpense(
locale: locale,
subset: filteredSubscriptions,
);
debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense'); debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense');
// 월별 데이터 계산 (로케일별 기본 통화로 환산) // 월별 데이터 계산 (로케일별 기본 통화로 환산)
_monthlyData = await provider.getMonthlyExpenseData(locale: locale); _monthlyData = await provider.getMonthlyExpenseData(
locale: locale,
subset: filteredSubscriptions,
);
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월'); debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
// 현재 데이터 해시값 저장 // 현재 데이터 해시값 저장
_lastDataHash = _calculateDataHash(provider); _lastDataHash =
_calculateDataHash(provider, filtered: filteredSubscriptions);
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash'); debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
setState(() { setState(() {
@@ -130,6 +184,128 @@ class _AnalysisScreenState extends State<AnalysisScreen>
); );
} }
Widget _buildCardFilterSection(
BuildContext context, PaymentCardProvider cardProvider) {
final loc = AppLocalizations.of(context);
final chips = <Widget>[
_buildGenericFilterChip(
context: context,
label: loc.analysisCardFilterAll,
icon: Icons.credit_card,
selected: _filterType == AnalysisCardFilterType.all,
onTap: () => _onFilterChanged(AnalysisCardFilterType.all),
),
const SizedBox(width: 8),
_buildGenericFilterChip(
context: context,
label: loc.paymentCardUnassigned,
icon: Icons.credit_card_off_rounded,
selected: _filterType == AnalysisCardFilterType.unassigned,
onTap: () => _onFilterChanged(AnalysisCardFilterType.unassigned),
),
];
for (final card in cardProvider.cards) {
chips.add(const SizedBox(width: 8));
chips.add(_buildPaymentCardChip(context, card));
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.analysisCardFilterLabel,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: chips),
),
],
),
),
);
}
Widget _buildGenericFilterChip({
required BuildContext context,
required String label,
required IconData icon,
required bool selected,
required VoidCallback onTap,
}) {
final cs = Theme.of(context).colorScheme;
return Semantics(
selected: selected,
button: true,
label: label,
child: ChoiceChip(
label: Text(label),
avatar: Icon(
icon,
size: 16,
color: selected ? cs.onPrimary : cs.onSurfaceVariant,
),
selected: selected,
onSelected: (_) => onTap(),
selectedColor: cs.primary,
labelStyle: TextStyle(
color: selected ? cs.onPrimary : cs.onSurface,
fontWeight: FontWeight.w600,
),
backgroundColor: cs.surface,
side: BorderSide(
color:
selected ? Colors.transparent : cs.outline.withValues(alpha: 0.5),
),
),
);
}
Widget _buildPaymentCardChip(BuildContext context, PaymentCardModel card) {
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
final cs = Theme.of(context).colorScheme;
final selected = _filterType == AnalysisCardFilterType.card &&
_selectedCardId == card.id;
final labelText = '${card.issuerName} · ****${card.last4}';
return Semantics(
label: labelText,
selected: selected,
button: true,
child: ChoiceChip(
avatar: CircleAvatar(
backgroundColor:
selected ? cs.onPrimary : color.withValues(alpha: 0.15),
child: Icon(
icon,
size: 16,
color: selected ? color : cs.onSurface,
),
),
label: Text(labelText),
selected: selected,
onSelected: (_) =>
_onFilterChanged(AnalysisCardFilterType.card, cardId: card.id),
selectedColor: color,
backgroundColor: cs.surface,
labelStyle: TextStyle(
color: selected ? cs.onPrimary : cs.onSurface,
fontWeight: FontWeight.w600,
),
side: BorderSide(
color: selected ? Colors.transparent : color.withValues(alpha: 0.5),
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Provider를 직접 사용하여 변경 감지 // Provider를 직접 사용하여 변경 감지
@@ -142,35 +318,39 @@ class _AnalysisScreenState extends State<AnalysisScreen>
); );
} }
final cardProvider = Provider.of<PaymentCardProvider>(context);
final filteredSubscriptions = _filterSubscriptions(subscriptions);
return CustomScrollView( return CustomScrollView(
controller: _scrollController, controller: _scrollController,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: <Widget>[ slivers: <Widget>[
SliverToBoxAdapter( SliverPadding(
child: SizedBox( padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
height: kToolbarHeight + MediaQuery.of(context).padding.top, sliver: _buildCardFilterSection(context, cardProvider),
),
),
// 네이티브 광고 위젯
SliverToBoxAdapter(
child: _buildAnimatedAd(),
), ),
const AnalysisScreenSpacer(), const AnalysisScreenSpacer(),
// 1. 구독 비율 파이 차트 // 1. 구독 비율 파이 차트
SubscriptionPieChartCard( SubscriptionPieChartCard(
subscriptions: subscriptions, subscriptions: filteredSubscriptions,
animationController: _animationController, animationController: _animationController,
), ),
const AnalysisScreenSpacer(), const AnalysisScreenSpacer(),
// 네이티브 광고 위젯 (구독 비율 차트 하단)
SliverToBoxAdapter(
child: _buildAnimatedAd(),
),
const AnalysisScreenSpacer(),
// 2. 총 지출 요약 카드 // 2. 총 지출 요약 카드
TotalExpenseSummaryCard( TotalExpenseSummaryCard(
key: ValueKey('total_expense_$_lastDataHash'), key: ValueKey('total_expense_$_lastDataHash'),
subscriptions: subscriptions, subscriptions: filteredSubscriptions,
totalExpense: _totalExpense, totalExpense: _totalExpense,
animationController: _animationController, animationController: _animationController,
), ),
@@ -189,6 +369,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 4. 이벤트 분석 // 4. 이벤트 분석
EventAnalysisCard( EventAnalysisCard(
animationController: _animationController, animationController: _animationController,
subscriptions: filteredSubscriptions,
), ),
// FloatingNavigationBar를 위한 충분한 하단 여백 // FloatingNavigationBar를 위한 충분한 하단 여백

View File

@@ -1,38 +1,41 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/app_lock_provider.dart'; import '../providers/app_lock_provider.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
class AppLockScreen extends StatelessWidget { class AppLockScreen extends StatelessWidget {
const AppLockScreen({super.key}); const AppLockScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold( return Scaffold(
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon( Icon(
Icons.lock_outline, Icons.lock_outline,
size: 80, size: 80,
color: AppColors.navyGray, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( Text(
'앱이 잠겨 있습니다', loc.appLocked,
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'생체 인증으로 잠금을 해제하세요', loc.appLockDesc,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.navyGray, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -41,21 +44,22 @@ class AppLockScreen extends StatelessWidget {
final appLock = context.read<AppLockProvider>(); final appLock = context.read<AppLockProvider>();
final success = await appLock.authenticate(); final success = await appLock.authenticate();
if (!success && context.mounted) { if (!success && context.mounted) {
final cs = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text( content: Text(
'인증에 실패했습니다. 다시 시도해주세요.', loc.authenticationFailed,
style: TextStyle( style: TextStyle(
color: AppColors.pureWhite, color: cs.onPrimary,
), ),
), ),
backgroundColor: AppColors.dangerColor, backgroundColor: cs.error,
), ),
); );
} }
}, },
icon: const Icon(Icons.fingerprint), icon: const Icon(Icons.fingerprint),
label: const Text('생체 인증으로 잠금 해제'), label: Text(loc.unlockWithBiometric),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 24, horizontal: 24,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
class CategoryManagementScreen extends StatefulWidget { class CategoryManagementScreen extends StatefulWidget {
@@ -41,15 +41,16 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text( title: Text(
'카테고리 관리', loc.categoryManagement,
style: TextStyle( style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.pureWhite, color: Theme.of(context).colorScheme.onPrimary,
), ),
), ),
backgroundColor: AppColors.primaryColor, backgroundColor: Theme.of(context).colorScheme.primary,
), ),
body: Consumer<CategoryProvider>( body: Consumer<CategoryProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
@@ -66,26 +67,30 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '카테고리 이름', labelText: loc.categoryName,
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '카테고리 이름을 입력하세요'; return loc.categoryNameRequired;
} }
return null; return null;
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedColor, initialValue: _selectedColor,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '색상 선택', labelText: loc.selectColor,
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
), ),
items: [ items: [
@@ -93,32 +98,42 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
value: '#1976D2', value: '#1976D2',
child: Text( child: Text(
AppLocalizations.of(context).colorBlue, AppLocalizations.of(context).colorBlue,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: '#4CAF50', value: '#4CAF50',
child: Text( child: Text(
AppLocalizations.of(context).colorGreen, AppLocalizations.of(context).colorGreen,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: '#FF9800', value: '#FF9800',
child: Text( child: Text(
AppLocalizations.of(context).colorOrange, AppLocalizations.of(context).colorOrange,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: '#F44336', value: '#F44336',
child: Text( child: Text(
AppLocalizations.of(context).colorRed, AppLocalizations.of(context).colorRed,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: '#9C27B0', value: '#9C27B0',
child: Text( child: Text(
AppLocalizations.of(context).colorPurple, AppLocalizations.of(context).colorPurple,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -128,39 +143,51 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedIcon, initialValue: _selectedIcon,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '아이콘 선택', labelText: loc.selectIcon,
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
), ),
items: const [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 'subscriptions', value: 'subscriptions',
child: Text('구독', child: Text(loc.subscription,
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'movie', value: 'movie',
child: Text('영화', child: Text(loc.movie,
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'music_note', value: 'music_note',
child: Text('음악', child: Text(loc.music,
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'fitness_center', value: 'fitness_center',
child: Text('운동', child: Text(loc.exercise,
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'shopping_cart', value: 'shopping_cart',
child: Text('쇼핑', child: Text(loc.shopping,
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -171,12 +198,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _addCategory, onPressed: _addCategory,
child: const Text( child: Text(loc.addCategory),
'카테고리 추가',
style: TextStyle(
color: AppColors.pureWhite,
),
),
), ),
], ],
), ),
@@ -201,8 +223,8 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
title: Text( title: Text(
provider.getLocalizedCategoryName( provider.getLocalizedCategoryName(
context, category.name), context, category.name),
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
trailing: IconButton( trailing: IconButton(

View File

@@ -3,11 +3,12 @@ import 'package:provider/provider.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../controllers/detail_screen_controller.dart'; import '../controllers/detail_screen_controller.dart';
import '../widgets/detail/detail_header_section.dart'; import '../widgets/detail/detail_header_section.dart';
import '../widgets/detail/detail_payment_info_section.dart';
import '../widgets/detail/detail_form_section.dart'; import '../widgets/detail/detail_form_section.dart';
import '../widgets/detail/detail_event_section.dart'; import '../widgets/detail/detail_event_section.dart';
import '../widgets/detail/detail_url_section.dart'; import '../widgets/detail/detail_url_section.dart';
import '../widgets/detail/detail_action_buttons.dart'; import '../widgets/detail/detail_action_buttons.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
/// 구독 상세 정보를 표시하고 편집할 수 있는 화면 /// 구독 상세 정보를 표시하고 편집할 수 있는 화면
@@ -50,7 +51,7 @@ class _DetailScreenState extends State<DetailScreen>
return ChangeNotifierProvider<DetailScreenController>.value( return ChangeNotifierProvider<DetailScreenController>.value(
value: _controller, value: _controller,
child: Scaffold( child: Scaffold(
backgroundColor: AppColors.backgroundColor, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView( body: CustomScrollView(
controller: _controller.scrollController, controller: _controller.scrollController,
slivers: [ slivers: [
@@ -77,17 +78,16 @@ class _DetailScreenState extends State<DetailScreen>
vertical: 12, vertical: 12,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: Theme.of(context)
colors: [ .colorScheme
baseColor.withValues(alpha: 0.15), .surfaceContainerHighest
baseColor.withValues(alpha: 0.08), .withValues(alpha: 0.4),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: baseColor.withValues(alpha: 0.2), color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
@@ -111,9 +111,9 @@ class _DetailScreenState extends State<DetailScreen>
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.changesAppliedAfterSave, .changesAppliedAfterSave,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
], ],
@@ -121,6 +121,13 @@ class _DetailScreenState extends State<DetailScreen>
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DetailPaymentInfoSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 기본 정보 폼 섹션 // 기본 정보 폼 섹션
DetailFormSection( DetailFormSection(
controller: _controller, controller: _controller,

View File

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

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/payment_card_model.dart';
import '../providers/payment_card_provider.dart';
import '../utils/payment_card_utils.dart';
import '../widgets/payment_card/payment_card_form_sheet.dart';
class PaymentCardManagementScreen extends StatelessWidget {
const PaymentCardManagementScreen({super.key});
Future<void> _openForm(BuildContext context, {PaymentCardModel? card}) async {
await PaymentCardFormSheet.show(context, card: card);
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(loc.paymentCardManagement),
),
body: Consumer<PaymentCardProvider>(
builder: (context, provider, child) {
final cards = provider.cards;
if (cards.isEmpty) {
return Center(
child: Text(
loc.noPaymentCards,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 16),
itemCount: cards.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final card = cards[index];
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
return ListTile(
leading: CircleAvatar(
backgroundColor: color.withValues(alpha: 0.15),
child: Icon(icon, color: color),
),
title: Row(
children: [
Expanded(child: Text(card.issuerName)),
if (card.isDefault)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
loc.cardDefaultBadge,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
subtitle: Text('****${card.last4}'),
trailing: PopupMenuButton<String>(
onSelected: (value) =>
_handleMenuSelection(context, value, card, provider),
itemBuilder: (_) => [
PopupMenuItem(
value: 'default',
child: Text(loc.setAsDefaultCard),
),
PopupMenuItem(
value: 'edit',
child: Text(loc.editPaymentCard),
),
PopupMenuItem(
value: 'delete',
child: Text(loc.delete),
),
],
),
onTap: () => _openForm(context, card: card),
);
},
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openForm(context),
icon: const Icon(Icons.add),
label: Text(loc.addPaymentCard),
),
);
}
void _handleMenuSelection(
BuildContext context,
String value,
PaymentCardModel card,
PaymentCardProvider provider,
) async {
switch (value) {
case 'default':
await provider.setDefaultCard(card.id);
break;
case 'edit':
await _openForm(context, card: card);
break;
case 'delete':
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(AppLocalizations.of(context).delete),
content: Text(AppLocalizations.of(context).areYouSure),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(AppLocalizations.of(context).cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(AppLocalizations.of(context).delete),
),
],
),
);
if (confirmed == true) {
await provider.deleteCard(card.id);
}
break;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart' as permission; import 'package:permission_handler/permission_handler.dart' as permission;
import '../theme/app_colors.dart'; // Material colors only
import '../widgets/glassmorphism_card.dart'; // Glass 제거: Material 3 Card 사용
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../services/sms_service.dart'; import '../services/sms_service.dart';
@@ -92,12 +92,13 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.sms, size: 64, color: AppColors.primaryColor), Icon(Icons.sms,
size: 64, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
loc.smsPermissionTitle, loc.smsPermissionTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.textPrimary, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -105,24 +106,39 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
Text( Text(
loc.smsPermissionRequired, loc.smsPermissionRequired,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary), style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
GlassmorphismCard( Card(
padding: const EdgeInsets.all(16), elevation: 1,
child: Column( shape: RoundedRectangleBorder(
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(12),
children: [ side: BorderSide(
Text(loc.smsPermissionReasonTitle, color: Theme.of(context)
style: const TextStyle(fontWeight: FontWeight.bold)), .colorScheme
const SizedBox(height: 8), .outline
Text(loc.smsPermissionReasonBody), .withValues(alpha: 0.5),
const SizedBox(height: 12), ),
Text(loc.smsPermissionScopeTitle, ),
style: const TextStyle(fontWeight: FontWeight.bold)), child: Padding(
const SizedBox(height: 8), padding: const EdgeInsets.all(16),
Text(loc.smsPermissionScopeBody), child: Column(
], crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loc.smsPermissionReasonTitle,
style:
const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(loc.smsPermissionReasonBody),
const SizedBox(height: 12),
Text(loc.smsPermissionScopeTitle,
style:
const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(loc.smsPermissionScopeBody),
],
),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@@ -6,6 +6,10 @@ import '../widgets/sms_scan/scan_progress_widget.dart';
import '../widgets/sms_scan/subscription_card_widget.dart'; import '../widgets/sms_scan/subscription_card_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/payment_card/payment_card_form_sheet.dart';
import '../routes/app_routes.dart';
import '../models/payment_card_suggestion.dart';
import '../theme/ui_constants.dart';
class SmsScanScreen extends StatefulWidget { class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key}); const SmsScanScreen({super.key});
@@ -16,18 +20,21 @@ class SmsScanScreen extends StatefulWidget {
class _SmsScanScreenState extends State<SmsScanScreen> { class _SmsScanScreenState extends State<SmsScanScreen> {
late SmsScanController _controller; late SmsScanController _controller;
late final ScrollController _scrollController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = SmsScanController(); _controller = SmsScanController();
_controller.addListener(_handleControllerUpdate); _controller.addListener(_handleControllerUpdate);
_scrollController = ScrollController();
} }
@override @override
void dispose() { void dispose() {
_controller.removeListener(_handleControllerUpdate); _controller.removeListener(_handleControllerUpdate);
_controller.dispose(); _controller.dispose();
_scrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -40,7 +47,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_controller.initializeWebsiteUrl(); _controller.initializeWebsiteUrl(context);
} }
Widget _buildContent() { Widget _buildContent() {
@@ -50,7 +57,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (_controller.scannedSubscriptions.isEmpty) { if (_controller.scannedSubscriptions.isEmpty) {
return ScanInitialWidget( return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context), onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage, errorMessage: _controller.errorMessage,
); );
} }
@@ -69,7 +76,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
} }
}); });
return ScanInitialWidget( return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context), onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage, errorMessage: _controller.errorMessage,
); );
} }
@@ -90,32 +97,89 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
SubscriptionCardWidget( SubscriptionCardWidget(
subscription: currentSubscription, subscription: currentSubscription,
serviceNameController: _controller.serviceNameController,
websiteUrlController: _controller.websiteUrlController, websiteUrlController: _controller.websiteUrlController,
selectedCategoryId: _controller.selectedCategoryId, selectedCategoryId: _controller.selectedCategoryId,
onCategoryChanged: _controller.setSelectedCategoryId, onCategoryChanged: _controller.setSelectedCategoryId,
onAdd: () => _controller.addCurrentSubscription(context), selectedPaymentCardId: _controller.selectedPaymentCardId,
onSkip: () => _controller.skipCurrentSubscription(context), onPaymentCardChanged: _controller.setSelectedPaymentCardId,
enableServiceNameEditing: _controller.isServiceNameEditable,
onServiceNameChanged: _controller.isServiceNameEditable
? (value) => _controller.updateCurrentServiceName(context, value)
: null,
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
if (newCardId != null) {
_controller.setSelectedPaymentCardId(newCardId);
}
},
onManageCards: () {
Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
},
onAdd: _handleAddSubscription,
onSkip: _handleSkipSubscription,
detectedCardSuggestion: _controller.currentSuggestion,
showDetectedCardShortcut: _controller.shouldSuggestCardCreation,
onAddDetectedCard: (suggestion) =>
_handleDetectedCardCreation(suggestion),
), ),
], ],
); );
} }
Future<void> _handleAddSubscription() async {
await _controller.addCurrentSubscription(context);
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop());
}
void _handleSkipSubscription() {
_controller.skipCurrentSubscription(context);
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop());
}
Future<void> _handleDetectedCardCreation(
PaymentCardSuggestion suggestion) async {
final newCardId = await PaymentCardFormSheet.show(
context,
initialIssuerName: suggestion.issuerName,
initialLast4: suggestion.last4,
);
if (newCardId != null) {
_controller.setSelectedPaymentCardId(newCardId);
}
}
void _scrollToTop() {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 로딩 중일 때는 화면 정중앙에 표시
if (_controller.isLoading) {
return const ScanLoadingWidget();
}
return SingleChildScrollView( return SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: Column( child: Padding(
children: [ padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
// toolbar 높이 추가 child: Column(
SizedBox( children: [
height: kToolbarHeight + MediaQuery.of(context).padding.top, _buildContent(),
), // FloatingNavigationBar를 위한 충분한 하단 여백
_buildContent(), SizedBox(
// FloatingNavigationBar를 위한 충분한 하단 여백 height: 120 + MediaQuery.of(context).padding.bottom,
SizedBox( ),
height: 120 + MediaQuery.of(context).padding.bottom, ],
), ),
],
), ),
); );
} }

View File

@@ -1,11 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../services/sms_service.dart'; import '../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,19 +77,19 @@ class _SplashScreenState extends State<SplashScreen>
}); });
} }
void _generateParticles() { void _generateParticles({bool reduced = false}) {
final random = DateTime.now().millisecondsSinceEpoch; final random = DateTime.now().millisecondsSinceEpoch;
final total = reduced ? 6 : 20;
for (int i = 0; i < 20; i++) { for (int i = 0; i < total; i++) {
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기 final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
final x = (random % 100) / 100 * 300; // 랜덤 X 위치 final x = (random % 100) / 100 * 300; // 랜덤 X 위치
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치 final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도 final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
final duration = (random % 10) / 10 * 3000 + 2000; // 2-5초 사이의 지속시간 final duration = (random % 10) / 10 * (reduced ? 1800 : 3000) +
(reduced ? 1200 : 2000); // 축소 시 더 짧게
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간 final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
int colorIndex = (random + i) % AppColors.blueGradient.length;
_particles.add({ _particles.add({
'size': size, 'size': size,
'x': x, 'x': x,
@@ -95,7 +97,7 @@ class _SplashScreenState extends State<SplashScreen>
'opacity': opacity, 'opacity': opacity,
'duration': duration, 'duration': duration,
'delay': delay, 'delay': delay,
'color': AppColors.blueGradient[colorIndex], // color computed at render from ColorScheme.primary
}); });
} }
} }
@@ -133,23 +135,15 @@ class _SplashScreenState extends State<SplashScreen>
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
// 배경 그라디언트 // 단색 배경
Container( Container(color: Theme.of(context).colorScheme.surface),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.dayGradient[0],
AppColors.dayGradient[1],
],
),
),
),
// 글래스모피즘 오버레이 // 글래스모피즘 오버레이
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.pureWhite.withValues(alpha: 0.05), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
), ),
), ),
Stack( Stack(
@@ -176,11 +170,14 @@ class _SplashScreenState extends State<SplashScreen>
width: particle['size'], width: particle['size'],
height: particle['size'], height: particle['size'],
decoration: BoxDecoration( decoration: BoxDecoration(
color: particle['color'], color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: particle['color'].withValues(alpha: 0.3), color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
blurRadius: 10, blurRadius: 10,
spreadRadius: 1, spreadRadius: 1,
), ),
@@ -189,45 +186,25 @@ class _SplashScreenState extends State<SplashScreen>
), ),
), ),
); );
}).toList(), }),
// 상단 원형 그라데이션 // 상단 원형 장식 제거(단색 배경 유지)
Positioned( Positioned(
top: -size.height * 0.2, top: -size.height * 0.2,
right: -size.width * 0.2, right: -size.width * 0.2,
child: Container( child: SizedBox(
width: size.width * 0.8, width: size.width * 0.8,
height: size.width * 0.8, height: size.width * 0.8,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.pureWhite.withValues(alpha: 0.1),
AppColors.pureWhite.withValues(alpha: 0.0),
],
stops: const [0.2, 1.0],
),
),
), ),
), ),
// 하단 원형 그라데이션 // 하단 원형 장식 제거
Positioned( Positioned(
bottom: -size.height * 0.1, bottom: -size.height * 0.1,
left: -size.width * 0.3, left: -size.width * 0.3,
child: Container( child: SizedBox(
width: size.width * 0.9, width: size.width * 0.9,
height: size.width * 0.9, height: size.width * 0.9,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.pureWhite.withValues(alpha: 0.07),
AppColors.pureWhite.withValues(alpha: 0.0),
],
stops: const [0.4, 1.0],
),
),
), ),
), ),
@@ -257,61 +234,42 @@ class _SplashScreenState extends State<SplashScreen>
BorderRadius.circular(30), BorderRadius.circular(30),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur( filter: ImageFilter.blur(
sigmaX: 20, sigmaY: 20), sigmaX: ReduceMotion.scale(
context,
normal: 20,
reduced: 8),
sigmaY: ReduceMotion.scale(
context,
normal: 20,
reduced: 8)),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: Theme.of(context)
begin: Alignment.topLeft, .colorScheme
end: Alignment.bottomRight, .surface
colors: [ .withValues(alpha: 0.6),
AppColors.pureWhite
.withValues(alpha: 0.2),
AppColors.pureWhite
.withValues(alpha: 0.1),
],
),
borderRadius: borderRadius:
BorderRadius.circular(30), BorderRadius.circular(30),
border: Border.all( border: Border.all(
color: AppColors.pureWhite color: Theme.of(context)
.withValues(alpha: 0.3), .colorScheme
.outline
.withValues(alpha: 0.2),
width: 1.5, width: 1.5,
), ),
boxShadow: const [
BoxShadow(
color:
AppColors.shadowBlack,
spreadRadius: 0,
blurRadius: 30,
offset: Offset(0, 10),
),
],
), ),
child: Center( child: Center(
child: AnimatedBuilder( child: AnimatedBuilder(
animation: animation:
_animationController, _animationController,
builder: (context, _) { builder: (context, _) {
return ShaderMask( return Icon(
blendMode: Icons
BlendMode.srcIn, .subscriptions_outlined,
shaderCallback: (bounds) => size: 64,
const LinearGradient( color: Theme.of(context)
colors: AppColors .colorScheme
.blueGradient, .primary,
begin:
Alignment.topLeft,
end: Alignment
.bottomRight,
).createShader(bounds),
child: Icon(
Icons
.subscriptions_outlined,
size: 64,
color:
Theme.of(context)
.primaryColor,
),
); );
}), }),
), ),
@@ -341,7 +299,9 @@ class _SplashScreenState extends State<SplashScreen>
style: TextStyle( style: TextStyle(
fontSize: 36, fontSize: 36,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.primaryColor color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.9), .withValues(alpha: 0.9),
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
@@ -367,7 +327,9 @@ class _SplashScreenState extends State<SplashScreen>
AppLocalizations.of(context).appSubtitle, AppLocalizations.of(context).appSubtitle,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.primaryColor color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7), .withValues(alpha: 0.7),
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
@@ -389,18 +351,22 @@ class _SplashScreenState extends State<SplashScreen>
height: 60, height: 60,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.pureWhite color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.1), .withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
border: Border.all( border: Border.all(
color: AppColors.pureWhite color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.2), .withValues(alpha: 0.2),
width: 1, width: 1,
), ),
), ),
child: const CircularProgressIndicator( child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>( color:
AppColors.pureWhite), Theme.of(context).colorScheme.primary,
strokeWidth: 3, strokeWidth: 3,
), ),
), ),
@@ -421,7 +387,10 @@ class _SplashScreenState extends State<SplashScreen>
'© 2025 NatureBridgeAI. All rights reserved.', '© 2025 NatureBridgeAI. All rights reserved.',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.pureWhite.withValues(alpha: 0.6), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),

View File

@@ -0,0 +1,198 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform;
import '../utils/logger.dart';
/// 전면 광고(Interstitial Ad) 서비스
/// lunchpick 프로젝트의 AdService 패턴을 참조하여 구현
class AdService {
InterstitialAd? _interstitialAd;
Completer<bool>? _loadingCompleter;
/// 모바일 플랫폼 여부 확인
bool get _isMobilePlatform {
if (kIsWeb) return false;
return Platform.isAndroid || Platform.isIOS;
}
/// 전면 광고 Unit ID 반환
String get _interstitialAdUnitId {
if (Platform.isAndroid) {
return 'ca-app-pub-6691216385521068/5281562472';
} else if (Platform.isIOS) {
// iOS 테스트 광고 ID
return 'ca-app-pub-3940256099942544/1033173712';
}
return '';
}
/// 광고를 로드하고 표시한 뒤 완료 여부를 반환
/// true: 광고 시청 완료 또는 미지원 플랫폼
/// false: 광고 로드/표시 실패
Future<bool> showInterstitialAd(BuildContext context) async {
if (!_isMobilePlatform) return true;
Log.i('광고 표시 시작');
// 1. 로딩 오버레이 표시 (앱이 foreground 상태 유지)
final closeLoading = _showLoadingOverlay(context);
// 2. 몰입형 모드 진입
await _enterImmersiveMode();
// 3. 광고 로드
final loaded = await _ensureAdLoaded();
// 4. 로딩 오버레이 닫기
closeLoading();
if (!loaded) {
Log.w('광고 로드 실패, 건너뜀');
await _restoreSystemUi();
return false;
}
final ad = _interstitialAd;
if (ad == null) {
Log.w('광고 인스턴스 없음, 건너뜀');
await _restoreSystemUi();
return false;
}
// 현재 광고를 null로 설정 (다음 광고 미리로드)
_interstitialAd = null;
final completer = Completer<bool>();
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdShowedFullScreenContent: (ad) {
Log.i('전면 광고 표시됨 (콜백)');
},
onAdDismissedFullScreenContent: (ad) {
Log.i('전면 광고 닫힘 (콜백)');
ad.dispose();
_preload();
unawaited(_restoreSystemUi());
if (!completer.isCompleted) {
completer.complete(true);
}
},
onAdFailedToShowFullScreenContent: (ad, error) {
Log.e('전면 광고 표시 실패 (콜백)', error);
ad.dispose();
_preload();
unawaited(_restoreSystemUi());
if (!completer.isCompleted) {
completer.complete(false);
}
},
);
// 전체 화면으로 표시하도록 immersive 모드 설정
ad.setImmersiveMode(true);
Log.i('ad.show() 호출 직전');
try {
ad.show();
Log.i('ad.show() 호출 완료');
} catch (e) {
Log.e('광고 show() 호출 실패', e);
unawaited(_restoreSystemUi());
if (!completer.isCompleted) {
completer.complete(false);
}
return false;
}
// 타임아웃 설정 (15초 후 자동 건너뜀)
return completer.future.timeout(
const Duration(seconds: 15),
onTimeout: () {
Log.w('광고 표시 타임아웃, 건너뜀');
unawaited(_restoreSystemUi());
return false;
},
);
}
/// 로딩 오버레이 표시 (앱이 foreground 상태 유지)
VoidCallback _showLoadingOverlay(BuildContext context) {
final navigator = Navigator.of(context, rootNavigator: true);
showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.35),
builder: (_) => const Center(child: CircularProgressIndicator()),
);
return () {
if (navigator.mounted && navigator.canPop()) {
navigator.pop();
}
};
}
/// 몰입형 모드 진입 (상하단 시스템 UI 숨김)
Future<void> _enterImmersiveMode() async {
try {
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
overlays: [],
);
} catch (_) {}
}
/// UI 복구 (main.dart의 설정과 동일하게 immersiveSticky 유지)
Future<void> _restoreSystemUi() async {
try {
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
overlays: [SystemUiOverlay.top], // 상태바만 유지
);
} catch (_) {}
}
/// 광고 로드 보장 (이미 로드된 경우 즉시 반환)
Future<bool> _ensureAdLoaded() async {
if (_interstitialAd != null) return true;
if (_loadingCompleter != null) {
return _loadingCompleter!.future;
}
final completer = Completer<bool>();
_loadingCompleter = completer;
Log.i('전면 광고 로드 시작: $_interstitialAdUnitId');
InterstitialAd.load(
adUnitId: _interstitialAdUnitId,
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
Log.i('전면 광고 로드 성공');
_interstitialAd = ad;
completer.complete(true);
_loadingCompleter = null;
},
onAdFailedToLoad: (error) {
Log.e('전면 광고 로드 실패', error);
completer.complete(false);
_loadingCompleter = null;
},
),
);
return completer.future;
}
/// 다음 광고 미리로드
void _preload() {
if (_interstitialAd != null || _loadingCompleter != null) return;
_ensureAdLoaded();
}
}

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,18 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../utils/billing_cost_util.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 +88,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,20 +111,27 @@ 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;
} }
/// 구독 목록의 총 비용을 계산 (언어별 기본 통화로) /// 구독 목록의 이번 달 총 비용을 계산 (언어별 기본 통화로)
/// 이번 달에 결제가 발생하는 구독만 포함하며, 실제 결제 금액을 사용
static Future<double> calculateTotalMonthlyExpenseInDefaultCurrency( static Future<double> calculateTotalMonthlyExpenseInDefaultCurrency(
List<SubscriptionModel> subscriptions, List<SubscriptionModel> subscriptions,
String locale, String locale,
@@ -116,16 +139,33 @@ class CurrencyUtil {
final defaultCurrency = getDefaultCurrency(locale); final defaultCurrency = getDefaultCurrency(locale);
double total = 0.0; double total = 0.0;
final now = DateTime.now();
final currentYear = now.year;
final currentMonth = now.month;
for (var subscription in subscriptions) { for (var subscription in subscriptions) {
final price = subscription.currentPrice; // 이번 달에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
currentYear,
currentMonth,
);
if (!hasBilling) continue;
// 실제 결제 금액으로 역변환
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
final converted = await _exchangeRateService.convertBetweenCurrencies( final converted = await _exchangeRateService.convertBetweenCurrencies(
price, actualPrice,
subscription.currency, subscription.currency,
defaultCurrency, defaultCurrency,
); );
total += converted ?? price; total += converted ?? actualPrice;
} }
return total; return total;
@@ -137,11 +177,53 @@ class CurrencyUtil {
return calculateTotalMonthlyExpenseInDefaultCurrency(subscriptions, 'ko'); return calculateTotalMonthlyExpenseInDefaultCurrency(subscriptions, 'ko');
} }
/// 구독 목록의 예상 연간 총 비용을 계산 (언어별 기본 통화로)
/// 모든 구독의 연간 비용을 합산 (월 환산 비용 × 12)
static Future<double> calculateTotalAnnualExpenseInDefaultCurrency(
List<SubscriptionModel> subscriptions,
String locale,
) async {
final defaultCurrency = getDefaultCurrency(locale);
double total = 0.0;
for (var subscription in subscriptions) {
// 월 환산 비용 × 12 = 연간 비용
final annualPrice = subscription.currentPrice * 12;
final converted = await _exchangeRateService.convertBetweenCurrencies(
annualPrice,
subscription.currency,
defaultCurrency,
);
total += converted ?? annualPrice;
}
return total;
}
/// 구독의 월 비용을 표시 형식에 맞게 변환 (언어별 통화) /// 구독의 월 비용을 표시 형식에 맞게 변환 (언어별 통화)
static Future<String> formatSubscriptionAmountWithLocale( static Future<String> formatSubscriptionAmountWithLocale(
SubscriptionModel subscription, String locale) async { SubscriptionModel subscription, String locale) async {
final price = subscription.currentPrice; // 월 환산 금액을 실제 결제 금액으로 역변환
return formatAmountWithLocale(price, subscription.currency, locale); final price = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
// 구독 단위 캐시 키 (통화/가격/locale + id + billingCycle)
final decimals =
(subscription.currency == 'KRW' || subscription.currency == 'JPY')
? 0
: 2;
final key =
'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}:${subscription.billingCycle}';
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

@@ -2,6 +2,7 @@ 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 '../utils/logger.dart';
import 'cache_manager.dart';
/// 환율 정보 서비스 클래스 /// 환율 정보 서비스 클래스
class ExchangeRateService { class ExchangeRateService {
@@ -16,6 +17,14 @@ 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;
@@ -62,6 +71,8 @@ class ExchangeRateService {
_usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble(); _usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
_usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble(); _usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
_lastUpdated = DateTime.now(); _lastUpdated = DateTime.now();
// 환율 갱신 시 포맷 캐시 무효화
_fmtCache.clear();
Log.d( Log.d(
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate'); '환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
return; return;
@@ -177,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

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

View File

@@ -0,0 +1,14 @@
import '../../models/subscription_model.dart';
import '../../models/payment_card_suggestion.dart';
class SmsScanResult {
final SubscriptionModel model;
final PaymentCardSuggestion? cardSuggestion;
final String? rawMessage;
SmsScanResult({
required this.model,
this.cardSuggestion,
this.rawMessage,
});
}

View File

@@ -1,21 +1,21 @@
import '../../models/subscription.dart'; import '../../models/subscription.dart';
import '../../models/subscription_model.dart'; import 'sms_scan_result.dart';
class SubscriptionConverter { class SubscriptionConverter {
// SubscriptionModel 리스트를 Subscription 리스트로 변환 // SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> convertModelsToSubscriptions( List<Subscription> convertResultsToSubscriptions(
List<SubscriptionModel> models) { List<SmsScanResult> results) {
final result = <Subscription>[]; final result = <Subscription>[];
for (var model in models) { for (final smsResult in results) {
try { try {
final subscription = _convertSingle(model); final subscription = _convertSingle(smsResult);
result.add(subscription); result.add(subscription);
// 개발 편의를 위한 디버그 로그 // 개발 편의를 위한 디버그 로그
// ignore: avoid_print // ignore: avoid_print
print( print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); '모델 변환 성공: ${smsResult.model.serviceName}, 카테고리ID: ${smsResult.model.categoryId}, URL: ${smsResult.model.websiteUrl}, 통화: ${smsResult.model.currency}');
} catch (e) { } catch (e) {
// ignore: avoid_print // ignore: avoid_print
print('모델 변환 중 오류 발생: $e'); print('모델 변환 중 오류 발생: $e');
@@ -26,7 +26,8 @@ class SubscriptionConverter {
} }
// 단일 모델 변환 // 단일 모델 변환
Subscription _convertSingle(SubscriptionModel model) { Subscription _convertSingle(SmsScanResult result) {
final model = result.model;
return Subscription( return Subscription(
id: model.id, id: model.id,
serviceName: model.serviceName, serviceName: model.serviceName,
@@ -38,6 +39,9 @@ class SubscriptionConverter {
lastPaymentDate: model.lastPaymentDate, lastPaymentDate: model.lastPaymentDate,
websiteUrl: model.websiteUrl, websiteUrl: model.websiteUrl,
currency: model.currency, currency: model.currency,
paymentCardId: model.paymentCardId,
paymentCardSuggestion: result.cardSuggestion,
rawMessage: result.rawMessage,
); );
} }

View File

@@ -1,47 +1,22 @@
import 'package:flutter/foundation.dart' show kIsWeb; import 'dart:math' as math;
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 '../utils/logger.dart';
import '../temp/test_sms_data.dart'; import '../temp/test_sms_data.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../models/payment_card_suggestion.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SmsScanner { class SmsScanner {
// 반복 사용되는 리소스 상수화로 파싱 성능 최적화
static const List<String> _subscriptionKeywords = [
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'subscription',
'payment',
'billing',
'charge',
'넷플릭스',
'Netflix',
'유튜브',
'YouTube',
'Spotify',
'멜론',
'웨이브',
'Disney+',
'디즈니플러스',
'Apple',
'Microsoft',
'GitHub',
'Adobe',
'Amazon'
];
static final List<RegExp> _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 SmsQuery _query = SmsQuery(); final SmsQuery _query = SmsQuery();
Future<List<SubscriptionModel>> scanForSubscriptions() async { Future<List<SmsScanResult>> scanForSubscriptions() async {
try { try {
List<dynamic> smsList; List<dynamic> smsList;
Log.d('SmsScanner: 스캔 시작'); Log.d('SmsScanner: 스캔 시작');
@@ -67,38 +42,41 @@ class SmsScanner {
return []; return [];
} }
// SMS 데이터를 분석하여 반복 결제되는 구독 식별 final filteredSms = smsList
final List<SubscriptionModel> subscriptions = []; .whereType<Map<String, dynamic>>()
final Map<String, List<Map<String, dynamic>>> serviceGroups = {}; .where(_isEligibleSubscriptionSms)
.toList();
// 서비스명별로 SMS 메시지 그룹화 Log.d(
for (final sms in smsList) { 'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}');
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
if (!serviceGroups.containsKey(serviceName)) { if (filteredSms.isEmpty) {
serviceGroups[serviceName] = []; Log.w('SmsScanner: 결제 패턴 SMS 미검출');
} return [];
serviceGroups[serviceName]!.add(sms);
} }
Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); // SMS 데이터를 분석하여 반복 결제되는 구독 식별
final List<SmsScanResult> subscriptions = [];
final serviceGroups = _groupMessagesByIdentifier(filteredSms);
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
// 그룹화된 데이터로 구독 분석
for (final entry in serviceGroups.entries) { for (final entry in serviceGroups.entries) {
Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); Log.d('SmsScanner: 그룹 "${entry.key}" - 메시지 ${entry.value.length}');
final repeatResult = _detectRepeatingSubscriptions(entry.value);
if (repeatResult == null) {
Log.d('SmsScanner: 반복 조건 불충족 - ${entry.key}');
continue;
}
// 2회 이상 반복된 서비스만 구독으로 간주 final result =
if (entry.value.length >= 2) { _parseSms(repeatResult.baseMessage, repeatResult.repeatCount);
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용 if (result != null) {
final subscription = _parseSms(serviceSms, entry.value.length); Log.i(
if (subscription != null) { 'SmsScanner: 구독 추가: ${result.model.serviceName}, 반복 횟수: ${result.model.repeatCount}');
Log.i( subscriptions.add(result);
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
subscriptions.add(subscription);
} else {
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
}
} else { } else {
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
} }
} }
@@ -106,7 +84,9 @@ class SmsScanner {
return subscriptions; return subscriptions;
} catch (e) { } catch (e) {
Log.e('SmsScanner: 예외 발생', e); Log.e('SmsScanner: 예외 발생', e);
throw Exception('SMS 스캔 중 오류 발생: $e'); final loc = _loc();
throw Exception(loc?.smsScanErrorWithMessage(e.toString()) ??
'Error occurred during SMS scan: $e');
} }
} }
@@ -114,16 +94,21 @@ 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) {
Log.e('SmsScanner: Android SMS 스캔 실패', e); Log.e('SmsScanner: Android SMS 스캔 실패', e);
@@ -131,135 +116,17 @@ class SmsScanner {
} }
} }
// 실제 SMS 메시지싱하여 구독 정보 추출 // (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
Map<String, dynamic>? _parseRawSms(SmsMessage message) {
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try { try {
final body = message.body ?? ''; final loc = _loc();
final sender = message.address ?? ''; final unknownLabel = loc?.unknownService ?? 'Unknown service';
final date = message.date ?? DateTime.now(); final serviceNameRaw = sms['serviceName'] as String?;
final serviceName =
// 구독 관련 키워드가 있는지 확인 (serviceNameRaw == null || serviceNameRaw.trim().isEmpty)
bool isSubscription = _subscriptionKeywords.any((keyword) => ? unknownLabel
body.toLowerCase().contains(keyword.toLowerCase()) || : serviceNameRaw;
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) {
Log.e('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) {
// 다양한 금액 패턴 매칭(사전 컴파일)
for (final pattern in _amountPatterns) {
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) {
try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
final billingCycle = SubscriptionModel.normalizeBillingCycle( final billingCycle = SubscriptionModel.normalizeBillingCycle(
sms['billingCycle'] as String? ?? 'monthly'); sms['billingCycle'] as String? ?? 'monthly');
@@ -285,7 +152,11 @@ class SmsScanner {
} }
DateTime? nextBillingDate; DateTime? nextBillingDate;
if (nextBillingDateStr != null) { // 외부에서 계산된 다음 결제일이 있으면 우선 사용
final overrideNext = sms['overrideNextBillingDate'] as String?;
if (overrideNext != null) {
nextBillingDate = DateTime.tryParse(overrideNext);
} else if (nextBillingDateStr != null) {
nextBillingDate = DateTime.tryParse(nextBillingDateStr); nextBillingDate = DateTime.tryParse(nextBillingDateStr);
} }
@@ -297,10 +168,14 @@ class SmsScanner {
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정 // 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
DateTime adjustedNextBillingDate = _calculateNextBillingDate( DateTime adjustedNextBillingDate = _calculateNextBillingDate(
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)), nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
billingCycle); billingCycle,
);
// 주말/공휴일 보정
adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
return SubscriptionModel( final model = SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(), id: DateTime.now().millisecondsSinceEpoch.toString(),
serviceName: serviceName, serviceName: serviceName,
monthlyCost: monthlyCost, monthlyCost: monthlyCost,
@@ -312,11 +187,85 @@ class SmsScanner {
websiteUrl: _extractWebsiteUrl(serviceName), websiteUrl: _extractWebsiteUrl(serviceName),
currency: currency, // 통화 단위 설정 currency: currency, // 통화 단위 설정
); );
final suggestion = _extractPaymentCardSuggestion(message);
return SmsScanResult(
model: model,
cardSuggestion: suggestion,
rawMessage: message,
);
} catch (e) { } catch (e) {
return null; return null;
} }
} }
PaymentCardSuggestion? _extractPaymentCardSuggestion(String message) {
if (message.isEmpty) return null;
final issuer = _detectCardIssuer(message);
final last4 = _detectCardLast4(message);
if (issuer == null && last4 == null) {
return null;
}
final loc = _loc();
return PaymentCardSuggestion(
issuerName: issuer ?? loc?.paymentCard ?? 'Payment card',
last4: last4,
source: 'sms',
);
}
String? _detectCardIssuer(String message) {
final normalized = message.toLowerCase();
const issuerKeywords = {
'KB국민카드': ['kb국민', '국민카드', 'kb card', 'kookmin'],
'신한카드': ['신한', 'shinhan'],
'우리카드': ['우리카드', 'woori'],
'하나카드': ['하나카드', 'hana card', 'hana'],
'농협카드': ['농협', 'nh', '농협카드'],
'BC카드': ['bc카드', 'bc card'],
'삼성카드': ['삼성카드', 'samsung card'],
'롯데카드': ['롯데카드', 'lotte card'],
'현대카드': ['현대카드', 'hyundai card'],
'씨티카드': ['씨티카드', 'citi card', 'citibank'],
'카카오뱅크': ['카카오뱅크', 'kakaobank'],
'토스뱅크': ['토스뱅크', 'toss bank'],
'Visa': ['visa'],
'Mastercard': ['mastercard', 'master card'],
'American Express': ['amex', 'american express'],
};
for (final entry in issuerKeywords.entries) {
final match = entry.value.any((keyword) => normalized.contains(keyword));
if (match) {
return entry.key;
}
}
return null;
}
String? _detectCardLast4(String message) {
final patterns = [
RegExp(r'\*{3,}\s*(\d{4})'),
RegExp(r'끝번호\s*(\d{4})'),
RegExp(r'마지막\s*(\d{4})'),
RegExp(r'\((\d{4})\)'),
RegExp(r'ending(?: in)?\s*(\d{4})', caseSensitive: false),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(message);
if (match != null && match.groupCount >= 1) {
final candidate = match.group(1);
if (candidate != null && candidate.length == 4) {
return candidate;
}
}
}
return null;
}
// 다음 결제일 계산 (현재 날짜 기준으로 조정) // 다음 결제일 계산 (현재 날짜 기준으로 조정)
DateTime _calculateNextBillingDate( DateTime _calculateNextBillingDate(
DateTime billingDate, String billingCycle) { DateTime billingDate, String billingCycle) {
@@ -341,7 +290,9 @@ class SmsScanner {
} }
} }
return DateTime(year, month, billingDate.day); final dim = BusinessDayUtil.daysInMonth(year, month);
final day = billingDate.day.clamp(1, dim);
return DateTime(year, month, day);
} else if (billingCycle == 'yearly') { } else if (billingCycle == 'yearly') {
// 올해의 결제일이 지났는지 확인 // 올해의 결제일이 지났는지 확인
final thisYearBilling = final thisYearBilling =
@@ -426,4 +377,472 @@ class SmsScanner {
// 기본값은 원화 // 기본값은 원화
return 'KRW'; return 'KRW';
} }
AppLocalizations? _loc() {
final ctx = navigatorKey.currentContext;
if (ctx == null) return null;
return AppLocalizations.of(ctx);
}
}
const List<String> _paymentLikeKeywords = [
'승인',
'결제',
'청구',
'charged',
'charge',
'payment',
'billed',
'purchase',
];
const List<String> _blockedKeywords = [
'otp',
'인증',
'보안',
'verification',
'code',
'코드',
'password',
'pw',
'일회성',
'1회용',
'보안문자',
];
bool _containsPaymentKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _paymentLikeKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _containsBlockedKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _blockedKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _isEligibleSubscriptionSms(Map<String, dynamic> sms) {
final amount = (sms['monthlyCost'] as num?)?.toDouble();
if (amount == null || amount <= 0) {
return false;
}
final message = sms['message'] as String? ?? '';
final isPaymentLike =
(sms['isPaymentLike'] as bool?) ?? _containsPaymentKeyword(message);
final isBlocked =
(sms['isBlocked'] as bool?) ?? _containsBlockedKeyword(message);
if (!isPaymentLike || isBlocked) {
return false;
}
return true;
}
// ===== Isolate 오프로딩용 Top-level 파서 =====
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
List<Map<String, dynamic>> _parseRawSmsBatch(
List<Map<String, dynamic>> messages) {
final amountPatterns = <RegExp>[
RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'),
RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false),
RegExp(r'(\d+(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:USD|KRW)',
caseSensitive: false),
RegExp(r'(?:USD|KRW)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)',
caseSensitive: false),
RegExp(r'(?:결제|승인)[^0-9]{0,12}(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
];
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 serviceName = _isoExtractServiceName(body, sender);
final amount = _isoExtractAmount(body, amountPatterns);
final isPaymentLike = _containsPaymentKeyword(body);
final isBlocked = _containsBlockedKeyword(body);
final billingCycle = _isoExtractBillingCycle(body);
final nextBillingDate =
_isoCalculateNextBillingFromDate(date, billingCycle);
final normalizedBody = _isoNormalizeBody(body);
results.add({
'serviceName': serviceName,
'address': sender,
'monthlyCost': amount,
'billingCycle': billingCycle,
'message': body,
'normalizedBody': normalizedBody,
'nextBillingDate': nextBillingDate.toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
'isPaymentLike': isPaymentLike,
'isBlocked': isBlocked,
});
}
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)) {
// Isolate에서 실행되므로 하드코딩 사용 (Flutter 바인딩 접근 불가)
return 'Unknown service';
}
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';
}
String _isoNormalizeBody(String body) {
final patterns = <RegExp>[
RegExp(r'\d{4}[./-]\d{1,2}[./-]\d{1,2}'),
RegExp(r'\d{1,2}[./-]\d{1,2}[./-]\d{2,4}'),
RegExp(r'\d{4}\s*년\s*\d{1,2}\s*월\s*\d{1,2}\s*일'),
RegExp(r'\d{1,2}\s*월\s*\d{1,2}\s*일'),
RegExp(r'\d{1,2}:\d{2}'),
];
var normalized = body;
for (final pattern in patterns) {
normalized = normalized.replaceAll(pattern, ' ');
}
return normalized.replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase();
}
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));
}
}
Map<String, List<Map<String, dynamic>>> _groupMessagesByIdentifier(
List<dynamic> smsList) {
final Map<String, List<Map<String, dynamic>>> groups = {};
for (final smsEntry in smsList) {
if (smsEntry is! Map) continue;
final sms = Map<String, dynamic>.from(smsEntry as Map<String, dynamic>);
final serviceName = (sms['serviceName'] as String?)?.trim();
final address = (sms['address'] as String?)?.trim();
final sender = (sms['sender'] as String?)?.trim();
final unknownLabel = _unknownServiceLabel();
String key = (serviceName != null &&
serviceName.isNotEmpty &&
serviceName != unknownLabel)
? serviceName
: (address?.isNotEmpty == true
? address!
: (sender?.isNotEmpty == true ? sender! : unknownLabel));
groups.putIfAbsent(key, () => []).add(sms);
}
return groups;
}
class _RepeatDetectionResult {
_RepeatDetectionResult({
required this.baseMessage,
required this.repeatCount,
});
final Map<String, dynamic> baseMessage;
final int repeatCount;
}
enum _MatchType { none, monthly, yearly, identical }
String _unknownServiceLabel() {
final ctx = navigatorKey.currentContext;
if (ctx == null) return 'Unknown service';
return AppLocalizations.of(ctx).unknownService;
}
class _MatchedPair {
_MatchedPair(this.first, this.second, this.type);
final int first;
final int second;
final _MatchType type;
}
_RepeatDetectionResult? _detectRepeatingSubscriptions(
List<Map<String, dynamic>> messages) {
if (messages.length < 2) return null;
final sorted = messages.map((sms) => Map<String, dynamic>.from(sms)).toList()
..sort((a, b) {
final da = _parsePaymentDate(a['previousPaymentDate']);
final db = _parsePaymentDate(b['previousPaymentDate']);
return (db ?? DateTime.fromMillisecondsSinceEpoch(0))
.compareTo(da ?? DateTime.fromMillisecondsSinceEpoch(0));
});
final matchedIndices = <int>{};
final matchedPairs = <_MatchedPair>[];
for (int i = 0; i < sorted.length - 1; i++) {
for (int j = i + 1; j < sorted.length && j <= i + 5; j++) {
final matchType = _evaluateMatch(sorted[i], sorted[j]);
if (matchType == _MatchType.none) continue;
matchedIndices.add(i);
matchedIndices.add(j);
matchedPairs.add(_MatchedPair(i, j, matchType));
break;
}
}
if (matchedIndices.length < 2) return null;
final hasValidInterval = matchedPairs.any((pair) =>
pair.type == _MatchType.monthly || pair.type == _MatchType.yearly);
if (!hasValidInterval) return null;
final baseIndex = matchedIndices
.reduce((value, element) => value < element ? value : element);
final baseMessage = Map<String, dynamic>.from(sorted[baseIndex]);
final overrideDate = _deriveNextBillingDate(sorted, matchedPairs);
if (overrideDate != null) {
baseMessage['overrideNextBillingDate'] = overrideDate.toIso8601String();
}
return _RepeatDetectionResult(
baseMessage: baseMessage,
repeatCount: matchedIndices.length,
);
}
_MatchType _evaluateMatch(
Map<String, dynamic> recent, Map<String, dynamic> previous) {
final amountMatch = _matchByAmountAndInterval(recent, previous);
if (amountMatch != _MatchType.none) {
return amountMatch;
}
if (_areBodiesEquivalent(recent, previous)) {
final inferredInterval = _classifyIntervalByDates(recent, previous);
return inferredInterval == _MatchType.none
? _MatchType.identical
: inferredInterval;
}
return _MatchType.none;
}
_MatchType _matchByAmountAndInterval(
Map<String, dynamic> a, Map<String, dynamic> b) {
final amountA = (a['monthlyCost'] as num?)?.toDouble();
final amountB = (b['monthlyCost'] as num?)?.toDouble();
if (amountA == null || amountB == null) return _MatchType.none;
if (!_isAmountSimilar(amountA, amountB)) return _MatchType.none;
return _classifyIntervalByDates(a, b);
}
_MatchType _classifyIntervalByDates(
Map<String, dynamic> a, Map<String, dynamic> b) {
final dateA = _parsePaymentDate(a['previousPaymentDate']);
final dateB = _parsePaymentDate(b['previousPaymentDate']);
if (dateA == null || dateB == null) return _MatchType.none;
final diffDays = (dateA.difference(dateB).inDays).abs();
if (diffDays >= 27 && diffDays <= 34) {
return _MatchType.monthly;
}
if (diffDays >= 350 && diffDays <= 380) {
return _MatchType.yearly;
}
return _MatchType.none;
}
bool _areBodiesEquivalent(Map<String, dynamic> a, Map<String, dynamic> b) {
final normalizedA = _getNormalizedBody(a);
final normalizedB = _getNormalizedBody(b);
if (normalizedA.isEmpty || normalizedB.isEmpty) return false;
return normalizedA == normalizedB;
}
String _getNormalizedBody(Map<String, dynamic> sms) {
final cached = sms['normalizedBody'] as String?;
if (cached != null && cached.isNotEmpty) return cached;
final message = sms['message'] as String? ?? '';
final normalized = _isoNormalizeBody(message);
sms['normalizedBody'] = normalized;
return normalized;
}
DateTime? _deriveNextBillingDate(
List<Map<String, dynamic>> sorted, List<_MatchedPair> pairs) {
if (pairs.isEmpty) return null;
final targetPair = pairs.firstWhere(
(pair) => pair.type == _MatchType.monthly || pair.type == _MatchType.yearly,
orElse: () => pairs.first,
);
final recent = sorted[targetPair.first];
final previous = sorted[targetPair.second];
final recentDate = _parsePaymentDate(recent['previousPaymentDate']);
final prevDate = _parsePaymentDate(previous['previousPaymentDate']);
return _calculateNextBillingFromPair(recentDate, prevDate, targetPair.type);
}
DateTime? _calculateNextBillingFromPair(
DateTime? recentDate, DateTime? prevDate, _MatchType type) {
if (recentDate == null) return null;
if (type == _MatchType.monthly) {
DateTime candidate = _addMonths(recentDate, 1);
while (!candidate.isAfter(DateTime.now())) {
candidate = _addMonths(candidate, 1);
}
return BusinessDayUtil.nextBusinessDay(candidate);
}
if (type == _MatchType.yearly) {
DateTime candidate = DateTime(
recentDate.year + 1,
recentDate.month,
_clampDay(
recentDate.day,
BusinessDayUtil.daysInMonth(recentDate.year + 1, recentDate.month),
),
);
while (!candidate.isAfter(DateTime.now())) {
candidate = DateTime(candidate.year + 1, candidate.month, candidate.day);
}
return BusinessDayUtil.nextBusinessDay(candidate);
}
return _inferMonthlyNextBilling(recentDate, prevDate);
}
DateTime? _inferMonthlyNextBilling(DateTime recentDate, DateTime? prevDate) {
int baseDay = recentDate.day;
if (prevDate != null) {
final candidate = DateTime(prevDate.year, prevDate.month, baseDay);
if (BusinessDayUtil.isWeekend(candidate)) {
final diff = prevDate.difference(candidate).inDays;
if (diff < 1 || diff > 3) {
baseDay = prevDate.day;
}
}
}
final now = DateTime.now();
int year = now.year;
int month = now.month;
if (now.day >= baseDay) {
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = _clampDay(baseDay, dim);
var nextBilling = DateTime(year, month, day);
return BusinessDayUtil.nextBusinessDay(nextBilling);
}
DateTime _addMonths(DateTime date, int months) {
final totalMonths = (date.month - 1) + months;
final year = date.year + totalMonths ~/ 12;
final month = totalMonths % 12 + 1;
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = _clampDay(date.day, dim);
return DateTime(year, month, day);
}
int _clampDay(int day, int maxDay) {
if (day < 1) return 1;
if (day > maxDay) return maxDay;
return day;
}
DateTime? _parsePaymentDate(dynamic value) {
if (value is DateTime) return value;
if (value is String && value.isNotEmpty) {
return DateTime.tryParse(value);
}
return null;
}
bool _isAmountSimilar(double a, double b) {
final diff = (a - b).abs();
final base = math.max(a.abs(), b.abs());
final tolerance = base * 0.01; // 1% 허용
final minTolerance = base < 10 ? 0.1 : 1.0;
return diff <= math.max(tolerance, minTolerance);
} }

View File

@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart' as permission; import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SMSService { class SMSService {
static const platform = MethodChannel('com.submanager/sms'); static const platform = MethodChannel('com.submanager/sms');
@@ -37,14 +39,24 @@ class SMSService {
try { try {
if (!await hasSMSPermission()) { if (!await hasSMSPermission()) {
throw Exception('SMS 권한이 없습니다.'); final loc = _loc();
throw Exception(
loc?.smsPermissionRequired ?? 'SMS permission required.');
} }
final List<dynamic> result = final List<dynamic> result =
await platform.invokeMethod('scanSubscriptions'); await platform.invokeMethod('scanSubscriptions');
return result.map((item) => item as Map<String, dynamic>).toList(); return result.map((item) => item as Map<String, dynamic>).toList();
} on PlatformException catch (e) { } on PlatformException catch (e) {
throw Exception('SMS 스캔 중 오류 발생: ${e.message}'); final loc = _loc();
throw Exception(loc?.smsScanErrorWithMessage(e.message ?? '') ??
'Error occurred during SMS scan: ${e.message}');
} }
} }
static AppLocalizations? _loc() {
final ctx = navigatorKey.currentContext;
if (ctx == null) return null;
return AppLocalizations.of(ctx);
}
} }

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,2 +1,2 @@
/// URL Matcher 패키지의 export 파일 // URL Matcher 패키지의 export 파일
export 'models/service_info.dart'; export 'models/service_info.dart';

View File

@@ -166,6 +166,21 @@ class TestSmsData {
'message': 'message':
'[GitHub] Your Pro plan has been renewed for \$4.00 USD. View your receipt at github.com/receipt. Next bill on ${DateTime(now.year, now.month + 1, 3).day}' '[GitHub] Your Pro plan has been renewed for \$4.00 USD. View your receipt at github.com/receipt. Next bill on ${DateTime(now.year, now.month + 1, 3).day}'
}, },
{
'serviceName': 'Enterprise Cloud Suite',
'monthlyCost': 990.0,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 25).year}-${DateTime(now.year, now.month + 1, 25).month.toString().padLeft(2, '0')}-25',
'isRecurring': true,
'repeatCount': 3,
'sender': '445566',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 25).year}-${DateTime(now.year, now.month - 1, 25).month.toString().padLeft(2, '0')}-25',
'message':
'[Enterprise Cloud] Your enterprise tier has been renewed. \$990.00 USD charged to your card. Next billing date: ${DateTime(now.year, now.month + 1, 25).day}'
},
]; ];
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해) // 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)

View File

@@ -10,114 +10,119 @@ class AdaptiveTheme {
/// 다크 테마 /// 다크 테마
static ThemeData get darkTheme { static ThemeData get darkTheme {
const scheme = ColorScheme.dark(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.errorColor,
surface: Color(0xFF1E1E1E),
);
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
colorScheme: const ColorScheme.dark( colorScheme: scheme,
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.dangerColor,
surface: Color(0xFF1E1E1E),
),
scaffoldBackgroundColor: const Color(0xFF121212), scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: const Color(0xFF1E1E1E), color: scheme.surface,
elevation: 2, elevation: 1,
shadowColor: Colors.black.withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: BorderSide( side: BorderSide(
color: Colors.white.withValues(alpha: 0.1), width: 0.5), color: const Color(0xFFFFFFFF).withValues(alpha: 0.08),
width: 1,
),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: scheme.surface,
foregroundColor: Colors.white, foregroundColor: scheme.onSurface,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: const TextStyle( // title/icon colors inherit from foregroundColor
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
iconTheme: IconThemeData(
color: Colors.white.withValues(alpha: 0.9),
size: 24,
),
),
textTheme: TextTheme(
headlineLarge: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
titleLarge: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.4,
),
bodyLarge: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
), ),
textTheme: ThemeData.dark(useMaterial3: true)
.textTheme
.copyWith(
headlineLarge: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
titleLarge: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
),
bodyLarge: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
labelLarge: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
),
labelMedium: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
)
.apply(bodyColor: scheme.onSurface, displayColor: scheme.onSurface),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: const Color(0xFF2A2A2A), fillColor: scheme.surface,
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16), const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -126,33 +131,31 @@ class AdaptiveTheme {
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: borderSide: BorderSide(color: scheme.outline, width: 1),
BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: borderSide: BorderSide(color: scheme.primary, width: 1.5),
const BorderSide(color: AppColors.primaryColor, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), borderSide: BorderSide(color: scheme.error, width: 1),
), ),
labelStyle: TextStyle( labelStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.7), color: scheme.onSurfaceVariant,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
hintStyle: TextStyle( hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5), color: scheme.onSurfaceVariant,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor, backgroundColor: scheme.primary,
foregroundColor: Colors.white, foregroundColor: scheme.onPrimary,
minimumSize: const Size(0, 48), minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -161,8 +164,66 @@ class AdaptiveTheme {
elevation: 0, elevation: 0,
), ),
), ),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary.withValues(alpha: 0.5);
}
return scheme.surfaceContainerHighest.withValues(alpha: 0.5);
}),
),
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return Colors.transparent;
}),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: BorderSide(color: scheme.outline, width: 1.5),
),
radioTheme: RadioThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
),
sliderTheme: SliderThemeData(
activeTrackColor: scheme.primary,
inactiveTrackColor: scheme.onSurfaceVariant,
thumbColor: scheme.primary,
overlayColor: scheme.primary.withValues(alpha: 0.5),
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
),
tabBarTheme: TabBarThemeData(
labelColor: scheme.primary,
unselectedLabelColor: scheme.onSurfaceVariant,
indicatorColor: scheme.primary,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
dividerTheme: DividerThemeData( dividerTheme: DividerThemeData(
color: Colors.white.withValues(alpha: 0.1), color: scheme.outline,
thickness: 1, thickness: 1,
space: 16, space: 16,
), ),
@@ -171,19 +232,15 @@ class AdaptiveTheme {
/// OLED 최적화 다크 테마 /// OLED 최적화 다크 테마
static ThemeData get oledTheme { static ThemeData get oledTheme {
return darkTheme.copyWith( final base = darkTheme;
const oledSurface = Color(0xFF0A0A0A);
return base.copyWith(
scaffoldBackgroundColor: Colors.black, scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith( colorScheme: base.colorScheme.copyWith(surface: oledSurface),
surface: const Color(0xFF0A0A0A), cardTheme: base.cardTheme.copyWith(color: oledSurface),
), appBarTheme: base.appBarTheme.copyWith(backgroundColor: Colors.black),
cardTheme: darkTheme.cardTheme.copyWith( inputDecorationTheme: base.inputDecorationTheme.copyWith(
color: const Color(0xFF0A0A0A), fillColor: oledSurface,
),
appBarTheme: darkTheme.appBarTheme.copyWith(
backgroundColor: Colors.black,
),
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
fillColor: const Color(0xFF0A0A0A),
), ),
); );
} }
@@ -248,9 +305,9 @@ class AdaptiveTheme {
} }
/// 시스템 테마에 따른 상태바 스타일 적용 /// 시스템 테마에 따른 상태바 스타일 적용
/// Android 15+ edge-to-edge 호환: deprecated된 네비게이션바 색상 API 제거
static void applySystemUIOverlay(BuildContext context) { static void applySystemUIOverlay(BuildContext context) {
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent, statusBarColor: Colors.transparent,
@@ -258,13 +315,8 @@ class AdaptiveTheme {
brightness == Brightness.dark ? Brightness.light : Brightness.dark, brightness == Brightness.dark ? Brightness.light : Brightness.dark,
statusBarBrightness: statusBarBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark, brightness == Brightness.dark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isOled // Android 15+: 네비게이션바 색상은 시스템이 자동 처리
? Colors.black systemNavigationBarContrastEnforced: false,
: (brightness == Brightness.dark
? const Color(0xFF121212)
: Colors.white),
systemNavigationBarIconBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
)); ));
} }

View File

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

View File

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

View File

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

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