28 Commits

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

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

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

## 버전
- 1.0.1+2 → 1.0.1+3
2025-12-08 18:14:52 +09:00
JiWoong Sul
bac4acf9a3 i8n과 광고 수정 2025-12-07 21:14:54 +09:00
JiWoong Sul
64da0c5fd3 스토어등록용 이미지 및 앱아이콘 2025-11-17 19:28:51 +09:00
JiWoong Sul
d9435bbee5 앱 키 설정 및 버전업 처리 2025-11-17 19:28:33 +09:00
JiWoong Sul
b018e5eb2f 옵션창 정보팝업 단절 처리 2025-11-17 19:26:46 +09:00
JiWoong Sul
b22df5daf3 i8n누락 사항 추가 적용 2025-11-17 19:26:14 +09:00
JiWoong Sul
2cd46a303e feat: improve sms scan review and detail layouts 2025-11-14 19:33:32 +09:00
JiWoong Sul
a9f42f6f01 fix: adjust subscription card layout 2025-11-14 17:14:16 +09:00
JiWoong Sul
132ae758de feat: add payment card grouping and analysis 2025-11-14 16:53:41 +09:00
JiWoong Sul
cba7d082bd docs: outline payment card grouping plan 2025-11-14 14:29:36 +09:00
JiWoong Sul
8cec03f181 feat: enhance sms scanner repeat detection 2025-11-14 14:29:32 +09:00
101 changed files with 5746 additions and 2023 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

377
CLAUDE.md
View File

@@ -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 **ArrangeActAssert** pattern
- Clear test variable naming: `inputX`, `mockX`, `actualX`, `expectedX`
- **Write unit tests for every public method**
### Test Doubles Usage
- Use **test doubles** (mock/fake/stub) for dependencies
- Exception: allow real use of **lightweight third-party libraries**
### Integration Testing
- Write **integration tests per module**
- Follow **GivenWhenThen** structure
- Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures
## 📝 Git Commit Guidelines
### Commit Message Format
- **Use clear, descriptive commit messages in Korean**
- **Follow conventional commit format**: `type: description`
- **Keep commit messages concise and focused**
- **DO NOT include Claude Code attribution or co-author tags**
### Commit Message Structure
```
type: brief description in Korean
Optional detailed explanation if needed
```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!
```

View File

@@ -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")
}
}
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View File

@@ -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",
@@ -129,7 +161,10 @@
"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)",
@@ -217,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.",
@@ -228,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": "디지털 월세 관리자",
@@ -242,6 +285,9 @@
"save": "저장",
"cancel": "취소",
"delete": "삭제",
"deleteSubscriptionTitle": "구독 삭제",
"deleteSubscriptionMessage": "정말로 @ 구독을 삭제하시겠습니까?",
"deleteIrreversibleWarning": "이 작업은 되돌릴 수 없습니다",
"edit": "수정",
"totalSubscriptions": "총 구독",
"totalMonthlyExpense": "이번 달 총 지출",
@@ -256,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": "권한 요청",
@@ -272,6 +343,7 @@
"dailyReminderEnabled": "결제일까지 매일 알림을 받습니다",
"dailyReminderDisabled": "결제 @일 전에 알림을 받습니다",
"notificationPermissionDenied": "알림 권한이 거부되었습니다",
"permissionGranted": "권한이 허용되었습니다.",
"appInfo": "앱 정보",
"version": "버전",
"appDescription": "디지털 월세 관리 앱",
@@ -291,6 +363,7 @@
"twoDaysBefore": "2일 전",
"threeDaysBefore": "3일 전",
"requiredFieldsError": "필수 항목을 모두 입력해주세요",
"categoryNameRequired": "카테고리 이름을 입력하세요",
"subscriptionUpdated": "구독 정보가 업데이트되었습니다.",
"subscriptionDeleted": "@ 구독이 삭제되었습니다.",
"officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.",
@@ -300,6 +373,7 @@
"changesAppliedAfterSave": "변경사항은 저장 후 적용됩니다",
"saveChanges": "변경사항 저장",
"monthlyExpense": "월 지출",
"billingAmount": "결제 금액",
"websiteUrl": "웹사이트 URL",
"websiteUrlOptional": "웹사이트 URL (선택)",
"eventPrice": "이벤트 가격",
@@ -319,6 +393,7 @@
"appLockDesc": "생체 인증으로 앱 잠금",
"unlockWithBiometric": "생체 인증으로 잠금 해제",
"authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.",
"nextBillingDateAdjusted": "다음 결제 예정일로 저장됨",
"totalExpenseCopied": "총 지출액이 복사되었습니다: @",
"smsPermissionRequired": "SMS 권한이 필요합니다.",
"noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.",
@@ -360,7 +435,10 @@
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.",
"startScanning": "스캔 시작하기",
"foundSubscription": "다음 구독을 찾았습니다",
"latestSmsMessage": "최신 SMS 메시지",
"smsDetectedDate": "SMS 수신일: @",
"serviceName": "서비스명",
"unknownService": "알 수 없는 서비스",
"nextBillingDateLabel": "다음 결제일",
"category": "카테고리",
"websiteUrlAuto": "웹사이트 URL (자동 추출됨)",
@@ -448,8 +526,12 @@
"subscriptionDetail": "구독 상세",
"enterAmount": "금액을 입력하세요",
"invalidAmount": "올바른 금액을 입력해주세요",
"featureComingSoon": "이 기능은 곧 출시됩니다"
,
"featureComingSoon": "이 기능은 곧 출시됩니다",
"exactAlarmPermission": "정확 알람 권한(알람 및 리마인더)",
"exactAlarmPermissionDesc": "정확한 시각에 알림을 보장하려면 권한이 필요합니다.",
"allowAlarmsInSettings": "설정에서 \"알람 및 리마인더\"를 허용해 주세요.",
"testNotification": "테스트 알림",
"testSubscriptionBody": "테스트 구독 • @",
"smsPermissionTitle": "SMS 권한 요청",
"smsPermissionReasonTitle": "이유",
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
@@ -459,7 +541,11 @@
"openSettings": "설정 열기",
"later": "나중에 하기",
"requesting": "요청 중...",
"smsPermissionLabel": "SMS 권한"
"smsPermissionLabel": "SMS 권한",
"expirationReminderBody": "@ 구독이 #일 후 만료됩니다.",
"eventEndNotificationTitle": "이벤트 종료 알림",
"eventEndNotificationBody": "@의 할인 이벤트가 종료되었습니다.",
"paymentChargeNotification": "@ 구독료 @이 결제되었습니다."
},
"ja": {
"appTitle": "デジタル月額管理者",
@@ -473,6 +559,9 @@
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"deleteSubscriptionTitle": "サブスクリプション削除",
"deleteSubscriptionMessage": "本当に@のサブスクリプションを削除しますか?",
"deleteIrreversibleWarning": "この操作は取り消せません",
"edit": "編集",
"totalSubscriptions": "総サブスクリプション",
"totalMonthlyExpense": "今月の総支出",
@@ -487,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": "権限をリクエスト",
@@ -503,6 +617,7 @@
"dailyReminderEnabled": "支払い日まで毎日通知を受け取ります",
"dailyReminderDisabled": "支払い@日前に通知を受け取ります",
"notificationPermissionDenied": "通知権限が拒否されました",
"permissionGranted": "権限が許可されました。",
"appInfo": "アプリ情報",
"version": "バージョン",
"appDescription": "デジタル月額管理アプリ",
@@ -522,6 +637,7 @@
"twoDaysBefore": "2日前",
"threeDaysBefore": "3日前",
"requiredFieldsError": "すべての必須項目を入力してください",
"categoryNameRequired": "カテゴリ名を入力してください",
"subscriptionUpdated": "サブスクリプション情報が更新されました",
"subscriptionDeleted": "@サブスクリプションが削除されました",
"officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。",
@@ -531,6 +647,7 @@
"changesAppliedAfterSave": "変更は保存後に適用されます",
"saveChanges": "変更を保存",
"monthlyExpense": "月額支出",
"billingAmount": "請求金額",
"websiteUrl": "ウェブサイトURL",
"websiteUrlOptional": "ウェブサイトURLオプション",
"eventPrice": "イベント価格",
@@ -550,6 +667,7 @@
"appLockDesc": "生体認証でアプリをロック",
"unlockWithBiometric": "生体認証でロック解除",
"authenticationFailed": "認証に失敗しました。もう一度お試しください。",
"nextBillingDateAdjusted": "次回請求日に保存しました",
"totalExpenseCopied": "総支出がコピーされました:@",
"smsPermissionRequired": "SMS権限が必要です",
"noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません",
@@ -591,7 +709,10 @@
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。",
"startScanning": "スキャン開始",
"foundSubscription": "サブスクリプションが見つかりました",
"latestSmsMessage": "最新のSMSメッセージ",
"smsDetectedDate": "SMS受信日: @",
"serviceName": "サービス名",
"unknownService": "不明なサービス",
"nextBillingDateLabel": "次回請求日",
"category": "カテゴリー",
"websiteUrlAuto": "ウェブサイトURL自動抽出",
@@ -679,7 +800,16 @@
"subscriptionDetail": "サブスクリプション詳細",
"enterAmount": "金額を入力してください",
"invalidAmount": "正しい金額を入力してください",
"featureComingSoon": "この機能は近日公開予定です"
"featureComingSoon": "この機能は近日公開予定です",
"exactAlarmPermission": "正確なアラーム権限(アラームとリマインダー)",
"exactAlarmPermissionDesc": "正確な時刻に通知するには権限が必要です。",
"allowAlarmsInSettings": "設定で「アラームとリマインダー」を許可してください。",
"testNotification": "テスト通知",
"testSubscriptionBody": "テストサブスクリプション • @",
"expirationReminderBody": "@ のサブスクリプションは #日後に期限切れになります。",
"eventEndNotificationTitle": "イベント終了通知",
"eventEndNotificationBody": "@ の割引イベントが終了しました。",
"paymentChargeNotification": "@ の購読料 @ が請求されました。"
},
"zh": {
"appTitle": "数字月租管理器",
@@ -693,6 +823,9 @@
"save": "保存",
"cancel": "取消",
"delete": "删除",
"deleteSubscriptionTitle": "删除订阅",
"deleteSubscriptionMessage": "确定要删除@订阅吗?",
"deleteIrreversibleWarning": "此操作无法撤销",
"edit": "编辑",
"totalSubscriptions": "订阅总数",
"totalMonthlyExpense": "本月总支出",
@@ -707,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": "请求权限",
@@ -723,6 +881,7 @@
"dailyReminderEnabled": "直到付款日期每天接收通知",
"dailyReminderDisabled": "在付款@天前接收通知",
"notificationPermissionDenied": "通知权限被拒绝",
"permissionGranted": "已获得权限。",
"appInfo": "应用信息",
"version": "版本",
"appDescription": "数字月租管理应用",
@@ -742,6 +901,7 @@
"twoDaysBefore": "2天前",
"threeDaysBefore": "3天前",
"requiredFieldsError": "请填写所有必填项",
"categoryNameRequired": "请输入分类名称",
"subscriptionUpdated": "订阅信息已更新",
"subscriptionDeleted": "@订阅已删除",
"officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。",
@@ -751,6 +911,7 @@
"changesAppliedAfterSave": "更改将在保存后应用",
"saveChanges": "保存更改",
"monthlyExpense": "每月支出",
"billingAmount": "账单金额",
"websiteUrl": "网站URL",
"websiteUrlOptional": "网站URL可选",
"eventPrice": "活动价格",
@@ -770,6 +931,7 @@
"appLockDesc": "使用生物识别锁定应用",
"unlockWithBiometric": "使用生物识别解锁",
"authenticationFailed": "认证失败。请重试。",
"nextBillingDateAdjusted": "已保存为下一次账单日",
"totalExpenseCopied": "总支出已复制:@",
"smsPermissionRequired": "需要短信权限",
"noSubscriptionSmsFound": "未找到订阅相关的短信",
@@ -811,7 +973,10 @@
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果并在需要时手动添加或修改。",
"startScanning": "开始扫描",
"foundSubscription": "找到订阅",
"latestSmsMessage": "最新短信内容",
"smsDetectedDate": "短信接收日期:@",
"serviceName": "服务名称",
"unknownService": "未知服务",
"nextBillingDateLabel": "下次付款日期",
"category": "类别",
"websiteUrlAuto": "网站URL自动提取",
@@ -899,6 +1064,15 @@
"subscriptionDetail": "订阅详情",
"enterAmount": "请输入金额",
"invalidAmount": "请输入有效的金额",
"featureComingSoon": "此功能即将推出"
"featureComingSoon": "此功能即将推出",
"exactAlarmPermission": "精确闹钟权限(闹钟和提醒)",
"exactAlarmPermissionDesc": "需要权限以确保在准确时间发送提醒。",
"allowAlarmsInSettings": "请在设置中允许“闹钟和提醒”。",
"testNotification": "测试通知",
"testSubscriptionBody": "测试订阅 • @",
"expirationReminderBody": "@ 订阅将在 # 天后到期。",
"eventEndNotificationTitle": "活动结束通知",
"eventEndNotificationBody": "@ 的优惠活动已结束。",
"paymentChargeNotification": "@ 订阅费用 @ 已扣款。"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

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

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

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

Binary file not shown.

122
doc/payment_card_plan.md Normal file
View File

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

View File

@@ -4,11 +4,13 @@ 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
@@ -31,6 +33,7 @@ class AddSubscriptionController {
DateTime? nextBillingDate;
bool isLoading = false;
String? selectedCategoryId;
String? selectedPaymentCardId;
// Event State
bool isEventActive = false;
@@ -126,6 +129,13 @@ class AddSubscriptionController {
// Localizations가 아직 준비되지 않은 경우 기본값 유지
}
// 기본 결제수단 설정
try {
final paymentCardProvider =
Provider.of<PaymentCardProvider>(context, listen: false);
selectedPaymentCardId = paymentCardProvider.defaultCard?.id;
} catch (_) {}
// 애니메이션 시작
animationController!.forward();
}
@@ -476,14 +486,22 @@ 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);
}
}
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
@@ -503,6 +521,7 @@ class AddSubscriptionController {
nextBillingDate: adjustedNext,
websiteUrl: websiteUrlController.text.trim(),
categoryId: selectedCategoryId,
paymentCardId: selectedPaymentCardId,
currency: currency,
isEventActive: isEventActive,
eventStartDate: eventStartDate,
@@ -515,7 +534,7 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
message: AppLocalizations.of(context).nextBillingDateAdjusted,
);
}
}

View File

@@ -13,6 +13,7 @@ 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 {
@@ -34,6 +35,7 @@ class DetailScreenController extends ChangeNotifier {
late String _billingCycle;
late DateTime _nextBillingDate;
String? _selectedCategoryId;
String? _selectedPaymentCardId;
late String _currency;
bool _isLoading = false;
@@ -46,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;
@@ -56,6 +59,8 @@ class DetailScreenController extends ChangeNotifier {
set billingCycle(String value) {
if (_billingCycle != value) {
_billingCycle = value;
// 결제 주기 변경 시 금액 표시 업데이트
_updateMonthlyCostFormat();
notifyListeners();
}
}
@@ -74,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;
@@ -153,6 +165,7 @@ class DetailScreenController extends ChangeNotifier {
_billingCycle = subscription.billingCycle;
_nextBillingDate = subscription.nextBillingDate;
_selectedCategoryId = subscription.categoryId;
_selectedPaymentCardId = subscription.paymentCardId;
_currency = subscription.currency;
// Event State 초기화
@@ -160,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);
}
}
@@ -261,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);
}
}
@@ -390,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;
@@ -402,7 +429,7 @@ 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;
@@ -415,6 +442,7 @@ class DetailScreenController extends ChangeNotifier {
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
subscription.nextBillingDate = adjustedNext;
subscription.categoryId = _selectedCategoryId;
subscription.paymentCardId = _selectedPaymentCardId;
subscription.currency = _currency;
// 이벤트 정보 업데이트
@@ -422,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;
}
@@ -443,7 +473,7 @@ class DetailScreenController extends ChangeNotifier {
if (adjustedNext.isAfter(originalDateOnly)) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
message: AppLocalizations.of(context).nextBillingDateAdjusted,
);
}

View File

@@ -1,16 +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 'package:flutter/foundation.dart' show kIsWeb;
import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/logger.dart';
import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
class SmsScanController extends ChangeNotifier {
// 상태 관리
@@ -22,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();
}
@@ -47,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 {
@@ -58,6 +114,11 @@ class SmsScanController extends ChangeNotifier {
_currentIndex = 0;
notifyListeners();
await _performSmsScan(context);
}
/// 실제 SMS 스캔 수행 (로딩 상태는 이미 설정되어 있음)
Future<void> _performSmsScan(BuildContext context) async {
try {
// Android에서 SMS 권한 확인 및 요청
final ctx = context;
@@ -88,18 +149,18 @@ class SmsScanController extends ChangeNotifier {
// 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;
@@ -109,7 +170,7 @@ class SmsScanController extends ChangeNotifier {
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
_converter.convertResultsToSubscriptions(scanResults);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
@@ -155,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);
@@ -196,16 +259,23 @@ class SmsScanController extends ChangeNotifier {
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
@@ -217,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,
@@ -226,6 +296,7 @@ class SmsScanController extends ChangeNotifier {
repeatCount: subscription.repeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: finalCategoryId,
paymentCardId: finalPaymentCardId,
currency: subscription.currency,
);
@@ -248,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) {
@@ -270,6 +344,11 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = [];
_currentIndex = 0;
_errorMessage = null;
_selectedPaymentCardId = null;
_currentSuggestion = null;
_shouldSuggestCardCreation = false;
serviceNameController.clear();
_forceServiceNameEditing = false;
notifyListeners();
}
@@ -282,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;
}
}

View File

@@ -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,6 +175,8 @@ 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';
@@ -147,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';
@@ -165,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)';
@@ -199,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 =>
@@ -407,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';
@@ -602,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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import 'payment_card_suggestion.dart';
class Subscription {
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?,
);
}

View File

@@ -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,
});
// 주기적 결제 여부 확인

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,9 @@ import '../services/notification_service.dart';
import '../services/exchange_rate_service.dart';
import '../services/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();
@@ -118,6 +146,7 @@ class SubscriptionProvider extends ChangeNotifier {
required DateTime nextBillingDate,
String? websiteUrl,
String? categoryId,
String? paymentCardId,
bool isAutoDetected = false,
int repeatCount = 1,
DateTime? lastPaymentDate,
@@ -136,6 +165,7 @@ class SubscriptionProvider extends ChangeNotifier {
nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl,
categoryId: categoryId,
paymentCardId: paymentCardId,
isAutoDetected: isAutoDetected,
repeatCount: repeatCount,
lastPaymentDate: lastPaymentDate,
@@ -237,10 +267,13 @@ 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,
);
@@ -267,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 =
@@ -320,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) {
@@ -397,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') {
@@ -541,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('💰 모든 구독이 이미 월 비용으로 저장되어 있습니다');
}
}
}

View File

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

View File

@@ -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를 위한 충분한 하단 여백

View File

@@ -1,5 +1,7 @@
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';
@@ -8,6 +10,7 @@ class AppLockScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
body: Center(
child: Column(
@@ -20,7 +23,7 @@ class AppLockScreen extends StatelessWidget {
),
const SizedBox(height: 24),
Text(
'앱이 잠겨 있습니다',
loc.appLocked,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
@@ -29,7 +32,7 @@ class AppLockScreen extends StatelessWidget {
),
const SizedBox(height: 16),
Text(
'생체 인증으로 잠금을 해제하세요',
loc.appLockDesc,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
@@ -45,7 +48,7 @@ class AppLockScreen extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'인증에 실패했습니다. 다시 시도해주세요.',
loc.authenticationFailed,
style: TextStyle(
color: cs.onPrimary,
),
@@ -56,7 +59,7 @@ class AppLockScreen extends StatelessWidget {
}
},
icon: const Icon(Icons.fingerprint),
label: const Text('생체 인증으로 잠금 해제'),
label: Text(loc.unlockWithBiometric),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,

View File

@@ -41,10 +41,11 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(
'카테고리 관리',
loc.categoryManagement,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
@@ -67,7 +68,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: '카테고리 이름',
labelText: loc.categoryName,
labelStyle: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -76,7 +77,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
),
validator: (value) {
if (value == null || value.isEmpty) {
return '카테고리 이름을 입력하세요';
return loc.categoryNameRequired;
}
return null;
},
@@ -85,7 +86,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
DropdownButtonFormField<String>(
initialValue: _selectedColor,
decoration: InputDecoration(
labelText: '색상 선택',
labelText: loc.selectColor,
labelStyle: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -144,7 +145,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
DropdownButtonFormField<String>(
initialValue: _selectedIcon,
decoration: InputDecoration(
labelText: '아이콘 선택',
labelText: loc.selectIcon,
labelStyle: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -154,35 +155,35 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
items: [
DropdownMenuItem(
value: 'subscriptions',
child: Text('구독',
child: Text(loc.subscription,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'movie',
child: Text('영화',
child: Text(loc.movie,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'music_note',
child: Text('음악',
child: Text(loc.music,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'fitness_center',
child: Text('운동',
child: Text(loc.exercise,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'shopping_cart',
child: Text('쇼핑',
child: Text(loc.shopping,
style: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -197,7 +198,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16),
ElevatedButton(
onPressed: _addCategory,
child: const Text('카테고리 추가'),
child: Text(loc.addCategory),
),
],
),

View File

@@ -3,6 +3,7 @@ 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';
@@ -120,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,

View File

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

View File

@@ -1,13 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../providers/notification_provider.dart';
import 'dart:io';
import '../services/notification_service.dart';
import 'package:url_launcher/url_launcher.dart';
// import '../widgets/glassmorphism_card.dart';
// import '../theme/app_colors.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart';
@@ -17,6 +16,8 @@ import '../providers/theme_provider.dart';
import '../theme/adaptive_theme.dart';
import '../widgets/common/layout/page_container.dart';
import '../theme/color_scheme_ext.dart';
import '../widgets/app_navigator.dart';
import '../theme/ui_constants.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -79,26 +80,62 @@ class SettingsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Column(
children: [
Expanded(
child: PageContainer(
padding: EdgeInsets.zero,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.fromLTRB(
16,
UIConstants.pageTopPadding,
16,
0,
),
children: [
// toolbar 높이 추가
SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
// 광고 위젯 추가
const NativeAdWidget(
key: ValueKey('settings_ad'),
useOuterPadding: true,
),
const SizedBox(height: 16),
// 테마 모드 설정
Card(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Semantics(
button: true,
label: loc.paymentCardManagement,
hint: loc.paymentCardManagementDescription,
child: ListTile(
leading: Icon(
Icons.credit_card,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(
loc.paymentCardManagement,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
subtitle: Text(
loc.paymentCardManagementDescription,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: Icon(Icons.chevron_right_rounded,
color:
Theme.of(context).colorScheme.onSurfaceVariant),
onTap: () =>
AppNavigator.toPaymentCardManagement(context),
),
),
),
Card(
margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
@@ -148,7 +185,7 @@ class SettingsScreen extends StatelessWidget {
leading: Icon(Icons.color_lens,
color: cs.onSurfaceVariant),
title: Text(
'테마',
loc.theme,
style: TextStyle(color: cs.onSurface),
),
),
@@ -317,14 +354,14 @@ class SettingsScreen extends StatelessWidget {
.colorScheme
.onSurfaceVariant),
title: Text(
'정확 알람 권한(알람 및 리마인더)',
loc.exactAlarmPermission,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
),
subtitle: Text(
'정확한 시각에 알림을 보장하려면 권한이 필요합니다.',
loc.exactAlarmPermissionDesc,
style: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -342,19 +379,19 @@ class SettingsScreen extends StatelessWidget {
if (ok || recheck) {
AppSnackBar.showSuccess(
context: context,
message: '권한이 허용되었습니다.',
message: loc.permissionGranted,
);
} else {
AppSnackBar.showInfo(
context: context,
message:
'설정에서 "알람 및 리마인더"를 허용해 주세요.',
loc.allowAlarmsInSettings,
);
}
(context as Element).markNeedsBuild();
}
},
child: const Text('허용 요청'),
child: Text(loc.requestPermission),
),
);
},
@@ -704,8 +741,8 @@ class SettingsScreen extends StatelessWidget {
child: OutlinedButton.icon(
icon: const Icon(Icons
.notifications_active),
label:
const Text('테스트 알림'),
label: Text(
loc.testNotification),
onPressed: () {
NotificationService
.showTestPaymentNotification();
@@ -848,7 +885,11 @@ class SettingsScreen extends StatelessWidget {
.withValues(alpha: 0.5),
),
),
child: ListTile(
child: FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) {
final version = snapshot.data?.version ?? '-';
return ListTile(
contentPadding: const EdgeInsets.all(8),
title: Text(
AppLocalizations.of(context).appInfo,
@@ -856,65 +897,70 @@ class SettingsScreen extends StatelessWidget {
color: Theme.of(context).colorScheme.onSurface),
),
subtitle: Text(
'${AppLocalizations.of(context).version} 1.0.0',
'${AppLocalizations.of(context).version} $version',
style: TextStyle(
color:
Theme.of(context).colorScheme.onSurfaceVariant),
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
),
leading: Icon(Icons.info,
color: Theme.of(context).colorScheme.onSurfaceVariant),
onTap: () async {
// 항상 앱 내 About 다이얼로그를 우선 표시
showAboutDialog(
context: context,
applicationName: AppLocalizations.of(context).appTitle,
applicationVersion: '1.0.0',
applicationIcon: const FlutterLogo(size: 50),
children: [
Text(AppLocalizations.of(context).appDescription),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context).developer}: Julian Sul'),
const SizedBox(height: 12),
Builder(builder: (ctx) {
return TextButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(AppLocalizations.of(ctx).openStore),
onPressed: () async {
try {
if (Platform.isAndroid) {
// 우선 Play 스토어 앱 시도
const pkg =
'com.naturebridgeai.digitalrentmanager';
final marketUri =
Uri.parse('market://details?id=$pkg');
final webUri = Uri.parse(
'https://play.google.com/store/apps/details?id=$pkg');
final ok = await launchUrl(marketUri,
mode: LaunchMode.externalApplication);
if (!ok) {
await launchUrl(webUri,
mode: LaunchMode.externalApplication);
}
} else if (Platform.isIOS) {
final uri = Uri.parse(
'https://apps.apple.com/app/id123456789');
await launchUrl(uri,
mode: LaunchMode.externalApplication);
}
} catch (e) {
if (ctx.mounted) {
AppSnackBar.showError(
context: ctx,
message: AppLocalizations.of(ctx)
.cannotOpenStore,
);
}
}
},
);
}),
],
color:
Theme.of(context).colorScheme.onSurfaceVariant),
onTap: null,
// onTap: () async {
// // 항상 앱 내 About 다이얼로그를 우선 표시 (현재 미사용)
// showAboutDialog(
// context: context,
// applicationName: AppLocalizations.of(context).appTitle,
// applicationVersion: '1.0.0',
// applicationIcon: const FlutterLogo(size: 50),
// children: [
// Text(AppLocalizations.of(context).appDescription),
// const SizedBox(height: 8),
// Text(
// '${AppLocalizations.of(context).developer}: Julian Sul'),
// const SizedBox(height: 12),
// Builder(builder: (ctx) {
// return TextButton.icon(
// icon: const Icon(Icons.open_in_new),
// label: Text(AppLocalizations.of(ctx).openStore),
// onPressed: () async {
// try {
// if (Platform.isAndroid) {
// // 우선 Play 스토어 앱 시도
// const pkg =
// 'com.naturebridgeai.digitalrentmanager';
// final marketUri =
// Uri.parse('market://details?id=$pkg');
// final webUri = Uri.parse(
// 'https://play.google.com/store/apps/details?id=$pkg');
// final ok = await launchUrl(marketUri,
// mode: LaunchMode.externalApplication);
// if (!ok) {
// await launchUrl(webUri,
// mode: LaunchMode.externalApplication);
// }
// } else if (Platform.isIOS) {
// final uri = Uri.parse(
// 'https://apps.apple.com/app/id123456789');
// await launchUrl(uri,
// mode: LaunchMode.externalApplication);
// }
// } catch (e) {
// if (ctx.mounted) {
// AppSnackBar.showError(
// context: ctx,
// message: AppLocalizations.of(ctx)
// .cannotOpenStore,
// );
// }
// }
// },
// );
// }),
// ],
// );
// },
);
},
),

View File

@@ -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});
@@ -43,7 +47,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
_controller.initializeWebsiteUrl();
_controller.initializeWebsiteUrl(context);
}
Widget _buildContent() {
@@ -53,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,
);
}
@@ -72,7 +76,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
}
});
return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context),
onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage,
);
}
@@ -93,11 +97,31 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 24),
SubscriptionCardWidget(
subscription: currentSubscription,
serviceNameController: _controller.serviceNameController,
websiteUrlController: _controller.websiteUrlController,
selectedCategoryId: _controller.selectedCategoryId,
onCategoryChanged: _controller.setSelectedCategoryId,
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),
),
],
);
@@ -114,6 +138,18 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
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(
@@ -125,15 +161,18 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@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(
@@ -141,6 +180,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
),
],
),
),
);
}
}

View File

@@ -384,7 +384,7 @@ 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: Theme.of(context)

View File

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

View File

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

View File

@@ -635,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;
@@ -656,8 +658,9 @@ 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(
@@ -849,11 +852,14 @@ class NotificationService {
if (_isWeb || !_initialized) return;
try {
final locale = _getLocaleCode();
final title = _paymentReminderTitle(locale);
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 = '테스트 구독 • $amountText';
final body = loc?.testSubscriptionBody(amountText) ??
'Test subscription • $amountText';
await _notifications.show(
DateTime.now().millisecondsSinceEpoch.remainder(1 << 31),
@@ -880,7 +886,11 @@ class NotificationService {
}
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(
@@ -925,6 +935,10 @@ class NotificationService {
}
static String _paymentReminderTitle(String locale) {
final ctx = navigatorKey.currentContext;
if (ctx != null) {
return AppLocalizations.of(ctx).paymentReminder;
}
switch (locale) {
case 'ko':
return '결제 예정 알림';

View File

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

View File

@@ -1,21 +1,21 @@
import '../../models/subscription.dart';
import '../../models/subscription_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,
);
}

View File

@@ -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';
@@ -6,11 +8,15 @@ 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: 스캔 시작');
@@ -36,100 +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}');
// 2회 이상 반복된 서비스만 구독으로 간주
if (entry.value.length >= 2) {
// 결제일 패턴 유추를 위해 최근 2개의 결제일을 사용
final messages = [...entry.value];
messages.sort((a, b) {
final da = DateTime.tryParse(a['previousPaymentDate'] ?? '') ??
DateTime(1970);
final db = DateTime.tryParse(b['previousPaymentDate'] ?? '') ??
DateTime(1970);
return db.compareTo(da); // desc
});
final mostRecent = messages.first;
DateTime? recentDate =
DateTime.tryParse(mostRecent['previousPaymentDate'] ?? '');
DateTime? prevDate = messages.length > 1
? DateTime.tryParse(messages[1]['previousPaymentDate'] ?? '')
: null;
// 기본 결제 일자(일단위) 추정: 가장 최근 결제의 일자
int baseDay = recentDate?.day ?? DateTime.now().day;
// 이전 결제가 주말 이월로 보이는 패턴인지 검사하여 baseDay 보정
if (recentDate != null && prevDate != null) {
final candidate = DateTime(prevDate.year, prevDate.month, baseDay);
if (BusinessDayUtil.isWeekend(candidate)) {
final diff = prevDate.difference(candidate).inDays;
if (diff >= 1 && diff <= 3) {
// 예: 12일(토)→14일(월)
baseDay = baseDay; // 유지
} else {
// 차이가 크면 이전 달의 일자를 채택
baseDay = prevDate.day;
}
}
Log.d('SmsScanner: 그룹 "${entry.key}" - 메시지 ${entry.value.length}');
final repeatResult = _detectRepeatingSubscriptions(entry.value);
if (repeatResult == null) {
Log.d('SmsScanner: 반복 조건 불충족 - ${entry.key}');
continue;
}
// 다음 결제일 계산: 기준 일자를 바탕으로 다음 달 또는 이번 달로 설정 후 영업일 보정
final DateTime now = DateTime.now();
int year = now.year;
int month = now.month;
if (now.day >= baseDay) {
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = baseDay.clamp(1, dim);
DateTime nextBilling = DateTime(year, month, day);
nextBilling = BusinessDayUtil.nextBusinessDay(nextBilling);
// 가장 최근 SMS 맵에 override 값으로 주입
final serviceSms = Map<String, dynamic>.from(mostRecent);
serviceSms['overrideNextBillingDate'] = nextBilling.toIso8601String();
final subscription = _parseSms(serviceSms, entry.value.length);
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');
}
}
@@ -161,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');
@@ -212,7 +175,7 @@ class SmsScanner {
adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
return SubscriptionModel(
final model = SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
serviceName: serviceName,
monthlyCost: monthlyCost,
@@ -224,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) {
@@ -340,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 파서 =====
@@ -347,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>>[];
@@ -389,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,
});
}
@@ -440,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();
}
@@ -473,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) {
@@ -486,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);
}

View File

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

View File

@@ -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}'
},
];
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)

View File

@@ -305,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,
@@ -315,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,
));
}

View File

@@ -1,7 +1,10 @@
class UIConstants {
static const double pageHorizontalPadding = 16;
static const double adVerticalPadding = 12;
static const double adCardHeight = 88;
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
}

View 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;
}
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
/// 결제수단 관련 공통 유틸리티
class PaymentCardUtils {
static const List<String> colorPalette = [
'#FF6B6B',
'#F97316',
'#F59E0B',
'#10B981',
'#06B6D4',
'#3B82F6',
'#6366F1',
'#8B5CF6',
'#EC4899',
'#14B8A6',
'#0EA5E9',
'#94A3B8',
];
static const Map<String, IconData> iconMap = {
'credit_card': Icons.credit_card_rounded,
'payments': Icons.payments_rounded,
'wallet': Icons.account_balance_wallet_rounded,
'bank': Icons.account_balance_rounded,
'shopping': Icons.shopping_bag_rounded,
'subscriptions': Icons.subscriptions_rounded,
'bolt': Icons.bolt_rounded,
};
static IconData iconForName(String name) {
return iconMap[name] ?? Icons.credit_card_rounded;
}
static Color colorFromHex(String hex) {
var value = hex.replaceAll('#', '');
if (value.length == 6) {
value = 'ff$value';
}
return Color(int.parse(value, radix: 16));
}
}

View File

@@ -35,7 +35,7 @@ class SmsDateFormatter {
);
}
return '다음 결제일 확인 필요 (과거 날짜)';
return AppLocalizations.of(context).nextBillingDatePastRequired;
}
// 미래 날짜 처리

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../models/payment_card_model.dart';
import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import 'subscription_category_helper.dart';
enum SubscriptionGroupingMode { category, paymentCard }
class SubscriptionGroupData {
final String id;
final String title;
final List<SubscriptionModel> subscriptions;
final SubscriptionGroupingMode mode;
final PaymentCardModel? paymentCard;
final bool isUnassignedCard;
final String? categoryKey;
final String? subtitle;
const SubscriptionGroupData({
required this.id,
required this.title,
required this.subscriptions,
required this.mode,
this.paymentCard,
this.isUnassignedCard = false,
this.categoryKey,
this.subtitle,
});
}
class SubscriptionGroupingHelper {
static const _unassignedCardKey = '__unassigned__';
static List<SubscriptionGroupData> buildGroups({
required BuildContext context,
required List<SubscriptionModel> subscriptions,
required SubscriptionGroupingMode mode,
required CategoryProvider categoryProvider,
required PaymentCardProvider paymentCardProvider,
}) {
if (mode == SubscriptionGroupingMode.paymentCard) {
return _groupByPaymentCard(
context: context,
subscriptions: subscriptions,
paymentCardProvider: paymentCardProvider,
);
}
return _groupByCategory(
context: context,
subscriptions: subscriptions,
categoryProvider: categoryProvider,
);
}
static List<SubscriptionGroupData> _groupByCategory({
required BuildContext context,
required List<SubscriptionModel> subscriptions,
required CategoryProvider categoryProvider,
}) {
final localizedMap = SubscriptionCategoryHelper.categorizeSubscriptions(
subscriptions, categoryProvider, context);
final orderMap = <String, int>{};
for (var i = 0; i < categoryProvider.categories.length; i++) {
orderMap[categoryProvider.categories[i].name] = i;
}
final groups = localizedMap.entries.map((entry) {
final title =
categoryProvider.getLocalizedCategoryName(context, entry.key);
return SubscriptionGroupData(
id: entry.key,
title: title,
subscriptions: entry.value,
mode: SubscriptionGroupingMode.category,
categoryKey: entry.key,
);
}).toList();
groups.sort((a, b) {
final ai = orderMap[a.categoryKey] ?? 999;
final bi = orderMap[b.categoryKey] ?? 999;
if (ai != bi) {
return ai.compareTo(bi);
}
return a.title.compareTo(b.title);
});
return groups;
}
static List<SubscriptionGroupData> _groupByPaymentCard({
required BuildContext context,
required List<SubscriptionModel> subscriptions,
required PaymentCardProvider paymentCardProvider,
}) {
final map = <String, List<SubscriptionModel>>{};
for (final sub in subscriptions) {
final key = sub.paymentCardId ?? _unassignedCardKey;
map.putIfAbsent(key, () => []).add(sub);
}
final loc = AppLocalizations.of(context);
final groups = <SubscriptionGroupData>[];
map.forEach((key, subs) {
if (key == _unassignedCardKey) {
groups.add(
SubscriptionGroupData(
id: key,
title: loc.paymentCardUnassigned,
subtitle: loc.paymentCardUnassigned,
subscriptions: subs,
mode: SubscriptionGroupingMode.paymentCard,
isUnassignedCard: true,
),
);
} else {
final card = paymentCardProvider.getCardById(key);
final title = card?.issuerName ?? loc.paymentCardUnassigned;
final subtitle =
card != null ? '****${card.last4}' : loc.paymentCardUnassigned;
groups.add(
SubscriptionGroupData(
id: key,
title: title,
subtitle: subtitle,
subscriptions: subs,
mode: SubscriptionGroupingMode.paymentCard,
paymentCard: card,
),
);
}
});
groups.sort((a, b) {
if (a.isUnassignedCard != b.isUnassignedCard) {
return a.isUnassignedCard ? 1 : -1;
}
final aDefault = a.paymentCard?.isDefault ?? false;
final bDefault = b.paymentCard?.isDefault ?? false;
if (aDefault != bDefault) {
return aDefault ? -1 : 1;
}
return a.title.toLowerCase().compareTo(b.title.toLowerCase());
});
return groups;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
// import '../../theme/app_colors.dart';
@@ -72,23 +73,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
const SizedBox(width: 12),
Builder(
builder: (context) {
final locale = Localizations.localeOf(context);
String titleText;
switch (locale.languageCode) {
case 'ko':
titleText = '이벤트 가격';
break;
case 'ja':
titleText = 'イベント価格';
break;
case 'zh':
titleText = '活动价格';
break;
default:
titleText = 'Event Price';
}
final loc = AppLocalizations.of(context);
return Text(
titleText,
loc.eventPrice,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
@@ -157,23 +144,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Expanded(
child: Builder(
builder: (context) {
final locale =
Localizations.localeOf(context);
String infoText;
switch (locale.languageCode) {
case 'ko':
infoText = '할인 또는 프로모션 가격을 설정하세요';
break;
case 'ja':
infoText = '割引またはプロモーション価格を設定してください';
break;
case 'zh':
infoText = '设置折扣或促销价格';
break;
default:
infoText =
'Set up discount or promotion price';
}
final loc = AppLocalizations.of(context);
final infoText = loc.eventPriceHint;
return Text(
infoText,
style: TextStyle(
@@ -195,26 +167,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
// 이벤트 기간
Builder(
builder: (context) {
final locale = Localizations.localeOf(context);
String startLabel;
String endLabel;
switch (locale.languageCode) {
case 'ko':
startLabel = '시작일';
endLabel = '종료일';
break;
case 'ja':
startLabel = '開始日';
endLabel = '終了日';
break;
case 'zh':
startLabel = '开始日期';
endLabel = '结束日期';
break;
default:
startLabel = 'Start Date';
endLabel = 'End Date';
}
final loc = AppLocalizations.of(context);
final startLabel = loc.startDate;
final endLabel = loc.endDate;
return DateRangePickerField(
startDate: controller.eventStartDate,
endDate: controller.eventEndDate,
@@ -245,37 +200,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
// 이벤트 가격
Builder(
builder: (BuildContext innerContext) {
// 현재 로케일 확인
final currentLocale =
Localizations.localeOf(innerContext);
// 로케일에 따라 직접 텍스트 설정
String eventPriceLabel;
String eventPriceHint;
switch (currentLocale.languageCode) {
case 'ko':
eventPriceLabel = '이벤트 가격';
eventPriceHint = '할인된 가격을 입력하세요';
break;
case 'ja':
eventPriceLabel = 'イベント価格';
eventPriceHint = '割引価格を入力してください';
break;
case 'zh':
eventPriceLabel = '活动价格';
eventPriceHint = '输入折扣价格';
break;
default:
eventPriceLabel = 'Event Price';
eventPriceHint = 'Enter discounted price';
}
final loc = AppLocalizations.of(innerContext);
return CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: eventPriceLabel,
hintText: eventPriceHint,
label: loc.eventPrice,
hintText: loc.eventPriceHint,
enabled: controller.isEventActive,
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
validator:

View File

@@ -10,6 +10,9 @@ import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_dropdown_field.dart';
import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../payment_card/payment_card_selector.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../routes/app_routes.dart';
// Glass 제거: Material 3 Card 사용
// Material colors only
@@ -234,6 +237,35 @@ class AddSubscriptionForm extends StatelessWidget {
);
},
),
const SizedBox(height: 20),
Text(
AppLocalizations.of(context).paymentCard,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
PaymentCardSelector(
selectedCardId: controller.selectedPaymentCardId,
onChanged: (cardId) {
setState(() {
controller.selectedPaymentCardId = cardId;
});
},
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
if (newCardId != null) {
setState(() {
controller.selectedPaymentCardId = newCardId;
});
}
},
onManageCards: () {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
},
),
],
),
),

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
@@ -11,21 +11,31 @@ import '../../theme/color_scheme_ext.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget {
final AnimationController animationController;
final List<SubscriptionModel> subscriptions;
const EventAnalysisCard({
super.key,
required this.animationController,
required this.subscriptions,
});
@override
Widget build(BuildContext context) {
final activeEventSubscriptions =
subscriptions.where((sub) => sub.isCurrentlyInEvent).toList();
if (activeEventSubscriptions.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final totalSavings = activeEventSubscriptions.fold<double>(
0,
(sum, sub) => sum + sub.eventSavings,
);
return SliverToBoxAdapter(
child: Consumer<SubscriptionProvider>(
builder: (context, provider, child) {
return Padding(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: provider.activeEventSubscriptions.isNotEmpty
? FadeTransition(
child: FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
@@ -34,10 +44,12 @@ class EventAnalysisCard extends StatelessWidget {
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
).animate(
CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
)),
),
),
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
@@ -55,15 +67,12 @@ class EventAnalysisCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: AppLocalizations.of(context)
.eventDiscountStatus,
style: const TextStyle(
fontSize: 18,
),
text:
AppLocalizations.of(context).eventDiscountStatus,
style: const TextStyle(fontSize: 18),
),
Container(
padding: const EdgeInsets.symmetric(
@@ -71,8 +80,7 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.error,
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(4),
),
child: Row(
@@ -80,22 +88,16 @@ class EventAnalysisCard extends StatelessWidget {
FaIcon(
FontAwesomeIcons.fire,
size: 12,
color: Theme.of(context)
.colorScheme
.onError,
color: Theme.of(context).colorScheme.onError,
),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)
.servicesInProgress(provider
.activeEventSubscriptions
.length),
AppLocalizations.of(context).servicesInProgress(
activeEventSubscriptions.length),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.onError,
color: Theme.of(context).colorScheme.onError,
),
),
],
@@ -110,28 +112,26 @@ class EventAnalysisCard extends StatelessWidget {
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.3),
.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Icon(
Icons.savings,
color:
Theme.of(context).colorScheme.error,
color: Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context)
@@ -143,15 +143,11 @@ class EventAnalysisCard extends StatelessWidget {
),
const SizedBox(height: 4),
ThemedText(
CurrencyUtil.formatTotalAmount(
provider.calculateTotalSavings(),
),
CurrencyUtil.formatTotalAmount(totalSavings),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.error,
color: Theme.of(context).colorScheme.error,
),
),
],
@@ -169,12 +165,11 @@ class EventAnalysisCard extends StatelessWidget {
),
),
const SizedBox(height: 12),
...provider.activeEventSubscriptions.map((sub) {
...activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice -
(sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100)
.round();
((savings / sub.originalPrice) * 100).round();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
@@ -195,8 +190,7 @@ class EventAnalysisCard extends StatelessWidget {
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
sub.serviceName,
@@ -209,10 +203,8 @@ class EventAnalysisCard extends StatelessWidget {
Row(
children: [
FutureBuilder<String>(
future:
CurrencyUtil.formatAmount(
sub.originalPrice,
sub.currency),
future: CurrencyUtil.formatAmount(
sub.originalPrice, sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
@@ -220,10 +212,8 @@ class EventAnalysisCard extends StatelessWidget {
style: TextStyle(
fontSize: 12,
decoration:
TextDecoration
.lineThrough,
color: Theme.of(
context)
TextDecoration.lineThrough,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
@@ -242,21 +232,18 @@ class EventAnalysisCard extends StatelessWidget {
),
const SizedBox(width: 8),
FutureBuilder<String>(
future:
CurrencyUtil.formatAmount(
sub.eventPrice ??
sub.originalPrice,
sub.currency),
future: CurrencyUtil.formatAmount(
sub.eventPrice ?? sub.originalPrice,
sub.currency,
),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: TextStyle(
fontSize: 12,
fontWeight:
FontWeight.bold,
color:
Theme.of(context)
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.success,
),
@@ -280,18 +267,14 @@ class EventAnalysisCard extends StatelessWidget {
.colorScheme
.error
.withValues(alpha: 0.2),
borderRadius:
BorderRadius.circular(4),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatDiscountPercent(
context, discountRate),
_formatDiscountPercent(context, discountRate),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.error,
color: Theme.of(context).colorScheme.error,
),
),
),
@@ -304,10 +287,7 @@ class EventAnalysisCard extends StatelessWidget {
),
),
),
)
: const SizedBox.shrink(),
);
},
),
),
);
}

View File

@@ -12,6 +12,7 @@ import 'analysis_badge.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/locale_provider.dart';
import '../../utils/reduce_motion.dart';
import '../../utils/billing_cost_util.dart';
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
class SubscriptionPieChartCard extends StatefulWidget {
@@ -94,9 +95,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
List<double> sectionValues = [];
// 각 구독의 현재 가격을 언어별 기본 통화로 환산
// 각 구독의 실제 결제 금액을 언어별 기본 통화로 환산
for (var subscription in widget.subscriptions) {
double value = subscription.currentPrice;
// 월 환산 금액을 실제 결제 금액으로 역변환
double value = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
if (subscription.currency == defaultCurrency) {
// 이미 기본 통화인 경우 그대로 사용
@@ -233,24 +238,32 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText.headline(
Expanded(
child: ThemedText.headline(
text: AppLocalizations.of(context)
.subscriptionServiceRatio,
style: const TextStyle(
fontSize: 18,
),
),
FutureBuilder<String>(
),
Flexible(
child: FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfoForLocale(
context
.watch<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return Container(
if (snapshot.hasData &&
snapshot.data!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(left: 12),
child: Align(
alignment: Alignment.topRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
@@ -269,14 +282,15 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
width: 1,
),
),
child: Text(
child: ThemedText(
AppLocalizations.of(context)
.exchangeRateFormat(snapshot.data!),
style: TextStyle(
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color:
Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.right,
),
),
),
);
@@ -284,6 +298,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
return const SizedBox.shrink();
},
),
),
],
),
const SizedBox(height: 8),

View File

@@ -77,6 +77,12 @@ class AppNavigator {
await Navigator.of(context).pushNamed(AppRoutes.settings);
}
/// 결제수단 관리 화면으로 네비게이션
static Future<void> toPaymentCardManagement(BuildContext context) async {
HapticFeedback.lightImpact();
await Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
}
/// 카테고리 관리 화면으로 네비게이션
static Future<void> toCategoryManagement(BuildContext context) async {
HapticFeedback.lightImpact();

View File

@@ -1,126 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../l10n/app_localizations.dart';
/// 카테고리별 구독 그룹의 헤더 위젯
///
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
/// 통화별로 구분하여 표시하며, 혼재된 경우 각각 표시합니다.
class CategoryHeaderWidget extends StatelessWidget {
final String categoryName;
final int subscriptionCount;
final double totalCostUSD;
final double totalCostKRW;
final double totalCostJPY;
final double totalCostCNY;
const CategoryHeaderWidget({
super.key,
required this.categoryName,
required this.subscriptionCount,
required this.totalCostUSD,
required this.totalCostKRW,
required this.totalCostJPY,
required this.totalCostCNY,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
categoryName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
Text(
_buildCostDisplay(context),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
Divider(
height: 1,
thickness: 1,
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
),
],
),
);
}
/// 통화별 합계를 표시하는 문자열을 생성합니다.
String _buildCostDisplay(BuildContext context) {
final parts = <String>[];
// 개수는 항상 표시
parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
// 통화 부분을 별도로 처리
final currencyParts = <String>[];
// 달러가 있는 경우
if (totalCostUSD > 0) {
final formatter = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostUSD));
}
// 원화가 있는 경우
if (totalCostKRW > 0) {
final formatter = NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostKRW));
}
// 엔화가 있는 경우
if (totalCostJPY > 0) {
final formatter = NumberFormat.currency(
locale: 'ja_JP',
symbol: '¥',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostJPY));
}
// 위안화가 있는 경우
if (totalCostCNY > 0) {
final formatter = NumberFormat.currency(
locale: 'zh_CN',
symbol: '¥',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostCNY));
}
// 통화가 하나 이상 있는 경우
if (currencyParts.isNotEmpty) {
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
final currencyDisplay = currencyParts.join(' + ');
parts.add(currencyDisplay);
}
return parts.join(' · ');
}
}

View File

@@ -1,358 +0,0 @@
import 'package:flutter/material.dart';
import '../../../theme/color_scheme_ext.dart';
/// 확인 다이얼로그 위젯
/// 사용자에게 중요한 작업을 확인받을 때 사용합니다.
class ConfirmationDialog extends StatelessWidget {
final String title;
final String? message;
final Widget? content;
final String confirmText;
final String cancelText;
final VoidCallback? onConfirm;
final VoidCallback? onCancel;
final Color? confirmColor;
final IconData? icon;
final Color? iconColor;
final double iconSize;
const ConfirmationDialog({
super.key,
required this.title,
this.message,
this.content,
this.confirmText = '확인',
this.cancelText = '취소',
this.onConfirm,
this.onCancel,
this.confirmColor,
this.icon,
this.iconColor,
this.iconSize = 48,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveConfirmColor = confirmColor ?? theme.primaryColor;
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color:
(iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: iconColor ?? effectiveConfirmColor,
size: iconSize,
),
),
const SizedBox(height: 16),
],
if (content != null)
content!
else if (message != null)
Text(
message!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
height: 1.5,
),
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
onCancel?.call();
},
child: Text(cancelText),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop(true);
onConfirm?.call();
},
style: ElevatedButton.styleFrom(
backgroundColor: effectiveConfirmColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
confirmText,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
);
}
/// 다이얼로그를 표시하고 결과를 반환하는 정적 메서드
static Future<bool?> show({
required BuildContext context,
required String title,
String? message,
Widget? content,
String confirmText = '확인',
String cancelText = '취소',
Color? confirmColor,
IconData? icon,
Color? iconColor,
double iconSize = 48,
}) {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => ConfirmationDialog(
title: title,
message: message,
content: content,
confirmText: confirmText,
cancelText: cancelText,
confirmColor: confirmColor,
icon: icon,
iconColor: iconColor,
iconSize: iconSize,
),
);
}
}
/// 성공 다이얼로그
class SuccessDialog extends StatelessWidget {
final String title;
final String? message;
final String buttonText;
final VoidCallback? onPressed;
const SuccessDialog({
super.key,
required this.title,
this.message,
this.buttonText = '확인',
this.onPressed,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.success.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.success,
size: 64,
),
),
const SizedBox(height: 24),
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (message != null) ...[
const SizedBox(height: 12),
Text(
message!,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
],
),
actions: [
Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onPressed?.call();
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.success,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
child: Text(
buttonText,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 16,
),
),
),
),
],
);
}
static Future<void> show({
required BuildContext context,
required String title,
String? message,
String buttonText = '확인',
VoidCallback? onPressed,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => SuccessDialog(
title: title,
message: message,
buttonText: buttonText,
onPressed: onPressed,
),
);
}
}
/// 에러 다이얼로그
class ErrorDialog extends StatelessWidget {
final String title;
final String? message;
final String buttonText;
final VoidCallback? onPressed;
const ErrorDialog({
super.key,
required this.title,
this.message,
this.buttonText = '확인',
this.onPressed,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 64,
),
),
const SizedBox(height: 24),
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (message != null) ...[
const SizedBox(height: 12),
Text(
message!,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
],
),
actions: [
Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onPressed?.call();
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
child: Text(
buttonText,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 16,
),
),
),
),
],
);
}
static Future<void> show({
required BuildContext context,
required String title,
String? message,
String buttonText = '확인',
VoidCallback? onPressed,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => ErrorDialog(
title: title,
message: message,
buttonText: buttonText,
onPressed: onPressed,
),
);
}
}

View File

@@ -9,6 +9,9 @@ import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_dropdown_field.dart';
import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../payment_card/payment_card_selector.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../routes/app_routes.dart';
import '../../l10n/app_localizations.dart';
/// 상세 화면 폼 섹션
@@ -184,6 +187,33 @@ class DetailFormSection extends StatelessWidget {
);
},
),
const SizedBox(height: 20),
Text(
AppLocalizations.of(context).paymentCard,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
PaymentCardSelector(
selectedCardId: controller.selectedPaymentCardId,
onChanged: (cardId) {
controller.selectedPaymentCardId = cardId;
},
onAddCard: () async {
final newCardId =
await PaymentCardFormSheet.show(context);
if (newCardId != null) {
controller.selectedPaymentCardId = newCardId;
}
},
onManageCards: () {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
},
),
],
),
),

View File

@@ -3,7 +3,12 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../providers/locale_provider.dart';
import '../../providers/payment_card_provider.dart';
import '../../services/currency_util.dart';
import '../../utils/payment_card_utils.dart';
import '../../models/payment_card_model.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../routes/app_routes.dart';
import '../website_icon.dart';
import '../../l10n/app_localizations.dart';
@@ -30,9 +35,13 @@ class DetailHeaderSection extends StatelessWidget {
return Consumer<DetailScreenController>(
builder: (context, controller, child) {
final baseColor = controller.getCardColor();
final paymentCardProvider = context.watch<PaymentCardProvider>();
final paymentCard = paymentCardProvider.getCardById(
controller.selectedPaymentCardId ?? subscription.paymentCardId,
);
return Container(
height: 320,
constraints: const BoxConstraints(minHeight: 320),
decoration: BoxDecoration(color: baseColor),
child: Stack(
children: [
@@ -69,6 +78,7 @@ class DetailHeaderSection extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 뒤로가기 버튼
@@ -91,7 +101,7 @@ class DetailHeaderSection extends StatelessWidget {
),
],
),
const Spacer(),
const SizedBox(height: 16),
// 서비스 정보
FadeTransition(
opacity: fadeAnimation,
@@ -172,6 +182,11 @@ class DetailHeaderSection extends StatelessWidget {
.withValues(alpha: 0.8),
),
),
const SizedBox(height: 12),
_buildPaymentCardChip(
context,
paymentCard,
),
],
),
),
@@ -186,17 +201,19 @@ class DetailHeaderSection extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
_InfoColumn(
Expanded(
child: _InfoColumn(
label: AppLocalizations.of(context)
.nextBillingDate,
value: AppLocalizations.of(context)
.formatDate(
controller.nextBillingDate),
),
FutureBuilder<String>(
),
const SizedBox(width: 12),
Expanded(
child: FutureBuilder<String>(
future: () async {
final locale = context
.read<LocaleProvider>()
@@ -204,7 +221,8 @@ class DetailHeaderSection extends StatelessWidget {
.languageCode;
final amount = double.tryParse(
controller
.monthlyCostController.text
.monthlyCostController
.text
.replaceAll(',', '')) ??
0;
return CurrencyUtil
@@ -217,12 +235,14 @@ class DetailHeaderSection extends StatelessWidget {
builder: (context, snapshot) {
return _InfoColumn(
label: AppLocalizations.of(context)
.monthlyExpense,
.billingAmount,
value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end,
wrapValue: true,
);
},
),
),
],
),
),
@@ -245,29 +265,143 @@ class DetailHeaderSection extends StatelessWidget {
final loc = AppLocalizations.of(context);
switch (cycle.toLowerCase()) {
case '매월':
case '월간':
case 'monthly':
case '毎月':
case '月付':
case '月間':
case '每月':
return loc.billingCycleMonthly;
case '분기별':
case '분기':
case 'quarterly':
case 'quarter':
case '季付':
case '季度付':
case '四半期':
case '每季度':
return loc.billingCycleQuarterly;
case '반기별':
case 'half-yearly':
case 'half yearly':
case 'semiannual':
case 'semi-annual':
case '半年付':
case '半年払い':
case '半年ごと':
case '每半年':
return loc.billingCycleHalfYearly;
case '매년':
case '연간':
case 'yearly':
case 'annual':
case 'annually':
case '年間':
case '年付':
case '每年':
return loc.billingCycleYearly;
default:
return cycle;
}
}
Widget _buildPaymentCardChip(
BuildContext context,
PaymentCardModel? card,
) {
final loc = AppLocalizations.of(context);
if (card == null) {
return GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.credit_card_off_rounded,
color: Colors.white,
size: 16,
),
const SizedBox(width: 8),
Text(
loc.paymentCardUnassigned,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios_rounded,
color: Colors.white.withValues(alpha: 0.7),
size: 14,
),
],
),
),
);
}
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
return GestureDetector(
onTap: () async {
await PaymentCardFormSheet.show(context, card: card);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: color.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 14,
backgroundColor: Colors.white,
child: Icon(
icon,
size: 16,
color: color,
),
),
const SizedBox(width: 10),
Text(
'${card.issuerName} · ****${card.last4}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(width: 8),
Icon(
Icons.edit_rounded,
size: 16,
color: Colors.white.withValues(alpha: 0.8),
),
],
),
),
);
}
}
/// 정보 표시 컬럼
@@ -275,11 +409,13 @@ class _InfoColumn extends StatelessWidget {
final String label;
final String value;
final CrossAxisAlignment alignment;
final bool wrapValue;
const _InfoColumn({
required this.label,
required this.value,
this.alignment = CrossAxisAlignment.start,
this.wrapValue = false,
});
@override
@@ -296,6 +432,19 @@ class _InfoColumn extends StatelessWidget {
),
),
const SizedBox(height: 4),
if (wrapValue)
Text(
value,
textAlign: TextAlign.end,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
)
else
Text(
value,
style: const TextStyle(

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../models/payment_card_model.dart';
import '../../providers/payment_card_provider.dart';
import '../../routes/app_routes.dart';
import '../../utils/payment_card_utils.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../l10n/app_localizations.dart';
/// 상세 화면 결제 정보 섹션
/// 현재 구독에 연결된 결제수단 정보를 요약하여 보여준다.
class DetailPaymentInfoSection extends StatelessWidget {
final DetailScreenController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const DetailPaymentInfoSection({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
return Consumer2<DetailScreenController, PaymentCardProvider>(
builder: (context, detailController, paymentCardProvider, child) {
final baseColor = detailController.getCardColor();
final paymentCard = paymentCardProvider.getCardById(
detailController.selectedPaymentCardId ??
detailController.subscription.paymentCardId,
);
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.credit_card_rounded,
color: baseColor,
size: 22,
),
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).paymentCard,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
const Spacer(),
TextButton.icon(
onPressed: () {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
},
icon: const Icon(Icons.settings_rounded, size: 18),
label: Text(
AppLocalizations.of(context).paymentCardManagement,
),
),
],
),
const SizedBox(height: 16),
_PaymentCardInfoTile(
card: paymentCard,
onTap: () async {
if (paymentCard != null) {
await PaymentCardFormSheet.show(
context,
card: paymentCard,
);
} else {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
}
},
),
],
),
),
),
),
);
},
);
}
}
class _PaymentCardInfoTile extends StatelessWidget {
final PaymentCardModel? card;
final VoidCallback onTap;
const _PaymentCardInfoTile({
required this.card,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context);
final hasCard = card != null;
final chipColor = hasCard
? PaymentCardUtils.colorFromHex(card!.colorHex)
: scheme.onSurfaceVariant;
final icon = hasCard
? PaymentCardUtils.iconForName(card!.iconName)
: Icons.credit_card_off_rounded;
return Material(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: chipColor.withValues(alpha: 0.15),
child: Icon(
icon,
color: chipColor,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.paymentCard,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
hasCard
? '${card!.issuerName} · ****${card!.last4}'
: loc.paymentCardUnassigned,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
],
),
),
Icon(
hasCard ? Icons.edit_rounded : Icons.add_rounded,
color: scheme.onSurfaceVariant,
),
],
),
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
// Material 3 기반 다이얼로그
import '../common/buttons/primary_button.dart';
import '../common/buttons/secondary_button.dart';
import '../../l10n/app_localizations.dart';
/// 삭제 확인 다이얼로그
/// 글래스모피즘 스타일의 삭제 확인 다이얼로그입니다.
@@ -15,8 +16,20 @@ class DeleteConfirmationDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
final textThemeColor = Theme.of(context).colorScheme;
final baseMessageStyle = TextStyle(
fontSize: 16,
color: textThemeColor.onSurfaceVariant,
height: 1.5,
);
final highlightStyle = baseMessageStyle.copyWith(
fontWeight: FontWeight.w600,
color: textThemeColor.onSurface,
);
return Dialog(
backgroundColor: Theme.of(context).colorScheme.surface,
backgroundColor: textThemeColor.surface,
elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Container(
@@ -44,11 +57,11 @@ class DeleteConfirmationDialog extends StatelessWidget {
// 타이틀
Text(
'구독 삭제',
loc.deleteSubscriptionTitle,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
color: textThemeColor.onSurface,
),
),
const SizedBox(height: 12),
@@ -57,22 +70,12 @@ class DeleteConfirmationDialog extends StatelessWidget {
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.5,
style: baseMessageStyle,
children: _buildLocalizedMessageSpans(
loc.deleteSubscriptionMessageTemplate,
serviceName,
highlightStyle,
),
children: [
const TextSpan(text: '정말로 '),
TextSpan(
text: serviceName,
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const TextSpan(text: ' 구독을\n삭제하시겠습니까?'),
],
),
),
const SizedBox(height: 8),
@@ -84,14 +87,10 @@ class DeleteConfirmationDialog extends StatelessWidget {
vertical: 12,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.error.withValues(alpha: 0.05),
color: textThemeColor.error.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
color: textThemeColor.error.withValues(alpha: 0.2),
width: 1,
),
),
@@ -100,18 +99,15 @@ class DeleteConfirmationDialog extends StatelessWidget {
children: [
Icon(
Icons.warning_amber_rounded,
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.8),
color: textThemeColor.error.withValues(alpha: 0.8),
size: 20,
),
const SizedBox(width: 8),
Text(
'이 작업은 되돌릴 수 없습니다',
loc.deleteIrreversibleWarning,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.error,
color: textThemeColor.error,
fontWeight: FontWeight.w500,
),
),
@@ -125,7 +121,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
children: [
Expanded(
child: SecondaryButton(
text: '취소',
text: loc.cancel,
onPressed: () {
Navigator.of(context).pop(false);
},
@@ -134,12 +130,12 @@ class DeleteConfirmationDialog extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: PrimaryButton(
text: '삭제',
text: loc.delete,
icon: Icons.delete_rounded,
onPressed: () {
Navigator.of(context).pop(true);
},
backgroundColor: Theme.of(context).colorScheme.error,
backgroundColor: textThemeColor.error,
),
),
],
@@ -166,4 +162,27 @@ class DeleteConfirmationDialog extends StatelessWidget {
return result ?? false;
}
List<TextSpan> _buildLocalizedMessageSpans(
String template,
String serviceName,
TextStyle highlightStyle,
) {
final parts = template.split('@');
if (parts.length == 1) {
return [TextSpan(text: template)];
}
final spans = <TextSpan>[];
for (int i = 0; i < parts.length; i++) {
final segment = parts[i];
if (segment.isNotEmpty) {
spans.add(TextSpan(text: segment));
}
if (i < parts.length - 1) {
spans.add(TextSpan(text: serviceName, style: highlightStyle));
}
}
return spans;
}
}

View File

@@ -1,16 +1,18 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../utils/subscription_category_helper.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/subscription_list_widget.dart';
import '../widgets/empty_state_widget.dart';
// import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
class HomeContent extends StatelessWidget {
import '../l10n/app_localizations.dart';
import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import '../providers/subscription_provider.dart';
import '../utils/subscription_grouping_helper.dart';
import '../widgets/empty_state_widget.dart';
import '../widgets/main_summary_card.dart';
import '../theme/ui_constants.dart';
import '../widgets/subscription_list_widget.dart';
class HomeContent extends StatefulWidget {
final AnimationController fadeController;
final AnimationController rotateController;
final AnimationController slideController;
@@ -31,10 +33,53 @@ class HomeContent extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
final provider = context.watch<SubscriptionProvider>();
State<HomeContent> createState() => _HomeContentState();
}
if (provider.isLoading) {
class _HomeContentState extends State<HomeContent> {
static const _groupingPrefKey = 'home_grouping_mode';
SubscriptionGroupingMode _groupingMode = SubscriptionGroupingMode.category;
@override
void initState() {
super.initState();
_loadGroupingPreference();
}
Future<void> _loadGroupingPreference() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(_groupingPrefKey);
if (stored == 'paymentCard') {
setState(() {
_groupingMode = SubscriptionGroupingMode.paymentCard;
});
}
}
Future<void> _saveGroupingPreference(SubscriptionGroupingMode mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_groupingPrefKey,
mode == SubscriptionGroupingMode.paymentCard
? 'paymentCard'
: 'category');
}
void _updateGroupingMode(SubscriptionGroupingMode mode) {
if (_groupingMode == mode) return;
setState(() {
_groupingMode = mode;
});
_saveGroupingPreference(mode);
}
@override
Widget build(BuildContext context) {
final subscriptionProvider = context.watch<SubscriptionProvider>();
final categoryProvider = context.watch<CategoryProvider>();
final paymentCardProvider = context.watch<PaymentCardProvider>();
if (subscriptionProvider.isLoading) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
@@ -44,41 +89,34 @@ class HomeContent extends StatelessWidget {
);
}
if (provider.subscriptions.isEmpty) {
if (subscriptionProvider.subscriptions.isEmpty) {
return EmptyStateWidget(
fadeController: fadeController,
rotateController: rotateController,
slideController: slideController,
onAddPressed: onAddPressed,
fadeController: widget.fadeController,
rotateController: widget.rotateController,
slideController: widget.slideController,
onAddPressed: widget.onAddPressed,
);
}
// 카테고리별 구독 구분
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categorizedSubscriptions =
SubscriptionCategoryHelper.categorizeSubscriptions(
provider.subscriptions,
categoryProvider,
context,
final groupedSubscriptions = SubscriptionGroupingHelper.buildGroups(
context: context,
subscriptions: subscriptionProvider.subscriptions,
mode: _groupingMode,
categoryProvider: categoryProvider,
paymentCardProvider: paymentCardProvider,
);
return RefreshIndicator(
onRefresh: () async {
await provider.refreshSubscriptions();
await subscriptionProvider.refreshSubscriptions();
},
color: Theme.of(context).colorScheme.primary,
child: CustomScrollView(
controller: scrollController,
controller: widget.scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
),
const SliverToBoxAdapter(
child: NativeAdWidget(key: ValueKey('home_ad')),
child: SizedBox(height: UIConstants.pageTopPadding),
),
SliverToBoxAdapter(
child: SlideTransition(
@@ -86,13 +124,13 @@ class HomeContent extends StatelessWidget {
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)),
parent: widget.slideController, curve: Curves.easeOutCubic)),
child: MainScreenSummaryCard(
provider: provider,
fadeController: fadeController,
pulseController: pulseController,
waveController: waveController,
slideController: slideController,
provider: subscriptionProvider,
fadeController: widget.fadeController,
pulseController: widget.pulseController,
waveController: widget.waveController,
slideController: widget.slideController,
),
),
),
@@ -107,7 +145,8 @@ class HomeContent extends StatelessWidget {
begin: const Offset(-0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)),
parent: widget.slideController,
curve: Curves.easeOutCubic)),
child: Text(
AppLocalizations.of(context).mySubscriptions,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
@@ -120,12 +159,13 @@ class HomeContent extends StatelessWidget {
begin: const Offset(0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)),
parent: widget.slideController,
curve: Curves.easeOutCubic)),
child: Row(
children: [
Text(
AppLocalizations.of(context)
.subscriptionCount(provider.subscriptions.length),
AppLocalizations.of(context).subscriptionCount(
subscriptionProvider.subscriptions.length),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -145,9 +185,33 @@ class HomeContent extends StatelessWidget {
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Wrap(
spacing: 8,
children: [
ChoiceChip(
label: Text(AppLocalizations.of(context).category),
selected:
_groupingMode == SubscriptionGroupingMode.category,
onSelected: (_) =>
_updateGroupingMode(SubscriptionGroupingMode.category),
),
ChoiceChip(
label: Text(AppLocalizations.of(context).paymentCard),
selected:
_groupingMode == SubscriptionGroupingMode.paymentCard,
onSelected: (_) => _updateGroupingMode(
SubscriptionGroupingMode.paymentCard),
),
],
),
),
),
SubscriptionListWidget(
categorizedSubscriptions: categorizedSubscriptions,
fadeController: fadeController,
groups: groupedSubscriptions,
fadeController: widget.fadeController,
),
SliverToBoxAdapter(
child: SizedBox(

View File

@@ -203,7 +203,7 @@ class MainScreenSummaryCard extends StatelessWidget {
// 연간 비용 및 총 구독 수 표시
FutureBuilder<double>(
future: CurrencyUtil
.calculateTotalMonthlyExpenseInDefaultCurrency(
.calculateTotalAnnualExpenseInDefaultCurrency(
provider.subscriptions,
locale,
),
@@ -211,8 +211,7 @@ class MainScreenSummaryCard extends StatelessWidget {
if (!snapshot.hasData) {
return const SizedBox();
}
final monthlyCost = snapshot.data!;
final yearlyCost = monthlyCost * 12;
final yearlyCost = snapshot.data!;
final decimals = (defaultCurrency == 'KRW' ||
defaultCurrency == 'JPY')
? 0

View File

@@ -11,7 +11,16 @@ import '../theme/ui_constants.dart';
/// SRP에 따라 광고 전용 위젯으로 분리
class NativeAdWidget extends StatefulWidget {
final bool useOuterPadding; // true이면 외부에서 페이지 패딩을 제공
const NativeAdWidget({super.key, this.useOuterPadding = false});
final TemplateType? templateTypeOverride;
final double? aspectRatioOverride;
final MediaAspectRatio? mediaAspectRatioOverride;
const NativeAdWidget({
super.key,
this.useOuterPadding = false,
this.templateTypeOverride,
this.aspectRatioOverride,
this.mediaAspectRatioOverride,
});
@override
State<NativeAdWidget> createState() => _NativeAdWidgetState();
@@ -58,10 +67,14 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
// 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다.
nativeTemplateStyle: NativeTemplateStyle(
templateType: TemplateType.small,
templateType: widget.templateTypeOverride ?? TemplateType.medium,
mainBackgroundColor: const Color(0x00000000),
cornerRadius: 12,
),
nativeAdOptions: NativeAdOptions(
mediaAspectRatio:
widget.mediaAspectRatioOverride ?? MediaAspectRatio.square,
),
request: const AdRequest(),
listener: NativeAdListener(
onAdLoaded: (ad) {
@@ -129,12 +142,19 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
super.dispose();
}
double _adSlotHeight(double availableWidth) {
final safeWidth =
availableWidth > 0 ? availableWidth : UIConstants.nativeAdWidth;
final aspectRatio =
widget.aspectRatioOverride ?? UIConstants.nativeAdAspectRatio;
return safeWidth / aspectRatio;
}
/// 웹용 광고 플레이스홀더 위젯
Widget _buildWebPlaceholder() {
Widget _buildWebPlaceholder(double slotHeight, double horizontalPadding) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal:
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
horizontal: horizontalPadding,
vertical: UIConstants.adVerticalPadding,
),
child: Card(
@@ -143,7 +163,7 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
borderRadius: BorderRadius.zero,
),
child: Container(
height: UIConstants.adCardHeight,
height: slotHeight,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
@@ -232,9 +252,19 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
return const SizedBox.shrink();
}
return LayoutBuilder(
builder: (context, constraints) {
final double horizontalPadding =
widget.useOuterPadding ? 0.0 : UIConstants.pageHorizontalPadding;
final availableWidth = (constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.of(context).size.width) -
(horizontalPadding * 2);
final double slotHeight = _adSlotHeight(availableWidth);
// 웹 환경인 경우 플레이스홀더 표시
if (kIsWeb) {
return _buildWebPlaceholder();
return _buildWebPlaceholder(slotHeight, horizontalPadding);
}
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
@@ -244,19 +274,18 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
if (_error != null) {
// 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지
return _buildWebPlaceholder();
return _buildWebPlaceholder(slotHeight, horizontalPadding);
}
if (!_isLoaded) {
// 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지
return _buildWebPlaceholder();
return _buildWebPlaceholder(slotHeight, horizontalPadding);
}
// 광고 정상 노출
return Padding(
padding: EdgeInsets.symmetric(
horizontal:
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
horizontal: horizontalPadding,
vertical: UIConstants.adVerticalPadding,
),
child: Card(
@@ -265,10 +294,12 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
borderRadius: BorderRadius.zero,
),
child: SizedBox(
height: UIConstants.adCardHeight,
height: slotHeight,
child: AdWidget(ad: _nativeAd!),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,290 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
class PaymentCardFormSheet extends StatefulWidget {
final PaymentCardModel? card;
final String? initialIssuerName;
final String? initialLast4;
final String? initialColorHex;
final String? initialIconName;
const PaymentCardFormSheet({
super.key,
this.card,
this.initialIssuerName,
this.initialLast4,
this.initialColorHex,
this.initialIconName,
});
static Future<String?> show(
BuildContext context, {
PaymentCardModel? card,
String? initialIssuerName,
String? initialLast4,
String? initialColorHex,
String? initialIconName,
}) async {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => PaymentCardFormSheet(
card: card,
initialIssuerName: initialIssuerName,
initialLast4: initialLast4,
initialColorHex: initialColorHex,
initialIconName: initialIconName,
),
);
}
@override
State<PaymentCardFormSheet> createState() => _PaymentCardFormSheetState();
}
class _PaymentCardFormSheetState extends State<PaymentCardFormSheet> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _issuerController;
late TextEditingController _last4Controller;
late String _selectedColor;
late String _selectedIcon;
late bool _isDefault;
bool _isSaving = false;
@override
void initState() {
super.initState();
_issuerController = TextEditingController(
text: widget.card?.issuerName ?? widget.initialIssuerName ?? '',
);
_last4Controller = TextEditingController(
text: widget.card?.last4 ?? widget.initialLast4 ?? '',
);
_selectedColor = widget.card?.colorHex ??
widget.initialColorHex ??
PaymentCardUtils.colorPalette.first;
_selectedIcon = widget.card?.iconName ??
widget.initialIconName ??
PaymentCardUtils.iconMap.keys.first;
_isDefault = widget.card?.isDefault ?? false;
}
@override
void dispose() {
_issuerController.dispose();
_last4Controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
final isEditing = widget.card != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
isEditing ? loc.editPaymentCard : loc.addPaymentCard,
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 16),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _issuerController,
decoration: InputDecoration(
labelText: loc.paymentCardIssuer,
border: const OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return loc.requiredFieldsError;
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _last4Controller,
decoration: InputDecoration(
labelText: loc.paymentCardLast4,
border: const OutlineInputBorder(),
counterText: '',
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
validator: (value) {
if (value == null || value.length != 4) {
return loc.paymentCardLast4;
}
return null;
},
),
const SizedBox(height: 16),
Text(
loc.paymentCardColor,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: PaymentCardUtils.colorPalette.map((hex) {
final color = PaymentCardUtils.colorFromHex(hex);
final selected = _selectedColor == hex;
return GestureDetector(
onTap: () {
setState(() {
_selectedColor = hex;
});
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: selected
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
loc.paymentCardIcon,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: PaymentCardUtils.iconMap.entries.map((entry) {
final selected = _selectedIcon == entry.key;
return ChoiceChip(
label: Icon(entry.value,
color: selected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface),
selected: selected,
onSelected: (_) {
setState(() {
_selectedIcon = entry.key;
});
},
selectedColor: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
);
}).toList(),
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(loc.setAsDefaultCard),
value: _isDefault,
onChanged: (value) {
setState(() {
_isDefault = value;
});
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _isSaving ? null : _handleSubmit,
child: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(loc.save),
),
),
],
),
),
],
),
),
);
}
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSaving = true;
});
try {
final provider = context.read<PaymentCardProvider>();
String cardId;
if (widget.card == null) {
final card = await provider.addCard(
issuerName: _issuerController.text.trim(),
last4: _last4Controller.text.trim(),
colorHex: _selectedColor,
iconName: _selectedIcon,
isDefault: _isDefault,
);
cardId = card.id;
} else {
widget.card!
..issuerName = _issuerController.text.trim()
..last4 = _last4Controller.text.trim()
..colorHex = _selectedColor
..iconName = _selectedIcon
..isDefault = _isDefault;
await provider.updateCard(widget.card!);
cardId = widget.card!.id;
}
if (mounted) {
Navigator.of(context).pop(cardId);
}
} finally {
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
}

View File

@@ -0,0 +1,142 @@
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';
class PaymentCardSelector extends StatelessWidget {
final String? selectedCardId;
final ValueChanged<String?> onChanged;
final Future<void> Function()? onAddCard;
final VoidCallback? onManageCards;
const PaymentCardSelector({
super.key,
required this.selectedCardId,
required this.onChanged,
this.onAddCard,
this.onManageCards,
});
@override
Widget build(BuildContext context) {
return Consumer<PaymentCardProvider>(
builder: (context, provider, child) {
final loc = AppLocalizations.of(context);
final cards = provider.cards;
final unassignedSelected = selectedCardId == null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Semantics(
label: loc.paymentCardUnassigned,
selected: unassignedSelected,
button: true,
child: ChoiceChip(
label: Text(loc.paymentCardUnassigned),
selected: unassignedSelected,
onSelected: (_) => onChanged(null),
avatar: const Icon(Icons.credit_card_off_rounded, size: 18),
),
),
...cards.map((card) => _PaymentCardChip(
card: card,
isSelected: selectedCardId == card.id,
onSelected: () => onChanged(card.id),
)),
],
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: cards.isEmpty && onAddCard == null
? null
: () async {
if (onAddCard != null) {
await onAddCard!();
}
},
icon: const Icon(Icons.add),
label: Text(loc.addNewCard),
),
const SizedBox(width: 8),
TextButton(
onPressed: onManageCards,
child: Text(loc.managePaymentCards),
),
],
),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
loc.noPaymentCards,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
),
],
);
},
);
}
}
class _PaymentCardChip extends StatelessWidget {
final PaymentCardModel card;
final bool isSelected;
final VoidCallback onSelected;
const _PaymentCardChip({
required this.card,
required this.isSelected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
final cs = Theme.of(context).colorScheme;
final labelText = '${card.issuerName} · ****${card.last4}';
return Semantics(
label: labelText,
selected: isSelected,
button: true,
child: ChoiceChip(
avatar: CircleAvatar(
backgroundColor:
isSelected ? cs.onPrimary : color.withValues(alpha: 0.15),
child: Icon(
icon,
color: isSelected ? color : cs.onSurface,
size: 16,
),
),
label: Text(labelText),
selected: isSelected,
onSelected: (_) => onSelected(),
selectedColor: color,
labelStyle: TextStyle(
color: isSelected ? cs.onPrimary : cs.onSurface,
fontWeight: FontWeight.w600,
),
backgroundColor: cs.surface,
side: BorderSide(
color: isSelected
? Colors.transparent
: cs.outline.withValues(alpha: 0.5),
),
),
);
}
}

View File

@@ -19,9 +19,6 @@ class ScanInitialWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
const SizedBox(height: 48),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
@@ -64,6 +61,8 @@ class ScanInitialWidget extends StatelessWidget {
],
),
),
const SizedBox(height: 32),
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
],
);
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../../widgets/native_ad_widget.dart';
import '../../widgets/themed_text.dart';
import '../../l10n/app_localizations.dart';
@@ -8,14 +7,11 @@ class ScanLoadingWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
const NativeAdWidget(key: ValueKey('sms_scan_loading_ad')),
const SizedBox(height: 48),
Padding(
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
@@ -24,17 +20,18 @@ class ScanLoadingWidget extends StatelessWidget {
ThemedText(
AppLocalizations.of(context).scanningMessages,
forceDark: true,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
ThemedText(
AppLocalizations.of(context).findingSubscriptions,
opacity: 0.7,
forceDark: true,
textAlign: TextAlign.center,
),
],
),
),
],
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/subscription.dart';
import '../../models/payment_card_suggestion.dart';
import '../../providers/category_provider.dart';
import '../../providers/locale_provider.dart';
import '../../widgets/themed_text.dart';
@@ -9,7 +10,7 @@ import '../../widgets/common/buttons/secondary_button.dart';
import '../../widgets/common/form_fields/base_text_field.dart';
import '../../widgets/common/form_fields/category_selector.dart';
import '../../widgets/common/snackbar/app_snackbar.dart';
import '../../widgets/native_ad_widget.dart';
import '../../widgets/payment_card/payment_card_selector.dart';
import '../../services/currency_util.dart';
import '../../utils/sms_scan/date_formatter.dart';
import '../../utils/sms_scan/category_icon_mapper.dart';
@@ -17,20 +18,41 @@ import '../../l10n/app_localizations.dart';
class SubscriptionCardWidget extends StatefulWidget {
final Subscription subscription;
final TextEditingController serviceNameController;
final TextEditingController websiteUrlController;
final String? selectedCategoryId;
final Function(String?) onCategoryChanged;
final String? selectedPaymentCardId;
final Function(String?) onPaymentCardChanged;
final Future<void> Function()? onAddCard;
final VoidCallback? onManageCards;
final VoidCallback onAdd;
final VoidCallback onSkip;
final PaymentCardSuggestion? detectedCardSuggestion;
final bool showDetectedCardShortcut;
final Future<void> Function(PaymentCardSuggestion suggestion)?
onAddDetectedCard;
final bool enableServiceNameEditing;
final ValueChanged<String>? onServiceNameChanged;
const SubscriptionCardWidget({
super.key,
required this.subscription,
required this.serviceNameController,
required this.websiteUrlController,
this.selectedCategoryId,
required this.onCategoryChanged,
required this.selectedPaymentCardId,
required this.onPaymentCardChanged,
this.onAddCard,
this.onManageCards,
required this.onAdd,
required this.onSkip,
this.detectedCardSuggestion,
this.showDetectedCardShortcut = false,
this.onAddDetectedCard,
this.enableServiceNameEditing = false,
this.onServiceNameChanged,
});
@override
@@ -64,9 +86,12 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16),
if (_hasRawSmsMessage)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _buildSmsPreviewCard(context),
),
if (_hasRawSmsMessage) const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
@@ -126,6 +151,75 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
);
}
bool get _hasRawSmsMessage {
return widget.subscription.rawMessage != null &&
widget.subscription.rawMessage!.trim().isNotEmpty;
}
Widget _buildSmsPreviewCard(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context);
final rawMessage = widget.subscription.rawMessage?.trim() ?? '';
final lastDate = widget.subscription.lastPaymentDate;
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.sms_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
loc.latestSmsMessage,
fontWeight: FontWeight.bold,
),
if (lastDate != null)
ThemedText(
loc.smsDetectedDate(loc.formatDate(lastDate)),
opacity: 0.7,
fontSize: 13,
),
],
),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
padding: const EdgeInsets.all(12),
child: SelectableText(
rawMessage,
style: TextStyle(
fontSize: 15,
height: 1.4,
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
);
}
// 정보 섹션 (클릭 가능)
Widget _buildInfoSection(CategoryProvider categoryProvider) {
return Column(
@@ -145,6 +239,15 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
opacity: 0.7,
),
const SizedBox(height: 4),
if (widget.enableServiceNameEditing)
BaseTextField(
controller: widget.serviceNameController,
hintText: AppLocalizations.of(context).serviceNameRequired,
onChanged: widget.onServiceNameChanged,
textInputAction: TextInputAction.done,
maxLines: 1,
)
else
ThemedText(
widget.subscription.serviceName,
fontSize: 22,
@@ -246,6 +349,39 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
),
const SizedBox(height: 24),
// 결제수단 선택
ThemedText(
AppLocalizations.of(context).paymentCard,
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 8),
PaymentCardSelector(
selectedCardId: widget.selectedPaymentCardId,
onChanged: widget.onPaymentCardChanged,
onAddCard: widget.onAddCard,
onManageCards: widget.onManageCards,
),
if (widget.showDetectedCardShortcut &&
widget.detectedCardSuggestion != null) ...[
const SizedBox(height: 12),
_DetectedCardSuggestionBanner(
suggestion: widget.detectedCardSuggestion!,
onAdd: widget.onAddDetectedCard,
),
],
if (widget.selectedPaymentCardId == null) ...[
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).paymentCardUnassignedWarning,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 24),
// 웹사이트 URL 입력 필드
BaseTextField(
controller: widget.websiteUrlController,
@@ -297,3 +433,84 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
return CategoryIconMapper.getCategoryColor(category);
}
}
class _DetectedCardSuggestionBanner extends StatelessWidget {
final PaymentCardSuggestion suggestion;
final Future<void> Function(PaymentCardSuggestion suggestion)? onAdd;
const _DetectedCardSuggestionBanner({
required this.suggestion,
this.onAdd,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: scheme.onSecondaryContainer.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.auto_fix_high_rounded,
color: scheme.onSecondaryContainer,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.detectedPaymentCard,
style: TextStyle(
fontWeight: FontWeight.w600,
color: scheme.onSecondaryContainer,
),
),
const SizedBox(height: 4),
Text(
loc.detectedPaymentCardDescription(
suggestion.issuerName,
suggestion.last4 ?? '****',
),
style: TextStyle(
fontSize: 13,
color: scheme.onSecondaryContainer.withValues(alpha: 0.9),
),
),
],
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: onAdd == null
? null
: () async {
await onAdd!(suggestion);
},
style: ElevatedButton.styleFrom(
backgroundColor: scheme.onSecondaryContainer,
foregroundColor: scheme.secondaryContainer,
),
icon: const Icon(Icons.add_rounded, size: 16),
label: Text(loc.addDetectedPaymentCard),
),
],
),
);
}
}

View File

@@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart';
import '../utils/billing_date_util.dart';
import '../utils/billing_cost_util.dart';
import '../utils/payment_card_utils.dart';
import 'website_icon.dart';
import 'app_navigator.dart';
// import '../theme/app_colors.dart';
@@ -270,25 +273,41 @@ class _SubscriptionCardState extends State<SubscriptionCard>
}
}
// 가격 포맷팅 함수 (언어별 통화)
// 가격 포맷팅 함수 (언어별 통화) - 실제 결제 금액 표시
Future<String> _getFormattedPrice() async {
final locale = context.read<LocaleProvider>().locale.languageCode;
final billingCycle = widget.subscription.billingCycle;
if (widget.subscription.isCurrentlyInEvent) {
// 이벤트 중인 경우 원래 가격과 현재 가격 모두 표시
final originalPrice = await CurrencyUtil.formatAmountWithLocale(
// 이벤트 중인 경우: 월 비용을 실제 결제 금액으로 역변환
final actualOriginalPrice = BillingCostUtil.convertFromMonthlyCost(
widget.subscription.monthlyCost,
billingCycle,
);
final actualCurrentPrice = BillingCostUtil.convertFromMonthlyCost(
widget.subscription.currentPrice,
billingCycle,
);
final originalPrice = await CurrencyUtil.formatAmountWithLocale(
actualOriginalPrice,
widget.subscription.currency,
locale,
);
final currentPrice = await CurrencyUtil.formatAmountWithLocale(
widget.subscription.currentPrice,
actualCurrentPrice,
widget.subscription.currency,
locale,
);
return '$originalPrice|$currentPrice';
} else {
return CurrencyUtil.formatAmountWithLocale(
// 월 비용을 실제 결제 금액으로 역변환 (연간이면 x12, 분기면 x3 등)
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
widget.subscription.currentPrice,
billingCycle,
);
return CurrencyUtil.formatAmountWithLocale(
actualPrice,
widget.subscription.currency,
locale,
);
@@ -299,6 +318,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
Widget build(BuildContext context) {
// LocaleProvider를 watch하여 언어 변경시 자동 업데이트
final localeProvider = context.watch<LocaleProvider>();
final paymentCardProvider = context.watch<PaymentCardProvider>();
// 언어가 변경되면 displayName 다시 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -464,26 +484,34 @@ class _SubscriptionCardState extends State<SubscriptionCard>
],
),
const SizedBox(height: 6),
const SizedBox(height: 8),
_buildPaymentCardBadge(
context, paymentCardProvider),
const SizedBox(height: 8),
// 가격 정보
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 가격 표시 (이벤트 가격 반영)
// 가격 표시 (언어별 통화)
FutureBuilder<String>(
Expanded(
child: FutureBuilder<String>(
future: _getFormattedPrice(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
if (widget
.subscription.isCurrentlyInEvent &&
if (widget.subscription
.isCurrentlyInEvent &&
snapshot.data!.contains('|')) {
final prices = snapshot.data!.split('|');
return Row(
final prices =
snapshot.data!.split('|');
return Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment:
WrapCrossAlignment.center,
children: [
Text(
prices[0],
@@ -497,7 +525,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
TextDecoration.lineThrough,
),
),
const SizedBox(width: 8),
Text(
prices[1],
style: TextStyle(
@@ -513,6 +540,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
} else {
return Text(
snapshot.data!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
@@ -529,6 +558,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
}
},
),
),
const SizedBox(width: 12),
// 결제 예정일 정보
Container(
@@ -673,4 +704,63 @@ class _SubscriptionCardState extends State<SubscriptionCard>
),
);
}
Widget _buildPaymentCardBadge(
BuildContext context, PaymentCardProvider provider) {
final scheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context);
final card = provider.getCardById(widget.subscription.paymentCardId);
if (card == null) {
return Chip(
avatar: Icon(
Icons.credit_card_off_rounded,
size: 14,
color: scheme.onSurfaceVariant,
),
label: Text(
loc.paymentCardUnassigned,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.5),
padding: const EdgeInsets.symmetric(horizontal: 6),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
return Chip(
avatar: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Icon(
icon,
size: 12,
color: color,
),
),
label: Text(
'${card.issuerName} · ****${card.last4}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
side: BorderSide(color: color.withValues(alpha: 0.3)),
backgroundColor: color.withValues(alpha: 0.12),
padding: const EdgeInsets.symmetric(horizontal: 8),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../l10n/app_localizations.dart';
import '../utils/payment_card_utils.dart';
import '../utils/subscription_grouping_helper.dart';
class SubscriptionGroupHeader extends StatelessWidget {
final SubscriptionGroupData group;
final int subscriptionCount;
final double totalCostUSD;
final double totalCostKRW;
final double totalCostJPY;
final double totalCostCNY;
const SubscriptionGroupHeader({
super.key,
required this.group,
required this.subscriptionCount,
required this.totalCostUSD,
required this.totalCostKRW,
required this.totalCostJPY,
required this.totalCostCNY,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (group.mode == SubscriptionGroupingMode.paymentCard &&
group.paymentCard != null)
_PaymentCardAvatar(colorHex: group.paymentCard!.colorHex)
else if (group.mode == SubscriptionGroupingMode.paymentCard)
const _PaymentCardAvatar(),
if (group.mode == SubscriptionGroupingMode.paymentCard)
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
if (group.subtitle != null)
Text(
group.subtitle!,
style: TextStyle(
fontSize: 13,
color: scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
const SizedBox(width: 12),
Flexible(
child: Text(
_buildCostDisplay(context),
textAlign: TextAlign.end,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: scheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 8),
Divider(
height: 1,
thickness: 1,
color: scheme.outline.withValues(alpha: 0.3),
),
],
),
);
}
String _buildCostDisplay(BuildContext context) {
final parts = <String>[];
parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
final currencyParts = <String>[];
if (totalCostUSD > 0) {
final formatter = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostUSD));
}
if (totalCostKRW > 0) {
final formatter = NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostKRW));
}
if (totalCostJPY > 0) {
final formatter = NumberFormat.currency(
locale: 'ja_JP',
symbol: '¥',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostJPY));
}
if (totalCostCNY > 0) {
final formatter = NumberFormat.currency(
locale: 'zh_CN',
symbol: '¥',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostCNY));
}
if (currencyParts.isNotEmpty) {
parts.add(currencyParts.join(' + '));
}
return parts.join(' · ');
}
}
class _PaymentCardAvatar extends StatelessWidget {
final String? colorHex;
const _PaymentCardAvatar({this.colorHex});
@override
Widget build(BuildContext context) {
final color = colorHex != null
? PaymentCardUtils.colorFromHex(colorHex!)
: Theme.of(context).colorScheme.outlineVariant;
final icon =
colorHex != null ? Icons.credit_card : Icons.credit_card_off_rounded;
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.15),
child: Icon(
icon,
color: color,
size: 16,
),
);
}
}

View File

@@ -1,80 +1,44 @@
import 'package:flutter/material.dart';
import '../models/subscription_model.dart';
import '../widgets/category_header_widget.dart';
import '../widgets/subscription_group_header.dart';
import '../widgets/swipeable_subscription_card.dart';
import '../widgets/staggered_list_animation.dart';
import '../widgets/app_navigator.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
import '../utils/billing_cost_util.dart';
import '../utils/subscription_grouping_helper.dart';
import 'native_ad_widget.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget {
final Map<String, List<SubscriptionModel>> categorizedSubscriptions;
final List<SubscriptionGroupData> groups;
final AnimationController fadeController;
const SubscriptionListWidget({
super.key,
required this.categorizedSubscriptions,
required this.groups,
required this.fadeController,
});
@override
Widget build(BuildContext context) {
// 카테고리 키 목록 (정렬된)
final categories = categorizedSubscriptions.keys.toList();
final sections = groups;
int itemCounter = 0;
final List<Widget> children = [];
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final category = categories[index];
final subscriptions = categorizedSubscriptions[category]!;
for (final group in sections) {
final subscriptions = group.subscriptions;
final List<Widget> subscriptionItems = [];
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 카테고리 헤더
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return CategoryHeaderWidget(
categoryName: categoryProvider.getLocalizedCategoryName(
context, category),
subscriptionCount: subscriptions.length,
totalCostUSD:
_calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW:
_calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY:
_calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY:
_calculateTotalByCurrency(subscriptions, 'CNY'),
);
},
),
),
// 카테고리별 구독 목록
FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: fadeController, curve: Curves.easeIn)),
child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
cacheExtent: 500,
prototypeItem: const SizedBox(height: 156),
itemCount: subscriptions.length,
itemBuilder: (context, subIndex) {
for (var subIndex = 0; subIndex < subscriptions.length; subIndex++) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
final delay = 0.05 * subIndex;
const animationBegin = 0.2;
@@ -83,20 +47,20 @@ class SubscriptionListWidget extends StatelessWidget {
final intervalEnd = intervalStart + 0.4;
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
final intervalStartNormalized =
intervalStart.clamp(0.0, 0.9);
final intervalStartNormalized = intervalStart.clamp(0.0, 0.9);
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
return FadeTransition(
opacity: Tween<double>(
begin: animationBegin, end: animationEnd)
subscriptionItems.add(
FadeTransition(
opacity: Tween<double>(begin: animationBegin, end: animationEnd)
.animate(CurvedAnimation(
parent: fadeController,
curve: Interval(intervalStartNormalized,
intervalEndNormalized,
curve: Interval(
intervalStartNormalized, intervalEndNormalized,
curve: Curves.easeOut))),
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6.0),
child: StaggeredAnimationItem(
index: subIndex,
delay: const Duration(milliseconds: 50),
@@ -107,30 +71,24 @@ class SubscriptionListWidget extends StatelessWidget {
onTap: () {
Log.d(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(
context, subscriptions[subIndex]);
AppNavigator.toDetail(context, subscriptions[subIndex]);
},
onDelete: () async {
// 현재 로케일에 맞는 서비스명 가져오기
final localeProvider =
Provider.of<LocaleProvider>(
final localeProvider = Provider.of<LocaleProvider>(
context,
listen: false,
);
final locale =
localeProvider.locale.languageCode;
final locale = localeProvider.locale.languageCode;
final displayName =
await SubscriptionUrlMatcher
.getServiceDisplayName(
serviceName:
subscriptions[subIndex].serviceName,
await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscriptions[subIndex].serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시
if (!context.mounted) return;
final shouldDelete =
await DeleteConfirmationDialog.show(
final shouldDelete = await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
@@ -138,8 +96,7 @@ class SubscriptionListWidget extends StatelessWidget {
if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행
final provider =
Provider.of<SubscriptionProvider>(
final provider = Provider.of<SubscriptionProvider>(
context,
listen: false,
);
@@ -161,25 +118,59 @@ class SubscriptionListWidget extends StatelessWidget {
),
),
),
),
);
},
itemCounter++;
if ((itemCounter - 1) % 10 == 0) {
subscriptionItems.add(
NativeAdWidget(
key: ValueKey('home_list_ad_$itemCounter'),
aspectRatioOverride: 320 / 80,
mediaAspectRatioOverride: MediaAspectRatio.landscape,
templateTypeOverride: TemplateType.small,
),
);
}
}
children.add(
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SubscriptionGroupHeader(
group: group,
subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
),
...subscriptionItems,
],
),
);
},
childCount: categories.length,
),
);
}
/// 특정 통화의 총 합계를 계산합니다.
return SliverList(
delegate: SliverChildListDelegate(children),
);
}
/// 특정 통화의 실제 결제 금액 총 합계를 계산합니다.
double _calculateTotalByCurrency(
List<SubscriptionModel> subscriptions, String currency) {
return subscriptions
.where((sub) => sub.currency == currency)
.fold(0.0, (sum, sub) => sum + sub.monthlyCost);
return subscriptions.where((sub) => sub.currency == currency).fold(
0.0,
(sum, sub) =>
sum +
BillingCostUtil.convertFromMonthlyCost(
sub.currentPrice,
sub.billingCycle,
));
}
}

View File

@@ -8,6 +8,7 @@ import Foundation
import flutter_local_notifications
import flutter_secure_storage_darwin
import local_auth_darwin
import package_info_plus
import path_provider_foundation
import share_plus
import shared_preferences_foundation
@@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -733,6 +733,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:

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