Compare commits
41 Commits
codex/perf
...
9a950ee6c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a950ee6c7 | ||
|
|
88569a57bf | ||
|
|
7125a4745a | ||
|
|
8d6b24ed6f | ||
|
|
0db1f12b40 | ||
|
|
595513b2e6 | ||
|
|
98488dbcd5 | ||
|
|
18a0004d57 | ||
|
|
6e7a7d2477 | ||
|
|
a0b24f9a75 | ||
|
|
58c00443c1 | ||
|
|
da530a99b7 | ||
|
|
0f92206833 | ||
|
|
db93c14105 | ||
|
|
c8c4746f52 | ||
|
|
48b2063499 | ||
|
|
843fa0601a | ||
|
|
83c43fb61f | ||
|
|
bac4acf9a3 | ||
|
|
64da0c5fd3 | ||
|
|
d9435bbee5 | ||
|
|
b018e5eb2f | ||
|
|
b22df5daf3 | ||
|
|
2cd46a303e | ||
|
|
a9f42f6f01 | ||
|
|
132ae758de | ||
|
|
cba7d082bd | ||
|
|
8cec03f181 | ||
|
|
7ace3afaf3 | ||
|
|
87f82546a4 | ||
|
|
e909ba59a4 | ||
|
|
3af9a1f839 | ||
|
|
44850a53cc | ||
|
|
a01d9092ba | ||
|
|
3d86316a2b | ||
|
|
55e3f67279 | ||
|
|
d111b5dd62 | ||
|
|
b944f6967d | ||
|
|
997c2f53a0 | ||
|
|
79f9aa3eb0 | ||
|
|
5b72fa196c |
89
.claude/skills/admob/SKILL.md
Normal 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()
|
||||
```
|
||||
39
.claude/skills/flutter-build/SKILL.md
Normal 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` |
|
||||
55
.claude/skills/hive-model/SKILL.md
Normal 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');
|
||||
```
|
||||
64
.claude/skills/release-deploy/SKILL.md
Normal 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 생성 완료
|
||||
60
.claude/skills/sms-scanner/SKILL.md
Normal 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 데이터 정의됨.
|
||||
@@ -36,6 +36,7 @@ Sensitive Areas (require explicit approval)
|
||||
Operational Conventions
|
||||
- Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-url-matcher`).
|
||||
- Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`).
|
||||
- Git push 후 공유하는 설명/보고는 반드시 한국어로 작성합니다.
|
||||
- PR description template:
|
||||
- Summary: what/why
|
||||
- Changes: key files and decisions
|
||||
|
||||
377
CLAUDE.md
@@ -1,314 +1,111 @@
|
||||
# Claude 프로젝트 컨텍스트
|
||||
# CLAUDE.md
|
||||
|
||||
## 언어 설정
|
||||
- 모든 답변은 한국어로 제공
|
||||
- 기술 용어는 영어와 한국어 병기 가능
|
||||
프로젝트별 가이드. 일반 규칙은 `~/.claude/CLAUDE.md` 참조.
|
||||
|
||||
## 프로젝트 정보
|
||||
- Flutter 기반 구독 관리 앱 (SubManager)
|
||||
## Project Overview
|
||||
|
||||
## 현재 작업
|
||||
- 구독카드가 클릭이 되지 않아서 문제를 찾는 중.
|
||||
**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
|
||||
|
||||
```
|
||||
[Model Name]. I have reviewed all the following rules: [rule file list or categories]. Proceeding with the task. Master!
|
||||
## Quick Commands
|
||||
|
||||
```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!`
|
||||
- For extensive rules: `coding style, class design, exception handling, testing rules` (categorized summary)
|
||||
|
||||
## 🚀 Mandatory 3-Phase Task Process
|
||||
|
||||
### Phase 1: Codebase Exploration & Analysis
|
||||
|
||||
**Required Actions:**
|
||||
|
||||
- Systematically discover ALL relevant files, directories, modules
|
||||
- Search for related keywords, functions, classes, patterns
|
||||
- Thoroughly examine each identified file
|
||||
- Document coding conventions and style guidelines
|
||||
- Identify framework/library usage patterns
|
||||
|
||||
### Phase 2: Implementation Planning
|
||||
|
||||
**Required Actions:**
|
||||
|
||||
- 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 **Arrange–Act–Assert** 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 **Given–When–Then** 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
|
||||
```text
|
||||
lib/
|
||||
├── controllers/ # 비즈니스 로직 (3개)
|
||||
│ ├── add_subscription_controller.dart
|
||||
│ ├── detail_screen_controller.dart
|
||||
│ └── sms_scan_controller.dart
|
||||
├── models/ # Hive 모델 (@HiveType)
|
||||
│ ├── subscription_model.dart (typeId: 0)
|
||||
│ ├── category_model.dart (typeId: 1)
|
||||
│ └── payment_card_model.dart (typeId: 2)
|
||||
├── providers/ # ChangeNotifier 상태관리
|
||||
├── screens/ # 화면 위젯
|
||||
├── services/ # 외부 서비스 연동
|
||||
├── widgets/ # 재사용 컴포넌트
|
||||
├── utils/ # 유틸리티 헬퍼
|
||||
├── routes/ # 라우팅 정의
|
||||
├── theme/ # 테마/색상
|
||||
└── l10n/ # 다국어 (ko/en/ja/zh)
|
||||
```
|
||||
|
||||
### Commit Types
|
||||
## Key Services
|
||||
|
||||
- `feat`: 새로운 기능 추가
|
||||
- `fix`: 버그 수정
|
||||
- `refactor`: 코드 리팩토링
|
||||
- `style`: 코드 스타일 변경 (formatting, missing semi-colons, etc)
|
||||
- `docs`: 문서 변경
|
||||
- `test`: 테스트 코드 추가 또는 수정
|
||||
- `chore`: 빌드 프로세스 또는 보조 도구 변경
|
||||
| Service | 역할 |
|
||||
|---------|------|
|
||||
| `AdService` | 전면 광고 (Completer 패턴) |
|
||||
| `SmsScanner` | SMS 파싱 → 구독 자동 감지 (Isolate 사용) |
|
||||
| `NotificationService` | 로컬 알림 |
|
||||
| `ExchangeRateService` | 환율 조회 |
|
||||
| `url_matcher/` | 서비스명 → URL 매칭 |
|
||||
|
||||
### Examples
|
||||
## Routes
|
||||
|
||||
✅ **Good Examples:**
|
||||
- `feat: 월별 차트 다국어 지원 추가`
|
||||
- `fix: 분석화면 총지출 금액 불일치 문제 해결`
|
||||
- `refactor: 통화 변환 로직 모듈화`
|
||||
| Path | Screen |
|
||||
|------|--------|
|
||||
| `/` | MainScreen |
|
||||
| `/add-subscription` | AddSubscriptionScreen |
|
||||
| `/subscription-detail` | DetailScreen (requires SubscriptionModel) |
|
||||
| `/sms-scanner` | SmsScanScreen |
|
||||
| `/analysis` | AnalysisScreen |
|
||||
| `/settings` | SettingsScreen |
|
||||
| `/payment-card-management` | PaymentCardManagementScreen |
|
||||
|
||||
❌ **Avoid These:**
|
||||
- Including "🤖 Generated with [Claude Code](https://claude.ai/code)"
|
||||
- Including "Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
- Vague messages like "update code" or "fix stuff"
|
||||
- English commit messages (use Korean)
|
||||
## Project Rules
|
||||
|
||||
### 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**
|
||||
- **Focus on what was changed and why**
|
||||
- **Use present tense and imperative mood**
|
||||
- **Keep the first line under 50 characters when possible**
|
||||
## Known Patterns
|
||||
|
||||
## 🧠 Error Analysis & Rule Documentation
|
||||
### 전면 광고 (AdService)
|
||||
|
||||
### Mandatory Process When Errors Occur
|
||||
|
||||
1. **Analyze root cause in detail**
|
||||
2. **Document preventive rule in `.cursor/rules/error_analysis.mdc`**
|
||||
3. **Write in English including**:
|
||||
- Error description and context
|
||||
- Cause and reproducibility steps
|
||||
- Resolution approach
|
||||
- Rule for preventing future recurrences
|
||||
- Sample code and references to related rules
|
||||
|
||||
### 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
|
||||
```dart
|
||||
// Completer 패턴으로 광고 완료 대기
|
||||
final completer = Completer<bool>();
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
completer.complete(true);
|
||||
},
|
||||
);
|
||||
ad.show();
|
||||
return completer.future;
|
||||
```
|
||||
|
||||
## 🏗️ Architectural Guidelines
|
||||
### SMS 스캔 (Isolate)
|
||||
|
||||
### Clean Architecture Compliance
|
||||
```dart
|
||||
// Isolate 내부에서는 하드코딩 사용
|
||||
// Flutter 바인딩, Context, Provider 접근 불가
|
||||
return 'Unknown service'; // AppLocalizations 사용 불가
|
||||
```
|
||||
|
||||
- **Layered structure**: `modules`, `controllers`, `services`, `repositories`, `entities`
|
||||
- Apply **Repository Pattern** for data abstraction
|
||||
- Use **Dependency Injection** (`getIt`, `inject`, etc.)
|
||||
- Controllers handle business logic (not view processing)
|
||||
## Response Format
|
||||
|
||||
### Code Structuring
|
||||
|
||||
- **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
|
||||
```text
|
||||
[모델명]. I have reviewed all the following rules: [규칙]. Proceeding with the task. Master!
|
||||
```
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +8,13 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties().apply {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.naturebridgeai.digitalrentmanager"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -31,11 +41,22 @@ android {
|
||||
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 {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
if (signingConfigs.findByName("release") != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,5 +66,5 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<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
|
||||
android:label="구독 관리"
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
@@ -13,7 +19,9 @@
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
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
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
@@ -36,6 +44,20 @@
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="ca-app-pub-6691216385521068~6638409932" />
|
||||
|
||||
<receiver
|
||||
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -2,7 +2,4 @@ package com.naturebridgeai.digitalrentmanager
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
// flutter_sms_inbox 패키지가 SMS 처리를 담당하므로
|
||||
// 기존 MethodChannel 코드는 제거되었습니다
|
||||
}
|
||||
class MainActivity: FlutterActivity()
|
||||
|
||||
5
android/app/src/main/res/values-ja/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">デジタル月額管理者</string>
|
||||
</resources>
|
||||
|
||||
5
android/app/src/main/res/values-ko/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">디지털 월세 관리자</string>
|
||||
</resources>
|
||||
|
||||
5
android/app/src/main/res/values-zh/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">数字月租管理器</string>
|
||||
</resources>
|
||||
|
||||
5
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Digital Rent Manager</string>
|
||||
</resources>
|
||||
|
||||
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
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")
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
assets/appicon/appicon512.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
@@ -11,6 +11,9 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"deleteSubscriptionTitle": "Delete Subscription",
|
||||
"deleteSubscriptionMessage": "Are you sure you want to delete @ subscription?",
|
||||
"deleteIrreversibleWarning": "This action cannot be undone",
|
||||
"edit": "Edit",
|
||||
"totalSubscriptions": "Total Subscriptions",
|
||||
"totalMonthlyExpense": "Total Monthly Expense",
|
||||
@@ -25,10 +28,35 @@
|
||||
"selectIcon": "Select Icon",
|
||||
"addCategory": "Add Category",
|
||||
"settings": "Settings",
|
||||
"theme": "Theme",
|
||||
"darkMode": "Dark Mode",
|
||||
"language": "Language",
|
||||
"notifications": "Notifications",
|
||||
"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",
|
||||
"notificationPermissionDesc": "Permission is required to receive notifications",
|
||||
"requestPermission": "Request Permission",
|
||||
@@ -41,6 +69,7 @@
|
||||
"dailyReminderEnabled": "Receive daily notifications until payment date",
|
||||
"dailyReminderDisabled": "Receive notification @ day(s) before payment",
|
||||
"notificationPermissionDenied": "Notification permission denied",
|
||||
"permissionGranted": "Permission granted.",
|
||||
"appInfo": "App Info",
|
||||
"version": "Version",
|
||||
"appDescription": "Digital Rent Management App",
|
||||
@@ -60,6 +89,7 @@
|
||||
"twoDaysBefore": "2 days before",
|
||||
"threeDaysBefore": "3 days before",
|
||||
"requiredFieldsError": "Please fill in all required fields",
|
||||
"categoryNameRequired": "Please enter category name",
|
||||
"subscriptionUpdated": "Subscription information has been updated",
|
||||
"subscriptionDeleted": "@ subscription has been deleted",
|
||||
"officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.",
|
||||
@@ -69,6 +99,7 @@
|
||||
"changesAppliedAfterSave": "Changes will be applied after saving",
|
||||
"saveChanges": "Save Changes",
|
||||
"monthlyExpense": "Monthly Expense",
|
||||
"billingAmount": "Billing Amount",
|
||||
"websiteUrl": "Website URL",
|
||||
"websiteUrlOptional": "Website URL (Optional)",
|
||||
"eventPrice": "Event Price",
|
||||
@@ -88,6 +119,7 @@
|
||||
"appLockDesc": "App lock with biometric authentication",
|
||||
"unlockWithBiometric": "Unlock with biometric authentication",
|
||||
"authenticationFailed": "Authentication failed. Please try again.",
|
||||
"nextBillingDateAdjusted": "Saved as the next billing date",
|
||||
"totalExpenseCopied": "Total expense copied: @",
|
||||
"smsPermissionRequired": "SMS permission required",
|
||||
"noSubscriptionSmsFound": "No subscription related SMS found",
|
||||
@@ -126,10 +158,13 @@
|
||||
"repeatSubscriptionNotFound": "No repeated subscription information found.",
|
||||
"newSubscriptionNotFound": "No new subscription SMS found",
|
||||
"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",
|
||||
"foundSubscription": "Found subscription",
|
||||
"latestSmsMessage": "Latest SMS message",
|
||||
"smsDetectedDate": "Detected on @",
|
||||
"serviceName": "Service Name",
|
||||
"unknownService": "Unknown service",
|
||||
"nextBillingDateLabel": "Next Billing Date",
|
||||
"category": "Category",
|
||||
"websiteUrlAuto": "Website URL (Auto-extracted)",
|
||||
@@ -147,6 +182,7 @@
|
||||
"estimatedAnnualCost": "Estimated Annual Cost",
|
||||
"totalSubscriptionServices": "Total Subscription Services",
|
||||
"eventDiscountActive": "Event Discount Active",
|
||||
"eventDiscountEndsBeforeBilling": "Event discount ends before billing date",
|
||||
"saving": "Saving",
|
||||
"paymentDueToday": "Payment Due Today",
|
||||
"paymentDueInDays": "Payment due in @ days",
|
||||
@@ -199,7 +235,7 @@
|
||||
"cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.",
|
||||
"goToCancelPage": "Go to Cancellation Page",
|
||||
"urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name",
|
||||
"discountPercent": "@% discount",
|
||||
"discountPercent": "% discount",
|
||||
"discountAmountWon": "Save ₩@",
|
||||
"discountAmountDollar": "Save $@",
|
||||
"discountAmountYen": "Save ¥@",
|
||||
@@ -216,8 +252,12 @@
|
||||
"subscriptionDetail": "Subscription Detail",
|
||||
"enterAmount": "Enter 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",
|
||||
"smsPermissionReasonTitle": "Why",
|
||||
"smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.",
|
||||
@@ -227,7 +267,11 @@
|
||||
"openSettings": "Open Settings",
|
||||
"later": "Later",
|
||||
"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": {
|
||||
"appTitle": "디지털 월세 관리자",
|
||||
@@ -241,6 +285,9 @@
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"delete": "삭제",
|
||||
"deleteSubscriptionTitle": "구독 삭제",
|
||||
"deleteSubscriptionMessage": "정말로 @ 구독을 삭제하시겠습니까?",
|
||||
"deleteIrreversibleWarning": "이 작업은 되돌릴 수 없습니다",
|
||||
"edit": "수정",
|
||||
"totalSubscriptions": "총 구독",
|
||||
"totalMonthlyExpense": "이번 달 총 지출",
|
||||
@@ -255,10 +302,35 @@
|
||||
"selectIcon": "아이콘 선택",
|
||||
"addCategory": "카테고리 추가",
|
||||
"settings": "설정",
|
||||
"theme": "테마",
|
||||
"darkMode": "다크 모드",
|
||||
"language": "언어",
|
||||
"notifications": "알림",
|
||||
"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": "알림 권한",
|
||||
"notificationPermissionDesc": "알림을 받으려면 권한이 필요합니다",
|
||||
"requestPermission": "권한 요청",
|
||||
@@ -271,6 +343,7 @@
|
||||
"dailyReminderEnabled": "결제일까지 매일 알림을 받습니다",
|
||||
"dailyReminderDisabled": "결제 @일 전에 알림을 받습니다",
|
||||
"notificationPermissionDenied": "알림 권한이 거부되었습니다",
|
||||
"permissionGranted": "권한이 허용되었습니다.",
|
||||
"appInfo": "앱 정보",
|
||||
"version": "버전",
|
||||
"appDescription": "디지털 월세 관리 앱",
|
||||
@@ -290,6 +363,7 @@
|
||||
"twoDaysBefore": "2일 전",
|
||||
"threeDaysBefore": "3일 전",
|
||||
"requiredFieldsError": "필수 항목을 모두 입력해주세요",
|
||||
"categoryNameRequired": "카테고리 이름을 입력하세요",
|
||||
"subscriptionUpdated": "구독 정보가 업데이트되었습니다.",
|
||||
"subscriptionDeleted": "@ 구독이 삭제되었습니다.",
|
||||
"officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.",
|
||||
@@ -299,6 +373,7 @@
|
||||
"changesAppliedAfterSave": "변경사항은 저장 후 적용됩니다",
|
||||
"saveChanges": "변경사항 저장",
|
||||
"monthlyExpense": "월 지출",
|
||||
"billingAmount": "결제 금액",
|
||||
"websiteUrl": "웹사이트 URL",
|
||||
"websiteUrlOptional": "웹사이트 URL (선택)",
|
||||
"eventPrice": "이벤트 가격",
|
||||
@@ -318,6 +393,7 @@
|
||||
"appLockDesc": "생체 인증으로 앱 잠금",
|
||||
"unlockWithBiometric": "생체 인증으로 잠금 해제",
|
||||
"authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.",
|
||||
"nextBillingDateAdjusted": "다음 결제 예정일로 저장됨",
|
||||
"totalExpenseCopied": "총 지출액이 복사되었습니다: @",
|
||||
"smsPermissionRequired": "SMS 권한이 필요합니다.",
|
||||
"noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.",
|
||||
@@ -356,10 +432,13 @@
|
||||
"repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.",
|
||||
"newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다",
|
||||
"findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기",
|
||||
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.",
|
||||
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.",
|
||||
"startScanning": "스캔 시작하기",
|
||||
"foundSubscription": "다음 구독을 찾았습니다",
|
||||
"latestSmsMessage": "최신 SMS 메시지",
|
||||
"smsDetectedDate": "SMS 수신일: @",
|
||||
"serviceName": "서비스명",
|
||||
"unknownService": "알 수 없는 서비스",
|
||||
"nextBillingDateLabel": "다음 결제일",
|
||||
"category": "카테고리",
|
||||
"websiteUrlAuto": "웹사이트 URL (자동 추출됨)",
|
||||
@@ -377,6 +456,7 @@
|
||||
"estimatedAnnualCost": "예상 연간 구독 비용",
|
||||
"totalSubscriptionServices": "총 구독 서비스",
|
||||
"eventDiscountActive": "이벤트 할인 중",
|
||||
"eventDiscountEndsBeforeBilling": "이벤트 할인이 결제일 전에 종료됩니다",
|
||||
"saving": "절약",
|
||||
"paymentDueToday": "오늘 결제 예정",
|
||||
"paymentDueInDays": "@일 후 결제 예정",
|
||||
@@ -429,7 +509,7 @@
|
||||
"cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.",
|
||||
"goToCancelPage": "해지 페이지로 이동",
|
||||
"urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다",
|
||||
"discountPercent": "@% 할인",
|
||||
"discountPercent": "% 할인",
|
||||
"discountAmountWon": "₩@원 절약",
|
||||
"discountAmountDollar": "$@ 절약",
|
||||
"discountAmountYen": "¥@ 절약",
|
||||
@@ -446,8 +526,12 @@
|
||||
"subscriptionDetail": "구독 상세",
|
||||
"enterAmount": "금액을 입력하세요",
|
||||
"invalidAmount": "올바른 금액을 입력해주세요",
|
||||
"featureComingSoon": "이 기능은 곧 출시됩니다"
|
||||
,
|
||||
"featureComingSoon": "이 기능은 곧 출시됩니다",
|
||||
"exactAlarmPermission": "정확 알람 권한(알람 및 리마인더)",
|
||||
"exactAlarmPermissionDesc": "정확한 시각에 알림을 보장하려면 권한이 필요합니다.",
|
||||
"allowAlarmsInSettings": "설정에서 \"알람 및 리마인더\"를 허용해 주세요.",
|
||||
"testNotification": "테스트 알림",
|
||||
"testSubscriptionBody": "테스트 구독 • @",
|
||||
"smsPermissionTitle": "SMS 권한 요청",
|
||||
"smsPermissionReasonTitle": "이유",
|
||||
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
|
||||
@@ -457,7 +541,11 @@
|
||||
"openSettings": "설정 열기",
|
||||
"later": "나중에 하기",
|
||||
"requesting": "요청 중...",
|
||||
"smsPermissionLabel": "SMS 권한"
|
||||
"smsPermissionLabel": "SMS 권한",
|
||||
"expirationReminderBody": "@ 구독이 #일 후 만료됩니다.",
|
||||
"eventEndNotificationTitle": "이벤트 종료 알림",
|
||||
"eventEndNotificationBody": "@의 할인 이벤트가 종료되었습니다.",
|
||||
"paymentChargeNotification": "@ 구독료 @이 결제되었습니다."
|
||||
},
|
||||
"ja": {
|
||||
"appTitle": "デジタル月額管理者",
|
||||
@@ -471,6 +559,9 @@
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"deleteSubscriptionTitle": "サブスクリプション削除",
|
||||
"deleteSubscriptionMessage": "本当に@のサブスクリプションを削除しますか?",
|
||||
"deleteIrreversibleWarning": "この操作は取り消せません",
|
||||
"edit": "編集",
|
||||
"totalSubscriptions": "総サブスクリプション",
|
||||
"totalMonthlyExpense": "今月の総支出",
|
||||
@@ -485,10 +576,35 @@
|
||||
"selectIcon": "アイコンを選択",
|
||||
"addCategory": "カテゴリー追加",
|
||||
"settings": "設定",
|
||||
"theme": "テーマ",
|
||||
"darkMode": "ダークモード",
|
||||
"language": "言語",
|
||||
"notifications": "通知",
|
||||
"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": "通知権限",
|
||||
"notificationPermissionDesc": "通知を受け取るには権限が必要です",
|
||||
"requestPermission": "権限をリクエスト",
|
||||
@@ -501,6 +617,7 @@
|
||||
"dailyReminderEnabled": "支払い日まで毎日通知を受け取ります",
|
||||
"dailyReminderDisabled": "支払い@日前に通知を受け取ります",
|
||||
"notificationPermissionDenied": "通知権限が拒否されました",
|
||||
"permissionGranted": "権限が許可されました。",
|
||||
"appInfo": "アプリ情報",
|
||||
"version": "バージョン",
|
||||
"appDescription": "デジタル月額管理アプリ",
|
||||
@@ -520,6 +637,7 @@
|
||||
"twoDaysBefore": "2日前",
|
||||
"threeDaysBefore": "3日前",
|
||||
"requiredFieldsError": "すべての必須項目を入力してください",
|
||||
"categoryNameRequired": "カテゴリ名を入力してください",
|
||||
"subscriptionUpdated": "サブスクリプション情報が更新されました",
|
||||
"subscriptionDeleted": "@サブスクリプションが削除されました",
|
||||
"officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。",
|
||||
@@ -529,6 +647,7 @@
|
||||
"changesAppliedAfterSave": "変更は保存後に適用されます",
|
||||
"saveChanges": "変更を保存",
|
||||
"monthlyExpense": "月額支出",
|
||||
"billingAmount": "請求金額",
|
||||
"websiteUrl": "ウェブサイトURL",
|
||||
"websiteUrlOptional": "ウェブサイトURL(オプション)",
|
||||
"eventPrice": "イベント価格",
|
||||
@@ -548,6 +667,7 @@
|
||||
"appLockDesc": "生体認証でアプリをロック",
|
||||
"unlockWithBiometric": "生体認証でロック解除",
|
||||
"authenticationFailed": "認証に失敗しました。もう一度お試しください。",
|
||||
"nextBillingDateAdjusted": "次回請求日に保存しました",
|
||||
"totalExpenseCopied": "総支出がコピーされました:@",
|
||||
"smsPermissionRequired": "SMS権限が必要です",
|
||||
"noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません",
|
||||
@@ -586,10 +706,13 @@
|
||||
"repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。",
|
||||
"newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません",
|
||||
"findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索",
|
||||
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。サービス名と金額を抽出して簡単にサブスクリプションを追加できます。",
|
||||
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。",
|
||||
"startScanning": "スキャン開始",
|
||||
"foundSubscription": "サブスクリプションが見つかりました",
|
||||
"latestSmsMessage": "最新のSMSメッセージ",
|
||||
"smsDetectedDate": "SMS受信日: @",
|
||||
"serviceName": "サービス名",
|
||||
"unknownService": "不明なサービス",
|
||||
"nextBillingDateLabel": "次回請求日",
|
||||
"category": "カテゴリー",
|
||||
"websiteUrlAuto": "ウェブサイトURL(自動抽出)",
|
||||
@@ -607,6 +730,7 @@
|
||||
"estimatedAnnualCost": "予想年間サブスクリプション費用",
|
||||
"totalSubscriptionServices": "総サブスクリプションサービス",
|
||||
"eventDiscountActive": "イベント割引中",
|
||||
"eventDiscountEndsBeforeBilling": "請求日前にイベント割引が終了します",
|
||||
"saving": "節約",
|
||||
"paymentDueToday": "本日支払い予定",
|
||||
"paymentDueInDays": "@日後に支払い予定",
|
||||
@@ -659,7 +783,7 @@
|
||||
"cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。",
|
||||
"goToCancelPage": "解約ページへ移動",
|
||||
"urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます",
|
||||
"discountPercent": "@%割引",
|
||||
"discountPercent": "%割引",
|
||||
"discountAmountWon": "₩@節約",
|
||||
"discountAmountDollar": "$@節約",
|
||||
"discountAmountYen": "¥@節約",
|
||||
@@ -676,7 +800,16 @@
|
||||
"subscriptionDetail": "サブスクリプション詳細",
|
||||
"enterAmount": "金額を入力してください",
|
||||
"invalidAmount": "正しい金額を入力してください",
|
||||
"featureComingSoon": "この機能は近日公開予定です"
|
||||
"featureComingSoon": "この機能は近日公開予定です",
|
||||
"exactAlarmPermission": "正確なアラーム権限(アラームとリマインダー)",
|
||||
"exactAlarmPermissionDesc": "正確な時刻に通知するには権限が必要です。",
|
||||
"allowAlarmsInSettings": "設定で「アラームとリマインダー」を許可してください。",
|
||||
"testNotification": "テスト通知",
|
||||
"testSubscriptionBody": "テストサブスクリプション • @",
|
||||
"expirationReminderBody": "@ のサブスクリプションは #日後に期限切れになります。",
|
||||
"eventEndNotificationTitle": "イベント終了通知",
|
||||
"eventEndNotificationBody": "@ の割引イベントが終了しました。",
|
||||
"paymentChargeNotification": "@ の購読料 @ が請求されました。"
|
||||
},
|
||||
"zh": {
|
||||
"appTitle": "数字月租管理器",
|
||||
@@ -690,6 +823,9 @@
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"deleteSubscriptionTitle": "删除订阅",
|
||||
"deleteSubscriptionMessage": "确定要删除@订阅吗?",
|
||||
"deleteIrreversibleWarning": "此操作无法撤销",
|
||||
"edit": "编辑",
|
||||
"totalSubscriptions": "订阅总数",
|
||||
"totalMonthlyExpense": "本月总支出",
|
||||
@@ -704,10 +840,35 @@
|
||||
"selectIcon": "选择图标",
|
||||
"addCategory": "添加分类",
|
||||
"settings": "设置",
|
||||
"theme": "主题",
|
||||
"darkMode": "深色模式",
|
||||
"language": "语言",
|
||||
"notifications": "通知",
|
||||
"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": "通知权限",
|
||||
"notificationPermissionDesc": "需要权限才能接收通知",
|
||||
"requestPermission": "请求权限",
|
||||
@@ -720,6 +881,7 @@
|
||||
"dailyReminderEnabled": "直到付款日期每天接收通知",
|
||||
"dailyReminderDisabled": "在付款@天前接收通知",
|
||||
"notificationPermissionDenied": "通知权限被拒绝",
|
||||
"permissionGranted": "已获得权限。",
|
||||
"appInfo": "应用信息",
|
||||
"version": "版本",
|
||||
"appDescription": "数字月租管理应用",
|
||||
@@ -739,6 +901,7 @@
|
||||
"twoDaysBefore": "2天前",
|
||||
"threeDaysBefore": "3天前",
|
||||
"requiredFieldsError": "请填写所有必填项",
|
||||
"categoryNameRequired": "请输入分类名称",
|
||||
"subscriptionUpdated": "订阅信息已更新",
|
||||
"subscriptionDeleted": "@订阅已删除",
|
||||
"officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。",
|
||||
@@ -748,6 +911,7 @@
|
||||
"changesAppliedAfterSave": "更改将在保存后应用",
|
||||
"saveChanges": "保存更改",
|
||||
"monthlyExpense": "每月支出",
|
||||
"billingAmount": "账单金额",
|
||||
"websiteUrl": "网站URL",
|
||||
"websiteUrlOptional": "网站URL(可选)",
|
||||
"eventPrice": "活动价格",
|
||||
@@ -767,6 +931,7 @@
|
||||
"appLockDesc": "使用生物识别锁定应用",
|
||||
"unlockWithBiometric": "使用生物识别解锁",
|
||||
"authenticationFailed": "认证失败。请重试。",
|
||||
"nextBillingDateAdjusted": "已保存为下一次账单日",
|
||||
"totalExpenseCopied": "总支出已复制:@",
|
||||
"smsPermissionRequired": "需要短信权限",
|
||||
"noSubscriptionSmsFound": "未找到订阅相关的短信",
|
||||
@@ -805,10 +970,13 @@
|
||||
"repeatSubscriptionNotFound": "未找到重复付款的订阅信息。",
|
||||
"newSubscriptionNotFound": "未找到新订阅短信",
|
||||
"findRepeatSubscriptions": "查找支付2次以上的订阅",
|
||||
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。可以提取服务名称和金额,轻松添加订阅。",
|
||||
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额,轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果,并在需要时手动添加或修改。",
|
||||
"startScanning": "开始扫描",
|
||||
"foundSubscription": "找到订阅",
|
||||
"latestSmsMessage": "最新短信内容",
|
||||
"smsDetectedDate": "短信接收日期:@",
|
||||
"serviceName": "服务名称",
|
||||
"unknownService": "未知服务",
|
||||
"nextBillingDateLabel": "下次付款日期",
|
||||
"category": "类别",
|
||||
"websiteUrlAuto": "网站URL(自动提取)",
|
||||
@@ -826,6 +994,7 @@
|
||||
"estimatedAnnualCost": "预计年度订阅费用",
|
||||
"totalSubscriptionServices": "总订阅服务",
|
||||
"eventDiscountActive": "活动折扣中",
|
||||
"eventDiscountEndsBeforeBilling": "活动折扣将在账单日之前结束",
|
||||
"saving": "节省",
|
||||
"paymentDueToday": "今日付款到期",
|
||||
"paymentDueInDays": "@天后付款到期",
|
||||
@@ -878,7 +1047,7 @@
|
||||
"cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。",
|
||||
"goToCancelPage": "前往取消页面",
|
||||
"urlAutoMatchInfo": "如果URL为空,将根据服务名称自动匹配",
|
||||
"discountPercent": "@%折扣",
|
||||
"discountPercent": "%折扣",
|
||||
"discountAmountWon": "节省₩@",
|
||||
"discountAmountDollar": "节省$@",
|
||||
"discountAmountYen": "节省¥@",
|
||||
@@ -895,6 +1064,15 @@
|
||||
"subscriptionDetail": "订阅详情",
|
||||
"enterAmount": "请输入金额",
|
||||
"invalidAmount": "请输入有效的金额",
|
||||
"featureComingSoon": "此功能即将推出"
|
||||
"featureComingSoon": "此功能即将推出",
|
||||
"exactAlarmPermission": "精确闹钟权限(闹钟和提醒)",
|
||||
"exactAlarmPermissionDesc": "需要权限以确保在准确时间发送提醒。",
|
||||
"allowAlarmsInSettings": "请在设置中允许“闹钟和提醒”。",
|
||||
"testNotification": "测试通知",
|
||||
"testSubscriptionBody": "测试订阅 • @",
|
||||
"expirationReminderBody": "@ 订阅将在 # 天后到期。",
|
||||
"eventEndNotificationTitle": "活动结束通知",
|
||||
"eventEndNotificationBody": "@ 的优惠活动已结束。",
|
||||
"paymentChargeNotification": "@ 订阅费用 @ 已扣款。"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
assets/screenShot/submanager_01_Cn.jpg
Executable file
|
After Width: | Height: | Size: 287 KiB |
BIN
assets/screenShot/submanager_01_En.jpg
Executable file
|
After Width: | Height: | Size: 320 KiB |
BIN
assets/screenShot/submanager_01_Jp.jpg
Executable file
|
After Width: | Height: | Size: 313 KiB |
BIN
assets/screenShot/submanager_01_kr.jpg
Executable file
|
After Width: | Height: | Size: 288 KiB |
BIN
assets/screenShot/submanager_02_Cn.jpg
Executable file
|
After Width: | Height: | Size: 272 KiB |
BIN
assets/screenShot/submanager_02_En.jpg
Executable file
|
After Width: | Height: | Size: 282 KiB |
BIN
assets/screenShot/submanager_02_Jp.jpg
Executable file
|
After Width: | Height: | Size: 281 KiB |
BIN
assets/screenShot/submanager_02_kr.jpg
Executable file
|
After Width: | Height: | Size: 260 KiB |
BIN
assets/screenShot/submanager_03_Cn.jpg
Executable file
|
After Width: | Height: | Size: 251 KiB |
BIN
assets/screenShot/submanager_03_En.jpg
Executable file
|
After Width: | Height: | Size: 289 KiB |
BIN
assets/screenShot/submanager_03_Jp.jpg
Executable file
|
After Width: | Height: | Size: 274 KiB |
BIN
assets/screenShot/submanager_03_kr.jpg
Executable file
|
After Width: | Height: | Size: 231 KiB |
BIN
assets/screenShot/submanager_04_Cn.jpg
Executable file
|
After Width: | Height: | Size: 237 KiB |
BIN
assets/screenShot/submanager_04_En.jpg
Executable file
|
After Width: | Height: | Size: 247 KiB |
BIN
assets/screenShot/submanager_04_Jp.jpg
Executable file
|
After Width: | Height: | Size: 284 KiB |
BIN
assets/screenShot/submanager_04_kr.jpg
Executable file
|
After Width: | Height: | Size: 234 KiB |
BIN
assets/screenShot/submanager_05_Cn.jpg
Executable file
|
After Width: | Height: | Size: 250 KiB |
BIN
assets/screenShot/submanager_05_En.jpg
Executable file
|
After Width: | Height: | Size: 283 KiB |
BIN
assets/screenShot/submanager_05_Jp.jpg
Executable file
|
After Width: | Height: | Size: 262 KiB |
BIN
assets/screenShot/submanager_05_kr.jpg
Executable file
|
After Width: | Height: | Size: 257 KiB |
BIN
assets/screenShot/submanager_image_en.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/screenShot/submanager_image_jp.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/screenShot/submanager_image_kr.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/screenShot/submanager_imgae_cn.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
83
doc/PersonalInformation/Personal information.txt
Normal 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
@@ -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` 로드 확인
|
||||
|
||||
253
doc/color.md
@@ -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 코드 | 설명/느낌 |
|
||||
|--------------|--------------|--------------|--------------------------|
|
||||
| 메인 | Deep Blue | #2563eb | 신뢰, 포인트 |
|
||||
| 서브 | Sky Blue | #60a5fa | 트렌디, 그라디언트 |
|
||||
| 포인트 | Soft Mint | #38bdf8 | 상쾌함, 포인트 |
|
||||
| 배경 | Light Gray | #f1f5f9 | 편안함, 밝은 배경 |
|
||||
| 글래스 효과 | White Glass | #ffffff(투명)| 반투명 글래스 효과 |
|
||||
| 포인트 | Pink Accent | #f472b6 | 트렌디, 액센트 |
|
||||
| 그림자 | Shadow Black | rgba(0,0,0,0.08) | 깊이감 부여 |
|
||||
## 1) 원칙(신뢰·접근성·일관성)
|
||||
- 신뢰: Primary는 딥 블루(#2563EB). 과장된 장식 대신 명확한 위계/역할색 사용.
|
||||
- 접근성: 본문 대비 WCAG AA(4.5:1) 충족. on-colors(onPrimary/onSurface/onError…) 일관 적용.
|
||||
- 일관성: 전역 ColorScheme/typography/shape/elevation 우선, 로컬 styleFrom 최소화.
|
||||
- 성능/가독성: Glass 제거 → 불투명 Surface + elevation/outline 중심으로 레이어 구분.
|
||||
|
||||
## 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(투명)) 위에는 **어두운 텍스트**를,
|
||||
진한 컬러(예: #2563eb, #38bdf8) 위에는 **밝은 텍스트**를 사용해야 가독성이 좋습니다.
|
||||
## 3) 타입·라디우스·간격·음영 스케일
|
||||
- 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)로 수직 리듬 고정
|
||||
|
||||
| 배경 컬러 | 추천 텍스트 컬러 | 용도/설명 |
|
||||
|------------------|----------------------|-----------------------------------|
|
||||
| Light Gray (#f1f5f9) | Dark Navy (#1e293b) | 메인 텍스트, 타이틀, 버튼 |
|
||||
| White Glass (투명) | Deep Blue (#2563eb) | 강조 텍스트, 버튼 |
|
||||
| Deep Blue (#2563eb) | Pure White (#ffffff) | 버튼, 반전 텍스트 |
|
||||
| Sky Blue (#60a5fa) | Navy Gray (#334155) | 서브 텍스트, 부가 설명 |
|
||||
| Soft Mint (#38bdf8) | Navy Gray (#334155) | 포인트 텍스트 |
|
||||
| Pink Accent (#f472b6)| Deep Blue (#2563eb) | 강조, 포인트 텍스트 |
|
||||
## 4) Glass 제거 및 대체 규칙
|
||||
- `lib/widgets/glassmorphism_card.dart` 사용부 전면 치환:
|
||||
- 대체: `Card(elevation: 3, color: colorScheme.surface, shape: 16)`
|
||||
- 경계: `Outline` 기반(라이트 #E2E8F0, 다크 #3F3F46, 투명도 60~80%)
|
||||
- 섀도우: 라이트만 약하게(8~12), 다크는 outline 위주
|
||||
- 내부 텍스트: 항상 `colorScheme.onSurface` 또는 전역 `textTheme` 사용(하드코딩 금지)
|
||||
- 그라데이션/반투명 배경 삭제(필요 시 Hero/그림·아이콘 등으로 시각적 흥미 보완)
|
||||
|
||||
## 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)
|
||||
- **글래스 카드**: White Glass (rgba(255,255,255,0.2)), 테두리 Deep Blue (#2563eb)
|
||||
- **메인 텍스트**: Dark Navy (#1e293b)
|
||||
- **서브/설명 텍스트**: Navy Gray (#334155)
|
||||
- **버튼 배경**: Deep Blue (#2563eb)
|
||||
- **버튼 텍스트**: Pure White (#ffffff)
|
||||
- **포인트/액센트**: Soft Mint (#38bdf8), Pink Accent (#f472b6)
|
||||
|
||||
## 4. 그라디언트 및 글래스 효과 예시
|
||||
## 6) 설정 화면에 모드 선택 UI 추가(계획)
|
||||
- 위치: `lib/screens/settings_screen.dart`
|
||||
- 섹션명: Appearance(또는 테마)
|
||||
- 구성: `Theme Mode` 라디오 그룹(시스템 / 라이트 / 다크)
|
||||
- RadioListTile 3개(버튼 나열 유지, 드롭다운 금지)
|
||||
- 값: `AppThemeMode.system|light|dark`
|
||||
- 동작: `context.read<ThemeProvider>().setThemeMode(mode)` 호출
|
||||
- 추가 토글(유지): 큰 텍스트/모션 감소/고대비(현 Provider 연동)
|
||||
|
||||
샘플 코드
|
||||
```dart
|
||||
// Flutter 예시 (Dart)
|
||||
LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF2563eb),
|
||||
Color(0xFF60a5fa),
|
||||
Color(0xFFe0e7ef),
|
||||
],
|
||||
)
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
Column(children: [
|
||||
ListTile(title: Text('Theme Mode')),
|
||||
RadioListTile(
|
||||
title: Text('System'),
|
||||
value: AppThemeMode.system,
|
||||
groupValue: themeProvider.themeMode,
|
||||
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) 회귀·접근성 검증(라이트/다크/시스템)
|
||||
|
||||
- **텍스트 대비**를 항상 체크하세요.
|
||||
밝은 배경에는 어두운 텍스트, 진한 배경에는 밝은 텍스트!
|
||||
- **포인트 컬러**는 버튼, 아이콘, 강조 텍스트에만 제한적으로 사용하면 세련됨이 살아납니다.
|
||||
- **글래스 효과**는 투명도와 블러, 그리고 경계선 컬러(예: #2563eb, #60a5fa)로 깊이감을 더하세요.
|
||||
## 8) 검증
|
||||
- 스크립트: `scripts/check.sh` (format/analyze/test)
|
||||
- 시각: 모든 화면에서 텍스트 대비(AA) 확인, 상태(Hover/Pressed/Disabled) 점검
|
||||
- 성능: Glass 제거 후 저사양 단말 스크롤/애니메이션 프레임 확인
|
||||
|
||||
## 6. 컬러/텍스트 조합 요약표
|
||||
## 9) 요약
|
||||
- Glass 제거 + 완전 Material 3 전환으로 신뢰감, 가독성, 성능을 함께 강화합니다.
|
||||
- 오류색은 레드로 통일, on-colors로 대비를 보장합니다.
|
||||
- 설정에 시스템/라이트/다크 선택을 제공하고, 버튼 나열 UI는 유지합니다.
|
||||
|
||||
| 배경색 | 텍스트색 | 용도 예시 |
|
||||
|------------------|------------------|--------------------|
|
||||
| #f1f5f9 | #1e293b | 메인 타이틀, 내용 |
|
||||
| #ffffff(투명) | #2563eb | 카드 내 강조 |
|
||||
| #2563eb | #ffffff | 버튼, 반전 강조 |
|
||||
| #60a5fa | #334155 | 서브, 설명 |
|
||||
| #38bdf8 | #334155 | 포인트, 서브텍스트 |
|
||||
## 진행 현황(Work Log)
|
||||
- [완료] 전역 스킴 교정: `ColorScheme.error`를 레드(#EF4444)로 교정 (라이트/다크)
|
||||
- [완료] 스낵바 오류색 정렬: `AppSnackBar.showError`가 `colorScheme.error` 사용
|
||||
- [완료] 설정 화면 테마 모드 UI: System/Light/Dark SegmentedButton 추가(드롭다운/라디오 대체, M3 준수)
|
||||
- [완료] Glass 제거(설정 화면): `GlassmorphismCard` → `Card` 치환
|
||||
- [완료] Glass 제거(빈 상태 위젯): `EmptyStateWidget`를 `Card` 기반으로 재구성
|
||||
- [완료] 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
@@ -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에 노출되지 않도록 관리하는 것을 권장합니다.
|
||||
BIN
doc/key/submanager-release.keystore
Normal file
122
doc/payment_card_plan.md
Normal 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
@@ -0,0 +1,113 @@
|
||||
# SubManager UI 리디자인/리팩터링 계획 (flutter-shadcn-ui 기반)
|
||||
|
||||
## 개요
|
||||
- 목적: 앱 전반 UI를 `flutter-shadcn-ui`로 표준화하고, 라이트/다크 테마와 의미 있는 컬러 체계를 구축. 사용하지 않는 코드/파일 정리, 복잡한 알고리즘을 동등 효과의 단순한 구현으로 교체.
|
||||
- 원칙: 색채심리학/게슈탈트 심리학/피츠의 법칙/마이크로인터랙션을 반영. 화면 간 일관성 유지. 사이드 이펙트 최소화(동작/데이터 모델 변경 없이 UI 중심).
|
||||
- 범위: `lib/screens`, `lib/widgets`, `lib/theme` 전반. 일부 `services/*` 단순화 대상 포함(동일 기능 유지).
|
||||
|
||||
## 사전 승인 필요(착수 전)
|
||||
1) 의존성 추가: `flutter_shadcn_ui` (pubspec.yaml).
|
||||
2) 테마 구조 재구성: 기존 `app_theme.dart`, `app_colors.dart` → shadcn 토큰/스케일 중심으로 정리.
|
||||
3) 불용 파일 삭제: 기존 커스텀 위젯·스타일(예: 글라스모피즘 계열 등) 제거.
|
||||
4) 점진적 마이그레이션 방식 선택(권장) 또는 일괄 치환(위험도 높음).
|
||||
|
||||
## 접근 전략(옵션)
|
||||
- 옵션 A: 점진적 이행(권장)
|
||||
1단계 토대(테마/토큰/기본 컴포넌트) → 2단계 주요 화면 치환 → 3단계 잔여 위젯/정리. 리스크 낮고 롤백 용이.
|
||||
- 옵션 B: 일괄 치환
|
||||
모든 화면/컴포넌트를 한 번에 교체. 속도는 빠르나 충돌/리스크 큼. 권장하지 않음.
|
||||
|
||||
이 계획서는 옵션 A를 기준으로 작성합니다.
|
||||
|
||||
## 테마·컬러 설계
|
||||
- 토큰: primary, secondary, success, warning, danger, info, background, foreground, muted, accent, border, card, popover, ring, overlay.
|
||||
- 라이트/다크 지원: 동일 의미 색상(semantics)을 양 테마에 매핑. 최소 WCAG 4.5:1 대비.
|
||||
- 색채심리학 반영(과장 금지, 절제된 사용):
|
||||
- info: 블루(신뢰/안정),
|
||||
- success: 그린(완료/안도),
|
||||
- warning: 앰버(주의 환기),
|
||||
- danger: 레드(중단/삭제),
|
||||
- neutral: 슬레이트/징크 계열(콘텐츠 중심).
|
||||
- 게슈탈트: 시각적 그룹화(카드/섹션/간격 체계), 시선 흐름(타이포·계층), 근접성·유사성 활용.
|
||||
- 피츠의 법칙: 주요 액션 버튼 터치 타깃 ≥ 44dp, 간격 여유.
|
||||
- 마이크로인터랙션: 진입/전환 120–200ms, 물리 기반 커브, Reduced Motion 설정 반영(`utils/reduce_motion.dart` 유지/연동).
|
||||
|
||||
구현 포인트(코드 단계에서 적용):
|
||||
- `ShadcnTheme` 확장 혹은 테마 브리지 레이어 생성(예: `lib/theme/shadcn_theme.dart`) 후 기존 `ThemeData`와 연결.
|
||||
- `TextTheme`/`ColorScheme`를 shadcn 토큰으로 역매핑해 타 3rd-party 위젯과도 일관성 유지.
|
||||
|
||||
## 컴포넌트 매핑(현행 → shadcn)
|
||||
- 버튼: `common/buttons/(primary|secondary)_button.dart` → `Button(variant: primary/secondary)`
|
||||
- 카드: 다수의 카드형 위젯 → `Card` + `CardHeader/Content/Footer`
|
||||
- 다이얼로그: `dialogs/*` → `Dialog`/`AlertDialog` + 의미 색상(위험=red)
|
||||
- 스낵바: `app_snackbar.dart` → `Toast` 또는 `Inline Alert`(상황별)
|
||||
- 입력: `base_text_field.dart`, `currency_input_field.dart`, `date_picker_field.dart`, `selector`류 → `Input`, `Select`, `Popover+Calendar`(날짜)
|
||||
- 네비게이션: `floating_navigation_bar.dart` → shadcn 스타일 버튼/탭/세그먼트 조합(기능은 Navigator 유지)
|
||||
- 리스트/아이템: `subscription_*_card(_widget).dart` → `Card`+`List` 조합, 의미 색상 배지 사용
|
||||
- 배지/상태: `analysis_badge.dart` → `Badge`(success/warning/info)
|
||||
|
||||
차트는 기존 라이브러리 유지, `Card`/토큰 색상만 적용.
|
||||
|
||||
## 화면별 리디자인 가이드
|
||||
- 메인(`main_screen.dart`): 상단 요약(카드), 탭형 네비게이션, FAB 대신 우선작업 배치(피츠 법칙 반영).
|
||||
- 구독 추가(`add_subscription_screen.dart`): 단계적 폼(섹션 카드), 필수/보조 액션 분리, 에러/힌트 색상 표준화.
|
||||
- 상세(`detail_screen.dart`): 정보/행동 분리, 위험 액션은 `danger` 톤, url 영역은 `info` 톤.
|
||||
- 분석(`analysis_screen.dart`): KPI 카드 3열(태블릿), 1열(폰), 차트 색상은 의미 기반 팔레트.
|
||||
- 카테고리 관리(`category_management_screen.dart`): 리스트+인라인 편집, 확인/취소 분리, 경고 색상 남용 금지.
|
||||
- 설정(`settings_screen.dart`): 토글/셀리스트 일관, 테마 전환 즉시 반영, 접근성 강조.
|
||||
- SMS 권한(`sms_permission_screen.dart`): 단일 초점 화면, primary 호출-행동 버튼 + 보조 링크.
|
||||
- 스플래시/잠금: 단순한 브랜드/배경, 과도한 애니메이션 제거.
|
||||
|
||||
## 정리/삭제 대상(마이그레이션 완료 후)
|
||||
- 강한 시각효과 위젯: `animated_wave_background.dart`, `glassmorphism_card.dart`, `glassmorphic_scaffold.dart` 등
|
||||
- 중복/대체 가능: 커스텀 버튼/카드/스낵바/다이얼로그 구현체(치환 완료 후)
|
||||
- 사용되지 않는 유틸: 실사용 참조 0인 파일 전부
|
||||
- 임시/백업: 오래된 백업/실험 파일
|
||||
|
||||
삭제는 단계별 PR에서 “치환 완료 확인 → 삭제” 순으로 안전하게 진행.
|
||||
|
||||
## 알고리즘 단순화(동일 효과 유지)
|
||||
- SMS 스캔(`services/sms_scanner.dart`): 필터→파서→정규화 단일 파이프라인(순수 함수)로 재구성, 캐시/메모리 최적화 과잉 제거.
|
||||
- URL 매처(`services/url_matcher/*`): 정규식 테이블 기반 단일 매칭기로 단순화(사전컴파일 RegExp), 서비스 데이터는 레포지토리 1곳에서 주입.
|
||||
- 환율(`exchange_rate_service.dart`): `CacheManager` TTL 캐시 단일 책임, 만료 시 새로고침. 중복 포맷터/파서 제거.
|
||||
- 알림(`notification_service.dart`): 스케줄/권한 체크를 단일 파사드로 노출, 내부 분기 축소.
|
||||
- 성능 유틸(`performance_optimizer.dart`, `memory_manager.dart`): 체감·유지보수 이점 낮은 미세 최적화 제거, 프레임 드랍 유발 가능 애니메이션 단순화.
|
||||
|
||||
모든 변경은 퍼블릭 API/데이터 모델을 유지해 사이드 이펙트 방지.
|
||||
|
||||
## 테스트/검증
|
||||
- 스크립트: `scripts/check.sh` 전 단계 실행(포맷/분석/테스트). 기존 deprecation 경고는 별 PR로 정리.
|
||||
- 위젯/골든 테스트: 핵심 화면(메인/추가/상세/분석/설정) 라이트/다크 2종 캡처 비교.
|
||||
- 유닛 테스트: URL 매처/환율 캐시/SMS 파이프라인.
|
||||
- 접근성: 대비·포커스·터치 타깃 수동 점검 체크리스트.
|
||||
|
||||
## 작업 단위/PR 계획
|
||||
1) 토대 구축: 의존성 추가 + 테마 브리지 + 핵심 컴포넌트(Button/Input/Card/Dialog) 도입.
|
||||
2) 공용 UI 치환: 스낵바/다이얼로그/폼 필드/카드 템플릿 적용.
|
||||
3) 화면별 리디자인: 메인→추가→상세→분석→설정 순.
|
||||
4) 불용 코드 삭제: 치환 완료 파일 제거.
|
||||
5) 알고리즘 단순화: sms/url/환율/알림 순으로 단일화 + 테스트.
|
||||
6) 마감: 디테일 조정/접근성/성능 점검.
|
||||
|
||||
- 브랜치: `codex/feat-shadcn-migration-*` (단계별).
|
||||
- 커밋: Conventional Commits + 한국어 본문.
|
||||
- 롤백: 각 단계는 기능 플래그/치환 전후 비교가 쉬운 최소 단위로 유지.
|
||||
|
||||
## 위험 및 완화
|
||||
- 리소스 색상/테마 충돌 → 토큰 브리지로 양방향 매핑, 미호환 위젯은 유지.
|
||||
- 3rd-party 차트/네이티브 UI → 표면 색/텍스트만 토큰 적용.
|
||||
- 분석 실패(deprecation) → 별 PR로 API 교체(`activeColor` 등), 마이그레이션과 분리 처리.
|
||||
|
||||
## 승인 체크리스트(Yes/No)
|
||||
- [ ] `flutter_shadcn_ui` 의존성 추가 승인이 필요합니다.
|
||||
- [ ] 테마 구조(shadcn 토큰 중심) 재구성 승인.
|
||||
- [ ] 단계별 불용 파일 삭제 승인.
|
||||
- [ ] 점진적 이행(옵션 A)로 진행 승인.
|
||||
|
||||
## 완료 기준(각 단계)
|
||||
- `scripts/check.sh` 무사 통과(분석 경고 해결 내역은 별 PR 또는 병행).
|
||||
- 라이트/다크 스냅샷 비교 이상 없음.
|
||||
- 대상 화면/컴포넌트 치환 100% 및 구식 코드 제거.
|
||||
|
||||
---
|
||||
작성자 메모: 본 계획은 코드 변경 없이 문서만 추가되었습니다. 승인 후 단계별 구현을 진행합니다.
|
||||
70
doc/plan_color.md
Normal 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.
|
||||
43
doc/pr/codex-fix-notification-reliability.md
Normal 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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -7,7 +8,17 @@ import UIKit
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
// 앱이 포그라운드일 때도 배너/사운드/배지 표시
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
}
|
||||
}
|
||||
|
||||
3
ios/Runner/en.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
||||
/* Localized display name */
|
||||
"CFBundleDisplayName" = "Digital Rent Manager";
|
||||
|
||||
3
ios/Runner/ja.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
||||
/* ローカライズされたアプリ表示名 */
|
||||
"CFBundleDisplayName" = "デジタル月額管理者";
|
||||
|
||||
3
ios/Runner/ko.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
||||
/* 로컬라이즈된 앱 표시 이름 */
|
||||
"CFBundleDisplayName" = "디지털 월세 관리자";
|
||||
|
||||
3
ios/Runner/zh.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
||||
/* 本地化的应用显示名称 */
|
||||
"CFBundleDisplayName" = "数字月租管理器";
|
||||
|
||||
@@ -4,10 +4,14 @@ import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../providers/payment_card_provider.dart';
|
||||
import '../services/sms_service.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import '../widgets/common/snackbar/app_snackbar.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
|
||||
class AddSubscriptionController {
|
||||
@@ -29,6 +33,7 @@ class AddSubscriptionController {
|
||||
DateTime? nextBillingDate;
|
||||
bool isLoading = false;
|
||||
String? selectedCategoryId;
|
||||
String? selectedPaymentCardId;
|
||||
|
||||
// Event State
|
||||
bool isEventActive = false;
|
||||
@@ -104,6 +109,33 @@ class AddSubscriptionController {
|
||||
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();
|
||||
}
|
||||
@@ -284,25 +316,55 @@ class AddSubscriptionController {
|
||||
setState(() => isLoading = true);
|
||||
|
||||
try {
|
||||
final ctx = context;
|
||||
if (!await SMSService.hasSMSPermission()) {
|
||||
final granted = await SMSService.requestSMSPermission();
|
||||
if (!ctx.mounted) return;
|
||||
if (!granted) {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).smsPermissionRequired,
|
||||
if (ctx.mounted) {
|
||||
// 영구 거부 여부 확인 후 설정 화면 안내
|
||||
final status = await permission.Permission.sms.status;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
final subscriptions = await SMSService.scanSubscriptions();
|
||||
if (!ctx.mounted) return;
|
||||
if (subscriptions.isEmpty) {
|
||||
if (context.mounted) {
|
||||
if (ctx.mounted) {
|
||||
AppSnackBar.showWarning(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).noSubscriptionSmsFound,
|
||||
context: ctx,
|
||||
message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -424,24 +486,42 @@ class AddSubscriptionController {
|
||||
|
||||
try {
|
||||
// 콤마 제거하고 숫자만 추출
|
||||
final monthlyCost =
|
||||
final inputCost =
|
||||
double.parse(monthlyCostController.text.replaceAll(',', ''));
|
||||
|
||||
// 이벤트 가격 파싱
|
||||
// 결제 주기에 따라 월 비용으로 변환
|
||||
final monthlyCost =
|
||||
BillingCostUtil.convertToMonthlyCost(inputCost, billingCycle);
|
||||
|
||||
// 이벤트 가격 파싱 및 월 비용 변환
|
||||
double? eventPrice;
|
||||
if (isEventActive && eventPriceController.text.isNotEmpty) {
|
||||
eventPrice =
|
||||
final inputEventPrice =
|
||||
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)
|
||||
.addSubscription(
|
||||
serviceName: serviceNameController.text.trim(),
|
||||
monthlyCost: monthlyCost,
|
||||
billingCycle: billingCycle,
|
||||
nextBillingDate: nextBillingDate!,
|
||||
nextBillingDate: adjustedNext,
|
||||
websiteUrl: websiteUrlController.text.trim(),
|
||||
categoryId: selectedCategoryId,
|
||||
paymentCardId: selectedPaymentCardId,
|
||||
currency: currency,
|
||||
isEventActive: isEventActive,
|
||||
eventStartDate: eventStartDate,
|
||||
@@ -449,6 +529,16 @@ class AddSubscriptionController {
|
||||
eventPrice: eventPrice,
|
||||
);
|
||||
|
||||
// 자동 보정이 발생했으면 안내
|
||||
if (adjustedNext.isAfter(originalDateOnly)) {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showInfo(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).nextBillingDateAdjusted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context, true); // 성공 여부 반환
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import 'package:intl/intl.dart';
|
||||
import '../widgets/dialogs/delete_confirmation_dialog.dart';
|
||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/billing_date_util.dart';
|
||||
import '../utils/billing_cost_util.dart';
|
||||
|
||||
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
|
||||
class DetailScreenController extends ChangeNotifier {
|
||||
@@ -33,6 +35,7 @@ class DetailScreenController extends ChangeNotifier {
|
||||
late String _billingCycle;
|
||||
late DateTime _nextBillingDate;
|
||||
String? _selectedCategoryId;
|
||||
String? _selectedPaymentCardId;
|
||||
late String _currency;
|
||||
bool _isLoading = false;
|
||||
|
||||
@@ -45,6 +48,7 @@ class DetailScreenController extends ChangeNotifier {
|
||||
String get billingCycle => _billingCycle;
|
||||
DateTime get nextBillingDate => _nextBillingDate;
|
||||
String? get selectedCategoryId => _selectedCategoryId;
|
||||
String? get selectedPaymentCardId => _selectedPaymentCardId;
|
||||
String get currency => _currency;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isEventActive => _isEventActive;
|
||||
@@ -55,6 +59,8 @@ class DetailScreenController extends ChangeNotifier {
|
||||
set billingCycle(String value) {
|
||||
if (_billingCycle != value) {
|
||||
_billingCycle = value;
|
||||
// 결제 주기 변경 시 금액 표시 업데이트
|
||||
_updateMonthlyCostFormat();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -73,6 +79,13 @@ class DetailScreenController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
set selectedPaymentCardId(String? value) {
|
||||
if (_selectedPaymentCardId != value) {
|
||||
_selectedPaymentCardId = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
set currency(String value) {
|
||||
if (_currency != value) {
|
||||
_currency = value;
|
||||
@@ -152,6 +165,7 @@ class DetailScreenController extends ChangeNotifier {
|
||||
_billingCycle = subscription.billingCycle;
|
||||
_nextBillingDate = subscription.nextBillingDate;
|
||||
_selectedCategoryId = subscription.categoryId;
|
||||
_selectedPaymentCardId = subscription.paymentCardId;
|
||||
_currency = subscription.currency;
|
||||
|
||||
// Event State 초기화
|
||||
@@ -159,14 +173,18 @@ class DetailScreenController extends ChangeNotifier {
|
||||
_eventStartDate = subscription.eventStartDate;
|
||||
_eventEndDate = subscription.eventEndDate;
|
||||
|
||||
// 이벤트 가격 초기화
|
||||
// 이벤트 가격 초기화 (월 비용을 결제 주기별 실제 금액으로 변환)
|
||||
if (subscription.eventPrice != null) {
|
||||
final actualEventPrice = BillingCostUtil.convertFromMonthlyCost(
|
||||
subscription.eventPrice!,
|
||||
_billingCycle,
|
||||
);
|
||||
if (currency == 'KRW') {
|
||||
eventPriceController.text = NumberFormat.decimalPattern()
|
||||
.format(subscription.eventPrice!.toInt());
|
||||
.format(actualEventPrice.toInt());
|
||||
} else {
|
||||
eventPriceController.text =
|
||||
NumberFormat('#,##0.00').format(subscription.eventPrice!);
|
||||
NumberFormat('#,##0.00').format(actualEventPrice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,16 +278,23 @@ class DetailScreenController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 통화 단위에 따른 금액 표시 형식 업데이트
|
||||
/// 월 비용을 결제 주기에 맞는 실제 금액으로 변환하여 표시
|
||||
void _updateMonthlyCostFormat() {
|
||||
// 월 비용을 결제 주기별 실제 금액으로 변환
|
||||
final actualCost = BillingCostUtil.convertFromMonthlyCost(
|
||||
subscription.monthlyCost,
|
||||
_billingCycle,
|
||||
);
|
||||
|
||||
if (_currency == 'KRW') {
|
||||
// 원화는 소수점 없이 표시
|
||||
final intValue = subscription.monthlyCost.toInt();
|
||||
final intValue = actualCost.toInt();
|
||||
monthlyCostController.text =
|
||||
NumberFormat.decimalPattern().format(intValue);
|
||||
} else {
|
||||
// 달러는 소수점 2자리까지 표시
|
||||
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;
|
||||
try {
|
||||
monthlyCost =
|
||||
final inputCost =
|
||||
double.parse(monthlyCostController.text.replaceAll(',', ''));
|
||||
// 결제 주기에 따라 월 비용으로 변환
|
||||
monthlyCost =
|
||||
BillingCostUtil.convertToMonthlyCost(inputCost, _billingCycle);
|
||||
} catch (e) {
|
||||
// 파싱 오류 발생 시 기본값 사용
|
||||
monthlyCost = subscription.monthlyCost;
|
||||
@@ -401,14 +429,20 @@ class DetailScreenController extends ChangeNotifier {
|
||||
|
||||
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
|
||||
'${subscription.serviceName} → ${serviceNameController.text}, '
|
||||
'금액: $subscription.monthlyCost → $monthlyCost $_currency');
|
||||
'금액: ${subscription.monthlyCost} → $monthlyCost $_currency');
|
||||
|
||||
subscription.serviceName = serviceNameController.text;
|
||||
subscription.monthlyCost = monthlyCost;
|
||||
subscription.websiteUrl = websiteUrl;
|
||||
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.paymentCardId = _selectedPaymentCardId;
|
||||
subscription.currency = _currency;
|
||||
|
||||
// 이벤트 정보 업데이트
|
||||
@@ -416,11 +450,13 @@ class DetailScreenController extends ChangeNotifier {
|
||||
subscription.eventStartDate = _eventStartDate;
|
||||
subscription.eventEndDate = _eventEndDate;
|
||||
|
||||
// 이벤트 가격 파싱
|
||||
// 이벤트 가격 파싱 및 월 비용 변환
|
||||
if (_isEventActive && eventPriceController.text.isNotEmpty) {
|
||||
try {
|
||||
subscription.eventPrice =
|
||||
final inputEventPrice =
|
||||
double.parse(eventPriceController.text.replaceAll(',', ''));
|
||||
subscription.eventPrice =
|
||||
BillingCostUtil.convertToMonthlyCost(inputEventPrice, _billingCycle);
|
||||
} catch (e) {
|
||||
subscription.eventPrice = null;
|
||||
}
|
||||
@@ -433,6 +469,14 @@ class DetailScreenController extends ChangeNotifier {
|
||||
'이벤트활성=${subscription.isEventActive}');
|
||||
|
||||
// 구독 업데이트
|
||||
// 자동 보정이 발생했으면 안내
|
||||
if (adjustedNext.isAfter(originalDateOnly)) {
|
||||
AppSnackBar.showInfo(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).nextBillingDateAdjusted,
|
||||
);
|
||||
}
|
||||
|
||||
await provider.updateSubscription(subscription);
|
||||
|
||||
if (context.mounted) {
|
||||
@@ -575,15 +619,5 @@ class DetailScreenController extends ChangeNotifier {
|
||||
return colors[hash % colors.length];
|
||||
}
|
||||
|
||||
/// 그라데이션 가져오기
|
||||
LinearGradient getGradient(Color baseColor) {
|
||||
return LinearGradient(
|
||||
colors: [
|
||||
baseColor,
|
||||
baseColor.withValues(alpha: 0.8),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
}
|
||||
// getGradient 제거됨 (그라데이션 미사용)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
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 '../models/subscription.dart';
|
||||
import '../services/ad_service.dart';
|
||||
import '../services/sms_scan/subscription_converter.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 'package:provider/provider.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../providers/payment_card_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
class SmsScanController extends ChangeNotifier {
|
||||
// 상태 관리
|
||||
@@ -20,22 +28,33 @@ class SmsScanController extends ChangeNotifier {
|
||||
|
||||
List<Subscription> _scannedSubscriptions = [];
|
||||
List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
|
||||
PaymentCardSuggestion? _currentSuggestion;
|
||||
PaymentCardSuggestion? get currentSuggestion => _currentSuggestion;
|
||||
bool _shouldSuggestCardCreation = false;
|
||||
bool get shouldSuggestCardCreation => _shouldSuggestCardCreation;
|
||||
|
||||
int _currentIndex = 0;
|
||||
int get currentIndex => _currentIndex;
|
||||
|
||||
String? _selectedCategoryId;
|
||||
String? get selectedCategoryId => _selectedCategoryId;
|
||||
String? _selectedPaymentCardId;
|
||||
String? get selectedPaymentCardId => _selectedPaymentCardId;
|
||||
|
||||
final TextEditingController websiteUrlController = TextEditingController();
|
||||
final TextEditingController serviceNameController = TextEditingController();
|
||||
|
||||
// 의존성
|
||||
final SmsScanner _smsScanner = SmsScanner();
|
||||
final SubscriptionConverter _converter = SubscriptionConverter();
|
||||
final SubscriptionFilter _filter = SubscriptionFilter();
|
||||
final AdService _adService = AdService();
|
||||
bool _forceServiceNameEditing = false;
|
||||
bool get isServiceNameEditable => _forceServiceNameEditing;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
serviceNameController.dispose();
|
||||
websiteUrlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -45,8 +64,47 @@ class SmsScanController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSelectedPaymentCardId(String? paymentCardId) {
|
||||
_selectedPaymentCardId = paymentCardId;
|
||||
if (paymentCardId != null) {
|
||||
_shouldSuggestCardCreation = false;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetWebsiteUrl() {
|
||||
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 {
|
||||
@@ -56,21 +114,53 @@ class SmsScanController extends ChangeNotifier {
|
||||
_currentIndex = 0;
|
||||
notifyListeners();
|
||||
|
||||
await _performSmsScan(context);
|
||||
}
|
||||
|
||||
/// 실제 SMS 스캔 수행 (로딩 상태는 이미 설정되어 있음)
|
||||
Future<void> _performSmsScan(BuildContext context) async {
|
||||
try {
|
||||
// Android에서 SMS 권한 확인 및 요청
|
||||
final ctx = context;
|
||||
if (!kIsWeb) {
|
||||
final smsStatus = await permission.Permission.sms.status;
|
||||
if (!smsStatus.isGranted) {
|
||||
if (smsStatus.isPermanentlyDenied) {
|
||||
// 설정 유도 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
await _showPermissionSettingsDialog(ctx);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final req = await permission.Permission.sms.request();
|
||||
if (!ctx.mounted) return;
|
||||
if (!req.isGranted) {
|
||||
// 거부됨: 안내 후 종료
|
||||
if (!ctx.mounted) return;
|
||||
_errorMessage = AppLocalizations.of(ctx).smsPermissionRequired;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SMS 스캔 실행
|
||||
Log.i('SMS 스캔 시작');
|
||||
final scannedSubscriptionModels =
|
||||
final List<SmsScanResult> scanResults =
|
||||
await _smsScanner.scanForSubscriptions();
|
||||
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}개');
|
||||
Log.d('스캔된 구독: ${scanResults.length}개');
|
||||
|
||||
if (scannedSubscriptionModels.isNotEmpty) {
|
||||
if (scanResults.isNotEmpty) {
|
||||
Log.d(
|
||||
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
|
||||
'첫 번째 구독: ${scanResults[0].model.serviceName}, 반복 횟수: ${scanResults[0].model.repeatCount}');
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (scannedSubscriptionModels.isEmpty) {
|
||||
if (scanResults.isEmpty) {
|
||||
Log.i('스캔된 구독이 없음');
|
||||
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
|
||||
_isLoading = false;
|
||||
@@ -80,7 +170,7 @@ class SmsScanController extends ChangeNotifier {
|
||||
|
||||
// SubscriptionModel을 Subscription으로 변환
|
||||
final scannedSubscriptions =
|
||||
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
|
||||
_converter.convertResultsToSubscriptions(scanResults);
|
||||
|
||||
// 2회 이상 반복 결제된 구독만 필터링
|
||||
final repeatSubscriptions =
|
||||
@@ -126,7 +216,9 @@ class SmsScanController extends ChangeNotifier {
|
||||
|
||||
_scannedSubscriptions = filteredSubscriptions;
|
||||
_isLoading = false;
|
||||
websiteUrlController.text = ''; // URL 입력 필드 초기화
|
||||
websiteUrlController.text = '';
|
||||
_currentSuggestion = null;
|
||||
_prepareCurrentSelection(context);
|
||||
notifyListeners();
|
||||
} catch (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 {
|
||||
if (_currentIndex >= _scannedSubscriptions.length) return;
|
||||
|
||||
final subscription = _scannedSubscriptions[_currentIndex];
|
||||
final inputName = serviceNameController.text.trim();
|
||||
final resolvedServiceName =
|
||||
inputName.isNotEmpty ? inputName : subscription.serviceName;
|
||||
|
||||
try {
|
||||
final provider =
|
||||
Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
final categoryProvider =
|
||||
Provider.of<CategoryProvider>(context, listen: false);
|
||||
final paymentCardProvider =
|
||||
Provider.of<PaymentCardProvider>(context, listen: false);
|
||||
|
||||
final finalCategoryId = _selectedCategoryId ??
|
||||
subscription.category ??
|
||||
getDefaultCategoryId(categoryProvider);
|
||||
final finalPaymentCardId =
|
||||
_selectedPaymentCardId ?? paymentCardProvider.defaultCard?.id;
|
||||
|
||||
// websiteUrl 처리
|
||||
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
|
||||
@@ -164,7 +287,7 @@ class SmsScanController extends ChangeNotifier {
|
||||
|
||||
// addSubscription 호출
|
||||
await provider.addSubscription(
|
||||
serviceName: subscription.serviceName,
|
||||
serviceName: resolvedServiceName,
|
||||
monthlyCost: subscription.monthlyCost,
|
||||
billingCycle: subscription.billingCycle,
|
||||
nextBillingDate: subscription.nextBillingDate,
|
||||
@@ -173,6 +296,7 @@ class SmsScanController extends ChangeNotifier {
|
||||
repeatCount: subscription.repeatCount,
|
||||
lastPaymentDate: subscription.lastPaymentDate,
|
||||
categoryId: finalCategoryId,
|
||||
paymentCardId: finalPaymentCardId,
|
||||
currency: subscription.currency,
|
||||
);
|
||||
|
||||
@@ -195,8 +319,11 @@ class SmsScanController extends ChangeNotifier {
|
||||
|
||||
void moveToNextSubscription(BuildContext context) {
|
||||
_currentIndex++;
|
||||
websiteUrlController.text = ''; // URL 입력 필드 초기화
|
||||
_selectedCategoryId = null; // 카테고리 선택 초기화
|
||||
websiteUrlController.text = '';
|
||||
serviceNameController.text = '';
|
||||
_selectedCategoryId = null;
|
||||
_forceServiceNameEditing = false;
|
||||
_prepareCurrentSelection(context);
|
||||
|
||||
// 모든 구독을 처리했으면 홈 화면으로 이동
|
||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||
@@ -217,6 +344,11 @@ class SmsScanController extends ChangeNotifier {
|
||||
_scannedSubscriptions = [];
|
||||
_currentIndex = 0;
|
||||
_errorMessage = null;
|
||||
_selectedPaymentCardId = null;
|
||||
_currentSuggestion = null;
|
||||
_shouldSuggestCardCreation = false;
|
||||
serviceNameController.clear();
|
||||
_forceServiceNameEditing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -229,12 +361,114 @@ class SmsScanController extends ChangeNotifier {
|
||||
return otherCategory.id;
|
||||
}
|
||||
|
||||
void initializeWebsiteUrl() {
|
||||
void initializeWebsiteUrl(BuildContext context) {
|
||||
if (_currentIndex < _scannedSubscriptions.length) {
|
||||
final currentSub = _scannedSubscriptions[_currentIndex];
|
||||
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ class AppLocalizations {
|
||||
String get save => _localizedStrings['save'] ?? 'Save';
|
||||
String get cancel => _localizedStrings['cancel'] ?? 'Cancel';
|
||||
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 totalSubscriptions =>
|
||||
_localizedStrings['totalSubscriptions'] ?? 'Total Subscriptions';
|
||||
@@ -58,11 +68,63 @@ class AppLocalizations {
|
||||
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
|
||||
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
|
||||
String get settings => _localizedStrings['settings'] ?? 'Settings';
|
||||
String get theme => _localizedStrings['theme'] ?? 'Theme';
|
||||
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
|
||||
String get language => _localizedStrings['language'] ?? 'Language';
|
||||
String get notifications =>
|
||||
_localizedStrings['notifications'] ?? 'Notifications';
|
||||
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 권한 온보딩/설정
|
||||
String get smsPermissionTitle =>
|
||||
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
|
||||
@@ -113,9 +175,12 @@ class AppLocalizations {
|
||||
String get notificationPermissionDenied =>
|
||||
_localizedStrings['notificationPermissionDenied'] ??
|
||||
'Notification permission denied';
|
||||
String get permissionGranted =>
|
||||
_localizedStrings['permissionGranted'] ?? 'Permission granted.';
|
||||
// 앱 정보
|
||||
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
|
||||
String get version => _localizedStrings['version'] ?? 'Version';
|
||||
String get openStore => _localizedStrings['openStore'] ?? 'Open Store';
|
||||
String get appDescription =>
|
||||
_localizedStrings['appDescription'] ?? 'Subscription Management App';
|
||||
String get developer => _localizedStrings['developer'] ?? 'Developer';
|
||||
@@ -146,6 +211,8 @@ class AppLocalizations {
|
||||
String get requiredFieldsError =>
|
||||
_localizedStrings['requiredFieldsError'] ??
|
||||
'Please fill in all required fields';
|
||||
String get categoryNameRequired =>
|
||||
_localizedStrings['categoryNameRequired'] ?? 'Please enter category name';
|
||||
String get subscriptionUpdated =>
|
||||
_localizedStrings['subscriptionUpdated'] ??
|
||||
'Subscription information has been updated';
|
||||
@@ -164,6 +231,8 @@ class AppLocalizations {
|
||||
String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes';
|
||||
String get monthlyExpense =>
|
||||
_localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
|
||||
String get billingAmount =>
|
||||
_localizedStrings['billingAmount'] ?? 'Billing Amount';
|
||||
String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL';
|
||||
String get websiteUrlOptional =>
|
||||
_localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
|
||||
@@ -198,6 +267,9 @@ class AppLocalizations {
|
||||
String get authenticationFailed =>
|
||||
_localizedStrings['authenticationFailed'] ??
|
||||
'Authentication failed. Please try again.';
|
||||
String get nextBillingDateAdjusted =>
|
||||
_localizedStrings['nextBillingDateAdjusted'] ??
|
||||
'Saved as the next billing date';
|
||||
String get smsPermissionRequired =>
|
||||
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
|
||||
String get noSubscriptionSmsFound =>
|
||||
@@ -367,6 +439,9 @@ class AppLocalizations {
|
||||
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
|
||||
String get eventDiscountStatus =>
|
||||
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
|
||||
String get eventDiscountEndsBeforeBilling =>
|
||||
_localizedStrings['eventDiscountEndsBeforeBilling'] ??
|
||||
'Event discount ends before billing date';
|
||||
String get inProgressUnit =>
|
||||
_localizedStrings['inProgressUnit'] ?? 'in progress';
|
||||
String get monthlySavingAmount =>
|
||||
@@ -403,6 +478,15 @@ class AppLocalizations {
|
||||
String get foundSubscription =>
|
||||
_localizedStrings['foundSubscription'] ?? 'Found subscription';
|
||||
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 =>
|
||||
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
|
||||
String get category => _localizedStrings['category'] ?? 'Category';
|
||||
@@ -598,6 +682,49 @@ class AppLocalizations {
|
||||
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
|
||||
String get featureComingSoon =>
|
||||
_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) {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'models/subscription_model.dart';
|
||||
import 'models/category_model.dart';
|
||||
import 'models/payment_card_model.dart';
|
||||
import 'providers/subscription_provider.dart';
|
||||
import 'providers/app_lock_provider.dart';
|
||||
import 'providers/notification_provider.dart';
|
||||
import 'providers/navigation_provider.dart';
|
||||
import 'providers/payment_card_provider.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'providers/category_provider.dart';
|
||||
import 'providers/locale_provider.dart';
|
||||
@@ -32,6 +35,10 @@ const bool enableAdMob = true;
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Android 15 edge-to-edge 모드 활성화
|
||||
// 콘텐츠가 시스템 바 영역까지 확장됨
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
|
||||
// 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만)
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && enableAdMob) {
|
||||
unawaited(MobileAds.instance.initialize());
|
||||
@@ -69,14 +76,17 @@ Future<void> main() async {
|
||||
await Hive.initFlutter();
|
||||
Hive.registerAdapter(SubscriptionModelAdapter());
|
||||
Hive.registerAdapter(CategoryModelAdapter());
|
||||
Hive.registerAdapter(PaymentCardModelAdapter());
|
||||
await Hive.openBox<SubscriptionModel>('subscriptions');
|
||||
await Hive.openBox<CategoryModel>('categories');
|
||||
await Hive.openBox<PaymentCardModel>('payment_cards');
|
||||
final appLockBox = await Hive.openBox<bool>('app_lock');
|
||||
// 알림 서비스를 가장 먼저 초기화
|
||||
await NotificationService.init();
|
||||
|
||||
final subscriptionProvider = SubscriptionProvider();
|
||||
final categoryProvider = CategoryProvider();
|
||||
final paymentCardProvider = PaymentCardProvider();
|
||||
final localeProvider = LocaleProvider();
|
||||
final notificationProvider = NotificationProvider();
|
||||
final themeProvider = ThemeProvider();
|
||||
@@ -84,6 +94,7 @@ Future<void> main() async {
|
||||
|
||||
await subscriptionProvider.init();
|
||||
await categoryProvider.init();
|
||||
await paymentCardProvider.init();
|
||||
await localeProvider.init();
|
||||
await notificationProvider.init();
|
||||
await themeProvider.initialize();
|
||||
@@ -110,6 +121,7 @@ Future<void> main() async {
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => subscriptionProvider),
|
||||
ChangeNotifierProvider(create: (_) => categoryProvider),
|
||||
ChangeNotifierProvider(create: (_) => paymentCardProvider),
|
||||
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
|
||||
ChangeNotifierProvider(create: (_) => notificationProvider),
|
||||
ChangeNotifierProvider(create: (_) => localeProvider),
|
||||
@@ -133,7 +145,9 @@ class SubManagerApp extends StatelessWidget {
|
||||
|
||||
return MaterialApp(
|
||||
key: ValueKey(localeProvider.locale),
|
||||
title: 'Digital Rent Manager',
|
||||
// Localizations는 MaterialApp 내부에서 초기화되므로
|
||||
// onGenerateTitle을 사용해 로딩 이후 로컬라이즈된 타이틀을 설정합니다.
|
||||
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: themeProvider.getTheme(context),
|
||||
locale: localeProvider.locale,
|
||||
|
||||
33
lib/models/payment_card_model.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
56
lib/models/payment_card_model.g.dart
Normal 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;
|
||||
}
|
||||
14
lib/models/payment_card_suggestion.dart
Normal 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;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'payment_card_suggestion.dart';
|
||||
|
||||
class Subscription {
|
||||
final String id;
|
||||
final String serviceName;
|
||||
@@ -10,6 +12,9 @@ class Subscription {
|
||||
final DateTime? lastPaymentDate;
|
||||
final String? websiteUrl;
|
||||
final String currency;
|
||||
final String? paymentCardId;
|
||||
final PaymentCardSuggestion? paymentCardSuggestion;
|
||||
final String? rawMessage;
|
||||
|
||||
Subscription({
|
||||
required this.id,
|
||||
@@ -23,8 +28,52 @@ class Subscription {
|
||||
this.lastPaymentDate,
|
||||
this.websiteUrl,
|
||||
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() {
|
||||
return {
|
||||
'id': id,
|
||||
@@ -38,6 +87,11 @@ class Subscription {
|
||||
'lastPaymentDate': lastPaymentDate?.toIso8601String(),
|
||||
'websiteUrl': websiteUrl,
|
||||
'currency': currency,
|
||||
'paymentCardId': paymentCardId,
|
||||
'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName,
|
||||
'paymentCardSuggestionLast4': paymentCardSuggestion?.last4,
|
||||
'paymentCardSuggestionSource': paymentCardSuggestion?.source,
|
||||
'rawMessage': rawMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +110,15 @@ class Subscription {
|
||||
: null,
|
||||
websiteUrl: map['websiteUrl'] as String?,
|
||||
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?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ class SubscriptionModel extends HiveObject {
|
||||
@HiveField(14)
|
||||
double? eventPrice; // 이벤트 기간 중 가격
|
||||
|
||||
@HiveField(15)
|
||||
String? paymentCardId; // 연결된 결제수단의 ID
|
||||
|
||||
SubscriptionModel({
|
||||
required this.id,
|
||||
required this.serviceName,
|
||||
@@ -65,6 +68,7 @@ class SubscriptionModel extends HiveObject {
|
||||
this.eventStartDate,
|
||||
this.eventEndDate,
|
||||
this.eventPrice,
|
||||
this.paymentCardId,
|
||||
});
|
||||
|
||||
// 주기적 결제 여부 확인
|
||||
|
||||
@@ -32,13 +32,14 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
|
||||
eventStartDate: fields[12] as DateTime?,
|
||||
eventEndDate: fields[13] as DateTime?,
|
||||
eventPrice: fields[14] as double?,
|
||||
paymentCardId: fields[15] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, SubscriptionModel obj) {
|
||||
writer
|
||||
..writeByte(15)
|
||||
..writeByte(16)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
@@ -68,7 +69,9 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
|
||||
..writeByte(13)
|
||||
..write(obj.eventEndDate)
|
||||
..writeByte(14)
|
||||
..write(obj.eventPrice);
|
||||
..write(obj.eventPrice)
|
||||
..writeByte(15)
|
||||
..write(obj.paymentCardId);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
|
||||
class AppNavigationObserver extends NavigatorObserver {
|
||||
@override
|
||||
@@ -47,6 +48,12 @@ class AppNavigationObserver extends NavigatorObserver {
|
||||
final routeName = route.settings.name;
|
||||
if (routeName == null) return;
|
||||
|
||||
// 메인 화면('/')은 하단 탭으로 상태를 관리하므로
|
||||
// 모달 닫힘 등으로 인해 홈 탭으로 강제 전환하지 않도록 무시한다.
|
||||
if (routeName == AppRoutes.main || routeName == '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
// build 완료 후 업데이트하도록 변경
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (navigator?.context == null) return;
|
||||
|
||||
@@ -6,6 +6,8 @@ import '../services/notification_service.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../navigator_key.dart';
|
||||
|
||||
class AppLockProvider extends ChangeNotifier {
|
||||
final Box<bool> _appLockBox;
|
||||
@@ -65,6 +67,12 @@ class AppLockProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
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();
|
||||
if (!canCheck) {
|
||||
_isLocked = false;
|
||||
@@ -73,7 +81,7 @@ class AppLockProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final authenticated = await _localAuth.authenticate(
|
||||
localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.',
|
||||
localizedReason: localizedReason,
|
||||
options: const AuthenticationOptions(
|
||||
stickyAuth: true,
|
||||
biometricOnly: true,
|
||||
|
||||
@@ -114,6 +114,23 @@ class NotificationProvider extends ChangeNotifier {
|
||||
|
||||
// 알림이 활성화된 경우에만 알림 재예약 (비활성화 시에는 필요 없음)
|
||||
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 응답성 향상
|
||||
Future.microtask(() => _rescheduleNotificationsIfNeeded());
|
||||
|
||||
124
lib/providers/payment_card_provider.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import '../services/notification_service.dart';
|
||||
import '../services/exchange_rate_service.dart';
|
||||
import '../services/currency_util.dart';
|
||||
import 'category_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../navigator_key.dart';
|
||||
import '../utils/billing_cost_util.dart';
|
||||
|
||||
class SubscriptionProvider extends ChangeNotifier {
|
||||
late Box<SubscriptionModel> _subscriptionBox;
|
||||
@@ -22,18 +25,40 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
final rate = exchangeRateService.cachedUsdToKrwRate ??
|
||||
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentYear = now.year;
|
||||
final currentMonth = now.month;
|
||||
|
||||
final total = _subscriptions.fold(
|
||||
0.0,
|
||||
(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') {
|
||||
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
|
||||
'\$$price × ₩$rate = ₩${price * rate}');
|
||||
return sum + (price * rate);
|
||||
'\$$actualPrice × ₩$rate = ₩${actualPrice * rate}');
|
||||
return sum + (actualPrice * rate);
|
||||
}
|
||||
debugPrint(
|
||||
'[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
|
||||
return sum + price;
|
||||
'[SubscriptionProvider] ${subscription.serviceName}: ₩$actualPrice');
|
||||
return sum + actualPrice;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -74,6 +99,9 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
// categoryId 마이그레이션
|
||||
await _migrateCategoryIds();
|
||||
|
||||
// billingCycle별 비용 마이그레이션 (연간/분기별 구독 월 비용 변환)
|
||||
await _migrateBillingCosts();
|
||||
|
||||
// 앱 시작 시 이벤트 상태 확인
|
||||
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({
|
||||
required String serviceName,
|
||||
required double monthlyCost,
|
||||
@@ -110,6 +146,7 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
required DateTime nextBillingDate,
|
||||
String? websiteUrl,
|
||||
String? categoryId,
|
||||
String? paymentCardId,
|
||||
bool isAutoDetected = false,
|
||||
int repeatCount = 1,
|
||||
DateTime? lastPaymentDate,
|
||||
@@ -128,6 +165,7 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
nextBillingDate: nextBillingDate,
|
||||
websiteUrl: websiteUrl,
|
||||
categoryId: categoryId,
|
||||
paymentCardId: paymentCardId,
|
||||
isAutoDetected: isAutoDetected,
|
||||
repeatCount: repeatCount,
|
||||
lastPaymentDate: lastPaymentDate,
|
||||
@@ -145,6 +183,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
if (isEventActive && eventEndDate != null) {
|
||||
await _scheduleEventEndNotification(subscription);
|
||||
}
|
||||
|
||||
await _reschedulePaymentNotifications();
|
||||
} catch (e) {
|
||||
debugPrint('구독 추가 중 오류 발생: $e');
|
||||
rethrow;
|
||||
@@ -176,6 +216,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
|
||||
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
|
||||
notifyListeners();
|
||||
|
||||
await _reschedulePaymentNotifications();
|
||||
} catch (e) {
|
||||
debugPrint('구독 업데이트 중 오류 발생: $e');
|
||||
rethrow;
|
||||
@@ -186,6 +228,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
try {
|
||||
await _subscriptionBox.delete(id);
|
||||
await refreshSubscriptions();
|
||||
|
||||
await _reschedulePaymentNotifications();
|
||||
} catch (e) {
|
||||
debugPrint('구독 삭제 중 오류 발생: $e');
|
||||
rethrow;
|
||||
@@ -213,6 +257,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
await _reschedulePaymentNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,11 +267,15 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
SubscriptionModel subscription) async {
|
||||
if (subscription.eventEndDate != null &&
|
||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||
final ctx = navigatorKey.currentContext;
|
||||
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
||||
await NotificationService.scheduleNotification(
|
||||
id: '${subscription.id}_event_end'.hashCode,
|
||||
title: '이벤트 종료 알림',
|
||||
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
|
||||
title: loc?.eventEndNotificationTitle ?? 'Event end notification',
|
||||
body: loc?.eventEndNotificationBody(subscription.serviceName) ??
|
||||
"${subscription.serviceName}'s discount event has ended.",
|
||||
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이 제공되지 않으면 현재 로케일 사용
|
||||
final targetCurrency =
|
||||
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
|
||||
|
||||
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
|
||||
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, '
|
||||
'대상 구독: ${targetSubscriptions.length}개, 현재 월: $currentYear-$currentMonth');
|
||||
double total = 0.0;
|
||||
|
||||
for (final subscription in _subscriptions) {
|
||||
final currentPrice = subscription.currentPrice;
|
||||
for (final subscription in targetSubscriptions) {
|
||||
// 이번 달에 결제가 발생하는지 확인
|
||||
final hasBilling = BillingCostUtil.hasBillingInMonth(
|
||||
subscription.nextBillingDate,
|
||||
subscription.billingCycle,
|
||||
currentYear,
|
||||
currentMonth,
|
||||
);
|
||||
|
||||
if (!hasBilling) {
|
||||
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
|
||||
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
||||
'이번 달 결제 없음 - 제외');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 실제 결제 금액으로 변환 (월 비용 → 실제 결제 금액)
|
||||
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
|
||||
subscription.currentPrice,
|
||||
subscription.billingCycle,
|
||||
);
|
||||
|
||||
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
|
||||
'실제 결제 금액 $actualPrice ${subscription.currency} '
|
||||
'(월 비용: ${subscription.currentPrice}, 주기: ${subscription.billingCycle})');
|
||||
|
||||
final converted = await ExchangeRateService().convertBetweenCurrencies(
|
||||
currentPrice,
|
||||
actualPrice,
|
||||
subscription.currency,
|
||||
targetCurrency,
|
||||
);
|
||||
|
||||
total += converted ?? currentPrice;
|
||||
total += converted ?? actualPrice;
|
||||
}
|
||||
|
||||
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency');
|
||||
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total '
|
||||
'$targetCurrency (대상 ${targetSubscriptions.length}개)');
|
||||
return total;
|
||||
}
|
||||
|
||||
/// 최근 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 List<Map<String, dynamic>> monthlyData = [];
|
||||
final targetSubscriptions = subset ?? _subscriptions;
|
||||
|
||||
// locale이 제공되지 않으면 현재 로케일 사용
|
||||
final targetCurrency =
|
||||
@@ -303,60 +390,63 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
'[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) {
|
||||
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
|
||||
final cost = subscription.currentPrice;
|
||||
debugPrint(
|
||||
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
|
||||
'$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
||||
|
||||
// 통화 변환
|
||||
final converted =
|
||||
await ExchangeRateService().convertBetweenCurrencies(
|
||||
cost,
|
||||
subscription.currency,
|
||||
targetCurrency,
|
||||
// 현재 월: 이벤트 가격 반영
|
||||
actualCost = BillingCostUtil.convertFromMonthlyCost(
|
||||
subscription.currentPrice,
|
||||
subscription.billingCycle,
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
// 과거 월: 이벤트 기간 확인 후 적용
|
||||
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)) {
|
||||
cost = subscription.eventPrice ?? subscription.monthlyCost;
|
||||
monthlyCost = subscription.eventPrice ?? subscription.monthlyCost;
|
||||
} else {
|
||||
cost = subscription.monthlyCost;
|
||||
monthlyCost = subscription.monthlyCost;
|
||||
}
|
||||
actualCost = BillingCostUtil.convertFromMonthlyCost(
|
||||
monthlyCost,
|
||||
subscription.billingCycle,
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentMonth) {
|
||||
debugPrint(
|
||||
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
|
||||
'실제 결제 금액 $actualCost ${subscription.currency}');
|
||||
}
|
||||
|
||||
// 통화 변환
|
||||
final converted =
|
||||
await ExchangeRateService().convertBetweenCurrencies(
|
||||
cost,
|
||||
actualCost,
|
||||
subscription.currency,
|
||||
targetCurrency,
|
||||
);
|
||||
|
||||
monthTotal += converted ?? cost;
|
||||
}
|
||||
}
|
||||
monthTotal += converted ?? actualCost;
|
||||
}
|
||||
|
||||
if (isCurrentMonth) {
|
||||
@@ -380,22 +470,6 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
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) {
|
||||
if (locale == 'ko') {
|
||||
@@ -524,4 +598,59 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
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('💰 모든 구독이 이미 월 비용으로 저장되어 있습니다');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:submanager/screens/settings_screen.dart';
|
||||
import 'package:submanager/screens/splash_screen.dart';
|
||||
import 'package:submanager/screens/sms_permission_screen.dart';
|
||||
import 'package:submanager/models/subscription_model.dart';
|
||||
import 'package:submanager/screens/payment_card_management_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class AppRoutes {
|
||||
static const String splash = '/splash';
|
||||
@@ -18,6 +20,7 @@ class AppRoutes {
|
||||
static const String analysis = '/analysis';
|
||||
static const String settings = '/settings';
|
||||
static const String smsPermission = '/sms-permission';
|
||||
static const String paymentCardManagement = '/payment-card-management';
|
||||
|
||||
static Map<String, WidgetBuilder> getRoutes() {
|
||||
return {
|
||||
@@ -28,6 +31,7 @@ class AppRoutes {
|
||||
analysis: (context) => const AnalysisScreen(),
|
||||
settings: (context) => const SettingsScreen(),
|
||||
smsPermission: (context) => const SmsPermissionScreen(),
|
||||
paymentCardManagement: (context) => const PaymentCardManagementScreen(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +65,8 @@ class AppRoutes {
|
||||
|
||||
case smsPermission:
|
||||
return _buildRoute(const SmsPermissionScreen(), routeSettings);
|
||||
case paymentCardManagement:
|
||||
return _buildRoute(const PaymentCardManagementScreen(), routeSettings);
|
||||
|
||||
default:
|
||||
return _errorRoute();
|
||||
@@ -76,9 +82,9 @@ class AppRoutes {
|
||||
|
||||
static Route<dynamic> _errorRoute() {
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => const Scaffold(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: Text('페이지를 찾을 수 없습니다'),
|
||||
child: Text(AppLocalizations.of(context).pageNotFound),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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_event_section.dart';
|
||||
import '../widgets/add_subscription/add_subscription_save_button.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
// import '../theme/app_colors.dart';
|
||||
|
||||
/// 새로운 구독을 추가하는 화면
|
||||
class AddSubscriptionScreen extends StatefulWidget {
|
||||
const AddSubscriptionScreen({Key? key}) : super(key: key);
|
||||
const AddSubscriptionScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AddSubscriptionScreen> createState() => _AddSubscriptionScreenState();
|
||||
@@ -45,7 +45,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
_controller.scrollController.addListener(_onScroll);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.backgroundColor,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AddSubscriptionAppBar(
|
||||
controller: _controller,
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import 'package:flutter/material.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/locale_provider.dart';
|
||||
import '../utils/payment_card_utils.dart';
|
||||
import '../widgets/native_ad_widget.dart';
|
||||
import '../widgets/analysis/analysis_screen_spacer.dart';
|
||||
import '../widgets/analysis/subscription_pie_chart_card.dart';
|
||||
import '../widgets/analysis/total_expense_summary_card.dart';
|
||||
import '../widgets/analysis/monthly_expense_chart_card.dart';
|
||||
import '../widgets/analysis/event_analysis_card.dart';
|
||||
import '../theme/ui_constants.dart';
|
||||
|
||||
enum AnalysisCardFilterType { all, unassigned, card }
|
||||
|
||||
class AnalysisScreen extends StatefulWidget {
|
||||
const AnalysisScreen({super.key});
|
||||
@@ -25,6 +33,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
List<Map<String, dynamic>> _monthlyData = [];
|
||||
bool _isLoading = true;
|
||||
String _lastDataHash = '';
|
||||
AnalysisCardFilterType _filterType = AnalysisCardFilterType.all;
|
||||
String? _selectedCardId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -42,7 +52,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
super.didChangeDependencies();
|
||||
// Provider 변경 감지
|
||||
final provider = Provider.of<SubscriptionProvider>(context);
|
||||
final currentHash = _calculateDataHash(provider);
|
||||
final filtered = _filterSubscriptions(provider.subscriptions);
|
||||
final currentHash = _calculateDataHash(provider, filtered: filtered);
|
||||
|
||||
debugPrint('[AnalysisScreen] didChangeDependencies: '
|
||||
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
|
||||
@@ -64,13 +75,16 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
}
|
||||
|
||||
/// 구독 데이터의 해시값을 계산하여 변경 감지
|
||||
String _calculateDataHash(SubscriptionProvider provider) {
|
||||
final subscriptions = provider.subscriptions;
|
||||
final buffer = StringBuffer();
|
||||
|
||||
buffer.write(subscriptions.length);
|
||||
buffer.write('_');
|
||||
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
|
||||
String _calculateDataHash(
|
||||
SubscriptionProvider provider, {
|
||||
List<SubscriptionModel>? filtered,
|
||||
}) {
|
||||
final subscriptions =
|
||||
filtered ?? _filterSubscriptions(provider.subscriptions);
|
||||
final buffer = StringBuffer()
|
||||
..write(_filterType.name)
|
||||
..write('_${_selectedCardId ?? 'all'}')
|
||||
..write('_${subscriptions.length}');
|
||||
|
||||
for (final sub in subscriptions) {
|
||||
buffer.write(
|
||||
@@ -80,6 +94,38 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
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 {
|
||||
debugPrint('[AnalysisScreen] _loadData 호출됨');
|
||||
setState(() {
|
||||
@@ -89,17 +135,25 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
|
||||
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');
|
||||
|
||||
// 월별 데이터 계산 (로케일별 기본 통화로 환산)
|
||||
_monthlyData = await provider.getMonthlyExpenseData(locale: locale);
|
||||
_monthlyData = await provider.getMonthlyExpenseData(
|
||||
locale: locale,
|
||||
subset: filteredSubscriptions,
|
||||
);
|
||||
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
|
||||
|
||||
// 현재 데이터 해시값 저장
|
||||
_lastDataHash = _calculateDataHash(provider);
|
||||
_lastDataHash =
|
||||
_calculateDataHash(provider, filtered: filteredSubscriptions);
|
||||
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
// Provider를 직접 사용하여 변경 감지
|
||||
@@ -142,35 +318,39 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
);
|
||||
}
|
||||
|
||||
final cardProvider = Provider.of<PaymentCardProvider>(context);
|
||||
final filteredSubscriptions = _filterSubscriptions(subscriptions);
|
||||
|
||||
return CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: <Widget>[
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||
),
|
||||
),
|
||||
|
||||
// 네이티브 광고 위젯
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAnimatedAd(),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
|
||||
sliver: _buildCardFilterSection(context, cardProvider),
|
||||
),
|
||||
|
||||
const AnalysisScreenSpacer(),
|
||||
|
||||
// 1. 구독 비율 파이 차트
|
||||
SubscriptionPieChartCard(
|
||||
subscriptions: subscriptions,
|
||||
subscriptions: filteredSubscriptions,
|
||||
animationController: _animationController,
|
||||
),
|
||||
|
||||
const AnalysisScreenSpacer(),
|
||||
|
||||
// 네이티브 광고 위젯 (구독 비율 차트 하단)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAnimatedAd(),
|
||||
),
|
||||
|
||||
const AnalysisScreenSpacer(),
|
||||
|
||||
// 2. 총 지출 요약 카드
|
||||
TotalExpenseSummaryCard(
|
||||
key: ValueKey('total_expense_$_lastDataHash'),
|
||||
subscriptions: subscriptions,
|
||||
subscriptions: filteredSubscriptions,
|
||||
totalExpense: _totalExpense,
|
||||
animationController: _animationController,
|
||||
),
|
||||
@@ -189,6 +369,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
// 4. 이벤트 분석
|
||||
EventAnalysisCard(
|
||||
animationController: _animationController,
|
||||
subscriptions: filteredSubscriptions,
|
||||
),
|
||||
|
||||
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/app_lock_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
// import '../theme/app_colors.dart';
|
||||
|
||||
class AppLockScreen extends StatelessWidget {
|
||||
const AppLockScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 80,
|
||||
color: AppColors.navyGray,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'앱이 잠겨 있습니다',
|
||||
Text(
|
||||
loc.appLocked,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.darkNavy,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'생체 인증으로 잠금을 해제하세요',
|
||||
Text(
|
||||
loc.appLockDesc,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.navyGray,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
@@ -41,21 +44,22 @@ class AppLockScreen extends StatelessWidget {
|
||||
final appLock = context.read<AppLockProvider>();
|
||||
final success = await appLock.authenticate();
|
||||
if (!success && context.mounted) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'인증에 실패했습니다. 다시 시도해주세요.',
|
||||
loc.authenticationFailed,
|
||||
style: TextStyle(
|
||||
color: AppColors.pureWhite,
|
||||
color: cs.onPrimary,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.dangerColor,
|
||||
backgroundColor: cs.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
label: const Text('생체 인증으로 잠금 해제'),
|
||||
label: Text(loc.unlockWithBiometric),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
// import '../theme/app_colors.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class CategoryManagementScreen extends StatefulWidget {
|
||||
@@ -41,15 +41,16 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'카테고리 관리',
|
||||
style: TextStyle(
|
||||
color: AppColors.pureWhite,
|
||||
title: Text(
|
||||
loc.categoryManagement,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
body: Consumer<CategoryProvider>(
|
||||
builder: (context, provider, child) {
|
||||
@@ -66,26 +67,30 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '카테고리 이름',
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.categoryName,
|
||||
labelStyle: TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '카테고리 이름을 입력하세요';
|
||||
return loc.categoryNameRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedColor,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '색상 선택',
|
||||
initialValue: _selectedColor,
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.selectColor,
|
||||
labelStyle: TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
items: [
|
||||
@@ -93,32 +98,42 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
value: '#1976D2',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorBlue,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
DropdownMenuItem(
|
||||
value: '#4CAF50',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorGreen,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
DropdownMenuItem(
|
||||
value: '#FF9800',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorOrange,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
DropdownMenuItem(
|
||||
value: '#F44336',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorRed,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
DropdownMenuItem(
|
||||
value: '#9C27B0',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorPurple,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
@@ -128,39 +143,51 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedIcon,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '아이콘 선택',
|
||||
initialValue: _selectedIcon,
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.selectIcon,
|
||||
labelStyle: TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'subscriptions',
|
||||
child: Text('구독',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
child: Text(loc.subscription,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
DropdownMenuItem(
|
||||
value: 'movie',
|
||||
child: Text('영화',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
child: Text(loc.movie,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
DropdownMenuItem(
|
||||
value: 'music_note',
|
||||
child: Text('음악',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
child: Text(loc.music,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
DropdownMenuItem(
|
||||
value: 'fitness_center',
|
||||
child: Text('운동',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
child: Text(loc.exercise,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
DropdownMenuItem(
|
||||
value: 'shopping_cart',
|
||||
child: Text('쇼핑',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
child: Text(loc.shopping,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface))),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
@@ -171,12 +198,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _addCategory,
|
||||
child: const Text(
|
||||
'카테고리 추가',
|
||||
style: TextStyle(
|
||||
color: AppColors.pureWhite,
|
||||
),
|
||||
),
|
||||
child: Text(loc.addCategory),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -201,8 +223,8 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
title: Text(
|
||||
provider.getLocalizedCategoryName(
|
||||
context, category.name),
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
|
||||
@@ -3,11 +3,12 @@ import 'package:provider/provider.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../controllers/detail_screen_controller.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_event_section.dart';
|
||||
import '../widgets/detail/detail_url_section.dart';
|
||||
import '../widgets/detail/detail_action_buttons.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
// import '../theme/app_colors.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 구독 상세 정보를 표시하고 편집할 수 있는 화면
|
||||
@@ -50,7 +51,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
return ChangeNotifierProvider<DetailScreenController>.value(
|
||||
value: _controller,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColors.backgroundColor,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(
|
||||
controller: _controller.scrollController,
|
||||
slivers: [
|
||||
@@ -77,17 +78,16 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
baseColor.withValues(alpha: 0.15),
|
||||
baseColor.withValues(alpha: 0.08),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: baseColor.withValues(alpha: 0.2),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -111,9 +111,9 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
Text(
|
||||
AppLocalizations.of(context)
|
||||
.changesAppliedAfterSave,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -121,6 +121,13 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
DetailPaymentInfoSection(
|
||||
controller: _controller,
|
||||
fadeAnimation: _controller.fadeAnimation!,
|
||||
slideAnimation: _controller.slideAnimation!,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 기본 정보 폼 섹션
|
||||
DetailFormSection(
|
||||
controller: _controller,
|
||||
|
||||
@@ -3,7 +3,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_lock_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 'analysis_screen.dart';
|
||||
import 'app_lock_screen.dart';
|
||||
@@ -11,7 +12,6 @@ import 'settings_screen.dart';
|
||||
import 'sms_scan_screen.dart';
|
||||
import '../utils/animation_controller_helper.dart';
|
||||
import '../widgets/floating_navigation_bar.dart';
|
||||
import '../widgets/glassmorphic_scaffold.dart';
|
||||
import '../widgets/home_content.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/platform_helper.dart';
|
||||
@@ -162,33 +162,34 @@ class _MainScreenState extends State<MainScreen>
|
||||
if (result == true) {
|
||||
// 상단에 스낵바 표시
|
||||
if (!context.mounted) return;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: AppColors.pureWhite,
|
||||
color: cs.onPrimary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
AppLocalizations.of(context).subscriptionAdded,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.pureWhite,
|
||||
color: cs.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.successColor,
|
||||
backgroundColor: cs.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 8, // 더 상단으로
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: MediaQuery.of(context).size.height - 100, // 더 상단으로
|
||||
bottom: MediaQuery.of(context).size.height - 100,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -223,8 +224,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
Widget build(BuildContext context) {
|
||||
final navigationProvider = context.watch<NavigationProvider>();
|
||||
|
||||
// 메인 그라데이션 사용
|
||||
List<Color> backgroundGradient = AppColors.mainGradient;
|
||||
// 그라데이션 제거: 단색 배경 사용
|
||||
|
||||
// 현재 인덱스가 유효한지 확인
|
||||
int currentIndex = navigationProvider.currentIndex;
|
||||
@@ -232,7 +232,14 @@ class _MainScreenState extends State<MainScreen>
|
||||
currentIndex = 0; // 추가 버튼은 홈으로 표시
|
||||
}
|
||||
|
||||
return GlassmorphicScaffold(
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(color: Theme.of(context).colorScheme.surface),
|
||||
),
|
||||
Scaffold(
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: IndexedStack(
|
||||
index: PlatformHelper.isIOS
|
||||
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
|
||||
@@ -243,14 +250,13 @@ class _MainScreenState extends State<MainScreen>
|
||||
: currentIndex), // Android: 기존 로직
|
||||
children: _screens,
|
||||
),
|
||||
backgroundGradient: backgroundGradient,
|
||||
useFloatingNavBar: true,
|
||||
floatingNavBarIndex: navigationProvider.currentIndex,
|
||||
onFloatingNavBarTapped: (index) {
|
||||
_handleNavigation(index, context);
|
||||
},
|
||||
enableParticles: false,
|
||||
enableWaveAnimation: false,
|
||||
),
|
||||
FloatingNavigationBar(
|
||||
selectedIndex: navigationProvider.currentIndex,
|
||||
isVisible: true,
|
||||
onItemTapped: (index) => _handleNavigation(index, context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
144
lib/screens/payment_card_management_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/glassmorphism_card.dart';
|
||||
// Material colors only
|
||||
// Glass 제거: Material 3 Card 사용
|
||||
import '../routes/app_routes.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../services/sms_service.dart';
|
||||
@@ -92,12 +92,13 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
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),
|
||||
Text(
|
||||
loc.smsPermissionTitle,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -105,26 +106,41 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
|
||||
Text(
|
||||
loc.smsPermissionRequired,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GlassmorphismCard(
|
||||
Card(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(loc.smsPermissionReasonTitle,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
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)),
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Text(loc.smsPermissionScopeBody),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
|
||||
@@ -6,6 +6,10 @@ import '../widgets/sms_scan/scan_progress_widget.dart';
|
||||
import '../widgets/sms_scan/subscription_card_widget.dart';
|
||||
import '../widgets/common/snackbar/app_snackbar.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 {
|
||||
const SmsScanScreen({super.key});
|
||||
@@ -16,18 +20,21 @@ class SmsScanScreen extends StatefulWidget {
|
||||
|
||||
class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
late SmsScanController _controller;
|
||||
late final ScrollController _scrollController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = SmsScanController();
|
||||
_controller.addListener(_handleControllerUpdate);
|
||||
_scrollController = ScrollController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_handleControllerUpdate);
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -40,7 +47,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_controller.initializeWebsiteUrl();
|
||||
_controller.initializeWebsiteUrl(context);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
@@ -50,7 +57,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
|
||||
if (_controller.scannedSubscriptions.isEmpty) {
|
||||
return ScanInitialWidget(
|
||||
onScanPressed: () => _controller.scanSms(context),
|
||||
onScanPressed: () => _controller.startScan(context),
|
||||
errorMessage: _controller.errorMessage,
|
||||
);
|
||||
}
|
||||
@@ -69,7 +76,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
}
|
||||
});
|
||||
return ScanInitialWidget(
|
||||
onScanPressed: () => _controller.scanSms(context),
|
||||
onScanPressed: () => _controller.startScan(context),
|
||||
errorMessage: _controller.errorMessage,
|
||||
);
|
||||
}
|
||||
@@ -90,26 +97,82 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
const SizedBox(height: 24),
|
||||
SubscriptionCardWidget(
|
||||
subscription: currentSubscription,
|
||||
serviceNameController: _controller.serviceNameController,
|
||||
websiteUrlController: _controller.websiteUrlController,
|
||||
selectedCategoryId: _controller.selectedCategoryId,
|
||||
onCategoryChanged: _controller.setSelectedCategoryId,
|
||||
onAdd: () => _controller.addCurrentSubscription(context),
|
||||
onSkip: () => _controller.skipCurrentSubscription(context),
|
||||
selectedPaymentCardId: _controller.selectedPaymentCardId,
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
// 로딩 중일 때는 화면 정중앙에 표시
|
||||
if (_controller.isLoading) {
|
||||
return const ScanLoadingWidget();
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
|
||||
child: Column(
|
||||
children: [
|
||||
// toolbar 높이 추가
|
||||
SizedBox(
|
||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||
),
|
||||
_buildContent(),
|
||||
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||
SizedBox(
|
||||
@@ -117,6 +180,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
// import '../theme/app_colors.dart';
|
||||
import '../services/sms_service.dart';
|
||||
import '../utils/platform_helper.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
@@ -90,8 +90,6 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
(reduced ? 1200 : 2000); // 축소 시 더 짧게
|
||||
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
||||
|
||||
int colorIndex = (random + i) % AppColors.blueGradient.length;
|
||||
|
||||
_particles.add({
|
||||
'size': size,
|
||||
'x': x,
|
||||
@@ -99,7 +97,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
'opacity': opacity,
|
||||
'duration': duration,
|
||||
'delay': delay,
|
||||
'color': AppColors.blueGradient[colorIndex],
|
||||
// color computed at render from ColorScheme.primary
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -137,23 +135,15 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// 배경 그라디언트
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.dayGradient[0],
|
||||
AppColors.dayGradient[1],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 단색 배경
|
||||
Container(color: Theme.of(context).colorScheme.surface),
|
||||
// 글래스모피즘 오버레이
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pureWhite.withValues(alpha: 0.05),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.05),
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
@@ -180,11 +170,14 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
width: particle['size'],
|
||||
height: particle['size'],
|
||||
decoration: BoxDecoration(
|
||||
color: particle['color'],
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: particle['color'].withValues(alpha: 0.3),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
@@ -193,45 +186,25 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
|
||||
// 상단 원형 그라데이션
|
||||
// 상단 원형 장식 제거(단색 배경 유지)
|
||||
Positioned(
|
||||
top: -size.height * 0.2,
|
||||
right: -size.width * 0.2,
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: 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(
|
||||
bottom: -size.height * 0.1,
|
||||
left: -size.width * 0.3,
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: 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],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -271,62 +244,32 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
reduced: 8)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.pureWhite
|
||||
.withValues(alpha: 0.2),
|
||||
AppColors.pureWhite
|
||||
.withValues(alpha: 0.1),
|
||||
],
|
||||
),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surface
|
||||
.withValues(alpha: 0.6),
|
||||
borderRadius:
|
||||
BorderRadius.circular(30),
|
||||
border: Border.all(
|
||||
color: AppColors.pureWhite
|
||||
.withValues(alpha: 0.3),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.2),
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
AppColors.shadowBlack,
|
||||
spreadRadius: 0,
|
||||
blurRadius:
|
||||
ReduceMotion.scale(
|
||||
context,
|
||||
normal: 30,
|
||||
reduced: 12),
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation:
|
||||
_animationController,
|
||||
builder: (context, _) {
|
||||
return ShaderMask(
|
||||
blendMode:
|
||||
BlendMode.srcIn,
|
||||
shaderCallback: (bounds) =>
|
||||
const LinearGradient(
|
||||
colors: AppColors
|
||||
.blueGradient,
|
||||
begin:
|
||||
Alignment.topLeft,
|
||||
end: Alignment
|
||||
.bottomRight,
|
||||
).createShader(bounds),
|
||||
child: Icon(
|
||||
return Icon(
|
||||
Icons
|
||||
.subscriptions_outlined,
|
||||
size: 64,
|
||||
color:
|
||||
Theme.of(context)
|
||||
.primaryColor,
|
||||
),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -356,7 +299,9 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primaryColor
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.9),
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
@@ -382,7 +327,9 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
AppLocalizations.of(context).appSubtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.primaryColor
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.7),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
@@ -404,18 +351,22 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
height: 60,
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pureWhite
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: Border.all(
|
||||
color: AppColors.pureWhite
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.pureWhite),
|
||||
child: CircularProgressIndicator(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
@@ -433,10 +384,13 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Text(
|
||||
'© 2025 NatureBridgeAI. All rights reserved.',
|
||||
'© 2025 NatureBridgeAI @ cclabs. All rights reserved.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.pureWhite.withValues(alpha: 0.6),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
|
||||
198
lib/services/ad_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../utils/billing_cost_util.dart';
|
||||
import 'exchange_rate_service.dart';
|
||||
import 'cache_manager.dart';
|
||||
|
||||
@@ -129,7 +130,8 @@ class CurrencyUtil {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
|
||||
/// 구독 목록의 이번 달 총 비용을 계산 (언어별 기본 통화로)
|
||||
/// 이번 달에 결제가 발생하는 구독만 포함하며, 실제 결제 금액을 사용
|
||||
static Future<double> calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
List<SubscriptionModel> subscriptions,
|
||||
String locale,
|
||||
@@ -137,16 +139,33 @@ class CurrencyUtil {
|
||||
final defaultCurrency = getDefaultCurrency(locale);
|
||||
double total = 0.0;
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentYear = now.year;
|
||||
final currentMonth = now.month;
|
||||
|
||||
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(
|
||||
price,
|
||||
actualPrice,
|
||||
subscription.currency,
|
||||
defaultCurrency,
|
||||
);
|
||||
|
||||
total += converted ?? price;
|
||||
total += converted ?? actualPrice;
|
||||
}
|
||||
|
||||
return total;
|
||||
@@ -158,17 +177,46 @@ class CurrencyUtil {
|
||||
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(
|
||||
SubscriptionModel subscription, String locale) async {
|
||||
final price = subscription.currentPrice;
|
||||
// 구독 단위 캐시 키 (통화/가격/locale + id)
|
||||
// 월 환산 금액을 실제 결제 금액으로 역변환
|
||||
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}';
|
||||
'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}:${subscription.billingCycle}';
|
||||
final cached = _fmtCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'package:flutter/foundation.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../models/subscription_model.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 {
|
||||
static final FlutterLocalNotificationsPlugin _notifications =
|
||||
@@ -17,6 +20,24 @@ class NotificationService {
|
||||
static const _reminderHourKey = 'reminder_hour';
|
||||
static const _reminderMinuteKey = 'reminder_minute';
|
||||
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;
|
||||
@@ -56,6 +77,33 @@ class NotificationService {
|
||||
InitializationSettings(android: androidSettings, iOS: iosSettings);
|
||||
|
||||
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;
|
||||
debugPrint('알림 서비스 초기화 완료');
|
||||
} catch (e) {
|
||||
@@ -122,20 +170,32 @@ class NotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 알림 모두 취소
|
||||
await cancelAllNotifications();
|
||||
final pendingRequests =
|
||||
await _notifications.pendingNotificationRequests();
|
||||
|
||||
// 알림 설정 가져오기
|
||||
final isPaymentEnabled = await isPaymentNotificationEnabled();
|
||||
if (!isPaymentEnabled) return;
|
||||
if (!isPaymentEnabled) {
|
||||
await _cancelOrphanedPaymentReminderNotifications(
|
||||
const <String>{},
|
||||
pendingRequests,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final reminderDays = await getReminderDays();
|
||||
final reminderHour = await getReminderHour();
|
||||
final reminderMinute = await getReminderMinute();
|
||||
final isDailyReminder = await isDailyReminderEnabled();
|
||||
|
||||
// 각 구독에 대해 알림 재설정
|
||||
final activeSubscriptionIds =
|
||||
subscriptions.map((subscription) => subscription.id).toSet();
|
||||
|
||||
for (final subscription in subscriptions) {
|
||||
await _cancelPaymentReminderNotificationsForSubscription(
|
||||
subscription,
|
||||
pendingRequests,
|
||||
);
|
||||
|
||||
await schedulePaymentReminder(
|
||||
subscription: subscription,
|
||||
reminderDays: reminderDays,
|
||||
@@ -144,11 +204,78 @@ class NotificationService {
|
||||
isDailyReminder: isDailyReminder,
|
||||
);
|
||||
}
|
||||
|
||||
await _cancelOrphanedPaymentReminderNotifications(
|
||||
activeSubscriptionIds,
|
||||
pendingRequests,
|
||||
);
|
||||
} catch (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 {
|
||||
// 웹 플랫폼인 경우 false 반환
|
||||
if (_isWeb) return false;
|
||||
@@ -218,12 +345,70 @@ class NotificationService {
|
||||
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({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledDate,
|
||||
String? payload,
|
||||
String? channelId,
|
||||
}) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
@@ -232,15 +417,34 @@ class NotificationService {
|
||||
}
|
||||
|
||||
try {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'구독 알림',
|
||||
channelDescription: '구독 관련 알림을 보여줍니다.',
|
||||
final ctx = navigatorKey.currentContext;
|
||||
String channelName;
|
||||
if (channelId == _expirationChannelId) {
|
||||
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,
|
||||
priority: Priority.high,
|
||||
autoCancel: false,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
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(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tz.TZDateTime.from(scheduledDate, location),
|
||||
const NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
target,
|
||||
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
androidScheduleMode: scheduleMode,
|
||||
payload: payload,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('알림 예약 중 오류 발생: $e');
|
||||
@@ -307,23 +522,25 @@ class NotificationService {
|
||||
try {
|
||||
final notificationId = subscription.id.hashCode;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'구독 알림',
|
||||
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
|
||||
final ctx = navigatorKey.currentContext;
|
||||
final title = ctx != null
|
||||
? AppLocalizations.of(ctx).expirationReminder
|
||||
: 'Expiration Reminder';
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_paymentChannelId,
|
||||
title,
|
||||
channelDescription: title,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
autoCancel: false,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
),
|
||||
);
|
||||
|
||||
// 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(
|
||||
notificationId,
|
||||
'구독 만료 알림',
|
||||
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
||||
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
||||
title,
|
||||
_buildExpirationBody(subscription),
|
||||
fireAt,
|
||||
notificationDetails,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
androidScheduleMode: scheduleMode,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
||||
@@ -373,55 +610,18 @@ class NotificationService {
|
||||
|
||||
static Future<void> schedulePaymentNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final paymentDate = subscription.nextBillingDate;
|
||||
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
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,
|
||||
if (_isWeb || !_initialized) return;
|
||||
final reminderDays = await getReminderDays();
|
||||
final hour = await getReminderHour();
|
||||
final minute = await getReminderMinute();
|
||||
final daily = await isDailyReminderEnabled();
|
||||
await schedulePaymentReminder(
|
||||
subscription: subscription,
|
||||
reminderDays: reminderDays,
|
||||
reminderHour: hour,
|
||||
reminderMinute: minute,
|
||||
isDailyReminder: daily,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> scheduleExpirationNotification(
|
||||
@@ -435,6 +635,8 @@ class NotificationService {
|
||||
try {
|
||||
final expirationDate = subscription.nextBillingDate;
|
||||
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
||||
final ctx = navigatorKey.currentContext;
|
||||
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
@@ -456,21 +658,26 @@ class NotificationService {
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
('${subscription.id}_expiration').hashCode,
|
||||
'구독 만료 예정 알림',
|
||||
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
|
||||
loc?.expirationReminder ?? _paymentReminderTitle(_getLocaleCode()),
|
||||
loc?.expirationReminderBody(subscription.serviceName, 7) ??
|
||||
'${subscription.serviceName} subscription expires in 7 days.',
|
||||
tz.TZDateTime.from(reminderDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'expiration_channel',
|
||||
_expirationChannelId,
|
||||
'Expiration Notifications',
|
||||
channelDescription: 'Channel for subscription expiration reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
autoCancel: false,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
androidScheduleMode: await _resolveAndroidScheduleMode(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
||||
@@ -491,6 +698,9 @@ class NotificationService {
|
||||
}
|
||||
|
||||
try {
|
||||
final locale = _getLocaleCode();
|
||||
final title = _paymentReminderTitle(locale);
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
@@ -510,7 +720,7 @@ class NotificationService {
|
||||
}
|
||||
|
||||
// 기본 알림 예약 (지정된 일수 전)
|
||||
final scheduledDate = subscription.nextBillingDate
|
||||
final baseLocal = subscription.nextBillingDate
|
||||
.subtract(Duration(days: reminderDays))
|
||||
.copyWith(
|
||||
hour: reminderHour,
|
||||
@@ -519,57 +729,65 @@ class NotificationService {
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String daysText = '$reminderDays일 후';
|
||||
if (reminderDays == 1) {
|
||||
daysText = '내일';
|
||||
final nowTz = tz.TZDateTime.now(location);
|
||||
var scheduledDate = tz.TZDateTime.from(baseLocal, location);
|
||||
if (kDebugMode) {
|
||||
debugPrint('[NotificationService] schedulePaymentReminder(base)'
|
||||
' id=${subscription.id.hashCode} tz=${location.name}'
|
||||
' now=$nowTz requested=$baseLocal scheduled=$scheduledDate'
|
||||
' days=$reminderDays time=${reminderHour.toString().padLeft(2, '0')}:${reminderMinute.toString().padLeft(2, '0')}'
|
||||
' service=${subscription.serviceName}');
|
||||
}
|
||||
if (!scheduledDate.isAfter(nowTz)) {
|
||||
// 지정일이 과거면 최소 1분 뒤로
|
||||
scheduledDate = nowTz.add(const Duration(minutes: 1));
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[NotificationService] schedulePaymentReminder(base) adjusted to $scheduledDate');
|
||||
}
|
||||
}
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
final daysText = _daysInText(locale, reminderDays);
|
||||
|
||||
// 이벤트 종료로 인한 가격 변동 확인
|
||||
String notificationBody;
|
||||
if (subscription.isEventActive &&
|
||||
subscription.eventEndDate != null &&
|
||||
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||
// 이벤트가 결제일 전에 종료되는 경우
|
||||
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 결제 예정입니다.';
|
||||
final body = await _buildPaymentBody(subscription, daysText);
|
||||
|
||||
final scheduleMode = await _resolveAndroidScheduleMode();
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[NotificationService] schedulePaymentReminder(base) scheduleMode=$scheduleMode');
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
subscription.id.hashCode,
|
||||
'구독 결제 예정 알림',
|
||||
notificationBody,
|
||||
tz.TZDateTime.from(scheduledDate, location),
|
||||
const NotificationDetails(
|
||||
title,
|
||||
body,
|
||||
scheduledDate,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'Subscription Notifications',
|
||||
channelDescription: 'Channel for subscription reminders',
|
||||
_paymentChannelId,
|
||||
title,
|
||||
channelDescription: title,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
autoCancel: false,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
iOS: const DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
),
|
||||
androidScheduleMode: scheduleMode,
|
||||
payload: _paymentPayload(subscription.id),
|
||||
);
|
||||
|
||||
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
|
||||
if (isDailyReminder && reminderDays >= 2) {
|
||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||
final dailyDate =
|
||||
final dailyLocal =
|
||||
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
@@ -577,50 +795,50 @@ class NotificationService {
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
final dailyDate = tz.TZDateTime.from(dailyLocal, location);
|
||||
if (kDebugMode) {
|
||||
debugPrint('[NotificationService] schedulePaymentReminder(daily)'
|
||||
' id=${subscription.id.hashCode + i} tz=${location.name}'
|
||||
' now=$nowTz requested=$dailyLocal scheduled=$dailyDate'
|
||||
' daysLeft=$i');
|
||||
}
|
||||
if (!dailyDate.isAfter(nowTz)) {
|
||||
// 과거면 건너뜀
|
||||
if (kDebugMode) {
|
||||
debugPrint('[NotificationService] skip daily (past)');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String remainingDaysText = '$i일 후';
|
||||
if (i == 1) {
|
||||
remainingDaysText = '내일';
|
||||
}
|
||||
final remainingDaysText = _daysInText(locale, i);
|
||||
|
||||
// 각 날짜에 대한 이벤트 종료 확인
|
||||
String dailyNotificationBody;
|
||||
if (subscription.isEventActive &&
|
||||
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 결제 예정입니다.';
|
||||
}
|
||||
final dailyNotificationBody =
|
||||
await _buildPaymentBody(subscription, remainingDaysText);
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
||||
'구독 결제 예정 알림',
|
||||
title,
|
||||
dailyNotificationBody,
|
||||
tz.TZDateTime.from(dailyDate, location),
|
||||
const NotificationDetails(
|
||||
dailyDate,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'Subscription Notifications',
|
||||
channelDescription: 'Channel for subscription reminders',
|
||||
_paymentChannelId,
|
||||
title,
|
||||
channelDescription: title,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
autoCancel: false,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
iOS: const DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
),
|
||||
androidScheduleMode: scheduleMode,
|
||||
payload: _paymentPayload(subscription.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
lib/services/sms_scan/sms_scan_result.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
import '../../models/subscription.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import 'sms_scan_result.dart';
|
||||
|
||||
class SubscriptionConverter {
|
||||
// SubscriptionModel 리스트를 Subscription 리스트로 변환
|
||||
List<Subscription> convertModelsToSubscriptions(
|
||||
List<SubscriptionModel> models) {
|
||||
List<Subscription> convertResultsToSubscriptions(
|
||||
List<SmsScanResult> results) {
|
||||
final result = <Subscription>[];
|
||||
|
||||
for (var model in models) {
|
||||
for (final smsResult in results) {
|
||||
try {
|
||||
final subscription = _convertSingle(model);
|
||||
final subscription = _convertSingle(smsResult);
|
||||
result.add(subscription);
|
||||
|
||||
// 개발 편의를 위한 디버그 로그
|
||||
// ignore: avoid_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) {
|
||||
// ignore: avoid_print
|
||||
print('모델 변환 중 오류 발생: $e');
|
||||
@@ -26,7 +26,8 @@ class SubscriptionConverter {
|
||||
}
|
||||
|
||||
// 단일 모델 변환
|
||||
Subscription _convertSingle(SubscriptionModel model) {
|
||||
Subscription _convertSingle(SmsScanResult result) {
|
||||
final model = result.model;
|
||||
return Subscription(
|
||||
id: model.id,
|
||||
serviceName: model.serviceName,
|
||||
@@ -38,6 +39,9 @@ class SubscriptionConverter {
|
||||
lastPaymentDate: model.lastPaymentDate,
|
||||
websiteUrl: model.websiteUrl,
|
||||
currency: model.currency,
|
||||
paymentCardId: model.paymentCardId,
|
||||
paymentCardSuggestion: result.cardSuggestion,
|
||||
rawMessage: result.rawMessage,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, compute;
|
||||
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
@@ -5,11 +7,16 @@ import '../utils/logger.dart';
|
||||
import '../temp/test_sms_data.dart';
|
||||
import '../services/subscription_url_matcher.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 {
|
||||
final SmsQuery _query = SmsQuery();
|
||||
|
||||
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
||||
Future<List<SmsScanResult>> scanForSubscriptions() async {
|
||||
try {
|
||||
List<dynamic> smsList;
|
||||
Log.d('SmsScanner: 스캔 시작');
|
||||
@@ -35,46 +42,51 @@ class SmsScanner {
|
||||
return [];
|
||||
}
|
||||
|
||||
final filteredSms = smsList
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.where(_isEligibleSubscriptionSms)
|
||||
.toList();
|
||||
|
||||
Log.d(
|
||||
'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}건');
|
||||
|
||||
if (filteredSms.isEmpty) {
|
||||
Log.w('SmsScanner: 결제 패턴 SMS 미검출');
|
||||
return [];
|
||||
}
|
||||
|
||||
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
||||
final List<SubscriptionModel> subscriptions = [];
|
||||
final Map<String, List<Map<String, dynamic>>> serviceGroups = {};
|
||||
final List<SmsScanResult> subscriptions = [];
|
||||
final serviceGroups = _groupMessagesByIdentifier(filteredSms);
|
||||
|
||||
// 서비스명별로 SMS 메시지 그룹화
|
||||
for (final sms in smsList) {
|
||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||
if (!serviceGroups.containsKey(serviceName)) {
|
||||
serviceGroups[serviceName] = [];
|
||||
}
|
||||
serviceGroups[serviceName]!.add(sms);
|
||||
}
|
||||
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
|
||||
|
||||
Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
||||
|
||||
// 그룹화된 데이터로 구독 분석
|
||||
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회 이상 반복된 서비스만 구독으로 간주
|
||||
if (entry.value.length >= 2) {
|
||||
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
|
||||
final subscription = _parseSms(serviceSms, entry.value.length);
|
||||
if (subscription != null) {
|
||||
final result =
|
||||
_parseSms(repeatResult.baseMessage, repeatResult.repeatCount);
|
||||
if (result != null) {
|
||||
Log.i(
|
||||
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
||||
subscriptions.add(subscription);
|
||||
'SmsScanner: 구독 추가: ${result.model.serviceName}, 반복 횟수: ${result.model.repeatCount}');
|
||||
subscriptions.add(result);
|
||||
} else {
|
||||
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||
}
|
||||
} else {
|
||||
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
||||
}
|
||||
}
|
||||
|
||||
Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
||||
return subscriptions;
|
||||
} catch (e) {
|
||||
Log.e('SmsScanner: 예외 발생', e);
|
||||
throw Exception('SMS 스캔 중 오류 발생: $e');
|
||||
final loc = _loc();
|
||||
throw Exception(loc?.smsScanErrorWithMessage(e.toString()) ??
|
||||
'Error occurred during SMS scan: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +118,15 @@ class SmsScanner {
|
||||
|
||||
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
|
||||
|
||||
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||
try {
|
||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||
final loc = _loc();
|
||||
final unknownLabel = loc?.unknownService ?? 'Unknown service';
|
||||
final serviceNameRaw = sms['serviceName'] as String?;
|
||||
final serviceName =
|
||||
(serviceNameRaw == null || serviceNameRaw.trim().isEmpty)
|
||||
? unknownLabel
|
||||
: serviceNameRaw;
|
||||
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
|
||||
final billingCycle = SubscriptionModel.normalizeBillingCycle(
|
||||
sms['billingCycle'] as String? ?? 'monthly');
|
||||
@@ -134,7 +152,11 @@ class SmsScanner {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -147,9 +169,13 @@ class SmsScanner {
|
||||
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
|
||||
DateTime adjustedNextBillingDate = _calculateNextBillingDate(
|
||||
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
|
||||
billingCycle);
|
||||
billingCycle,
|
||||
);
|
||||
// 주말/공휴일 보정
|
||||
adjustedNextBillingDate =
|
||||
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
|
||||
|
||||
return SubscriptionModel(
|
||||
final model = SubscriptionModel(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
serviceName: serviceName,
|
||||
monthlyCost: monthlyCost,
|
||||
@@ -161,11 +187,85 @@ class SmsScanner {
|
||||
websiteUrl: _extractWebsiteUrl(serviceName),
|
||||
currency: currency, // 통화 단위 설정
|
||||
);
|
||||
|
||||
final suggestion = _extractPaymentCardSuggestion(message);
|
||||
return SmsScanResult(
|
||||
model: model,
|
||||
cardSuggestion: suggestion,
|
||||
rawMessage: message,
|
||||
);
|
||||
} catch (e) {
|
||||
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 billingDate, String billingCycle) {
|
||||
@@ -190,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') {
|
||||
// 올해의 결제일이 지났는지 확인
|
||||
final thisYearBilling =
|
||||
@@ -275,6 +377,72 @@ class SmsScanner {
|
||||
// 기본값은 원화
|
||||
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 파서 =====
|
||||
@@ -282,38 +450,15 @@ class SmsScanner {
|
||||
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
||||
List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||
List<Map<String, dynamic>> messages) {
|
||||
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
|
||||
const subscriptionKeywords = [
|
||||
'구독',
|
||||
'결제',
|
||||
'정기결제',
|
||||
'자동결제',
|
||||
'월정액',
|
||||
'subscription',
|
||||
'payment',
|
||||
'billing',
|
||||
'charge',
|
||||
'넷플릭스',
|
||||
'Netflix',
|
||||
'유튜브',
|
||||
'YouTube',
|
||||
'Spotify',
|
||||
'멜론',
|
||||
'웨이브',
|
||||
'Disney+',
|
||||
'디즈니플러스',
|
||||
'Apple',
|
||||
'Microsoft',
|
||||
'GitHub',
|
||||
'Adobe',
|
||||
'Amazon'
|
||||
];
|
||||
|
||||
final amountPatterns = <RegExp>[
|
||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
||||
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
||||
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
||||
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>>[];
|
||||
@@ -324,28 +469,26 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||
final dateMillis =
|
||||
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
|
||||
|
||||
final lowerBody = body.toLowerCase();
|
||||
final lowerSender = sender.toLowerCase();
|
||||
final isSubscription = subscriptionKeywords.any((k) =>
|
||||
lowerBody.contains(k.toLowerCase()) ||
|
||||
lowerSender.contains(k.toLowerCase()));
|
||||
|
||||
if (!isSubscription) continue;
|
||||
|
||||
final serviceName = _isoExtractServiceName(body, sender);
|
||||
final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0;
|
||||
final 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,7 +518,8 @@ String _isoExtractServiceName(String body, String sender) {
|
||||
|
||||
String _isoExtractServiceNameFromSender(String sender) {
|
||||
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
||||
return '알 수 없는 서비스';
|
||||
// Isolate에서 실행되므로 하드코딩 사용 (Flutter 바인딩 접근 불가)
|
||||
return 'Unknown service';
|
||||
}
|
||||
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
|
||||
}
|
||||
@@ -408,6 +552,23 @@ String _isoExtractBillingCycle(String body) {
|
||||
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) {
|
||||
@@ -421,3 +582,267 @@ DateTime _isoCalculateNextBillingFromDate(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
import '../utils/platform_helper.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../navigator_key.dart';
|
||||
|
||||
class SMSService {
|
||||
static const platform = MethodChannel('com.submanager/sms');
|
||||
@@ -37,14 +39,24 @@ class SMSService {
|
||||
|
||||
try {
|
||||
if (!await hasSMSPermission()) {
|
||||
throw Exception('SMS 권한이 없습니다.');
|
||||
final loc = _loc();
|
||||
throw Exception(
|
||||
loc?.smsPermissionRequired ?? 'SMS permission required.');
|
||||
}
|
||||
|
||||
final List<dynamic> result =
|
||||
await platform.invokeMethod('scanSubscriptions');
|
||||
return result.map((item) => item as Map<String, dynamic>).toList();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/// URL Matcher 패키지의 export 파일
|
||||
// URL Matcher 패키지의 export 파일
|
||||
export 'models/service_info.dart';
|
||||
|
||||
@@ -166,6 +166,21 @@ class TestSmsData {
|
||||
'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}'
|
||||
},
|
||||
{
|
||||
'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}'
|
||||
},
|
||||
];
|
||||
|
||||
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)
|
||||
|
||||
@@ -10,114 +10,119 @@ class AdaptiveTheme {
|
||||
|
||||
/// 다크 테마
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
const scheme = ColorScheme.dark(
|
||||
primary: AppColors.primaryColor,
|
||||
onPrimary: Colors.white,
|
||||
secondary: AppColors.secondaryColor,
|
||||
tertiary: AppColors.infoColor,
|
||||
error: AppColors.dangerColor,
|
||||
error: AppColors.errorColor,
|
||||
surface: Color(0xFF1E1E1E),
|
||||
),
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||
cardTheme: CardThemeData(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.3),
|
||||
color: scheme.surface,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
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,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: scheme.surface,
|
||||
foregroundColor: scheme.onSurface,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
// title/icon colors inherit from foregroundColor
|
||||
),
|
||||
iconTheme: IconThemeData(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
textTheme: TextTheme(
|
||||
textTheme: ThemeData.dark(useMaterial3: true)
|
||||
.textTheme
|
||||
.copyWith(
|
||||
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),
|
||||
bodyLarge: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.5,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
bodyMedium: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.5,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.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(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF2A2A2A),
|
||||
fillColor: scheme.surface,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
@@ -126,33 +131,31 @@ class AdaptiveTheme {
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide:
|
||||
BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
|
||||
borderSide: BorderSide(color: scheme.outline, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide:
|
||||
const BorderSide(color: AppColors.primaryColor, width: 1.5),
|
||||
borderSide: BorderSide(color: scheme.primary, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
minimumSize: const Size(0, 48),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -161,8 +164,66 @@ class AdaptiveTheme {
|
||||
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(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
color: scheme.outline,
|
||||
thickness: 1,
|
||||
space: 16,
|
||||
),
|
||||
@@ -171,19 +232,15 @@ class AdaptiveTheme {
|
||||
|
||||
/// OLED 최적화 다크 테마
|
||||
static ThemeData get oledTheme {
|
||||
return darkTheme.copyWith(
|
||||
final base = darkTheme;
|
||||
const oledSurface = Color(0xFF0A0A0A);
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: Colors.black,
|
||||
colorScheme: darkTheme.colorScheme.copyWith(
|
||||
surface: const Color(0xFF0A0A0A),
|
||||
),
|
||||
cardTheme: darkTheme.cardTheme.copyWith(
|
||||
color: const Color(0xFF0A0A0A),
|
||||
),
|
||||
appBarTheme: darkTheme.appBarTheme.copyWith(
|
||||
backgroundColor: Colors.black,
|
||||
),
|
||||
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
|
||||
fillColor: const Color(0xFF0A0A0A),
|
||||
colorScheme: base.colorScheme.copyWith(surface: oledSurface),
|
||||
cardTheme: base.cardTheme.copyWith(color: oledSurface),
|
||||
appBarTheme: base.appBarTheme.copyWith(backgroundColor: Colors.black),
|
||||
inputDecorationTheme: base.inputDecorationTheme.copyWith(
|
||||
fillColor: oledSurface,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -248,9 +305,9 @@ class AdaptiveTheme {
|
||||
}
|
||||
|
||||
/// 시스템 테마에 따른 상태바 스타일 적용
|
||||
/// Android 15+ edge-to-edge 호환: deprecated된 네비게이션바 색상 API 제거
|
||||
static void applySystemUIOverlay(BuildContext context) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
@@ -258,13 +315,8 @@ class AdaptiveTheme {
|
||||
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
|
||||
statusBarBrightness:
|
||||
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isOled
|
||||
? Colors.black
|
||||
: (brightness == Brightness.dark
|
||||
? const Color(0xFF121212)
|
||||
: Colors.white),
|
||||
systemNavigationBarIconBrightness:
|
||||
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
|
||||
// Android 15+: 네비게이션바 색상은 시스템이 자동 처리
|
||||
systemNavigationBarContrastEnforced: false,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ class AppColors {
|
||||
static const successColor = Color(0xFF38BDF8); // 소프트 민트
|
||||
static const infoColor = Color(0xFF6366F1); // 인디고
|
||||
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
|
||||
@@ -31,27 +32,7 @@ class AppColors {
|
||||
// 그림자 (color.md 가이드)
|
||||
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 효과를 위한 색상
|
||||
static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity)
|
||||
@@ -66,47 +47,9 @@ class AppColors {
|
||||
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% 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), // 다크 네이비
|
||||
];
|
||||
// (시간대별 배경 그라데이션 제거됨)
|
||||
}
|
||||
|
||||
@@ -2,183 +2,160 @@ import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData lightTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: const ColorScheme.light(
|
||||
static ThemeData lightTheme = (() {
|
||||
// Color scheme for light theme
|
||||
const scheme = ColorScheme.light(
|
||||
primary: AppColors.primaryColor,
|
||||
onPrimary: Colors.white,
|
||||
secondary: AppColors.secondaryColor,
|
||||
tertiary: AppColors.infoColor,
|
||||
error: AppColors.dangerColor,
|
||||
error: AppColors.errorColor,
|
||||
surface: AppColors.surfaceColor,
|
||||
),
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
|
||||
// 기본 배경색
|
||||
scaffoldBackgroundColor: AppColors.backgroundColor,
|
||||
|
||||
// 카드 스타일 - 글래스모피즘 효과
|
||||
// 카드 스타일 - Material 3 표면 중심
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.glassCard,
|
||||
elevation: 0,
|
||||
shadowColor: AppColors.shadowBlack,
|
||||
elevation: 1,
|
||||
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),
|
||||
),
|
||||
|
||||
// 앱바 스타일 - 글래스모피즘 디자인
|
||||
// 앱바 스타일 - 기본 M3 사용(투명 배경 유지)
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
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 가이드: 메인 텍스트
|
||||
// 타이포그래피 - 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: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
headlineMedium: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.2,
|
||||
),
|
||||
headlineSmall: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
headlineSmall: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.25,
|
||||
height: 1.3,
|
||||
),
|
||||
|
||||
// 타이틀 - 카드, 섹션 제목
|
||||
titleLarge: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
titleLarge: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.4,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
titleMedium: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.1,
|
||||
height: 1.4,
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
titleSmall: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0,
|
||||
height: 1.4,
|
||||
),
|
||||
|
||||
// 본문 텍스트
|
||||
bodyLarge: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
bodyLarge: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.5,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||
bodyMedium: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.5,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||
bodySmall: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.5,
|
||||
),
|
||||
|
||||
// 라벨 텍스트
|
||||
labelLarge: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
labelLarge: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.4,
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||
labelMedium: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.4,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||
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: AppColors.glassBackground,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
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: const BorderSide(color: AppColors.textSecondary, width: 1),
|
||||
borderSide: BorderSide(color: scheme.outline, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5),
|
||||
borderSide: BorderSide(color: scheme.primary, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1.5),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1.5),
|
||||
),
|
||||
labelStyle: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
labelStyle: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
hintStyle: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
hintStyle: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
errorStyle: const TextStyle(
|
||||
color: AppColors.dangerColor,
|
||||
errorStyle: TextStyle(
|
||||
color: scheme.error,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
@@ -187,66 +164,53 @@ class AppTheme {
|
||||
// 버튼 스타일 - 프라이머리 버튼
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
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,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 텍스트 버튼 스타일
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryColor,
|
||||
foregroundColor: scheme.primary,
|
||||
minimumSize: const Size(0, 40),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
// Text style inherits from theme.labelLarge
|
||||
),
|
||||
),
|
||||
|
||||
// 아웃라인 버튼 스타일
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryColor,
|
||||
foregroundColor: scheme.primary,
|
||||
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,
|
||||
),
|
||||
side: BorderSide(color: scheme.outline, width: 1),
|
||||
),
|
||||
),
|
||||
|
||||
// FAB 스타일
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 2,
|
||||
extendedPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
extendedPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
extendedTextStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -254,19 +218,20 @@ class AppTheme {
|
||||
),
|
||||
),
|
||||
|
||||
// 스위치 스타일
|
||||
// 스위치 스타일 (공통 테마)
|
||||
switchTheme: SwitchThemeData(
|
||||
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
return scheme.primary;
|
||||
}
|
||||
return Colors.white;
|
||||
return scheme.onSurfaceVariant; // OFF 썸을 명확하게
|
||||
}),
|
||||
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.secondaryColor.withValues(alpha: 0.5);
|
||||
return scheme.primary.withValues(alpha: 0.5);
|
||||
}
|
||||
return AppColors.borderColor;
|
||||
// OFF 트랙 대비 강화
|
||||
return scheme.surfaceContainerHighest.withValues(alpha: 0.5);
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -274,48 +239,48 @@ class AppTheme {
|
||||
checkboxTheme: CheckboxThemeData(
|
||||
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
return scheme.primary;
|
||||
}
|
||||
return Colors.transparent;
|
||||
}),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
side: const BorderSide(color: AppColors.secondaryColor, width: 1.5),
|
||||
side: BorderSide(color: scheme.outline, width: 1.5),
|
||||
),
|
||||
|
||||
// 라디오 버튼 스타일
|
||||
radioTheme: RadioThemeData(
|
||||
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
return scheme.primary;
|
||||
}
|
||||
return AppColors.textSecondary;
|
||||
return scheme.onSurfaceVariant;
|
||||
}),
|
||||
),
|
||||
|
||||
// 슬라이더 스타일
|
||||
sliderTheme: SliderThemeData(
|
||||
activeTrackColor: AppColors.primaryColor,
|
||||
inactiveTrackColor: AppColors.textSecondary,
|
||||
thumbColor: AppColors.primaryColor,
|
||||
overlayColor: AppColors.primaryColor.withValues(alpha: 0.3),
|
||||
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: const TabBarThemeData(
|
||||
labelColor: AppColors.primaryColor,
|
||||
unselectedLabelColor: AppColors.textSecondary,
|
||||
indicatorColor: AppColors.primaryColor,
|
||||
labelStyle: TextStyle(
|
||||
tabBarTheme: TabBarThemeData(
|
||||
labelColor: scheme.primary,
|
||||
unselectedLabelColor: scheme.onSurfaceVariant,
|
||||
indicatorColor: scheme.primary,
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
unselectedLabelStyle: TextStyle(
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
@@ -323,8 +288,8 @@ class AppTheme {
|
||||
),
|
||||
|
||||
// 디바이더 스타일
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.dividerColor,
|
||||
dividerTheme: DividerThemeData(
|
||||
color: scheme.outline,
|
||||
thickness: 1,
|
||||
space: 16,
|
||||
),
|
||||
@@ -338,11 +303,11 @@ class AppTheme {
|
||||
},
|
||||
),
|
||||
|
||||
// 스낵바 스타일
|
||||
// 스낵바 스타일 (기본 유지)
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: AppColors.textPrimary,
|
||||
contentTextStyle: const TextStyle(
|
||||
color: Colors.white,
|
||||
backgroundColor: scheme.primary,
|
||||
contentTextStyle: TextStyle(
|
||||
color: scheme.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -352,4 +317,5 @@ class AppTheme {
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
8
lib/theme/color_scheme_ext.dart
Normal 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
|
||||
}
|
||||
10
lib/theme/ui_constants.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
class UIConstants {
|
||||
static const double pageHorizontalPadding = 16;
|
||||
static const double adVerticalPadding = 12;
|
||||
static const double nativeAdWidth = 320;
|
||||
static const double nativeAdHeight = 300;
|
||||
static const double nativeAdAspectRatio = nativeAdWidth / nativeAdHeight;
|
||||
static const double pageTopPadding = 40;
|
||||
static const double cardRadius = 16;
|
||||
static const double cardOutlineAlpha = 0.5; // for outline color alpha
|
||||
}
|
||||
@@ -32,17 +32,9 @@ class AnimationControllerHelper {
|
||||
pulseController.duration = const Duration(milliseconds: 1500);
|
||||
pulseController.repeat(reverse: true);
|
||||
|
||||
// 웨이브 컨트롤러 초기화
|
||||
// 웨이브 컨트롤러 초기화: 반복으로 부드럽게 루프
|
||||
waveController.duration = const Duration(milliseconds: 8000);
|
||||
waveController.forward();
|
||||
|
||||
// 웨이브 애니메이션이 끝나면 다시 처음부터 부드럽게 시작하도록 설정
|
||||
waveController.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
waveController.reset();
|
||||
waveController.forward();
|
||||
}
|
||||
});
|
||||
waveController.repeat();
|
||||
}
|
||||
|
||||
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드
|
||||
|
||||
248
lib/utils/billing_cost_util.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
/// 결제 주기에 따른 비용 변환 유틸리티
|
||||
class BillingCostUtil {
|
||||
/// 결제 주기별 비용을 월 비용으로 변환
|
||||
///
|
||||
/// [amount]: 입력된 비용
|
||||
/// [billingCycle]: 결제 주기 ('monthly', 'yearly', 'quarterly', 'half-yearly' 등)
|
||||
///
|
||||
/// Returns: 월 환산 비용
|
||||
static double convertToMonthlyCost(double amount, String billingCycle) {
|
||||
final normalizedCycle = _normalizeBillingCycle(billingCycle);
|
||||
|
||||
switch (normalizedCycle) {
|
||||
case 'monthly':
|
||||
return amount;
|
||||
case 'yearly':
|
||||
return amount / 12;
|
||||
case 'quarterly':
|
||||
return amount / 3;
|
||||
case 'half-yearly':
|
||||
return amount / 6;
|
||||
case 'weekly':
|
||||
return amount * 4.33; // 평균 주당 4.33주
|
||||
default:
|
||||
return amount; // 알 수 없는 주기는 그대로 반환
|
||||
}
|
||||
}
|
||||
|
||||
/// 월 비용을 결제 주기별 비용으로 역변환
|
||||
///
|
||||
/// [monthlyCost]: 월 비용
|
||||
/// [billingCycle]: 결제 주기
|
||||
///
|
||||
/// Returns: 해당 주기의 실제 결제 금액
|
||||
static double convertFromMonthlyCost(double monthlyCost, String billingCycle) {
|
||||
final normalizedCycle = _normalizeBillingCycle(billingCycle);
|
||||
|
||||
switch (normalizedCycle) {
|
||||
case 'monthly':
|
||||
return monthlyCost;
|
||||
case 'yearly':
|
||||
return monthlyCost * 12;
|
||||
case 'quarterly':
|
||||
return monthlyCost * 3;
|
||||
case 'half-yearly':
|
||||
return monthlyCost * 6;
|
||||
case 'weekly':
|
||||
return monthlyCost / 4.33;
|
||||
default:
|
||||
return monthlyCost;
|
||||
}
|
||||
}
|
||||
|
||||
/// 결제 주기를 정규화된 영어 키값으로 변환
|
||||
static String _normalizeBillingCycle(String cycle) {
|
||||
switch (cycle.toLowerCase()) {
|
||||
case 'monthly':
|
||||
case '월간':
|
||||
case '매월':
|
||||
case '月間':
|
||||
case '月付':
|
||||
case '每月':
|
||||
case '毎月':
|
||||
return 'monthly';
|
||||
|
||||
case 'yearly':
|
||||
case 'annual':
|
||||
case 'annually':
|
||||
case '연간':
|
||||
case '매년':
|
||||
case '年間':
|
||||
case '年付':
|
||||
case '每年':
|
||||
return 'yearly';
|
||||
|
||||
case 'quarterly':
|
||||
case 'quarter':
|
||||
case '분기별':
|
||||
case '분기':
|
||||
case '季付':
|
||||
case '季度付':
|
||||
case '四半期':
|
||||
case '每季度':
|
||||
return 'quarterly';
|
||||
|
||||
case 'half-yearly':
|
||||
case 'half yearly':
|
||||
case 'semiannual':
|
||||
case 'semi-annual':
|
||||
case '반기별':
|
||||
case '半年付':
|
||||
case '半年払い':
|
||||
case '半年ごと':
|
||||
case '每半年':
|
||||
return 'half-yearly';
|
||||
|
||||
case 'weekly':
|
||||
case '주간':
|
||||
case '週間':
|
||||
case '周付':
|
||||
case '每周':
|
||||
return 'weekly';
|
||||
|
||||
default:
|
||||
return 'monthly';
|
||||
}
|
||||
}
|
||||
|
||||
/// 결제 주기의 배수 반환 (월 기준)
|
||||
///
|
||||
/// 예: yearly = 12, quarterly = 3
|
||||
static double getBillingCycleMultiplier(String billingCycle) {
|
||||
final normalizedCycle = _normalizeBillingCycle(billingCycle);
|
||||
|
||||
switch (normalizedCycle) {
|
||||
case 'monthly':
|
||||
return 1.0;
|
||||
case 'yearly':
|
||||
return 12.0;
|
||||
case 'quarterly':
|
||||
return 3.0;
|
||||
case 'half-yearly':
|
||||
return 6.0;
|
||||
case 'weekly':
|
||||
return 1 / 4.33;
|
||||
default:
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 다음 결제일에서 이전 결제일 계산
|
||||
///
|
||||
/// [nextBillingDate]: 다음 결제 예정일
|
||||
/// [billingCycle]: 결제 주기
|
||||
///
|
||||
/// Returns: 이전 결제일 (마지막으로 결제가 발생한 날짜)
|
||||
static DateTime getLastBillingDate(
|
||||
DateTime nextBillingDate, String billingCycle) {
|
||||
final normalizedCycle = _normalizeBillingCycle(billingCycle);
|
||||
|
||||
switch (normalizedCycle) {
|
||||
case 'yearly':
|
||||
return DateTime(
|
||||
nextBillingDate.year - 1,
|
||||
nextBillingDate.month,
|
||||
nextBillingDate.day,
|
||||
);
|
||||
case 'half-yearly':
|
||||
return DateTime(
|
||||
nextBillingDate.month <= 6
|
||||
? nextBillingDate.year - 1
|
||||
: nextBillingDate.year,
|
||||
nextBillingDate.month <= 6
|
||||
? nextBillingDate.month + 6
|
||||
: nextBillingDate.month - 6,
|
||||
nextBillingDate.day,
|
||||
);
|
||||
case 'quarterly':
|
||||
return DateTime(
|
||||
nextBillingDate.month <= 3
|
||||
? nextBillingDate.year - 1
|
||||
: nextBillingDate.year,
|
||||
nextBillingDate.month <= 3
|
||||
? nextBillingDate.month + 9
|
||||
: nextBillingDate.month - 3,
|
||||
nextBillingDate.day,
|
||||
);
|
||||
case 'monthly':
|
||||
return DateTime(
|
||||
nextBillingDate.month == 1
|
||||
? nextBillingDate.year - 1
|
||||
: nextBillingDate.year,
|
||||
nextBillingDate.month == 1 ? 12 : nextBillingDate.month - 1,
|
||||
nextBillingDate.day,
|
||||
);
|
||||
case 'weekly':
|
||||
return nextBillingDate.subtract(const Duration(days: 7));
|
||||
default:
|
||||
return DateTime(
|
||||
nextBillingDate.month == 1
|
||||
? nextBillingDate.year - 1
|
||||
: nextBillingDate.year,
|
||||
nextBillingDate.month == 1 ? 12 : nextBillingDate.month - 1,
|
||||
nextBillingDate.day,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 특정 월에 결제가 발생하는지 확인
|
||||
///
|
||||
/// [nextBillingDate]: 다음 결제 예정일
|
||||
/// [billingCycle]: 결제 주기
|
||||
/// [targetYear]: 확인할 연도
|
||||
/// [targetMonth]: 확인할 월 (1-12)
|
||||
///
|
||||
/// Returns: 해당 월에 결제가 발생하면 true
|
||||
static bool hasBillingInMonth(
|
||||
DateTime nextBillingDate,
|
||||
String billingCycle,
|
||||
int targetYear,
|
||||
int targetMonth,
|
||||
) {
|
||||
final normalizedCycle = _normalizeBillingCycle(billingCycle);
|
||||
|
||||
// 주간 결제는 매주 발생하므로 항상 true
|
||||
if (normalizedCycle == 'weekly') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 월간 결제는 매월 발생하므로 항상 true
|
||||
if (normalizedCycle == 'monthly') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 결제 주기에 따른 개월 수
|
||||
final cycleMonths = _getCycleMonths(normalizedCycle);
|
||||
|
||||
// 결제 발생 월 계산 (nextBillingDate 기준으로 역산)
|
||||
final billingMonth = nextBillingDate.month;
|
||||
|
||||
// 대상 월이 결제 발생 월과 일치하는지 확인
|
||||
// 예: 연간 결제(1월), targetMonth = 1 → true
|
||||
// 예: 연간 결제(1월), targetMonth = 2 → false
|
||||
for (int i = 0; i < 12; i += cycleMonths) {
|
||||
final checkMonth = ((billingMonth - 1 + i) % 12) + 1;
|
||||
if (checkMonth == targetMonth) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 결제 주기별 개월 수 반환
|
||||
static int _getCycleMonths(String normalizedCycle) {
|
||||
switch (normalizedCycle) {
|
||||
case 'yearly':
|
||||
return 12;
|
||||
case 'half-yearly':
|
||||
return 6;
|
||||
case 'quarterly':
|
||||
return 3;
|
||||
case 'monthly':
|
||||
return 1;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||