Compare commits
6 Commits
2cd46a303e
...
83c43fb61f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83c43fb61f | ||
|
|
bac4acf9a3 | ||
|
|
64da0c5fd3 | ||
|
|
d9435bbee5 | ||
|
|
b018e5eb2f | ||
|
|
b22df5daf3 |
89
.claude/skills/admob/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: admob
|
||||
description: AdMob 전면 광고 구현 및 디버깅. 광고 표시, 로드 실패, foreground 이슈 시 사용.
|
||||
allowed-tools: Read, Edit, Grep
|
||||
---
|
||||
|
||||
# AdMob Integration
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `lib/services/ad_service.dart` | 전면 광고 서비스 |
|
||||
|
||||
## 광고 ID
|
||||
|
||||
| 플랫폼 | ID | 비고 |
|
||||
|--------|-----|------|
|
||||
| Android | `ca-app-pub-6691216385521068/5281562472` | 프로덕션 |
|
||||
| iOS | `ca-app-pub-3940256099942544/1033173712` | 테스트 |
|
||||
|
||||
## Completer 패턴 (필수)
|
||||
|
||||
광고 완료를 기다리려면 Completer 사용:
|
||||
|
||||
```dart
|
||||
Future<bool> showInterstitialAd(BuildContext context) async {
|
||||
final completer = Completer<bool>();
|
||||
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
ad.dispose();
|
||||
completer.complete(true);
|
||||
},
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
ad.dispose();
|
||||
completer.complete(false);
|
||||
},
|
||||
);
|
||||
|
||||
ad.show();
|
||||
return completer.future;
|
||||
}
|
||||
```
|
||||
|
||||
## "App not in foreground" 해결
|
||||
|
||||
광고 로드 중 앱이 백그라운드로 가면 오류 발생.
|
||||
|
||||
**해결책: 로딩 오버레이**
|
||||
|
||||
```dart
|
||||
// 광고 로드 전 다이얼로그 표시 → 앱이 foreground 유지
|
||||
final closeLoading = _showLoadingOverlay(context);
|
||||
await _enterImmersiveMode();
|
||||
final loaded = await _ensureAdLoaded();
|
||||
closeLoading();
|
||||
```
|
||||
|
||||
## 몰입형 모드
|
||||
|
||||
```dart
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
overlays: [],
|
||||
);
|
||||
```
|
||||
|
||||
## 흐름도
|
||||
|
||||
```
|
||||
startScan()
|
||||
↓
|
||||
showLoadingOverlay() ← 앱 foreground 유지
|
||||
↓
|
||||
enterImmersiveMode()
|
||||
↓
|
||||
loadAd()
|
||||
↓
|
||||
closeOverlay()
|
||||
↓
|
||||
ad.show()
|
||||
↓
|
||||
await Completer.future ← 광고 완료 대기
|
||||
↓
|
||||
restoreSystemUI()
|
||||
↓
|
||||
continueWithScan()
|
||||
```
|
||||
39
.claude/skills/flutter-build/SKILL.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: flutter-build
|
||||
description: Flutter 빌드 및 분석 수행. 앱 빌드, 릴리즈 APK/AAB 생성, 코드 분석 시 사용.
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
# Flutter Build
|
||||
|
||||
## 빌드 명령어
|
||||
|
||||
```bash
|
||||
# 코드 분석
|
||||
flutter analyze
|
||||
|
||||
# 디버그 빌드
|
||||
flutter run
|
||||
|
||||
# 릴리즈 APK (디바이스 테스트용)
|
||||
flutter build apk --release
|
||||
|
||||
# 릴리즈 AAB (Play Store용)
|
||||
flutter build appbundle --release
|
||||
|
||||
# 디바이스 설치
|
||||
flutter install --release
|
||||
```
|
||||
|
||||
## 빌드 전 체크리스트
|
||||
|
||||
1. `flutter analyze` 통과 확인
|
||||
2. pubspec.yaml 버전 확인
|
||||
3. 필요시 `flutter clean` 실행
|
||||
|
||||
## 출력 위치
|
||||
|
||||
| 타입 | 경로 |
|
||||
|------|------|
|
||||
| APK | `build/app/outputs/flutter-apk/app-release.apk` |
|
||||
| AAB | `build/app/outputs/bundle/release/app-release.aab` |
|
||||
55
.claude/skills/hive-model/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: hive-model
|
||||
description: Hive 데이터 모델 생성 및 수정. 새 모델 추가, 필드 변경, typeId 관리 시 사용.
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob
|
||||
---
|
||||
|
||||
# Hive Model Management
|
||||
|
||||
## 현재 모델 (typeId)
|
||||
|
||||
| Model | typeId | 파일 |
|
||||
|-------|--------|------|
|
||||
| SubscriptionModel | 0 | `lib/models/subscription_model.dart` |
|
||||
| CategoryModel | 1 | `lib/models/category_model.dart` |
|
||||
| PaymentCardModel | 2 | `lib/models/payment_card_model.dart` |
|
||||
|
||||
## 새 모델 생성 시
|
||||
|
||||
```dart
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'new_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 3) // 다음 사용 가능한 typeId
|
||||
class NewModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String name;
|
||||
|
||||
NewModel({required this.id, required this.name});
|
||||
}
|
||||
```
|
||||
|
||||
## 코드 생성
|
||||
|
||||
모델 변경 후 반드시 실행:
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **typeId 충돌 금지**: 기존 typeId 재사용 불가
|
||||
2. **HiveField 순서**: 기존 필드 인덱스 변경 금지 (데이터 손실)
|
||||
3. **마이그레이션**: 필드 추가는 안전, 삭제/변경은 마이그레이션 필요
|
||||
|
||||
## main.dart 등록
|
||||
|
||||
```dart
|
||||
Hive.registerAdapter(NewModelAdapter());
|
||||
await Hive.openBox<NewModel>('newModelBox');
|
||||
```
|
||||
64
.claude/skills/release-deploy/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: release-deploy
|
||||
description: 앱 릴리즈 및 배포 프로세스. 버전업, 빌드, 디바이스 설치, Play Store AAB 생성 시 사용.
|
||||
allowed-tools: Bash, Read, Edit
|
||||
---
|
||||
|
||||
# Release & Deploy
|
||||
|
||||
## 버전 관리
|
||||
|
||||
pubspec.yaml 버전 형식: `major.minor.patch+buildNumber`
|
||||
|
||||
```yaml
|
||||
version: 1.0.1+3
|
||||
# │ │ │ └─ 빌드번호 (내부 버전, 매 빌드마다 증가)
|
||||
# │ │ └─── 패치 (버그 수정)
|
||||
# │ └───── 마이너 (기능 추가)
|
||||
# └─────── 메이저 (대규모 변경)
|
||||
```
|
||||
|
||||
## 릴리즈 프로세스
|
||||
|
||||
### 1. 버전 업데이트
|
||||
|
||||
```bash
|
||||
# pubspec.yaml에서 버전 수정
|
||||
# 예: 1.0.1+3 → 1.0.1+4
|
||||
```
|
||||
|
||||
### 2. 릴리즈 빌드
|
||||
|
||||
```bash
|
||||
# APK (테스트용)
|
||||
flutter build apk --release
|
||||
|
||||
# AAB (Play Store용)
|
||||
flutter build appbundle --release
|
||||
```
|
||||
|
||||
### 3. 디바이스 설치
|
||||
|
||||
```bash
|
||||
flutter install --release
|
||||
```
|
||||
|
||||
## 전체 자동화
|
||||
|
||||
```bash
|
||||
# 버전업 + APK 빌드 + 설치 + AAB 빌드
|
||||
flutter build apk --release && flutter install --release && flutter build appbundle --release
|
||||
```
|
||||
|
||||
## Play Store 업로드
|
||||
|
||||
1. AAB 파일: `build/app/outputs/bundle/release/app-release.aab`
|
||||
2. Google Play Console에서 업로드
|
||||
3. 릴리즈 노트 작성 (한국어/영어/일본어/중국어)
|
||||
|
||||
## 체크리스트
|
||||
|
||||
- [ ] 버전 번호 증가
|
||||
- [ ] `flutter analyze` 통과
|
||||
- [ ] 테스트 디바이스에서 확인
|
||||
- [ ] AAB 생성 완료
|
||||
60
.claude/skills/sms-scanner/SKILL.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: sms-scanner
|
||||
description: SMS 스캔 기능 개발 및 디버깅. SMS 파싱, 구독 감지, Isolate 관련 이슈 시 사용.
|
||||
allowed-tools: Read, Edit, Grep, Glob
|
||||
---
|
||||
|
||||
# SMS Scanner
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `lib/services/sms_scanner.dart` | SMS 파싱 로직 (Isolate) |
|
||||
| `lib/controllers/sms_scan_controller.dart` | 스캔 플로우 제어 |
|
||||
| `lib/screens/sms_scan_screen.dart` | UI |
|
||||
| `lib/services/sms_scan/` | 보조 클래스 |
|
||||
|
||||
## Isolate 주의사항
|
||||
|
||||
`compute()` 내부에서 실행되는 함수는 별도 Isolate에서 실행됨.
|
||||
|
||||
**접근 불가 항목:**
|
||||
|
||||
- Flutter 바인딩 (`WidgetsBinding`)
|
||||
- `BuildContext`
|
||||
- `Provider`
|
||||
- `navigatorKey`
|
||||
- `AppLocalizations`
|
||||
|
||||
**올바른 패턴:**
|
||||
|
||||
```dart
|
||||
// Isolate 내부 함수 (접두사: _iso)
|
||||
String _isoExtractServiceName(String sender) {
|
||||
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
||||
return 'Unknown service'; // 하드코딩 사용
|
||||
}
|
||||
return sender;
|
||||
}
|
||||
```
|
||||
|
||||
**잘못된 패턴:**
|
||||
|
||||
```dart
|
||||
String _isoExtractServiceName(String sender) {
|
||||
// 오류: Isolate에서 Context 접근 불가
|
||||
return AppLocalizations.of(context).unknownService;
|
||||
}
|
||||
```
|
||||
|
||||
## 디버깅
|
||||
|
||||
```bash
|
||||
# SMS 스캔 로그 확인
|
||||
flutter logs | grep -i "sms\|scan\|isolate"
|
||||
```
|
||||
|
||||
## 테스트 데이터
|
||||
|
||||
`lib/temp/test_sms_data.dart`에 테스트용 SMS 데이터 정의됨.
|
||||
377
CLAUDE.md
@@ -1,314 +1,111 @@
|
||||
# Claude 프로젝트 컨텍스트
|
||||
# CLAUDE.md
|
||||
|
||||
## 언어 설정
|
||||
- 모든 답변은 한국어로 제공
|
||||
- 기술 용어는 영어와 한국어 병기 가능
|
||||
프로젝트별 가이드. 일반 규칙은 `~/.claude/CLAUDE.md` 참조.
|
||||
|
||||
## 프로젝트 정보
|
||||
- Flutter 기반 구독 관리 앱 (SubManager)
|
||||
## Project Overview
|
||||
|
||||
## 현재 작업
|
||||
- 구독카드가 클릭이 되지 않아서 문제를 찾는 중.
|
||||
**SubManager** - 구독 관리 앱 (Flutter 3.x)
|
||||
|
||||
## 🎯 Mandatory Response Format
|
||||
| 항목 | 기술 |
|
||||
|------|------|
|
||||
| DB | Hive (로컬 전용) |
|
||||
| 상태관리 | Provider + ChangeNotifier |
|
||||
| 디자인 | Material 3 |
|
||||
| 광고 | google_mobile_ads |
|
||||
|
||||
Before starting any task, you MUST respond in the following format:
|
||||
**버전**: 1.0.1+3
|
||||
|
||||
```
|
||||
[Model Name]. I have reviewed all the following rules: [rule file list or categories]. Proceeding with the task. Master!
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Hive 모델 생성
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
# 빌드
|
||||
flutter build apk --release # APK
|
||||
flutter build appbundle --release # AAB (Play Store)
|
||||
|
||||
# 버전업 후 디바이스 설치
|
||||
flutter install --release
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
## Architecture
|
||||
|
||||
- `Claude Sonnet 4. I have reviewed all the following rules: development guidelines, class structure, testing rules. Proceeding with the task. Master!`
|
||||
- For extensive rules: `coding style, class design, exception handling, testing rules` (categorized summary)
|
||||
|
||||
## 🚀 Mandatory 3-Phase Task Process
|
||||
|
||||
### Phase 1: Codebase Exploration & Analysis
|
||||
|
||||
**Required Actions:**
|
||||
|
||||
- Systematically discover ALL relevant files, directories, modules
|
||||
- Search for related keywords, functions, classes, patterns
|
||||
- Thoroughly examine each identified file
|
||||
- Document coding conventions and style guidelines
|
||||
- Identify framework/library usage patterns
|
||||
|
||||
### Phase 2: Implementation Planning
|
||||
|
||||
**Required Actions:**
|
||||
|
||||
- Create detailed implementation roadmap based on Phase 1 findings
|
||||
- Define specific task lists and acceptance criteria per module
|
||||
- Specify performance/quality requirements
|
||||
|
||||
### Phase 3: Implementation Execution
|
||||
|
||||
**Required Actions:**
|
||||
|
||||
- Implement each module following Phase 2 plan
|
||||
- Verify ALL acceptance criteria before proceeding
|
||||
- Ensure adherence to conventions identified in Phase 1
|
||||
|
||||
## ✅ Core Development Principles
|
||||
|
||||
### Language & Type Rules
|
||||
|
||||
- **Write ALL code, variables, and names in English**
|
||||
- **Write ALL comments, documentation, prompts, and responses in Korean**
|
||||
- **Always declare types explicitly** for variables, parameters, and return values
|
||||
- Avoid `any`, `dynamic`, or loosely typed declarations (except when strictly necessary)
|
||||
- Define **custom types** when needed
|
||||
- Extract magic numbers and literals into named constants or enums
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
|Element|Style|Example|
|
||||
|---|---|---|
|
||||
|Classes|`PascalCase`|`UserService`, `DataRepository`|
|
||||
|Variables/Methods|`camelCase`|`userName`, `calculateTotal`|
|
||||
|Files/Folders|`under_score_case`|`user_service.dart`, `data_models/`|
|
||||
|Environment Variables|`UPPERCASE`|`API_URL`, `DATABASE_PASSWORD`|
|
||||
|
||||
**Critical Rules:**
|
||||
|
||||
- **Boolean variables must be verb-based**: `isReady`, `hasError`, `canDelete`
|
||||
- **Function/method names start with verbs**: `executeLogin`, `saveUser`
|
||||
- Use meaningful, descriptive names
|
||||
- Avoid abbreviations unless widely accepted: `i`, `j`, `err`, `ctx`, `API`, `URL`
|
||||
|
||||
## 🔧 Function & Method Design
|
||||
|
||||
### Function Structure Principles
|
||||
|
||||
- **Keep functions short and focused** (≤20 lines recommended)
|
||||
- **Avoid blank lines inside functions**
|
||||
- **Follow Single Responsibility Principle**
|
||||
- **Use verb + object format** for naming:
|
||||
- Boolean return: `isValid`, `canRetry`, `hasPermission`
|
||||
- Void return: `executeLogin`, `saveUser`, `startAnimation`
|
||||
|
||||
### Function Optimization Techniques
|
||||
|
||||
- Use **early returns** to avoid nested logic
|
||||
- Extract logic into helper functions
|
||||
- Prefer **arrow functions** for short expressions (≤3 lines)
|
||||
- Use **named functions** for complex logic
|
||||
- Minimize null checks by using **default values**
|
||||
- Minimize parameters using **RO-RO pattern** (Receive Object – Return Object)
|
||||
|
||||
## 📦 Data & Class Design
|
||||
|
||||
### Class Design Principles
|
||||
|
||||
- **Strictly follow Single Responsibility Principle (SRP)**
|
||||
- **Favor composition over inheritance**
|
||||
- **Define interfaces/abstract classes** to establish contracts
|
||||
- **Prefer immutable data structures** (use `readonly`, `const`)
|
||||
|
||||
### File Size Management
|
||||
|
||||
- **Split by responsibility when exceeding 200 lines** (responsibility-based, not line-based)
|
||||
- ✅ **May remain as-is if**:
|
||||
- Has **single clear responsibility**
|
||||
- Is **easy to maintain**
|
||||
- 🔁 **Must split when**:
|
||||
- Contains **multiple concerns**
|
||||
- Requires **excessive scrolling**
|
||||
- Patterns repeat across files
|
||||
- Difficult for new developer onboarding
|
||||
|
||||
### Class Recommendations
|
||||
|
||||
- ≤ 200 lines (not mandatory)
|
||||
- ≤ 10 public methods
|
||||
- ≤ 10 properties
|
||||
|
||||
### Data Model Design
|
||||
|
||||
- Avoid excessive use of primitives — use **composite types or classes**
|
||||
- Move **validation logic inside data models** (not in business logic)
|
||||
|
||||
## ❗ Exception Handling
|
||||
|
||||
### Exception Usage Principles
|
||||
|
||||
- Use exceptions only for **truly unexpected or critical issues**
|
||||
- **Catch exceptions only to**:
|
||||
- Handle known failure scenarios
|
||||
- Add useful context
|
||||
- Otherwise, let global handlers manage them
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Structure
|
||||
|
||||
- Follow **Arrange–Act–Assert** pattern
|
||||
- Clear test variable naming: `inputX`, `mockX`, `actualX`, `expectedX`
|
||||
- **Write unit tests for every public method**
|
||||
|
||||
### Test Doubles Usage
|
||||
|
||||
- Use **test doubles** (mock/fake/stub) for dependencies
|
||||
- Exception: allow real use of **lightweight third-party libraries**
|
||||
|
||||
### Integration Testing
|
||||
|
||||
- Write **integration tests per module**
|
||||
- Follow **Given–When–Then** structure
|
||||
- Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures
|
||||
|
||||
## 📝 Git Commit Guidelines
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
- **Use clear, descriptive commit messages in Korean**
|
||||
- **Follow conventional commit format**: `type: description`
|
||||
- **Keep commit messages concise and focused**
|
||||
- **DO NOT include Claude Code attribution or co-author tags**
|
||||
|
||||
### Commit Message Structure
|
||||
|
||||
```
|
||||
type: brief description in Korean
|
||||
|
||||
Optional detailed explanation if needed
|
||||
```text
|
||||
lib/
|
||||
├── controllers/ # 비즈니스 로직 (3개)
|
||||
│ ├── add_subscription_controller.dart
|
||||
│ ├── detail_screen_controller.dart
|
||||
│ └── sms_scan_controller.dart
|
||||
├── models/ # Hive 모델 (@HiveType)
|
||||
│ ├── subscription_model.dart (typeId: 0)
|
||||
│ ├── category_model.dart (typeId: 1)
|
||||
│ └── payment_card_model.dart (typeId: 2)
|
||||
├── providers/ # ChangeNotifier 상태관리
|
||||
├── screens/ # 화면 위젯
|
||||
├── services/ # 외부 서비스 연동
|
||||
├── widgets/ # 재사용 컴포넌트
|
||||
├── utils/ # 유틸리티 헬퍼
|
||||
├── routes/ # 라우팅 정의
|
||||
├── theme/ # 테마/색상
|
||||
└── l10n/ # 다국어 (ko/en/ja/zh)
|
||||
```
|
||||
|
||||
### Commit Types
|
||||
## Key Services
|
||||
|
||||
- `feat`: 새로운 기능 추가
|
||||
- `fix`: 버그 수정
|
||||
- `refactor`: 코드 리팩토링
|
||||
- `style`: 코드 스타일 변경 (formatting, missing semi-colons, etc)
|
||||
- `docs`: 문서 변경
|
||||
- `test`: 테스트 코드 추가 또는 수정
|
||||
- `chore`: 빌드 프로세스 또는 보조 도구 변경
|
||||
| Service | 역할 |
|
||||
|---------|------|
|
||||
| `AdService` | 전면 광고 (Completer 패턴) |
|
||||
| `SmsScanner` | SMS 파싱 → 구독 자동 감지 (Isolate 사용) |
|
||||
| `NotificationService` | 로컬 알림 |
|
||||
| `ExchangeRateService` | 환율 조회 |
|
||||
| `url_matcher/` | 서비스명 → URL 매칭 |
|
||||
|
||||
### Examples
|
||||
## Routes
|
||||
|
||||
✅ **Good Examples:**
|
||||
- `feat: 월별 차트 다국어 지원 추가`
|
||||
- `fix: 분석화면 총지출 금액 불일치 문제 해결`
|
||||
- `refactor: 통화 변환 로직 모듈화`
|
||||
| Path | Screen |
|
||||
|------|--------|
|
||||
| `/` | MainScreen |
|
||||
| `/add-subscription` | AddSubscriptionScreen |
|
||||
| `/subscription-detail` | DetailScreen (requires SubscriptionModel) |
|
||||
| `/sms-scanner` | SmsScanScreen |
|
||||
| `/analysis` | AnalysisScreen |
|
||||
| `/settings` | SettingsScreen |
|
||||
| `/payment-card-management` | PaymentCardManagementScreen |
|
||||
|
||||
❌ **Avoid These:**
|
||||
- Including "🤖 Generated with [Claude Code](https://claude.ai/code)"
|
||||
- Including "Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
- Vague messages like "update code" or "fix stuff"
|
||||
- English commit messages (use Korean)
|
||||
## Project Rules
|
||||
|
||||
### Critical Rules
|
||||
1. **로컬 전용**: 서버/Firebase/외부 DB 금지
|
||||
2. **권한 거부 시**: 수동 입력 폴백 (SMS), 기능 비활성화 (알림)
|
||||
3. **외부 API**: Clearbit Logo API만 허용
|
||||
4. **Isolate 주의**: `compute()` 내부에서 Flutter 바인딩/Context 접근 불가
|
||||
|
||||
- **NEVER include AI tool attribution in commit messages**
|
||||
- **Focus on what was changed and why**
|
||||
- **Use present tense and imperative mood**
|
||||
- **Keep the first line under 50 characters when possible**
|
||||
## Known Patterns
|
||||
|
||||
## 🧠 Error Analysis & Rule Documentation
|
||||
### 전면 광고 (AdService)
|
||||
|
||||
### Mandatory Process When Errors Occur
|
||||
|
||||
1. **Analyze root cause in detail**
|
||||
2. **Document preventive rule in `.cursor/rules/error_analysis.mdc`**
|
||||
3. **Write in English including**:
|
||||
- Error description and context
|
||||
- Cause and reproducibility steps
|
||||
- Resolution approach
|
||||
- Rule for preventing future recurrences
|
||||
- Sample code and references to related rules
|
||||
|
||||
### Rule Writing Standards
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Clear, one-line description of what the rule enforces
|
||||
globs: path/to/files/*.ext, other/path/**/*
|
||||
alwaysApply: boolean
|
||||
---
|
||||
|
||||
**Main Points in Bold**
|
||||
- Sub-points with details
|
||||
- Examples and explanations
|
||||
```dart
|
||||
// Completer 패턴으로 광고 완료 대기
|
||||
final completer = Completer<bool>();
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
completer.complete(true);
|
||||
},
|
||||
);
|
||||
ad.show();
|
||||
return completer.future;
|
||||
```
|
||||
|
||||
## 🏗️ Architectural Guidelines
|
||||
### SMS 스캔 (Isolate)
|
||||
|
||||
### Clean Architecture Compliance
|
||||
```dart
|
||||
// Isolate 내부에서는 하드코딩 사용
|
||||
// Flutter 바인딩, Context, Provider 접근 불가
|
||||
return 'Unknown service'; // AppLocalizations 사용 불가
|
||||
```
|
||||
|
||||
- **Layered structure**: `modules`, `controllers`, `services`, `repositories`, `entities`
|
||||
- Apply **Repository Pattern** for data abstraction
|
||||
- Use **Dependency Injection** (`getIt`, `inject`, etc.)
|
||||
- Controllers handle business logic (not view processing)
|
||||
## Response Format
|
||||
|
||||
### Code Structuring
|
||||
|
||||
- **One export or public declaration per file**
|
||||
- Centralize constants, error messages, and configuration
|
||||
- Make **all shared logic reusable** and place in dedicated helper modules
|
||||
|
||||
## 🌲 UI Structure & Component Design
|
||||
|
||||
### UI Optimization Principles
|
||||
|
||||
- **Avoid deeply nested widget/component trees**:
|
||||
- Flatten hierarchy for **better performance and readability**
|
||||
- Easier **state management and testability**
|
||||
- **Split large components into small, focused widgets/components**
|
||||
- Use `const` constructors (or equivalents) for performance optimization
|
||||
- Apply clear **naming and separation** between view, logic, and data layers
|
||||
|
||||
## 📈 Continuous Rule Improvement
|
||||
|
||||
### Rule Improvement Triggers
|
||||
|
||||
- New code patterns not covered by existing rules
|
||||
- Repeated similar implementations across files
|
||||
- Common error patterns that could be prevented
|
||||
- New libraries or tools being used consistently
|
||||
- Emerging best practices in the codebase
|
||||
|
||||
### Rule Update Criteria
|
||||
|
||||
**Add New Rules When:**
|
||||
|
||||
- A new technology/pattern is used in 3+ files
|
||||
- Common bugs could be prevented by a rule
|
||||
- Code reviews repeatedly mention the same feedback
|
||||
|
||||
**Modify Existing Rules When:**
|
||||
|
||||
- Better examples exist in the codebase
|
||||
- Additional edge cases are discovered
|
||||
- Related rules have been updated
|
||||
|
||||
## ✅ Quality Validation Checklist
|
||||
|
||||
Before completing any task, confirm:
|
||||
|
||||
- ✅ All three phases completed sequentially
|
||||
- ✅ Each phase output meets specified format requirements
|
||||
- ✅ Implementation satisfies all acceptance criteria
|
||||
- ✅ Code quality meets professional standards
|
||||
- ✅ Started with mandatory response format
|
||||
- ✅ All naming conventions followed
|
||||
- ✅ Type safety ensured
|
||||
- ✅ Single Responsibility Principle adhered to
|
||||
|
||||
## 🎯 Success Validation Framework
|
||||
|
||||
### Expert-Level Standards Verification
|
||||
|
||||
- **Minimalistic Approach**: High-quality, clean solutions without unnecessary complexity
|
||||
- **Professional Standards**: Every output meets industry-standard software engineering practices
|
||||
- **Concrete Results**: Specific, actionable details at each step
|
||||
|
||||
### Final Quality Gates
|
||||
|
||||
- [ ] All acceptance criteria validated
|
||||
- [ ] Code follows established conventions
|
||||
- [ ] Minimalistic approach maintained
|
||||
- [ ] Expert-level implementation standards met
|
||||
- [ ] Korean comments and documentation provided
|
||||
- [ ] English code and variable names used consistently
|
||||
```text
|
||||
[모델명]. I have reviewed all the following rules: [규칙]. Proceeding with the task. Master!
|
||||
```
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +8,13 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties().apply {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.naturebridgeai.digitalrentmanager"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -31,11 +41,22 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystoreProperties.isNotEmpty()) {
|
||||
create("release") {
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
if (signingConfigs.findByName("release") != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
assets/appicon/appicon512.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
@@ -11,6 +11,9 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"deleteSubscriptionTitle": "Delete Subscription",
|
||||
"deleteSubscriptionMessage": "Are you sure you want to delete @ subscription?",
|
||||
"deleteIrreversibleWarning": "This action cannot be undone",
|
||||
"edit": "Edit",
|
||||
"totalSubscriptions": "Total Subscriptions",
|
||||
"totalMonthlyExpense": "Total Monthly Expense",
|
||||
@@ -25,10 +28,12 @@
|
||||
"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",
|
||||
@@ -64,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",
|
||||
@@ -83,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.",
|
||||
@@ -111,6 +118,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",
|
||||
@@ -155,6 +163,7 @@
|
||||
"latestSmsMessage": "Latest SMS message",
|
||||
"smsDetectedDate": "Detected on @",
|
||||
"serviceName": "Service Name",
|
||||
"unknownService": "Unknown service",
|
||||
"nextBillingDateLabel": "Next Billing Date",
|
||||
"category": "Category",
|
||||
"websiteUrlAuto": "Website URL (Auto-extracted)",
|
||||
@@ -242,8 +251,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.",
|
||||
@@ -253,7 +266,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": "디지털 월세 관리자",
|
||||
@@ -267,6 +284,9 @@
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"delete": "삭제",
|
||||
"deleteSubscriptionTitle": "구독 삭제",
|
||||
"deleteSubscriptionMessage": "정말로 @ 구독을 삭제하시겠습니까?",
|
||||
"deleteIrreversibleWarning": "이 작업은 되돌릴 수 없습니다",
|
||||
"edit": "수정",
|
||||
"totalSubscriptions": "총 구독",
|
||||
"totalMonthlyExpense": "이번 달 총 지출",
|
||||
@@ -281,10 +301,12 @@
|
||||
"selectIcon": "아이콘 선택",
|
||||
"addCategory": "카테고리 추가",
|
||||
"settings": "설정",
|
||||
"theme": "테마",
|
||||
"darkMode": "다크 모드",
|
||||
"language": "언어",
|
||||
"notifications": "알림",
|
||||
"appLock": "앱 잠금",
|
||||
"appLocked": "앱이 잠겨 있습니다",
|
||||
"paymentCard": "결제수단",
|
||||
"paymentCardManagement": "결제수단 관리",
|
||||
"paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다",
|
||||
@@ -320,6 +342,7 @@
|
||||
"dailyReminderEnabled": "결제일까지 매일 알림을 받습니다",
|
||||
"dailyReminderDisabled": "결제 @일 전에 알림을 받습니다",
|
||||
"notificationPermissionDenied": "알림 권한이 거부되었습니다",
|
||||
"permissionGranted": "권한이 허용되었습니다.",
|
||||
"appInfo": "앱 정보",
|
||||
"version": "버전",
|
||||
"appDescription": "디지털 월세 관리 앱",
|
||||
@@ -339,6 +362,7 @@
|
||||
"twoDaysBefore": "2일 전",
|
||||
"threeDaysBefore": "3일 전",
|
||||
"requiredFieldsError": "필수 항목을 모두 입력해주세요",
|
||||
"categoryNameRequired": "카테고리 이름을 입력하세요",
|
||||
"subscriptionUpdated": "구독 정보가 업데이트되었습니다.",
|
||||
"subscriptionDeleted": "@ 구독이 삭제되었습니다.",
|
||||
"officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.",
|
||||
@@ -367,6 +391,7 @@
|
||||
"appLockDesc": "생체 인증으로 앱 잠금",
|
||||
"unlockWithBiometric": "생체 인증으로 잠금 해제",
|
||||
"authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.",
|
||||
"nextBillingDateAdjusted": "다음 결제 예정일로 저장됨",
|
||||
"totalExpenseCopied": "총 지출액이 복사되었습니다: @",
|
||||
"smsPermissionRequired": "SMS 권한이 필요합니다.",
|
||||
"noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.",
|
||||
@@ -411,6 +436,7 @@
|
||||
"latestSmsMessage": "최신 SMS 메시지",
|
||||
"smsDetectedDate": "SMS 수신일: @",
|
||||
"serviceName": "서비스명",
|
||||
"unknownService": "알 수 없는 서비스",
|
||||
"nextBillingDateLabel": "다음 결제일",
|
||||
"category": "카테고리",
|
||||
"websiteUrlAuto": "웹사이트 URL (자동 추출됨)",
|
||||
@@ -498,8 +524,12 @@
|
||||
"subscriptionDetail": "구독 상세",
|
||||
"enterAmount": "금액을 입력하세요",
|
||||
"invalidAmount": "올바른 금액을 입력해주세요",
|
||||
"featureComingSoon": "이 기능은 곧 출시됩니다"
|
||||
,
|
||||
"featureComingSoon": "이 기능은 곧 출시됩니다",
|
||||
"exactAlarmPermission": "정확 알람 권한(알람 및 리마인더)",
|
||||
"exactAlarmPermissionDesc": "정확한 시각에 알림을 보장하려면 권한이 필요합니다.",
|
||||
"allowAlarmsInSettings": "설정에서 \"알람 및 리마인더\"를 허용해 주세요.",
|
||||
"testNotification": "테스트 알림",
|
||||
"testSubscriptionBody": "테스트 구독 • @",
|
||||
"smsPermissionTitle": "SMS 권한 요청",
|
||||
"smsPermissionReasonTitle": "이유",
|
||||
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
|
||||
@@ -509,7 +539,11 @@
|
||||
"openSettings": "설정 열기",
|
||||
"later": "나중에 하기",
|
||||
"requesting": "요청 중...",
|
||||
"smsPermissionLabel": "SMS 권한"
|
||||
"smsPermissionLabel": "SMS 권한",
|
||||
"expirationReminderBody": "@ 구독이 #일 후 만료됩니다.",
|
||||
"eventEndNotificationTitle": "이벤트 종료 알림",
|
||||
"eventEndNotificationBody": "@의 할인 이벤트가 종료되었습니다.",
|
||||
"paymentChargeNotification": "@ 구독료 @이 결제되었습니다."
|
||||
},
|
||||
"ja": {
|
||||
"appTitle": "デジタル月額管理者",
|
||||
@@ -523,6 +557,9 @@
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"deleteSubscriptionTitle": "サブスクリプション削除",
|
||||
"deleteSubscriptionMessage": "本当に@のサブスクリプションを削除しますか?",
|
||||
"deleteIrreversibleWarning": "この操作は取り消せません",
|
||||
"edit": "編集",
|
||||
"totalSubscriptions": "総サブスクリプション",
|
||||
"totalMonthlyExpense": "今月の総支出",
|
||||
@@ -537,10 +574,12 @@
|
||||
"selectIcon": "アイコンを選択",
|
||||
"addCategory": "カテゴリー追加",
|
||||
"settings": "設定",
|
||||
"theme": "テーマ",
|
||||
"darkMode": "ダークモード",
|
||||
"language": "言語",
|
||||
"notifications": "通知",
|
||||
"appLock": "アプリロック",
|
||||
"appLocked": "アプリがロックされています",
|
||||
"paymentCard": "支払いカード",
|
||||
"paymentCardManagement": "支払いカード管理",
|
||||
"paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します",
|
||||
@@ -576,6 +615,7 @@
|
||||
"dailyReminderEnabled": "支払い日まで毎日通知を受け取ります",
|
||||
"dailyReminderDisabled": "支払い@日前に通知を受け取ります",
|
||||
"notificationPermissionDenied": "通知権限が拒否されました",
|
||||
"permissionGranted": "権限が許可されました。",
|
||||
"appInfo": "アプリ情報",
|
||||
"version": "バージョン",
|
||||
"appDescription": "デジタル月額管理アプリ",
|
||||
@@ -595,6 +635,7 @@
|
||||
"twoDaysBefore": "2日前",
|
||||
"threeDaysBefore": "3日前",
|
||||
"requiredFieldsError": "すべての必須項目を入力してください",
|
||||
"categoryNameRequired": "カテゴリ名を入力してください",
|
||||
"subscriptionUpdated": "サブスクリプション情報が更新されました",
|
||||
"subscriptionDeleted": "@サブスクリプションが削除されました",
|
||||
"officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。",
|
||||
@@ -623,6 +664,7 @@
|
||||
"appLockDesc": "生体認証でアプリをロック",
|
||||
"unlockWithBiometric": "生体認証でロック解除",
|
||||
"authenticationFailed": "認証に失敗しました。もう一度お試しください。",
|
||||
"nextBillingDateAdjusted": "次回請求日に保存しました",
|
||||
"totalExpenseCopied": "総支出がコピーされました:@",
|
||||
"smsPermissionRequired": "SMS権限が必要です",
|
||||
"noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません",
|
||||
@@ -667,6 +709,7 @@
|
||||
"latestSmsMessage": "最新のSMSメッセージ",
|
||||
"smsDetectedDate": "SMS受信日: @",
|
||||
"serviceName": "サービス名",
|
||||
"unknownService": "不明なサービス",
|
||||
"nextBillingDateLabel": "次回請求日",
|
||||
"category": "カテゴリー",
|
||||
"websiteUrlAuto": "ウェブサイトURL(自動抽出)",
|
||||
@@ -754,7 +797,16 @@
|
||||
"subscriptionDetail": "サブスクリプション詳細",
|
||||
"enterAmount": "金額を入力してください",
|
||||
"invalidAmount": "正しい金額を入力してください",
|
||||
"featureComingSoon": "この機能は近日公開予定です"
|
||||
"featureComingSoon": "この機能は近日公開予定です",
|
||||
"exactAlarmPermission": "正確なアラーム権限(アラームとリマインダー)",
|
||||
"exactAlarmPermissionDesc": "正確な時刻に通知するには権限が必要です。",
|
||||
"allowAlarmsInSettings": "設定で「アラームとリマインダー」を許可してください。",
|
||||
"testNotification": "テスト通知",
|
||||
"testSubscriptionBody": "テストサブスクリプション • @",
|
||||
"expirationReminderBody": "@ のサブスクリプションは #日後に期限切れになります。",
|
||||
"eventEndNotificationTitle": "イベント終了通知",
|
||||
"eventEndNotificationBody": "@ の割引イベントが終了しました。",
|
||||
"paymentChargeNotification": "@ の購読料 @ が請求されました。"
|
||||
},
|
||||
"zh": {
|
||||
"appTitle": "数字月租管理器",
|
||||
@@ -768,6 +820,9 @@
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"deleteSubscriptionTitle": "删除订阅",
|
||||
"deleteSubscriptionMessage": "确定要删除@订阅吗?",
|
||||
"deleteIrreversibleWarning": "此操作无法撤销",
|
||||
"edit": "编辑",
|
||||
"totalSubscriptions": "订阅总数",
|
||||
"totalMonthlyExpense": "本月总支出",
|
||||
@@ -782,10 +837,12 @@
|
||||
"selectIcon": "选择图标",
|
||||
"addCategory": "添加分类",
|
||||
"settings": "设置",
|
||||
"theme": "主题",
|
||||
"darkMode": "深色模式",
|
||||
"language": "语言",
|
||||
"notifications": "通知",
|
||||
"appLock": "应用锁定",
|
||||
"appLocked": "应用已锁定",
|
||||
"paymentCard": "支付卡",
|
||||
"paymentCardManagement": "支付卡管理",
|
||||
"paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)",
|
||||
@@ -821,6 +878,7 @@
|
||||
"dailyReminderEnabled": "直到付款日期每天接收通知",
|
||||
"dailyReminderDisabled": "在付款@天前接收通知",
|
||||
"notificationPermissionDenied": "通知权限被拒绝",
|
||||
"permissionGranted": "已获得权限。",
|
||||
"appInfo": "应用信息",
|
||||
"version": "版本",
|
||||
"appDescription": "数字月租管理应用",
|
||||
@@ -840,6 +898,7 @@
|
||||
"twoDaysBefore": "2天前",
|
||||
"threeDaysBefore": "3天前",
|
||||
"requiredFieldsError": "请填写所有必填项",
|
||||
"categoryNameRequired": "请输入分类名称",
|
||||
"subscriptionUpdated": "订阅信息已更新",
|
||||
"subscriptionDeleted": "@订阅已删除",
|
||||
"officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。",
|
||||
@@ -868,6 +927,7 @@
|
||||
"appLockDesc": "使用生物识别锁定应用",
|
||||
"unlockWithBiometric": "使用生物识别解锁",
|
||||
"authenticationFailed": "认证失败。请重试。",
|
||||
"nextBillingDateAdjusted": "已保存为下一次账单日",
|
||||
"totalExpenseCopied": "总支出已复制:@",
|
||||
"smsPermissionRequired": "需要短信权限",
|
||||
"noSubscriptionSmsFound": "未找到订阅相关的短信",
|
||||
@@ -912,6 +972,7 @@
|
||||
"latestSmsMessage": "最新短信内容",
|
||||
"smsDetectedDate": "短信接收日期:@",
|
||||
"serviceName": "服务名称",
|
||||
"unknownService": "未知服务",
|
||||
"nextBillingDateLabel": "下次付款日期",
|
||||
"category": "类别",
|
||||
"websiteUrlAuto": "网站URL(自动提取)",
|
||||
@@ -999,6 +1060,15 @@
|
||||
"subscriptionDetail": "订阅详情",
|
||||
"enterAmount": "请输入金额",
|
||||
"invalidAmount": "请输入有效的金额",
|
||||
"featureComingSoon": "此功能即将推出"
|
||||
"featureComingSoon": "此功能即将推出",
|
||||
"exactAlarmPermission": "精确闹钟权限(闹钟和提醒)",
|
||||
"exactAlarmPermissionDesc": "需要权限以确保在准确时间发送提醒。",
|
||||
"allowAlarmsInSettings": "请在设置中允许“闹钟和提醒”。",
|
||||
"testNotification": "测试通知",
|
||||
"testSubscriptionBody": "测试订阅 • @",
|
||||
"expirationReminderBody": "@ 订阅将在 # 天后到期。",
|
||||
"eventEndNotificationTitle": "活动结束通知",
|
||||
"eventEndNotificationBody": "@ 的优惠活动已结束。",
|
||||
"paymentChargeNotification": "@ 订阅费用 @ 已扣款。"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
assets/screenShot/submanager_01_Cn.jpg
Executable file
|
After Width: | Height: | Size: 287 KiB |
BIN
assets/screenShot/submanager_01_En.jpg
Executable file
|
After Width: | Height: | Size: 320 KiB |
BIN
assets/screenShot/submanager_01_Jp.jpg
Executable file
|
After Width: | Height: | Size: 313 KiB |
BIN
assets/screenShot/submanager_01_kr.jpg
Executable file
|
After Width: | Height: | Size: 288 KiB |
BIN
assets/screenShot/submanager_02_Cn.jpg
Executable file
|
After Width: | Height: | Size: 272 KiB |
BIN
assets/screenShot/submanager_02_En.jpg
Executable file
|
After Width: | Height: | Size: 282 KiB |
BIN
assets/screenShot/submanager_02_Jp.jpg
Executable file
|
After Width: | Height: | Size: 281 KiB |
BIN
assets/screenShot/submanager_02_kr.jpg
Executable file
|
After Width: | Height: | Size: 260 KiB |
BIN
assets/screenShot/submanager_03_Cn.jpg
Executable file
|
After Width: | Height: | Size: 251 KiB |
BIN
assets/screenShot/submanager_03_En.jpg
Executable file
|
After Width: | Height: | Size: 289 KiB |
BIN
assets/screenShot/submanager_03_Jp.jpg
Executable file
|
After Width: | Height: | Size: 274 KiB |
BIN
assets/screenShot/submanager_03_kr.jpg
Executable file
|
After Width: | Height: | Size: 231 KiB |
BIN
assets/screenShot/submanager_04_Cn.jpg
Executable file
|
After Width: | Height: | Size: 237 KiB |
BIN
assets/screenShot/submanager_04_En.jpg
Executable file
|
After Width: | Height: | Size: 247 KiB |
BIN
assets/screenShot/submanager_04_Jp.jpg
Executable file
|
After Width: | Height: | Size: 284 KiB |
BIN
assets/screenShot/submanager_04_kr.jpg
Executable file
|
After Width: | Height: | Size: 234 KiB |
BIN
assets/screenShot/submanager_05_Cn.jpg
Executable file
|
After Width: | Height: | Size: 250 KiB |
BIN
assets/screenShot/submanager_05_En.jpg
Executable file
|
After Width: | Height: | Size: 283 KiB |
BIN
assets/screenShot/submanager_05_Jp.jpg
Executable file
|
After Width: | Height: | Size: 262 KiB |
BIN
assets/screenShot/submanager_05_kr.jpg
Executable file
|
After Width: | Height: | Size: 257 KiB |
BIN
assets/screenShot/submanager_image_en.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/screenShot/submanager_image_jp.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/screenShot/submanager_image_kr.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/screenShot/submanager_imgae_cn.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
83
doc/PersonalInformation/Personal information.txt
Normal file
@@ -0,0 +1,83 @@
|
||||
아하, 그런 구조라면 “로컬 저장 + 광고 SDK만 있음” 패턴으로 쓰면 되겠네요.
|
||||
---
|
||||
디지털 월세 관리자 개인정보 처리방
|
||||
|
||||
디지털 월세 관리자(이하 “앱”)는 사용자의 개인정보 보호를 최우선으로 합니다.
|
||||
이 앱은 사용자의 데이터를 외부 서버로 전송하지 않으며, 모든 사용 데이터는 사용자의 기기 내에만 저장됩니다.
|
||||
본 개인정보 처리방침은 앱이 어떤 정보를 어떻게 처리하는지 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
1. 수집하는 개인정보
|
||||
|
||||
이 앱은 다음과 같은 의미에서 사용자를 식별할 수 있는 개인정보(이름, 이메일, 전화번호 등)를 직접 수집하지 않습니다
|
||||
|
||||
* 회원가입, 로그인 기능이 없습니다.
|
||||
* 이메일, 전화번호, 주소 등의 정보를 요구하지 않습니다.
|
||||
* 개발자가 운영하는 서버로 어떠한 사용자 정보도 전송하지 않습니다.
|
||||
|
||||
다만, 사용자가 앱 내에서 입력한 구독·월세·지출 관련 정보는 오직 사용자의 기기 내에만 저 되며, 개발자는 해당 내용에 접근할 수 없습니다.
|
||||
|
||||
---
|
||||
|
||||
2. 데이터 저장 및 처리 방식
|
||||
|
||||
* 사용자가 입력한 모든 데이터는 로컬 저장소(기기 내 저장소, 데이터베이스 등 에만 보관됩니다.
|
||||
* 앱은 사용자의 데이터를 클라우드 서버나 외부 서비스로 자동 전송하지 않습니다.
|
||||
* 앱을 삭제하면, 기기에 저장된 데이터도 함께 삭제됩니다.
|
||||
|
||||
---
|
||||
|
||||
3. 광고 및 제3자 서비스
|
||||
|
||||
이 앱은 무료 제공을 위해 광고를 표 할 수 있으며, 이 과정에서 제3자 광고 네트워크(예: Google AdMob 등 가 참여할 수 있습니다.
|
||||
|
||||
개발자는 직접 사용자의 개인정보를 수집하지 않지만, 광고 네트워크는 다음과 같은 정 를 수집·처리할 수 있습니다.
|
||||
|
||||
* 광고 식별자(예: Android 광고 ID)
|
||||
* 기기 정보(단말기 모델, OS 버전 등)
|
||||
* 대략적인 위치 정보(국가/지역 수준)
|
||||
* 앱 사용 정보(광고 조회/클릭 여부 등)
|
||||
|
||||
제3자 광고 파트너의 데이터 처리 방식과 수집 항목은 각 서비스의 개인정보 처리방침을 따릅니다.
|
||||
자세한 내용은 사용 중인 광고 네트워크(예: Google AdMob)의 정책을 참고하시기 바랍니다.
|
||||
|
||||
---
|
||||
|
||||
4. 권한 사용
|
||||
|
||||
앱은 기능 제공을 위해 다음과 같은 권한을 사용할 수 있습니다.
|
||||
|
||||
|
||||
* 알림 권한: 결제 예정 알림 등 앱 내 알림 기능 제공을 위해 사용
|
||||
* SMS 읽기 권한: SMS를 읽어서 구독정보를 찾기 위해 사용
|
||||
* 네트워크 권한: 광고 로딩 및 앱 업데이트를 위해 사용
|
||||
|
||||
이 권한들은 앱 기능 및 광고 표시를 위한 용도 외에는 사용되지 않습니다
|
||||
|
||||
---
|
||||
|
||||
5. 아동의 개인정보
|
||||
|
||||
이 앱은 성인을 주요 대상으로 설계되었으며, 만 14세 미만(또는 각 국가의 관련 기준 미만)의 아동을 대상으로 개인정보를 수집하려는 의도가 없습니다
|
||||
만약 아동의 개인정보가 부주의로 수집된 사실을 인지하게 될 경우, 가능한 한 신속히 해당 정보를 삭제하기 위한 조치를 취하겠습니다.
|
||||
|
||||
---
|
||||
|
||||
6. 개인정보 처리방침의 변경
|
||||
|
||||
본 개인정보 처리방침은 서비스 개선이나 관련 법령 변경 등에 따라 수정될 수 있습니다.
|
||||
중요한 내용이 변경되는 경우, 앱 내 공지 또는 스토어 설명 등을 통해 변경 내용을 안내하겠습니다.
|
||||
|
||||
* 시행일자: 2025.11.17
|
||||
|
||||
---
|
||||
|
||||
7. 문의처
|
||||
|
||||
앱의 개인정보 처리방침에 대한 문의, 의견 또는 오류 신고가 필요하신 경우 아래 연락처로 문의해 주세요.
|
||||
|
||||
* 담당자: 네이처브릿지AI 앱개발팀
|
||||
* 이메일: naturebridgeai@gmail.com
|
||||
|
||||
---
|
||||
13
doc/key/readme.md
Normal file
@@ -0,0 +1,13 @@
|
||||
doc/key/submanager-release.keystore를 생성했습니다. 명령에 사용된 정보: alias submanager, 키 알고리즘 RSA 2048bit, 유효기간 10,000일, 인증서 DN CN=SubManager, OU=Engineering, O=SubManager, L=Seoul, ST=Seoul, C=KR입니다. 저장/키 비밀번호는 모두 submanager로 설정했습니다.
|
||||
|
||||
이 키스토어를 android/key.properties 등에서 참조하도록 설정하면 release 빌드를 실제 서명 키로 빌드할 수 있습니다. (예: storeFile=../doc/key/submanager-release.keystore, storePassword=submanager, keyAlias=submanager, keyPassword=submanager).
|
||||
|
||||
이후 buildTypes.release에서 signingConfig = signingConfigs.getByName("release")로 연결하면 됩니다.
|
||||
|
||||
android/key.properties을 새로 만들어 storeFile=../../doc/key/submanager-release.keystore 등 키 정보를 등록했습니다. (storePassword, keyPassword, keyAlias 모두 submanager로 설정)
|
||||
|
||||
android/app/build.gradle.kts (lines 1-52)에 Properties/FileInputStream을 import하고 keystoreProperties 로드 후 signingConfigs.release를 추가했습니다. 이제 buildTypes.release가 디버그 키 대신 새 릴리스 키로 서명합니다.
|
||||
|
||||
doc/key/submanager-release.keystore가 키스토어 위치이며, flutter build apk --release로 서명된 릴리스 APK(build/app/outputs/flutter-apk/app-release.apk)가 정상 생성되는 것을 확인했습니다.
|
||||
|
||||
다음 단계로는 이 keystore와 key.properties를 안전한 곳에 보관하고, 필요 시 .gitignore 등으로 민감 파일이 VCS에 노출되지 않도록 관리하는 것을 권장합니다.
|
||||
BIN
doc/key/submanager-release.keystore
Normal file
@@ -525,7 +525,7 @@ class AddSubscriptionController {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showInfo(
|
||||
context: context,
|
||||
message: '다음 결제 예정일로 저장됨',
|
||||
message: AppLocalizations.of(context).nextBillingDateAdjusted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@ class DetailScreenController extends ChangeNotifier {
|
||||
if (adjustedNext.isAfter(originalDateOnly)) {
|
||||
AppSnackBar.showInfo(
|
||||
context: context,
|
||||
message: '다음 결제 예정일로 저장됨',
|
||||
message: AppLocalizations.of(context).nextBillingDateAdjusted,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +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 '../models/payment_card_suggestion.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 '../l10n/app_localizations.dart';
|
||||
import '../providers/payment_card_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
class SmsScanController extends ChangeNotifier {
|
||||
// 상태 관리
|
||||
@@ -45,6 +48,7 @@ class SmsScanController extends ChangeNotifier {
|
||||
final SmsScanner _smsScanner = SmsScanner();
|
||||
final SubscriptionConverter _converter = SubscriptionConverter();
|
||||
final SubscriptionFilter _filter = SubscriptionFilter();
|
||||
final AdService _adService = AdService();
|
||||
bool _forceServiceNameEditing = false;
|
||||
bool get isServiceNameEditable => _forceServiceNameEditing;
|
||||
|
||||
@@ -73,15 +77,36 @@ class SmsScanController extends ChangeNotifier {
|
||||
serviceNameController.text = '';
|
||||
}
|
||||
|
||||
void updateCurrentServiceName(String value) {
|
||||
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 ? '알 수 없는 서비스' : trimmed);
|
||||
.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 {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
@@ -89,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;
|
||||
@@ -331,13 +361,14 @@ 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!;
|
||||
}
|
||||
if (_shouldEnableServiceNameEditing(currentSub)) {
|
||||
final unknownLabel = _unknownServiceLabel(context);
|
||||
if (_shouldEnableServiceNameEditing(currentSub, unknownLabel)) {
|
||||
if (serviceNameController.text != currentSub.serviceName) {
|
||||
serviceNameController.clear();
|
||||
}
|
||||
@@ -366,8 +397,10 @@ class SmsScanController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final current = _scannedSubscriptions[_currentIndex];
|
||||
_forceServiceNameEditing = _shouldEnableServiceNameEditing(current);
|
||||
if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') {
|
||||
final unknownLabel = _unknownServiceLabel(context);
|
||||
_forceServiceNameEditing =
|
||||
_shouldEnableServiceNameEditing(current, unknownLabel);
|
||||
if (_forceServiceNameEditing && current.serviceName == unknownLabel) {
|
||||
serviceNameController.clear();
|
||||
} else {
|
||||
serviceNameController.text = current.serviceName;
|
||||
@@ -429,8 +462,13 @@ class SmsScanController extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _shouldEnableServiceNameEditing(Subscription subscription) {
|
||||
bool _shouldEnableServiceNameEditing(
|
||||
Subscription subscription, String unknownLabel) {
|
||||
final name = subscription.serviceName.trim();
|
||||
return name.isEmpty || name == '알 수 없는 서비스';
|
||||
return name.isEmpty || name == unknownLabel;
|
||||
}
|
||||
|
||||
String _unknownServiceLabel(BuildContext context) {
|
||||
return AppLocalizations.of(context).unknownService;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ class AppLocalizations {
|
||||
String get save => _localizedStrings['save'] ?? 'Save';
|
||||
String get cancel => _localizedStrings['cancel'] ?? 'Cancel';
|
||||
String get delete => _localizedStrings['delete'] ?? 'Delete';
|
||||
String get deleteSubscriptionTitle =>
|
||||
_localizedStrings['deleteSubscriptionTitle'] ?? 'Delete Subscription';
|
||||
String get deleteSubscriptionMessageTemplate =>
|
||||
_localizedStrings['deleteSubscriptionMessage'] ??
|
||||
'Are you sure you want to delete @ subscription?';
|
||||
String deleteSubscriptionMessage(String serviceName) =>
|
||||
deleteSubscriptionMessageTemplate.replaceAll('@', serviceName);
|
||||
String get deleteIrreversibleWarning =>
|
||||
_localizedStrings['deleteIrreversibleWarning'] ??
|
||||
'This action cannot be undone';
|
||||
String get edit => _localizedStrings['edit'] ?? 'Edit';
|
||||
String get totalSubscriptions =>
|
||||
_localizedStrings['totalSubscriptions'] ?? 'Total Subscriptions';
|
||||
@@ -58,11 +68,13 @@ 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';
|
||||
@@ -163,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';
|
||||
@@ -197,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';
|
||||
@@ -249,6 +265,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 =>
|
||||
@@ -457,6 +476,8 @@ 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) {
|
||||
@@ -659,6 +680,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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -72,8 +74,11 @@ class AppLockProvider extends ChangeNotifier {
|
||||
return true;
|
||||
}
|
||||
|
||||
final ctx = navigatorKey.currentContext;
|
||||
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
||||
final authenticated = await _localAuth.authenticate(
|
||||
localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.',
|
||||
localizedReason:
|
||||
loc?.unlockWithBiometric ?? 'Unlock with biometric authentication.',
|
||||
options: const AuthenticationOptions(
|
||||
stickyAuth: true,
|
||||
biometricOnly: true,
|
||||
|
||||
@@ -8,6 +8,8 @@ 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';
|
||||
|
||||
class SubscriptionProvider extends ChangeNotifier {
|
||||
late Box<SubscriptionModel> _subscriptionBox;
|
||||
@@ -239,10 +241,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,
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
@@ -81,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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 }
|
||||
|
||||
@@ -324,21 +325,11 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
controller: _scrollController,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: <Widget>[
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
|
||||
sliver: _buildCardFilterSection(context, cardProvider),
|
||||
),
|
||||
|
||||
// 네이티브 광고 위젯
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAnimatedAd(),
|
||||
),
|
||||
|
||||
const AnalysisScreenSpacer(),
|
||||
|
||||
_buildCardFilterSection(context, cardProvider),
|
||||
|
||||
const AnalysisScreenSpacer(),
|
||||
|
||||
// 1. 구독 비율 파이 차트
|
||||
@@ -349,6 +340,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
|
||||
const AnalysisScreenSpacer(),
|
||||
|
||||
// 네이티브 광고 위젯 (구독 비율 차트 하단)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAnimatedAd(),
|
||||
),
|
||||
|
||||
const AnalysisScreenSpacer(),
|
||||
|
||||
// 2. 총 지출 요약 카드
|
||||
TotalExpenseSummaryCard(
|
||||
key: ValueKey('total_expense_$_lastDataHash'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,10 +4,8 @@ import 'package:provider/provider.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';
|
||||
@@ -18,6 +16,7 @@ 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});
|
||||
@@ -87,23 +86,16 @@ class SettingsScreen extends StatelessWidget {
|
||||
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.symmetric(vertical: 8, horizontal: 16),
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -192,7 +184,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
leading: Icon(Icons.color_lens,
|
||||
color: cs.onSurfaceVariant),
|
||||
title: Text(
|
||||
'테마',
|
||||
loc.theme,
|
||||
style: TextStyle(color: cs.onSurface),
|
||||
),
|
||||
),
|
||||
@@ -361,14 +353,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
|
||||
@@ -386,19 +378,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),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -748,8 +740,8 @@ class SettingsScreen extends StatelessWidget {
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons
|
||||
.notifications_active),
|
||||
label:
|
||||
const Text('테스트 알림'),
|
||||
label: Text(
|
||||
loc.testNotification),
|
||||
onPressed: () {
|
||||
NotificationService
|
||||
.showTestPaymentNotification();
|
||||
@@ -907,60 +899,61 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
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,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
// }),
|
||||
// ],
|
||||
// );
|
||||
// },
|
||||
),
|
||||
),
|
||||
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||
|
||||
@@ -9,6 +9,7 @@ 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});
|
||||
@@ -46,7 +47,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_controller.initializeWebsiteUrl();
|
||||
_controller.initializeWebsiteUrl(context);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
@@ -56,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,
|
||||
);
|
||||
}
|
||||
@@ -75,7 +76,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
}
|
||||
});
|
||||
return ScanInitialWidget(
|
||||
onScanPressed: () => _controller.scanSms(context),
|
||||
onScanPressed: () => _controller.startScan(context),
|
||||
errorMessage: _controller.errorMessage,
|
||||
);
|
||||
}
|
||||
@@ -104,7 +105,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
onPaymentCardChanged: _controller.setSelectedPaymentCardId,
|
||||
enableServiceNameEditing: _controller.isServiceNameEditable,
|
||||
onServiceNameChanged: _controller.isServiceNameEditable
|
||||
? _controller.updateCurrentServiceName
|
||||
? (value) => _controller.updateCurrentServiceName(context, value)
|
||||
: null,
|
||||
onAddCard: () async {
|
||||
final newCardId = await PaymentCardFormSheet.show(context);
|
||||
@@ -160,21 +161,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 로딩 중일 때는 화면 정중앙에 표시
|
||||
if (_controller.isLoading) {
|
||||
return const ScanLoadingWidget();
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
// toolbar 높이 추가
|
||||
SizedBox(
|
||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||
),
|
||||
_buildContent(),
|
||||
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||
SizedBox(
|
||||
height: 120 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildContent(),
|
||||
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||
SizedBox(
|
||||
height: 120 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
195
lib/services/ad_service.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
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 복구
|
||||
Future<void> _restoreSystemUi() async {
|
||||
try {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
@@ -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 '결제 예정 알림';
|
||||
|
||||
@@ -10,6 +10,8 @@ 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();
|
||||
@@ -82,7 +84,9 @@ class SmsScanner {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +120,13 @@ class SmsScanner {
|
||||
|
||||
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');
|
||||
@@ -196,8 +206,9 @@ class SmsScanner {
|
||||
if (issuer == null && last4 == null) {
|
||||
return null;
|
||||
}
|
||||
final loc = _loc();
|
||||
return PaymentCardSuggestion(
|
||||
issuerName: issuer ?? '결제수단',
|
||||
issuerName: issuer ?? loc?.paymentCard ?? 'Payment card',
|
||||
last4: last4,
|
||||
source: 'sms',
|
||||
);
|
||||
@@ -366,6 +377,12 @@ class SmsScanner {
|
||||
// 기본값은 원화
|
||||
return 'KRW';
|
||||
}
|
||||
|
||||
AppLocalizations? _loc() {
|
||||
final ctx = navigatorKey.currentContext;
|
||||
if (ctx == null) return null;
|
||||
return AppLocalizations.of(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
const List<String> _paymentLikeKeywords = [
|
||||
@@ -501,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();
|
||||
}
|
||||
@@ -576,13 +594,14 @@ Map<String, List<Map<String, dynamic>>> _groupMessagesByIdentifier(
|
||||
final address = (sms['address'] as String?)?.trim();
|
||||
final sender = (sms['sender'] as String?)?.trim();
|
||||
|
||||
final unknownLabel = _unknownServiceLabel();
|
||||
String key = (serviceName != null &&
|
||||
serviceName.isNotEmpty &&
|
||||
serviceName != '알 수 없는 서비스')
|
||||
serviceName != unknownLabel)
|
||||
? serviceName
|
||||
: (address?.isNotEmpty == true
|
||||
? address!
|
||||
: (sender?.isNotEmpty == true ? sender! : 'unknown'));
|
||||
: (sender?.isNotEmpty == true ? sender! : unknownLabel));
|
||||
|
||||
groups.putIfAbsent(key, () => []).add(sms);
|
||||
}
|
||||
@@ -602,6 +621,12 @@ class _RepeatDetectionResult {
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
import '../utils/platform_helper.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../navigator_key.dart';
|
||||
|
||||
class SMSService {
|
||||
static const platform = MethodChannel('com.submanager/sms');
|
||||
@@ -37,14 +39,24 @@ class SMSService {
|
||||
|
||||
try {
|
||||
if (!await hasSMSPermission()) {
|
||||
throw Exception('SMS 권한이 없습니다.');
|
||||
final loc = _loc();
|
||||
throw Exception(
|
||||
loc?.smsPermissionRequired ?? 'SMS permission required.');
|
||||
}
|
||||
|
||||
final List<dynamic> result =
|
||||
await platform.invokeMethod('scanSubscriptions');
|
||||
return result.map((item) => item as Map<String, dynamic>).toList();
|
||||
} on PlatformException catch (e) {
|
||||
throw Exception('SMS 스캔 중 오류 발생: ${e.message}');
|
||||
final loc = _loc();
|
||||
throw Exception(loc?.smsScanErrorWithMessage(e.message ?? '') ??
|
||||
'Error occurred during SMS scan: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
static AppLocalizations? _loc() {
|
||||
final ctx = navigatorKey.currentContext;
|
||||
if (ctx == null) return null;
|
||||
return AppLocalizations.of(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class SmsDateFormatter {
|
||||
);
|
||||
}
|
||||
|
||||
return '다음 결제일 확인 필요 (과거 날짜)';
|
||||
return AppLocalizations.of(context).nextBillingDatePastRequired;
|
||||
}
|
||||
|
||||
// 미래 날짜 처리
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -233,56 +233,66 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: AppLocalizations.of(context)
|
||||
.subscriptionServiceRatio,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
Expanded(
|
||||
child: ThemedText.headline(
|
||||
text: AppLocalizations.of(context)
|
||||
.subscriptionServiceRatio,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.getExchangeRateInfoForLocale(
|
||||
context
|
||||
.watch<LocaleProvider>()
|
||||
.locale
|
||||
.languageCode),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
Flexible(
|
||||
child: FutureBuilder<String>(
|
||||
future: CurrencyUtil.getExchangeRateInfoForLocale(
|
||||
context
|
||||
.watch<LocaleProvider>()
|
||||
.locale
|
||||
.languageCode),
|
||||
builder: (context, snapshot) {
|
||||
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,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ThemedText(
|
||||
AppLocalizations.of(context)
|
||||
.exchangeRateFormat(snapshot.data!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)
|
||||
.exchangeRateFormat(snapshot.data!),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -265,23 +265,39 @@ 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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import '../providers/subscription_provider.dart';
|
||||
import '../utils/subscription_grouping_helper.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../widgets/main_summary_card.dart';
|
||||
import '../widgets/native_ad_widget.dart';
|
||||
import '../theme/ui_constants.dart';
|
||||
import '../widgets/subscription_list_widget.dart';
|
||||
|
||||
class HomeContent extends StatefulWidget {
|
||||
@@ -115,13 +115,8 @@ class _HomeContentState extends State<HomeContent> {
|
||||
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(
|
||||
|
||||
@@ -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,43 +252,54 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// 웹 환경인 경우 플레이스홀더 표시
|
||||
if (kIsWeb) {
|
||||
return _buildWebPlaceholder();
|
||||
}
|
||||
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);
|
||||
|
||||
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
|
||||
if (!(Platform.isAndroid || Platform.isIOS)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// 웹 환경인 경우 플레이스홀더 표시
|
||||
if (kIsWeb) {
|
||||
return _buildWebPlaceholder(slotHeight, horizontalPadding);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
// 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지
|
||||
return _buildWebPlaceholder();
|
||||
}
|
||||
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
|
||||
if (!(Platform.isAndroid || Platform.isIOS)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!_isLoaded) {
|
||||
// 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지
|
||||
return _buildWebPlaceholder();
|
||||
}
|
||||
if (_error != null) {
|
||||
// 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지
|
||||
return _buildWebPlaceholder(slotHeight, horizontalPadding);
|
||||
}
|
||||
|
||||
// 광고 정상 노출
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal:
|
||||
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
|
||||
vertical: UIConstants.adVerticalPadding,
|
||||
),
|
||||
child: Card(
|
||||
elevation: 1,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: UIConstants.adCardHeight,
|
||||
child: AdWidget(ad: _nativeAd!),
|
||||
),
|
||||
),
|
||||
if (!_isLoaded) {
|
||||
// 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지
|
||||
return _buildWebPlaceholder(slotHeight, horizontalPadding);
|
||||
}
|
||||
|
||||
// 광고 정상 노출
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
vertical: UIConstants.adVerticalPadding,
|
||||
),
|
||||
child: Card(
|
||||
elevation: 1,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: slotHeight,
|
||||
child: AdWidget(ad: _nativeAd!),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,33 +7,31 @@ 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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ThemedText(
|
||||
AppLocalizations.of(context).scanningMessages,
|
||||
forceDark: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ThemedText(
|
||||
AppLocalizations.of(context).findingSubscriptions,
|
||||
opacity: 0.7,
|
||||
forceDark: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -87,9 +86,6 @@ 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),
|
||||
|
||||
@@ -13,6 +13,8 @@ import './common/snackbar/app_snackbar.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/logger.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 {
|
||||
@@ -28,134 +30,132 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sections = groups;
|
||||
int itemCounter = 0;
|
||||
final List<Widget> children = [];
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final group = sections[index];
|
||||
final subscriptions = group.subscriptions;
|
||||
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: [
|
||||
SubscriptionGroupHeader(
|
||||
group: group,
|
||||
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,
|
||||
itemCount: subscriptions.length,
|
||||
itemBuilder: (context, subIndex) {
|
||||
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
|
||||
final delay = 0.05 * subIndex;
|
||||
const animationBegin = 0.2;
|
||||
const animationEnd = 1.0;
|
||||
final intervalStart = delay;
|
||||
final intervalEnd = intervalStart + 0.4;
|
||||
for (var subIndex = 0; subIndex < subscriptions.length; subIndex++) {
|
||||
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
|
||||
final delay = 0.05 * subIndex;
|
||||
const animationBegin = 0.2;
|
||||
const animationEnd = 1.0;
|
||||
final intervalStart = delay;
|
||||
final intervalEnd = intervalStart + 0.4;
|
||||
|
||||
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
|
||||
final intervalStartNormalized =
|
||||
intervalStart.clamp(0.0, 0.9);
|
||||
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
|
||||
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
|
||||
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)
|
||||
.animate(CurvedAnimation(
|
||||
parent: fadeController,
|
||||
curve: Interval(intervalStartNormalized,
|
||||
intervalEndNormalized,
|
||||
curve: Curves.easeOut))),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: StaggeredAnimationItem(
|
||||
index: subIndex,
|
||||
delay: const Duration(milliseconds: 50),
|
||||
child: RepaintBoundary(
|
||||
child: SwipeableSubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
keepAlive: true,
|
||||
onTap: () {
|
||||
Log.d(
|
||||
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
||||
AppNavigator.toDetail(
|
||||
context, subscriptions[subIndex]);
|
||||
},
|
||||
onDelete: () async {
|
||||
// 현재 로케일에 맞는 서비스명 가져오기
|
||||
final localeProvider =
|
||||
Provider.of<LocaleProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final locale =
|
||||
localeProvider.locale.languageCode;
|
||||
final displayName =
|
||||
await SubscriptionUrlMatcher
|
||||
.getServiceDisplayName(
|
||||
serviceName:
|
||||
subscriptions[subIndex].serviceName,
|
||||
locale: locale,
|
||||
);
|
||||
|
||||
// 삭제 확인 다이얼로그 표시
|
||||
if (!context.mounted) return;
|
||||
final shouldDelete =
|
||||
await DeleteConfirmationDialog.show(
|
||||
context: context,
|
||||
serviceName: displayName,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (shouldDelete) {
|
||||
// 사용자가 확인한 경우에만 삭제 진행
|
||||
final provider =
|
||||
Provider.of<SubscriptionProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
await provider.deleteSubscription(
|
||||
subscriptions[subIndex].id,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context)
|
||||
.subscriptionDeleted(displayName),
|
||||
icon: Icons.delete_forever_rounded,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
subscriptionItems.add(
|
||||
FadeTransition(
|
||||
opacity: Tween<double>(begin: animationBegin, end: animationEnd)
|
||||
.animate(CurvedAnimation(
|
||||
parent: fadeController,
|
||||
curve: Interval(
|
||||
intervalStartNormalized, intervalEndNormalized,
|
||||
curve: Curves.easeOut))),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 6.0),
|
||||
child: StaggeredAnimationItem(
|
||||
index: subIndex,
|
||||
delay: const Duration(milliseconds: 50),
|
||||
child: RepaintBoundary(
|
||||
child: SwipeableSubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
keepAlive: true,
|
||||
onTap: () {
|
||||
Log.d(
|
||||
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||
},
|
||||
onDelete: () async {
|
||||
// 현재 로케일에 맞는 서비스명 가져오기
|
||||
final localeProvider = Provider.of<LocaleProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final locale = localeProvider.locale.languageCode;
|
||||
final displayName =
|
||||
await SubscriptionUrlMatcher.getServiceDisplayName(
|
||||
serviceName: subscriptions[subIndex].serviceName,
|
||||
locale: locale,
|
||||
);
|
||||
|
||||
// 삭제 확인 다이얼로그 표시
|
||||
if (!context.mounted) return;
|
||||
final shouldDelete = await DeleteConfirmationDialog.show(
|
||||
context: context,
|
||||
serviceName: displayName,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (shouldDelete) {
|
||||
// 사용자가 확인한 경우에만 삭제 진행
|
||||
final provider = Provider.of<SubscriptionProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
await provider.deleteSubscription(
|
||||
subscriptions[subIndex].id,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context)
|
||||
.subscriptionDeleted(displayName),
|
||||
icon: Icons.delete_forever_rounded,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
itemCounter++;
|
||||
if ((itemCounter - 1) % 10 == 0) {
|
||||
subscriptionItems.add(
|
||||
NativeAdWidget(
|
||||
key: ValueKey('home_list_ad_$itemCounter'),
|
||||
aspectRatioOverride: 320 / 80,
|
||||
mediaAspectRatioOverride: MediaAspectRatio.landscape,
|
||||
templateTypeOverride: TemplateType.small,
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: sections.length,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate(children),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: submanager
|
||||
description: A new Flutter project.
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
version: 1.0.1+3
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||