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` 참조.
|
||||||
- 모든 답변은 한국어로 제공
|
|
||||||
- 기술 용어는 영어와 한국어 병기 가능
|
|
||||||
|
|
||||||
## 프로젝트 정보
|
## Project Overview
|
||||||
- Flutter 기반 구독 관리 앱 (SubManager)
|
|
||||||
|
|
||||||
## 현재 작업
|
**SubManager** - 구독 관리 앱 (Flutter 3.x)
|
||||||
- 구독카드가 클릭이 되지 않아서 문제를 찾는 중.
|
|
||||||
|
|
||||||
## 🎯 Mandatory Response Format
|
| 항목 | 기술 |
|
||||||
|
|------|------|
|
||||||
|
| DB | Hive (로컬 전용) |
|
||||||
|
| 상태관리 | Provider + ChangeNotifier |
|
||||||
|
| 디자인 | Material 3 |
|
||||||
|
| 광고 | google_mobile_ads |
|
||||||
|
|
||||||
Before starting any task, you MUST respond in the following format:
|
**버전**: 1.0.1+3
|
||||||
|
|
||||||
```
|
## Quick Commands
|
||||||
[Model Name]. I have reviewed all the following rules: [rule file list or categories]. Proceeding with the task. Master!
|
|
||||||
|
```bash
|
||||||
|
# Hive 모델 생성
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
|
# 빌드
|
||||||
|
flutter build apk --release # APK
|
||||||
|
flutter build appbundle --release # AAB (Play Store)
|
||||||
|
|
||||||
|
# 버전업 후 디바이스 설치
|
||||||
|
flutter install --release
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:**
|
## Architecture
|
||||||
|
|
||||||
- `Claude Sonnet 4. I have reviewed all the following rules: development guidelines, class structure, testing rules. Proceeding with the task. Master!`
|
```text
|
||||||
- For extensive rules: `coding style, class design, exception handling, testing rules` (categorized summary)
|
lib/
|
||||||
|
├── controllers/ # 비즈니스 로직 (3개)
|
||||||
## 🚀 Mandatory 3-Phase Task Process
|
│ ├── add_subscription_controller.dart
|
||||||
|
│ ├── detail_screen_controller.dart
|
||||||
### Phase 1: Codebase Exploration & Analysis
|
│ └── sms_scan_controller.dart
|
||||||
|
├── models/ # Hive 모델 (@HiveType)
|
||||||
**Required Actions:**
|
│ ├── subscription_model.dart (typeId: 0)
|
||||||
|
│ ├── category_model.dart (typeId: 1)
|
||||||
- Systematically discover ALL relevant files, directories, modules
|
│ └── payment_card_model.dart (typeId: 2)
|
||||||
- Search for related keywords, functions, classes, patterns
|
├── providers/ # ChangeNotifier 상태관리
|
||||||
- Thoroughly examine each identified file
|
├── screens/ # 화면 위젯
|
||||||
- Document coding conventions and style guidelines
|
├── services/ # 외부 서비스 연동
|
||||||
- Identify framework/library usage patterns
|
├── widgets/ # 재사용 컴포넌트
|
||||||
|
├── utils/ # 유틸리티 헬퍼
|
||||||
### Phase 2: Implementation Planning
|
├── routes/ # 라우팅 정의
|
||||||
|
├── theme/ # 테마/색상
|
||||||
**Required Actions:**
|
└── l10n/ # 다국어 (ko/en/ja/zh)
|
||||||
|
|
||||||
- Create detailed implementation roadmap based on Phase 1 findings
|
|
||||||
- Define specific task lists and acceptance criteria per module
|
|
||||||
- Specify performance/quality requirements
|
|
||||||
|
|
||||||
### Phase 3: Implementation Execution
|
|
||||||
|
|
||||||
**Required Actions:**
|
|
||||||
|
|
||||||
- Implement each module following Phase 2 plan
|
|
||||||
- Verify ALL acceptance criteria before proceeding
|
|
||||||
- Ensure adherence to conventions identified in Phase 1
|
|
||||||
|
|
||||||
## ✅ Core Development Principles
|
|
||||||
|
|
||||||
### Language & Type Rules
|
|
||||||
|
|
||||||
- **Write ALL code, variables, and names in English**
|
|
||||||
- **Write ALL comments, documentation, prompts, and responses in Korean**
|
|
||||||
- **Always declare types explicitly** for variables, parameters, and return values
|
|
||||||
- Avoid `any`, `dynamic`, or loosely typed declarations (except when strictly necessary)
|
|
||||||
- Define **custom types** when needed
|
|
||||||
- Extract magic numbers and literals into named constants or enums
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
|Element|Style|Example|
|
|
||||||
|---|---|---|
|
|
||||||
|Classes|`PascalCase`|`UserService`, `DataRepository`|
|
|
||||||
|Variables/Methods|`camelCase`|`userName`, `calculateTotal`|
|
|
||||||
|Files/Folders|`under_score_case`|`user_service.dart`, `data_models/`|
|
|
||||||
|Environment Variables|`UPPERCASE`|`API_URL`, `DATABASE_PASSWORD`|
|
|
||||||
|
|
||||||
**Critical Rules:**
|
|
||||||
|
|
||||||
- **Boolean variables must be verb-based**: `isReady`, `hasError`, `canDelete`
|
|
||||||
- **Function/method names start with verbs**: `executeLogin`, `saveUser`
|
|
||||||
- Use meaningful, descriptive names
|
|
||||||
- Avoid abbreviations unless widely accepted: `i`, `j`, `err`, `ctx`, `API`, `URL`
|
|
||||||
|
|
||||||
## 🔧 Function & Method Design
|
|
||||||
|
|
||||||
### Function Structure Principles
|
|
||||||
|
|
||||||
- **Keep functions short and focused** (≤20 lines recommended)
|
|
||||||
- **Avoid blank lines inside functions**
|
|
||||||
- **Follow Single Responsibility Principle**
|
|
||||||
- **Use verb + object format** for naming:
|
|
||||||
- Boolean return: `isValid`, `canRetry`, `hasPermission`
|
|
||||||
- Void return: `executeLogin`, `saveUser`, `startAnimation`
|
|
||||||
|
|
||||||
### Function Optimization Techniques
|
|
||||||
|
|
||||||
- Use **early returns** to avoid nested logic
|
|
||||||
- Extract logic into helper functions
|
|
||||||
- Prefer **arrow functions** for short expressions (≤3 lines)
|
|
||||||
- Use **named functions** for complex logic
|
|
||||||
- Minimize null checks by using **default values**
|
|
||||||
- Minimize parameters using **RO-RO pattern** (Receive Object – Return Object)
|
|
||||||
|
|
||||||
## 📦 Data & Class Design
|
|
||||||
|
|
||||||
### Class Design Principles
|
|
||||||
|
|
||||||
- **Strictly follow Single Responsibility Principle (SRP)**
|
|
||||||
- **Favor composition over inheritance**
|
|
||||||
- **Define interfaces/abstract classes** to establish contracts
|
|
||||||
- **Prefer immutable data structures** (use `readonly`, `const`)
|
|
||||||
|
|
||||||
### File Size Management
|
|
||||||
|
|
||||||
- **Split by responsibility when exceeding 200 lines** (responsibility-based, not line-based)
|
|
||||||
- ✅ **May remain as-is if**:
|
|
||||||
- Has **single clear responsibility**
|
|
||||||
- Is **easy to maintain**
|
|
||||||
- 🔁 **Must split when**:
|
|
||||||
- Contains **multiple concerns**
|
|
||||||
- Requires **excessive scrolling**
|
|
||||||
- Patterns repeat across files
|
|
||||||
- Difficult for new developer onboarding
|
|
||||||
|
|
||||||
### Class Recommendations
|
|
||||||
|
|
||||||
- ≤ 200 lines (not mandatory)
|
|
||||||
- ≤ 10 public methods
|
|
||||||
- ≤ 10 properties
|
|
||||||
|
|
||||||
### Data Model Design
|
|
||||||
|
|
||||||
- Avoid excessive use of primitives — use **composite types or classes**
|
|
||||||
- Move **validation logic inside data models** (not in business logic)
|
|
||||||
|
|
||||||
## ❗ Exception Handling
|
|
||||||
|
|
||||||
### Exception Usage Principles
|
|
||||||
|
|
||||||
- Use exceptions only for **truly unexpected or critical issues**
|
|
||||||
- **Catch exceptions only to**:
|
|
||||||
- Handle known failure scenarios
|
|
||||||
- Add useful context
|
|
||||||
- Otherwise, let global handlers manage them
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
|
|
||||||
- Follow **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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commit Types
|
## Key Services
|
||||||
|
|
||||||
- `feat`: 새로운 기능 추가
|
| Service | 역할 |
|
||||||
- `fix`: 버그 수정
|
|---------|------|
|
||||||
- `refactor`: 코드 리팩토링
|
| `AdService` | 전면 광고 (Completer 패턴) |
|
||||||
- `style`: 코드 스타일 변경 (formatting, missing semi-colons, etc)
|
| `SmsScanner` | SMS 파싱 → 구독 자동 감지 (Isolate 사용) |
|
||||||
- `docs`: 문서 변경
|
| `NotificationService` | 로컬 알림 |
|
||||||
- `test`: 테스트 코드 추가 또는 수정
|
| `ExchangeRateService` | 환율 조회 |
|
||||||
- `chore`: 빌드 프로세스 또는 보조 도구 변경
|
| `url_matcher/` | 서비스명 → URL 매칭 |
|
||||||
|
|
||||||
### Examples
|
## Routes
|
||||||
|
|
||||||
✅ **Good Examples:**
|
| Path | Screen |
|
||||||
- `feat: 월별 차트 다국어 지원 추가`
|
|------|--------|
|
||||||
- `fix: 분석화면 총지출 금액 불일치 문제 해결`
|
| `/` | MainScreen |
|
||||||
- `refactor: 통화 변환 로직 모듈화`
|
| `/add-subscription` | AddSubscriptionScreen |
|
||||||
|
| `/subscription-detail` | DetailScreen (requires SubscriptionModel) |
|
||||||
|
| `/sms-scanner` | SmsScanScreen |
|
||||||
|
| `/analysis` | AnalysisScreen |
|
||||||
|
| `/settings` | SettingsScreen |
|
||||||
|
| `/payment-card-management` | PaymentCardManagementScreen |
|
||||||
|
|
||||||
❌ **Avoid These:**
|
## Project Rules
|
||||||
- Including "🤖 Generated with [Claude Code](https://claude.ai/code)"
|
|
||||||
- Including "Co-Authored-By: Claude <noreply@anthropic.com>"
|
|
||||||
- Vague messages like "update code" or "fix stuff"
|
|
||||||
- English commit messages (use Korean)
|
|
||||||
|
|
||||||
### Critical Rules
|
1. **로컬 전용**: 서버/Firebase/외부 DB 금지
|
||||||
|
2. **권한 거부 시**: 수동 입력 폴백 (SMS), 기능 비활성화 (알림)
|
||||||
|
3. **외부 API**: Clearbit Logo API만 허용
|
||||||
|
4. **Isolate 주의**: `compute()` 내부에서 Flutter 바인딩/Context 접근 불가
|
||||||
|
|
||||||
- **NEVER include AI tool attribution in commit messages**
|
## Known Patterns
|
||||||
- **Focus on what was changed and why**
|
|
||||||
- **Use present tense and imperative mood**
|
|
||||||
- **Keep the first line under 50 characters when possible**
|
|
||||||
|
|
||||||
## 🧠 Error Analysis & Rule Documentation
|
### 전면 광고 (AdService)
|
||||||
|
|
||||||
### Mandatory Process When Errors Occur
|
```dart
|
||||||
|
// Completer 패턴으로 광고 완료 대기
|
||||||
1. **Analyze root cause in detail**
|
final completer = Completer<bool>();
|
||||||
2. **Document preventive rule in `.cursor/rules/error_analysis.mdc`**
|
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||||
3. **Write in English including**:
|
onAdDismissedFullScreenContent: (ad) {
|
||||||
- Error description and context
|
completer.complete(true);
|
||||||
- Cause and reproducibility steps
|
},
|
||||||
- Resolution approach
|
);
|
||||||
- Rule for preventing future recurrences
|
ad.show();
|
||||||
- Sample code and references to related rules
|
return completer.future;
|
||||||
|
|
||||||
### Rule Writing Standards
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
description: Clear, one-line description of what the rule enforces
|
|
||||||
globs: path/to/files/*.ext, other/path/**/*
|
|
||||||
alwaysApply: boolean
|
|
||||||
---
|
|
||||||
|
|
||||||
**Main Points in Bold**
|
|
||||||
- Sub-points with details
|
|
||||||
- Examples and explanations
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ Architectural Guidelines
|
### SMS 스캔 (Isolate)
|
||||||
|
|
||||||
### Clean Architecture Compliance
|
```dart
|
||||||
|
// Isolate 내부에서는 하드코딩 사용
|
||||||
|
// Flutter 바인딩, Context, Provider 접근 불가
|
||||||
|
return 'Unknown service'; // AppLocalizations 사용 불가
|
||||||
|
```
|
||||||
|
|
||||||
- **Layered structure**: `modules`, `controllers`, `services`, `repositories`, `entities`
|
## Response Format
|
||||||
- Apply **Repository Pattern** for data abstraction
|
|
||||||
- Use **Dependency Injection** (`getIt`, `inject`, etc.)
|
|
||||||
- Controllers handle business logic (not view processing)
|
|
||||||
|
|
||||||
### Code Structuring
|
```text
|
||||||
|
[모델명]. I have reviewed all the following rules: [규칙]. Proceeding with the task. Master!
|
||||||
- **One export or public declaration per file**
|
```
|
||||||
- Centralize constants, error messages, and configuration
|
|
||||||
- Make **all shared logic reusable** and place in dedicated helper modules
|
|
||||||
|
|
||||||
## 🌲 UI Structure & Component Design
|
|
||||||
|
|
||||||
### UI Optimization Principles
|
|
||||||
|
|
||||||
- **Avoid deeply nested widget/component trees**:
|
|
||||||
- Flatten hierarchy for **better performance and readability**
|
|
||||||
- Easier **state management and testability**
|
|
||||||
- **Split large components into small, focused widgets/components**
|
|
||||||
- Use `const` constructors (or equivalents) for performance optimization
|
|
||||||
- Apply clear **naming and separation** between view, logic, and data layers
|
|
||||||
|
|
||||||
## 📈 Continuous Rule Improvement
|
|
||||||
|
|
||||||
### Rule Improvement Triggers
|
|
||||||
|
|
||||||
- New code patterns not covered by existing rules
|
|
||||||
- Repeated similar implementations across files
|
|
||||||
- Common error patterns that could be prevented
|
|
||||||
- New libraries or tools being used consistently
|
|
||||||
- Emerging best practices in the codebase
|
|
||||||
|
|
||||||
### Rule Update Criteria
|
|
||||||
|
|
||||||
**Add New Rules When:**
|
|
||||||
|
|
||||||
- A new technology/pattern is used in 3+ files
|
|
||||||
- Common bugs could be prevented by a rule
|
|
||||||
- Code reviews repeatedly mention the same feedback
|
|
||||||
|
|
||||||
**Modify Existing Rules When:**
|
|
||||||
|
|
||||||
- Better examples exist in the codebase
|
|
||||||
- Additional edge cases are discovered
|
|
||||||
- Related rules have been updated
|
|
||||||
|
|
||||||
## ✅ Quality Validation Checklist
|
|
||||||
|
|
||||||
Before completing any task, confirm:
|
|
||||||
|
|
||||||
- ✅ All three phases completed sequentially
|
|
||||||
- ✅ Each phase output meets specified format requirements
|
|
||||||
- ✅ Implementation satisfies all acceptance criteria
|
|
||||||
- ✅ Code quality meets professional standards
|
|
||||||
- ✅ Started with mandatory response format
|
|
||||||
- ✅ All naming conventions followed
|
|
||||||
- ✅ Type safety ensured
|
|
||||||
- ✅ Single Responsibility Principle adhered to
|
|
||||||
|
|
||||||
## 🎯 Success Validation Framework
|
|
||||||
|
|
||||||
### Expert-Level Standards Verification
|
|
||||||
|
|
||||||
- **Minimalistic Approach**: High-quality, clean solutions without unnecessary complexity
|
|
||||||
- **Professional Standards**: Every output meets industry-standard software engineering practices
|
|
||||||
- **Concrete Results**: Specific, actionable details at each step
|
|
||||||
|
|
||||||
### Final Quality Gates
|
|
||||||
|
|
||||||
- [ ] All acceptance criteria validated
|
|
||||||
- [ ] Code follows established conventions
|
|
||||||
- [ ] Minimalistic approach maintained
|
|
||||||
- [ ] Expert-level implementation standards met
|
|
||||||
- [ ] Korean comments and documentation provided
|
|
||||||
- [ ] English code and variable names used consistently
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +8,13 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.naturebridgeai.digitalrentmanager"
|
namespace = "com.naturebridgeai.digitalrentmanager"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -31,11 +41,22 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystoreProperties.isNotEmpty()) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
if (signingConfigs.findByName("release") != null) {
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/appicon/appicon512.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
@@ -11,6 +11,9 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"deleteSubscriptionTitle": "Delete Subscription",
|
||||||
|
"deleteSubscriptionMessage": "Are you sure you want to delete @ subscription?",
|
||||||
|
"deleteIrreversibleWarning": "This action cannot be undone",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"totalSubscriptions": "Total Subscriptions",
|
"totalSubscriptions": "Total Subscriptions",
|
||||||
"totalMonthlyExpense": "Total Monthly Expense",
|
"totalMonthlyExpense": "Total Monthly Expense",
|
||||||
@@ -25,10 +28,12 @@
|
|||||||
"selectIcon": "Select Icon",
|
"selectIcon": "Select Icon",
|
||||||
"addCategory": "Add Category",
|
"addCategory": "Add Category",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"theme": "Theme",
|
||||||
"darkMode": "Dark Mode",
|
"darkMode": "Dark Mode",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"appLock": "App Lock",
|
"appLock": "App Lock",
|
||||||
|
"appLocked": "App is locked",
|
||||||
"paymentCard": "Payment Card",
|
"paymentCard": "Payment Card",
|
||||||
"paymentCardManagement": "Payment Card Management",
|
"paymentCardManagement": "Payment Card Management",
|
||||||
"paymentCardManagementDescription": "Manage saved cards for subscriptions",
|
"paymentCardManagementDescription": "Manage saved cards for subscriptions",
|
||||||
@@ -64,6 +69,7 @@
|
|||||||
"dailyReminderEnabled": "Receive daily notifications until payment date",
|
"dailyReminderEnabled": "Receive daily notifications until payment date",
|
||||||
"dailyReminderDisabled": "Receive notification @ day(s) before payment",
|
"dailyReminderDisabled": "Receive notification @ day(s) before payment",
|
||||||
"notificationPermissionDenied": "Notification permission denied",
|
"notificationPermissionDenied": "Notification permission denied",
|
||||||
|
"permissionGranted": "Permission granted.",
|
||||||
"appInfo": "App Info",
|
"appInfo": "App Info",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"appDescription": "Digital Rent Management App",
|
"appDescription": "Digital Rent Management App",
|
||||||
@@ -83,6 +89,7 @@
|
|||||||
"twoDaysBefore": "2 days before",
|
"twoDaysBefore": "2 days before",
|
||||||
"threeDaysBefore": "3 days before",
|
"threeDaysBefore": "3 days before",
|
||||||
"requiredFieldsError": "Please fill in all required fields",
|
"requiredFieldsError": "Please fill in all required fields",
|
||||||
|
"categoryNameRequired": "Please enter category name",
|
||||||
"subscriptionUpdated": "Subscription information has been updated",
|
"subscriptionUpdated": "Subscription information has been updated",
|
||||||
"subscriptionDeleted": "@ subscription has been deleted",
|
"subscriptionDeleted": "@ subscription has been deleted",
|
||||||
"officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.",
|
"officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.",
|
||||||
@@ -111,6 +118,7 @@
|
|||||||
"appLockDesc": "App lock with biometric authentication",
|
"appLockDesc": "App lock with biometric authentication",
|
||||||
"unlockWithBiometric": "Unlock with biometric authentication",
|
"unlockWithBiometric": "Unlock with biometric authentication",
|
||||||
"authenticationFailed": "Authentication failed. Please try again.",
|
"authenticationFailed": "Authentication failed. Please try again.",
|
||||||
|
"nextBillingDateAdjusted": "Saved as the next billing date",
|
||||||
"totalExpenseCopied": "Total expense copied: @",
|
"totalExpenseCopied": "Total expense copied: @",
|
||||||
"smsPermissionRequired": "SMS permission required",
|
"smsPermissionRequired": "SMS permission required",
|
||||||
"noSubscriptionSmsFound": "No subscription related SMS found",
|
"noSubscriptionSmsFound": "No subscription related SMS found",
|
||||||
@@ -155,6 +163,7 @@
|
|||||||
"latestSmsMessage": "Latest SMS message",
|
"latestSmsMessage": "Latest SMS message",
|
||||||
"smsDetectedDate": "Detected on @",
|
"smsDetectedDate": "Detected on @",
|
||||||
"serviceName": "Service Name",
|
"serviceName": "Service Name",
|
||||||
|
"unknownService": "Unknown service",
|
||||||
"nextBillingDateLabel": "Next Billing Date",
|
"nextBillingDateLabel": "Next Billing Date",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"websiteUrlAuto": "Website URL (Auto-extracted)",
|
"websiteUrlAuto": "Website URL (Auto-extracted)",
|
||||||
@@ -242,8 +251,12 @@
|
|||||||
"subscriptionDetail": "Subscription Detail",
|
"subscriptionDetail": "Subscription Detail",
|
||||||
"enterAmount": "Enter amount",
|
"enterAmount": "Enter amount",
|
||||||
"invalidAmount": "Please enter a valid amount",
|
"invalidAmount": "Please enter a valid amount",
|
||||||
"featureComingSoon": "This feature is coming soon"
|
"featureComingSoon": "This feature is coming soon",
|
||||||
,
|
"exactAlarmPermission": "Exact alarm permission (Alarms & Reminders)",
|
||||||
|
"exactAlarmPermissionDesc": "We need permission to guarantee precise alarms.",
|
||||||
|
"allowAlarmsInSettings": "Please allow \"Alarms & reminders\" in Settings.",
|
||||||
|
"testNotification": "Test notification",
|
||||||
|
"testSubscriptionBody": "Test subscription • @",
|
||||||
"smsPermissionTitle": "Request SMS Permission",
|
"smsPermissionTitle": "Request SMS Permission",
|
||||||
"smsPermissionReasonTitle": "Why",
|
"smsPermissionReasonTitle": "Why",
|
||||||
"smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.",
|
"smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.",
|
||||||
@@ -253,7 +266,11 @@
|
|||||||
"openSettings": "Open Settings",
|
"openSettings": "Open Settings",
|
||||||
"later": "Later",
|
"later": "Later",
|
||||||
"requesting": "Requesting...",
|
"requesting": "Requesting...",
|
||||||
"smsPermissionLabel": "SMS Permission"
|
"smsPermissionLabel": "SMS Permission",
|
||||||
|
"expirationReminderBody": "@ subscription expires in # days.",
|
||||||
|
"eventEndNotificationTitle": "Event end notification",
|
||||||
|
"eventEndNotificationBody": "@'s discount event has ended.",
|
||||||
|
"paymentChargeNotification": "@ subscription charge @ was completed."
|
||||||
},
|
},
|
||||||
"ko": {
|
"ko": {
|
||||||
"appTitle": "디지털 월세 관리자",
|
"appTitle": "디지털 월세 관리자",
|
||||||
@@ -267,6 +284,9 @@
|
|||||||
"save": "저장",
|
"save": "저장",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
|
"deleteSubscriptionTitle": "구독 삭제",
|
||||||
|
"deleteSubscriptionMessage": "정말로 @ 구독을 삭제하시겠습니까?",
|
||||||
|
"deleteIrreversibleWarning": "이 작업은 되돌릴 수 없습니다",
|
||||||
"edit": "수정",
|
"edit": "수정",
|
||||||
"totalSubscriptions": "총 구독",
|
"totalSubscriptions": "총 구독",
|
||||||
"totalMonthlyExpense": "이번 달 총 지출",
|
"totalMonthlyExpense": "이번 달 총 지출",
|
||||||
@@ -281,10 +301,12 @@
|
|||||||
"selectIcon": "아이콘 선택",
|
"selectIcon": "아이콘 선택",
|
||||||
"addCategory": "카테고리 추가",
|
"addCategory": "카테고리 추가",
|
||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
|
"theme": "테마",
|
||||||
"darkMode": "다크 모드",
|
"darkMode": "다크 모드",
|
||||||
"language": "언어",
|
"language": "언어",
|
||||||
"notifications": "알림",
|
"notifications": "알림",
|
||||||
"appLock": "앱 잠금",
|
"appLock": "앱 잠금",
|
||||||
|
"appLocked": "앱이 잠겨 있습니다",
|
||||||
"paymentCard": "결제수단",
|
"paymentCard": "결제수단",
|
||||||
"paymentCardManagement": "결제수단 관리",
|
"paymentCardManagement": "결제수단 관리",
|
||||||
"paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다",
|
"paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다",
|
||||||
@@ -320,6 +342,7 @@
|
|||||||
"dailyReminderEnabled": "결제일까지 매일 알림을 받습니다",
|
"dailyReminderEnabled": "결제일까지 매일 알림을 받습니다",
|
||||||
"dailyReminderDisabled": "결제 @일 전에 알림을 받습니다",
|
"dailyReminderDisabled": "결제 @일 전에 알림을 받습니다",
|
||||||
"notificationPermissionDenied": "알림 권한이 거부되었습니다",
|
"notificationPermissionDenied": "알림 권한이 거부되었습니다",
|
||||||
|
"permissionGranted": "권한이 허용되었습니다.",
|
||||||
"appInfo": "앱 정보",
|
"appInfo": "앱 정보",
|
||||||
"version": "버전",
|
"version": "버전",
|
||||||
"appDescription": "디지털 월세 관리 앱",
|
"appDescription": "디지털 월세 관리 앱",
|
||||||
@@ -339,6 +362,7 @@
|
|||||||
"twoDaysBefore": "2일 전",
|
"twoDaysBefore": "2일 전",
|
||||||
"threeDaysBefore": "3일 전",
|
"threeDaysBefore": "3일 전",
|
||||||
"requiredFieldsError": "필수 항목을 모두 입력해주세요",
|
"requiredFieldsError": "필수 항목을 모두 입력해주세요",
|
||||||
|
"categoryNameRequired": "카테고리 이름을 입력하세요",
|
||||||
"subscriptionUpdated": "구독 정보가 업데이트되었습니다.",
|
"subscriptionUpdated": "구독 정보가 업데이트되었습니다.",
|
||||||
"subscriptionDeleted": "@ 구독이 삭제되었습니다.",
|
"subscriptionDeleted": "@ 구독이 삭제되었습니다.",
|
||||||
"officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.",
|
"officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.",
|
||||||
@@ -367,6 +391,7 @@
|
|||||||
"appLockDesc": "생체 인증으로 앱 잠금",
|
"appLockDesc": "생체 인증으로 앱 잠금",
|
||||||
"unlockWithBiometric": "생체 인증으로 잠금 해제",
|
"unlockWithBiometric": "생체 인증으로 잠금 해제",
|
||||||
"authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.",
|
"authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.",
|
||||||
|
"nextBillingDateAdjusted": "다음 결제 예정일로 저장됨",
|
||||||
"totalExpenseCopied": "총 지출액이 복사되었습니다: @",
|
"totalExpenseCopied": "총 지출액이 복사되었습니다: @",
|
||||||
"smsPermissionRequired": "SMS 권한이 필요합니다.",
|
"smsPermissionRequired": "SMS 권한이 필요합니다.",
|
||||||
"noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.",
|
"noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.",
|
||||||
@@ -411,6 +436,7 @@
|
|||||||
"latestSmsMessage": "최신 SMS 메시지",
|
"latestSmsMessage": "최신 SMS 메시지",
|
||||||
"smsDetectedDate": "SMS 수신일: @",
|
"smsDetectedDate": "SMS 수신일: @",
|
||||||
"serviceName": "서비스명",
|
"serviceName": "서비스명",
|
||||||
|
"unknownService": "알 수 없는 서비스",
|
||||||
"nextBillingDateLabel": "다음 결제일",
|
"nextBillingDateLabel": "다음 결제일",
|
||||||
"category": "카테고리",
|
"category": "카테고리",
|
||||||
"websiteUrlAuto": "웹사이트 URL (자동 추출됨)",
|
"websiteUrlAuto": "웹사이트 URL (자동 추출됨)",
|
||||||
@@ -498,8 +524,12 @@
|
|||||||
"subscriptionDetail": "구독 상세",
|
"subscriptionDetail": "구독 상세",
|
||||||
"enterAmount": "금액을 입력하세요",
|
"enterAmount": "금액을 입력하세요",
|
||||||
"invalidAmount": "올바른 금액을 입력해주세요",
|
"invalidAmount": "올바른 금액을 입력해주세요",
|
||||||
"featureComingSoon": "이 기능은 곧 출시됩니다"
|
"featureComingSoon": "이 기능은 곧 출시됩니다",
|
||||||
,
|
"exactAlarmPermission": "정확 알람 권한(알람 및 리마인더)",
|
||||||
|
"exactAlarmPermissionDesc": "정확한 시각에 알림을 보장하려면 권한이 필요합니다.",
|
||||||
|
"allowAlarmsInSettings": "설정에서 \"알람 및 리마인더\"를 허용해 주세요.",
|
||||||
|
"testNotification": "테스트 알림",
|
||||||
|
"testSubscriptionBody": "테스트 구독 • @",
|
||||||
"smsPermissionTitle": "SMS 권한 요청",
|
"smsPermissionTitle": "SMS 권한 요청",
|
||||||
"smsPermissionReasonTitle": "이유",
|
"smsPermissionReasonTitle": "이유",
|
||||||
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
|
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
|
||||||
@@ -509,7 +539,11 @@
|
|||||||
"openSettings": "설정 열기",
|
"openSettings": "설정 열기",
|
||||||
"later": "나중에 하기",
|
"later": "나중에 하기",
|
||||||
"requesting": "요청 중...",
|
"requesting": "요청 중...",
|
||||||
"smsPermissionLabel": "SMS 권한"
|
"smsPermissionLabel": "SMS 권한",
|
||||||
|
"expirationReminderBody": "@ 구독이 #일 후 만료됩니다.",
|
||||||
|
"eventEndNotificationTitle": "이벤트 종료 알림",
|
||||||
|
"eventEndNotificationBody": "@의 할인 이벤트가 종료되었습니다.",
|
||||||
|
"paymentChargeNotification": "@ 구독료 @이 결제되었습니다."
|
||||||
},
|
},
|
||||||
"ja": {
|
"ja": {
|
||||||
"appTitle": "デジタル月額管理者",
|
"appTitle": "デジタル月額管理者",
|
||||||
@@ -523,6 +557,9 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
|
"deleteSubscriptionTitle": "サブスクリプション削除",
|
||||||
|
"deleteSubscriptionMessage": "本当に@のサブスクリプションを削除しますか?",
|
||||||
|
"deleteIrreversibleWarning": "この操作は取り消せません",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"totalSubscriptions": "総サブスクリプション",
|
"totalSubscriptions": "総サブスクリプション",
|
||||||
"totalMonthlyExpense": "今月の総支出",
|
"totalMonthlyExpense": "今月の総支出",
|
||||||
@@ -537,10 +574,12 @@
|
|||||||
"selectIcon": "アイコンを選択",
|
"selectIcon": "アイコンを選択",
|
||||||
"addCategory": "カテゴリー追加",
|
"addCategory": "カテゴリー追加",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
|
"theme": "テーマ",
|
||||||
"darkMode": "ダークモード",
|
"darkMode": "ダークモード",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
"notifications": "通知",
|
"notifications": "通知",
|
||||||
"appLock": "アプリロック",
|
"appLock": "アプリロック",
|
||||||
|
"appLocked": "アプリがロックされています",
|
||||||
"paymentCard": "支払いカード",
|
"paymentCard": "支払いカード",
|
||||||
"paymentCardManagement": "支払いカード管理",
|
"paymentCardManagement": "支払いカード管理",
|
||||||
"paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します",
|
"paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します",
|
||||||
@@ -576,6 +615,7 @@
|
|||||||
"dailyReminderEnabled": "支払い日まで毎日通知を受け取ります",
|
"dailyReminderEnabled": "支払い日まで毎日通知を受け取ります",
|
||||||
"dailyReminderDisabled": "支払い@日前に通知を受け取ります",
|
"dailyReminderDisabled": "支払い@日前に通知を受け取ります",
|
||||||
"notificationPermissionDenied": "通知権限が拒否されました",
|
"notificationPermissionDenied": "通知権限が拒否されました",
|
||||||
|
"permissionGranted": "権限が許可されました。",
|
||||||
"appInfo": "アプリ情報",
|
"appInfo": "アプリ情報",
|
||||||
"version": "バージョン",
|
"version": "バージョン",
|
||||||
"appDescription": "デジタル月額管理アプリ",
|
"appDescription": "デジタル月額管理アプリ",
|
||||||
@@ -595,6 +635,7 @@
|
|||||||
"twoDaysBefore": "2日前",
|
"twoDaysBefore": "2日前",
|
||||||
"threeDaysBefore": "3日前",
|
"threeDaysBefore": "3日前",
|
||||||
"requiredFieldsError": "すべての必須項目を入力してください",
|
"requiredFieldsError": "すべての必須項目を入力してください",
|
||||||
|
"categoryNameRequired": "カテゴリ名を入力してください",
|
||||||
"subscriptionUpdated": "サブスクリプション情報が更新されました",
|
"subscriptionUpdated": "サブスクリプション情報が更新されました",
|
||||||
"subscriptionDeleted": "@サブスクリプションが削除されました",
|
"subscriptionDeleted": "@サブスクリプションが削除されました",
|
||||||
"officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。",
|
"officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。",
|
||||||
@@ -623,6 +664,7 @@
|
|||||||
"appLockDesc": "生体認証でアプリをロック",
|
"appLockDesc": "生体認証でアプリをロック",
|
||||||
"unlockWithBiometric": "生体認証でロック解除",
|
"unlockWithBiometric": "生体認証でロック解除",
|
||||||
"authenticationFailed": "認証に失敗しました。もう一度お試しください。",
|
"authenticationFailed": "認証に失敗しました。もう一度お試しください。",
|
||||||
|
"nextBillingDateAdjusted": "次回請求日に保存しました",
|
||||||
"totalExpenseCopied": "総支出がコピーされました:@",
|
"totalExpenseCopied": "総支出がコピーされました:@",
|
||||||
"smsPermissionRequired": "SMS権限が必要です",
|
"smsPermissionRequired": "SMS権限が必要です",
|
||||||
"noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません",
|
"noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません",
|
||||||
@@ -667,6 +709,7 @@
|
|||||||
"latestSmsMessage": "最新のSMSメッセージ",
|
"latestSmsMessage": "最新のSMSメッセージ",
|
||||||
"smsDetectedDate": "SMS受信日: @",
|
"smsDetectedDate": "SMS受信日: @",
|
||||||
"serviceName": "サービス名",
|
"serviceName": "サービス名",
|
||||||
|
"unknownService": "不明なサービス",
|
||||||
"nextBillingDateLabel": "次回請求日",
|
"nextBillingDateLabel": "次回請求日",
|
||||||
"category": "カテゴリー",
|
"category": "カテゴリー",
|
||||||
"websiteUrlAuto": "ウェブサイトURL(自動抽出)",
|
"websiteUrlAuto": "ウェブサイトURL(自動抽出)",
|
||||||
@@ -754,7 +797,16 @@
|
|||||||
"subscriptionDetail": "サブスクリプション詳細",
|
"subscriptionDetail": "サブスクリプション詳細",
|
||||||
"enterAmount": "金額を入力してください",
|
"enterAmount": "金額を入力してください",
|
||||||
"invalidAmount": "正しい金額を入力してください",
|
"invalidAmount": "正しい金額を入力してください",
|
||||||
"featureComingSoon": "この機能は近日公開予定です"
|
"featureComingSoon": "この機能は近日公開予定です",
|
||||||
|
"exactAlarmPermission": "正確なアラーム権限(アラームとリマインダー)",
|
||||||
|
"exactAlarmPermissionDesc": "正確な時刻に通知するには権限が必要です。",
|
||||||
|
"allowAlarmsInSettings": "設定で「アラームとリマインダー」を許可してください。",
|
||||||
|
"testNotification": "テスト通知",
|
||||||
|
"testSubscriptionBody": "テストサブスクリプション • @",
|
||||||
|
"expirationReminderBody": "@ のサブスクリプションは #日後に期限切れになります。",
|
||||||
|
"eventEndNotificationTitle": "イベント終了通知",
|
||||||
|
"eventEndNotificationBody": "@ の割引イベントが終了しました。",
|
||||||
|
"paymentChargeNotification": "@ の購読料 @ が請求されました。"
|
||||||
},
|
},
|
||||||
"zh": {
|
"zh": {
|
||||||
"appTitle": "数字月租管理器",
|
"appTitle": "数字月租管理器",
|
||||||
@@ -768,6 +820,9 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
|
"deleteSubscriptionTitle": "删除订阅",
|
||||||
|
"deleteSubscriptionMessage": "确定要删除@订阅吗?",
|
||||||
|
"deleteIrreversibleWarning": "此操作无法撤销",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"totalSubscriptions": "订阅总数",
|
"totalSubscriptions": "订阅总数",
|
||||||
"totalMonthlyExpense": "本月总支出",
|
"totalMonthlyExpense": "本月总支出",
|
||||||
@@ -782,10 +837,12 @@
|
|||||||
"selectIcon": "选择图标",
|
"selectIcon": "选择图标",
|
||||||
"addCategory": "添加分类",
|
"addCategory": "添加分类",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
|
"theme": "主题",
|
||||||
"darkMode": "深色模式",
|
"darkMode": "深色模式",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"notifications": "通知",
|
"notifications": "通知",
|
||||||
"appLock": "应用锁定",
|
"appLock": "应用锁定",
|
||||||
|
"appLocked": "应用已锁定",
|
||||||
"paymentCard": "支付卡",
|
"paymentCard": "支付卡",
|
||||||
"paymentCardManagement": "支付卡管理",
|
"paymentCardManagement": "支付卡管理",
|
||||||
"paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)",
|
"paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)",
|
||||||
@@ -821,6 +878,7 @@
|
|||||||
"dailyReminderEnabled": "直到付款日期每天接收通知",
|
"dailyReminderEnabled": "直到付款日期每天接收通知",
|
||||||
"dailyReminderDisabled": "在付款@天前接收通知",
|
"dailyReminderDisabled": "在付款@天前接收通知",
|
||||||
"notificationPermissionDenied": "通知权限被拒绝",
|
"notificationPermissionDenied": "通知权限被拒绝",
|
||||||
|
"permissionGranted": "已获得权限。",
|
||||||
"appInfo": "应用信息",
|
"appInfo": "应用信息",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"appDescription": "数字月租管理应用",
|
"appDescription": "数字月租管理应用",
|
||||||
@@ -840,6 +898,7 @@
|
|||||||
"twoDaysBefore": "2天前",
|
"twoDaysBefore": "2天前",
|
||||||
"threeDaysBefore": "3天前",
|
"threeDaysBefore": "3天前",
|
||||||
"requiredFieldsError": "请填写所有必填项",
|
"requiredFieldsError": "请填写所有必填项",
|
||||||
|
"categoryNameRequired": "请输入分类名称",
|
||||||
"subscriptionUpdated": "订阅信息已更新",
|
"subscriptionUpdated": "订阅信息已更新",
|
||||||
"subscriptionDeleted": "@订阅已删除",
|
"subscriptionDeleted": "@订阅已删除",
|
||||||
"officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。",
|
"officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。",
|
||||||
@@ -868,6 +927,7 @@
|
|||||||
"appLockDesc": "使用生物识别锁定应用",
|
"appLockDesc": "使用生物识别锁定应用",
|
||||||
"unlockWithBiometric": "使用生物识别解锁",
|
"unlockWithBiometric": "使用生物识别解锁",
|
||||||
"authenticationFailed": "认证失败。请重试。",
|
"authenticationFailed": "认证失败。请重试。",
|
||||||
|
"nextBillingDateAdjusted": "已保存为下一次账单日",
|
||||||
"totalExpenseCopied": "总支出已复制:@",
|
"totalExpenseCopied": "总支出已复制:@",
|
||||||
"smsPermissionRequired": "需要短信权限",
|
"smsPermissionRequired": "需要短信权限",
|
||||||
"noSubscriptionSmsFound": "未找到订阅相关的短信",
|
"noSubscriptionSmsFound": "未找到订阅相关的短信",
|
||||||
@@ -912,6 +972,7 @@
|
|||||||
"latestSmsMessage": "最新短信内容",
|
"latestSmsMessage": "最新短信内容",
|
||||||
"smsDetectedDate": "短信接收日期:@",
|
"smsDetectedDate": "短信接收日期:@",
|
||||||
"serviceName": "服务名称",
|
"serviceName": "服务名称",
|
||||||
|
"unknownService": "未知服务",
|
||||||
"nextBillingDateLabel": "下次付款日期",
|
"nextBillingDateLabel": "下次付款日期",
|
||||||
"category": "类别",
|
"category": "类别",
|
||||||
"websiteUrlAuto": "网站URL(自动提取)",
|
"websiteUrlAuto": "网站URL(自动提取)",
|
||||||
@@ -999,6 +1060,15 @@
|
|||||||
"subscriptionDetail": "订阅详情",
|
"subscriptionDetail": "订阅详情",
|
||||||
"enterAmount": "请输入金额",
|
"enterAmount": "请输入金额",
|
||||||
"invalidAmount": "请输入有效的金额",
|
"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) {
|
if (context.mounted) {
|
||||||
AppSnackBar.showInfo(
|
AppSnackBar.showInfo(
|
||||||
context: context,
|
context: context,
|
||||||
message: '다음 결제 예정일로 저장됨',
|
message: AppLocalizations.of(context).nextBillingDateAdjusted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
if (adjustedNext.isAfter(originalDateOnly)) {
|
if (adjustedNext.isAfter(originalDateOnly)) {
|
||||||
AppSnackBar.showInfo(
|
AppSnackBar.showInfo(
|
||||||
context: context,
|
context: context,
|
||||||
message: '다음 결제 예정일로 저장됨',
|
message: AppLocalizations.of(context).nextBillingDateAdjusted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import '../services/sms_scanner.dart';
|
import '../services/sms_scanner.dart';
|
||||||
import '../models/subscription.dart';
|
import '../services/ad_service.dart';
|
||||||
import '../models/payment_card_suggestion.dart';
|
|
||||||
import '../services/sms_scan/subscription_converter.dart';
|
import '../services/sms_scan/subscription_converter.dart';
|
||||||
import '../services/sms_scan/subscription_filter.dart';
|
import '../services/sms_scan/subscription_filter.dart';
|
||||||
import '../services/sms_scan/sms_scan_result.dart';
|
import '../services/sms_scan/sms_scan_result.dart';
|
||||||
|
import '../models/subscription.dart';
|
||||||
|
import '../models/payment_card_suggestion.dart';
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
|
||||||
import '../utils/logger.dart';
|
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
import '../providers/category_provider.dart';
|
import '../providers/category_provider.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
|
||||||
import '../providers/payment_card_provider.dart';
|
import '../providers/payment_card_provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/logger.dart';
|
||||||
|
|
||||||
class SmsScanController extends ChangeNotifier {
|
class SmsScanController extends ChangeNotifier {
|
||||||
// 상태 관리
|
// 상태 관리
|
||||||
@@ -45,6 +48,7 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
final SmsScanner _smsScanner = SmsScanner();
|
final SmsScanner _smsScanner = SmsScanner();
|
||||||
final SubscriptionConverter _converter = SubscriptionConverter();
|
final SubscriptionConverter _converter = SubscriptionConverter();
|
||||||
final SubscriptionFilter _filter = SubscriptionFilter();
|
final SubscriptionFilter _filter = SubscriptionFilter();
|
||||||
|
final AdService _adService = AdService();
|
||||||
bool _forceServiceNameEditing = false;
|
bool _forceServiceNameEditing = false;
|
||||||
bool get isServiceNameEditable => _forceServiceNameEditing;
|
bool get isServiceNameEditable => _forceServiceNameEditing;
|
||||||
|
|
||||||
@@ -73,15 +77,36 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
serviceNameController.text = '';
|
serviceNameController.text = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateCurrentServiceName(String value) {
|
void updateCurrentServiceName(BuildContext context, String value) {
|
||||||
if (_currentIndex >= _scannedSubscriptions.length) return;
|
if (_currentIndex >= _scannedSubscriptions.length) return;
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
|
final unknownLabel = _unknownServiceLabel(context);
|
||||||
final updated = _scannedSubscriptions[_currentIndex]
|
final updated = _scannedSubscriptions[_currentIndex]
|
||||||
.copyWith(serviceName: trimmed.isEmpty ? '알 수 없는 서비스' : trimmed);
|
.copyWith(serviceName: trimmed.isEmpty ? unknownLabel : trimmed);
|
||||||
_scannedSubscriptions[_currentIndex] = updated;
|
_scannedSubscriptions[_currentIndex] = updated;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SMS 스캔 시작 (전면 광고 표시 후 스캔 진행)
|
||||||
|
Future<void> startScan(BuildContext context) async {
|
||||||
|
if (_isLoading) return;
|
||||||
|
|
||||||
|
// 웹/비지원 플랫폼은 바로 스캔
|
||||||
|
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
|
||||||
|
await scanSms(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 광고 표시 (완료까지 대기)
|
||||||
|
// 광고 실패해도 스캔 진행 (사용자 경험 우선)
|
||||||
|
await _adService.showInterstitialAd(context);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// 광고 완료 후 SMS 스캔 실행
|
||||||
|
await scanSms(context);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> scanSms(BuildContext context) async {
|
Future<void> scanSms(BuildContext context) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
@@ -89,6 +114,11 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
_currentIndex = 0;
|
_currentIndex = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
await _performSmsScan(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 실제 SMS 스캔 수행 (로딩 상태는 이미 설정되어 있음)
|
||||||
|
Future<void> _performSmsScan(BuildContext context) async {
|
||||||
try {
|
try {
|
||||||
// Android에서 SMS 권한 확인 및 요청
|
// Android에서 SMS 권한 확인 및 요청
|
||||||
final ctx = context;
|
final ctx = context;
|
||||||
@@ -331,13 +361,14 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
return otherCategory.id;
|
return otherCategory.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
void initializeWebsiteUrl() {
|
void initializeWebsiteUrl(BuildContext context) {
|
||||||
if (_currentIndex < _scannedSubscriptions.length) {
|
if (_currentIndex < _scannedSubscriptions.length) {
|
||||||
final currentSub = _scannedSubscriptions[_currentIndex];
|
final currentSub = _scannedSubscriptions[_currentIndex];
|
||||||
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
||||||
websiteUrlController.text = currentSub.websiteUrl!;
|
websiteUrlController.text = currentSub.websiteUrl!;
|
||||||
}
|
}
|
||||||
if (_shouldEnableServiceNameEditing(currentSub)) {
|
final unknownLabel = _unknownServiceLabel(context);
|
||||||
|
if (_shouldEnableServiceNameEditing(currentSub, unknownLabel)) {
|
||||||
if (serviceNameController.text != currentSub.serviceName) {
|
if (serviceNameController.text != currentSub.serviceName) {
|
||||||
serviceNameController.clear();
|
serviceNameController.clear();
|
||||||
}
|
}
|
||||||
@@ -366,8 +397,10 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final current = _scannedSubscriptions[_currentIndex];
|
final current = _scannedSubscriptions[_currentIndex];
|
||||||
_forceServiceNameEditing = _shouldEnableServiceNameEditing(current);
|
final unknownLabel = _unknownServiceLabel(context);
|
||||||
if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') {
|
_forceServiceNameEditing =
|
||||||
|
_shouldEnableServiceNameEditing(current, unknownLabel);
|
||||||
|
if (_forceServiceNameEditing && current.serviceName == unknownLabel) {
|
||||||
serviceNameController.clear();
|
serviceNameController.clear();
|
||||||
} else {
|
} else {
|
||||||
serviceNameController.text = current.serviceName;
|
serviceNameController.text = current.serviceName;
|
||||||
@@ -429,8 +462,13 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldEnableServiceNameEditing(Subscription subscription) {
|
bool _shouldEnableServiceNameEditing(
|
||||||
|
Subscription subscription, String unknownLabel) {
|
||||||
final name = subscription.serviceName.trim();
|
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 save => _localizedStrings['save'] ?? 'Save';
|
||||||
String get cancel => _localizedStrings['cancel'] ?? 'Cancel';
|
String get cancel => _localizedStrings['cancel'] ?? 'Cancel';
|
||||||
String get delete => _localizedStrings['delete'] ?? 'Delete';
|
String get delete => _localizedStrings['delete'] ?? 'Delete';
|
||||||
|
String get deleteSubscriptionTitle =>
|
||||||
|
_localizedStrings['deleteSubscriptionTitle'] ?? 'Delete Subscription';
|
||||||
|
String get deleteSubscriptionMessageTemplate =>
|
||||||
|
_localizedStrings['deleteSubscriptionMessage'] ??
|
||||||
|
'Are you sure you want to delete @ subscription?';
|
||||||
|
String deleteSubscriptionMessage(String serviceName) =>
|
||||||
|
deleteSubscriptionMessageTemplate.replaceAll('@', serviceName);
|
||||||
|
String get deleteIrreversibleWarning =>
|
||||||
|
_localizedStrings['deleteIrreversibleWarning'] ??
|
||||||
|
'This action cannot be undone';
|
||||||
String get edit => _localizedStrings['edit'] ?? 'Edit';
|
String get edit => _localizedStrings['edit'] ?? 'Edit';
|
||||||
String get totalSubscriptions =>
|
String get totalSubscriptions =>
|
||||||
_localizedStrings['totalSubscriptions'] ?? 'Total Subscriptions';
|
_localizedStrings['totalSubscriptions'] ?? 'Total Subscriptions';
|
||||||
@@ -58,11 +68,13 @@ class AppLocalizations {
|
|||||||
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
|
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
|
||||||
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
|
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
|
||||||
String get settings => _localizedStrings['settings'] ?? 'Settings';
|
String get settings => _localizedStrings['settings'] ?? 'Settings';
|
||||||
|
String get theme => _localizedStrings['theme'] ?? 'Theme';
|
||||||
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
|
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
|
||||||
String get language => _localizedStrings['language'] ?? 'Language';
|
String get language => _localizedStrings['language'] ?? 'Language';
|
||||||
String get notifications =>
|
String get notifications =>
|
||||||
_localizedStrings['notifications'] ?? 'Notifications';
|
_localizedStrings['notifications'] ?? 'Notifications';
|
||||||
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
|
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
|
||||||
|
String get appLocked => _localizedStrings['appLocked'] ?? 'App is locked';
|
||||||
String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card';
|
String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card';
|
||||||
String get paymentCardManagement =>
|
String get paymentCardManagement =>
|
||||||
_localizedStrings['paymentCardManagement'] ?? 'Payment Card Management';
|
_localizedStrings['paymentCardManagement'] ?? 'Payment Card Management';
|
||||||
@@ -163,6 +175,8 @@ class AppLocalizations {
|
|||||||
String get notificationPermissionDenied =>
|
String get notificationPermissionDenied =>
|
||||||
_localizedStrings['notificationPermissionDenied'] ??
|
_localizedStrings['notificationPermissionDenied'] ??
|
||||||
'Notification permission denied';
|
'Notification permission denied';
|
||||||
|
String get permissionGranted =>
|
||||||
|
_localizedStrings['permissionGranted'] ?? 'Permission granted.';
|
||||||
// 앱 정보
|
// 앱 정보
|
||||||
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
|
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
|
||||||
String get version => _localizedStrings['version'] ?? 'Version';
|
String get version => _localizedStrings['version'] ?? 'Version';
|
||||||
@@ -197,6 +211,8 @@ class AppLocalizations {
|
|||||||
String get requiredFieldsError =>
|
String get requiredFieldsError =>
|
||||||
_localizedStrings['requiredFieldsError'] ??
|
_localizedStrings['requiredFieldsError'] ??
|
||||||
'Please fill in all required fields';
|
'Please fill in all required fields';
|
||||||
|
String get categoryNameRequired =>
|
||||||
|
_localizedStrings['categoryNameRequired'] ?? 'Please enter category name';
|
||||||
String get subscriptionUpdated =>
|
String get subscriptionUpdated =>
|
||||||
_localizedStrings['subscriptionUpdated'] ??
|
_localizedStrings['subscriptionUpdated'] ??
|
||||||
'Subscription information has been updated';
|
'Subscription information has been updated';
|
||||||
@@ -249,6 +265,9 @@ class AppLocalizations {
|
|||||||
String get authenticationFailed =>
|
String get authenticationFailed =>
|
||||||
_localizedStrings['authenticationFailed'] ??
|
_localizedStrings['authenticationFailed'] ??
|
||||||
'Authentication failed. Please try again.';
|
'Authentication failed. Please try again.';
|
||||||
|
String get nextBillingDateAdjusted =>
|
||||||
|
_localizedStrings['nextBillingDateAdjusted'] ??
|
||||||
|
'Saved as the next billing date';
|
||||||
String get smsPermissionRequired =>
|
String get smsPermissionRequired =>
|
||||||
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
|
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
|
||||||
String get noSubscriptionSmsFound =>
|
String get noSubscriptionSmsFound =>
|
||||||
@@ -457,6 +476,8 @@ class AppLocalizations {
|
|||||||
String get foundSubscription =>
|
String get foundSubscription =>
|
||||||
_localizedStrings['foundSubscription'] ?? 'Found subscription';
|
_localizedStrings['foundSubscription'] ?? 'Found subscription';
|
||||||
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
|
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
|
||||||
|
String get unknownService =>
|
||||||
|
_localizedStrings['unknownService'] ?? 'Unknown service';
|
||||||
String get latestSmsMessage =>
|
String get latestSmsMessage =>
|
||||||
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
|
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
|
||||||
String smsDetectedDate(String date) {
|
String smsDetectedDate(String date) {
|
||||||
@@ -659,6 +680,49 @@ class AppLocalizations {
|
|||||||
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
|
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
|
||||||
String get featureComingSoon =>
|
String get featureComingSoon =>
|
||||||
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
|
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
|
||||||
|
String get exactAlarmPermission =>
|
||||||
|
_localizedStrings['exactAlarmPermission'] ??
|
||||||
|
'Exact alarm permission (Alarms & Reminders)';
|
||||||
|
String get exactAlarmPermissionDesc =>
|
||||||
|
_localizedStrings['exactAlarmPermissionDesc'] ??
|
||||||
|
'We need permission to guarantee precise alarms.';
|
||||||
|
String get allowAlarmsInSettings =>
|
||||||
|
_localizedStrings['allowAlarmsInSettings'] ??
|
||||||
|
'Please allow "Alarms & reminders" in Settings.';
|
||||||
|
String get testNotification =>
|
||||||
|
_localizedStrings['testNotification'] ?? 'Test notification';
|
||||||
|
|
||||||
|
String testSubscriptionBody(String amountText) {
|
||||||
|
final template =
|
||||||
|
_localizedStrings['testSubscriptionBody'] ?? 'Test subscription • @';
|
||||||
|
return template.replaceAll('@', amountText);
|
||||||
|
}
|
||||||
|
|
||||||
|
String expirationReminderBody(String serviceName, int days) {
|
||||||
|
final template = _localizedStrings['expirationReminderBody'] ??
|
||||||
|
'@ subscription expires in # days.';
|
||||||
|
return template
|
||||||
|
.replaceAll('@', serviceName)
|
||||||
|
.replaceAll('#', days.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
String get eventEndNotificationTitle =>
|
||||||
|
_localizedStrings['eventEndNotificationTitle'] ??
|
||||||
|
'Event end notification';
|
||||||
|
|
||||||
|
String eventEndNotificationBody(String serviceName) {
|
||||||
|
final template = _localizedStrings['eventEndNotificationBody'] ??
|
||||||
|
"@'s discount event has ended.";
|
||||||
|
return template.replaceAll('@', serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
String paymentChargeNotification(String serviceName, String amountText) {
|
||||||
|
final template = _localizedStrings['paymentChargeNotification'] ??
|
||||||
|
'@ subscription charge @ was completed.';
|
||||||
|
return template
|
||||||
|
.replaceFirst('@', serviceName)
|
||||||
|
.replaceFirst('@', amountText);
|
||||||
|
}
|
||||||
|
|
||||||
// 결제 주기를 키값으로 변환하여 번역된 이름 반환
|
// 결제 주기를 키값으로 변환하여 번역된 이름 반환
|
||||||
String getBillingCycleName(String billingCycleKey) {
|
String getBillingCycleName(String billingCycleKey) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import '../services/notification_service.dart';
|
|||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../navigator_key.dart';
|
||||||
|
|
||||||
class AppLockProvider extends ChangeNotifier {
|
class AppLockProvider extends ChangeNotifier {
|
||||||
final Box<bool> _appLockBox;
|
final Box<bool> _appLockBox;
|
||||||
@@ -72,8 +74,11 @@ class AppLockProvider extends ChangeNotifier {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final ctx = navigatorKey.currentContext;
|
||||||
|
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
||||||
final authenticated = await _localAuth.authenticate(
|
final authenticated = await _localAuth.authenticate(
|
||||||
localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.',
|
localizedReason:
|
||||||
|
loc?.unlockWithBiometric ?? 'Unlock with biometric authentication.',
|
||||||
options: const AuthenticationOptions(
|
options: const AuthenticationOptions(
|
||||||
stickyAuth: true,
|
stickyAuth: true,
|
||||||
biometricOnly: true,
|
biometricOnly: true,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import '../services/notification_service.dart';
|
|||||||
import '../services/exchange_rate_service.dart';
|
import '../services/exchange_rate_service.dart';
|
||||||
import '../services/currency_util.dart';
|
import '../services/currency_util.dart';
|
||||||
import 'category_provider.dart';
|
import 'category_provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../navigator_key.dart';
|
||||||
|
|
||||||
class SubscriptionProvider extends ChangeNotifier {
|
class SubscriptionProvider extends ChangeNotifier {
|
||||||
late Box<SubscriptionModel> _subscriptionBox;
|
late Box<SubscriptionModel> _subscriptionBox;
|
||||||
@@ -239,10 +241,13 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
SubscriptionModel subscription) async {
|
SubscriptionModel subscription) async {
|
||||||
if (subscription.eventEndDate != null &&
|
if (subscription.eventEndDate != null &&
|
||||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||||
|
final ctx = navigatorKey.currentContext;
|
||||||
|
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
||||||
await NotificationService.scheduleNotification(
|
await NotificationService.scheduleNotification(
|
||||||
id: '${subscription.id}_event_end'.hashCode,
|
id: '${subscription.id}_event_end'.hashCode,
|
||||||
title: '이벤트 종료 알림',
|
title: loc?.eventEndNotificationTitle ?? 'Event end notification',
|
||||||
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
|
body: loc?.eventEndNotificationBody(subscription.serviceName) ??
|
||||||
|
"${subscription.serviceName}'s discount event has ended.",
|
||||||
scheduledDate: subscription.eventEndDate!,
|
scheduledDate: subscription.eventEndDate!,
|
||||||
channelId: NotificationService.expirationChannelId,
|
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/screens/sms_permission_screen.dart';
|
||||||
import 'package:submanager/models/subscription_model.dart';
|
import 'package:submanager/models/subscription_model.dart';
|
||||||
import 'package:submanager/screens/payment_card_management_screen.dart';
|
import 'package:submanager/screens/payment_card_management_screen.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class AppRoutes {
|
class AppRoutes {
|
||||||
static const String splash = '/splash';
|
static const String splash = '/splash';
|
||||||
@@ -81,9 +82,9 @@ class AppRoutes {
|
|||||||
|
|
||||||
static Route<dynamic> _errorRoute() {
|
static Route<dynamic> _errorRoute() {
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
builder: (_) => const Scaffold(
|
builder: (context) => Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Text('페이지를 찾을 수 없습니다'),
|
child: Text(AppLocalizations.of(context).pageNotFound),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../widgets/analysis/subscription_pie_chart_card.dart';
|
|||||||
import '../widgets/analysis/total_expense_summary_card.dart';
|
import '../widgets/analysis/total_expense_summary_card.dart';
|
||||||
import '../widgets/analysis/monthly_expense_chart_card.dart';
|
import '../widgets/analysis/monthly_expense_chart_card.dart';
|
||||||
import '../widgets/analysis/event_analysis_card.dart';
|
import '../widgets/analysis/event_analysis_card.dart';
|
||||||
|
import '../theme/ui_constants.dart';
|
||||||
|
|
||||||
enum AnalysisCardFilterType { all, unassigned, card }
|
enum AnalysisCardFilterType { all, unassigned, card }
|
||||||
|
|
||||||
@@ -324,21 +325,11 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: <Widget>[
|
slivers: <Widget>[
|
||||||
SliverToBoxAdapter(
|
SliverPadding(
|
||||||
child: SizedBox(
|
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
|
||||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
sliver: _buildCardFilterSection(context, cardProvider),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// 네이티브 광고 위젯
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: _buildAnimatedAd(),
|
|
||||||
),
|
|
||||||
|
|
||||||
const AnalysisScreenSpacer(),
|
|
||||||
|
|
||||||
_buildCardFilterSection(context, cardProvider),
|
|
||||||
|
|
||||||
const AnalysisScreenSpacer(),
|
const AnalysisScreenSpacer(),
|
||||||
|
|
||||||
// 1. 구독 비율 파이 차트
|
// 1. 구독 비율 파이 차트
|
||||||
@@ -349,6 +340,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
|
|
||||||
const AnalysisScreenSpacer(),
|
const AnalysisScreenSpacer(),
|
||||||
|
|
||||||
|
// 네이티브 광고 위젯 (구독 비율 차트 하단)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _buildAnimatedAd(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const AnalysisScreenSpacer(),
|
||||||
|
|
||||||
// 2. 총 지출 요약 카드
|
// 2. 총 지출 요약 카드
|
||||||
TotalExpenseSummaryCard(
|
TotalExpenseSummaryCard(
|
||||||
key: ValueKey('total_expense_$_lastDataHash'),
|
key: ValueKey('total_expense_$_lastDataHash'),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/app_lock_provider.dart';
|
import '../providers/app_lock_provider.dart';
|
||||||
// import '../theme/app_colors.dart';
|
// import '../theme/app_colors.dart';
|
||||||
|
|
||||||
@@ -8,6 +10,7 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -20,7 +23,7 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'앱이 잠겨 있습니다',
|
loc.appLocked,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -29,7 +32,7 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'생체 인증으로 잠금을 해제하세요',
|
loc.appLockDesc,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
@@ -45,7 +48,7 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'인증에 실패했습니다. 다시 시도해주세요.',
|
loc.authenticationFailed,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onPrimary,
|
color: cs.onPrimary,
|
||||||
),
|
),
|
||||||
@@ -56,7 +59,7 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.fingerprint),
|
icon: const Icon(Icons.fingerprint),
|
||||||
label: const Text('생체 인증으로 잠금 해제'),
|
label: Text(loc.unlockWithBiometric),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
|
|||||||
@@ -41,10 +41,11 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
'카테고리 관리',
|
loc.categoryManagement,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onPrimary,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
@@ -67,7 +68,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '카테고리 이름',
|
labelText: loc.categoryName,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
@@ -76,7 +77,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return '카테고리 이름을 입력하세요';
|
return loc.categoryNameRequired;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -85,7 +86,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: _selectedColor,
|
initialValue: _selectedColor,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '색상 선택',
|
labelText: loc.selectColor,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
@@ -144,7 +145,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: _selectedIcon,
|
initialValue: _selectedIcon,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '아이콘 선택',
|
labelText: loc.selectIcon,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
@@ -154,35 +155,35 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
items: [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'subscriptions',
|
value: 'subscriptions',
|
||||||
child: Text('구독',
|
child: Text(loc.subscription,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface))),
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'movie',
|
value: 'movie',
|
||||||
child: Text('영화',
|
child: Text(loc.movie,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface))),
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'music_note',
|
value: 'music_note',
|
||||||
child: Text('음악',
|
child: Text(loc.music,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface))),
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'fitness_center',
|
value: 'fitness_center',
|
||||||
child: Text('운동',
|
child: Text(loc.exercise,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface))),
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'shopping_cart',
|
value: 'shopping_cart',
|
||||||
child: Text('쇼핑',
|
child: Text(loc.shopping,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
@@ -197,7 +198,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _addCategory,
|
onPressed: _addCategory,
|
||||||
child: const Text('카테고리 추가'),
|
child: Text(loc.addCategory),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/notification_provider.dart';
|
import '../providers/notification_provider.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
// import '../widgets/glassmorphism_card.dart';
|
// import '../widgets/glassmorphism_card.dart';
|
||||||
// import '../theme/app_colors.dart';
|
// import '../theme/app_colors.dart';
|
||||||
import '../widgets/native_ad_widget.dart';
|
|
||||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/locale_provider.dart';
|
import '../providers/locale_provider.dart';
|
||||||
@@ -18,6 +16,7 @@ import '../theme/adaptive_theme.dart';
|
|||||||
import '../widgets/common/layout/page_container.dart';
|
import '../widgets/common/layout/page_container.dart';
|
||||||
import '../theme/color_scheme_ext.dart';
|
import '../theme/color_scheme_ext.dart';
|
||||||
import '../widgets/app_navigator.dart';
|
import '../widgets/app_navigator.dart';
|
||||||
|
import '../theme/ui_constants.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -87,23 +86,16 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
child: PageContainer(
|
child: PageContainer(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
UIConstants.pageTopPadding,
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
// toolbar 높이 추가
|
|
||||||
SizedBox(
|
|
||||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
|
||||||
),
|
|
||||||
// 광고 위젯 추가
|
|
||||||
const NativeAdWidget(
|
|
||||||
key: ValueKey('settings_ad'),
|
|
||||||
useOuterPadding: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 테마 모드 설정
|
// 테마 모드 설정
|
||||||
Card(
|
Card(
|
||||||
margin:
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -192,7 +184,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
leading: Icon(Icons.color_lens,
|
leading: Icon(Icons.color_lens,
|
||||||
color: cs.onSurfaceVariant),
|
color: cs.onSurfaceVariant),
|
||||||
title: Text(
|
title: Text(
|
||||||
'테마',
|
loc.theme,
|
||||||
style: TextStyle(color: cs.onSurface),
|
style: TextStyle(color: cs.onSurface),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -361,14 +353,14 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurfaceVariant),
|
.onSurfaceVariant),
|
||||||
title: Text(
|
title: Text(
|
||||||
'정확 알람 권한(알람 및 리마인더)',
|
loc.exactAlarmPermission,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface),
|
.onSurface),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'정확한 시각에 알림을 보장하려면 권한이 필요합니다.',
|
loc.exactAlarmPermissionDesc,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
@@ -386,19 +378,19 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
if (ok || recheck) {
|
if (ok || recheck) {
|
||||||
AppSnackBar.showSuccess(
|
AppSnackBar.showSuccess(
|
||||||
context: context,
|
context: context,
|
||||||
message: '권한이 허용되었습니다.',
|
message: loc.permissionGranted,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
AppSnackBar.showInfo(
|
AppSnackBar.showInfo(
|
||||||
context: context,
|
context: context,
|
||||||
message:
|
message:
|
||||||
'설정에서 "알람 및 리마인더"를 허용해 주세요.',
|
loc.allowAlarmsInSettings,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
(context as Element).markNeedsBuild();
|
(context as Element).markNeedsBuild();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('허용 요청'),
|
child: Text(loc.requestPermission),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -748,8 +740,8 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons
|
icon: const Icon(Icons
|
||||||
.notifications_active),
|
.notifications_active),
|
||||||
label:
|
label: Text(
|
||||||
const Text('테스트 알림'),
|
loc.testNotification),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
NotificationService
|
NotificationService
|
||||||
.showTestPaymentNotification();
|
.showTestPaymentNotification();
|
||||||
@@ -907,60 +899,61 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
leading: Icon(Icons.info,
|
leading: Icon(Icons.info,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
onTap: () async {
|
onTap: null,
|
||||||
// 항상 앱 내 About 다이얼로그를 우선 표시
|
// onTap: () async {
|
||||||
showAboutDialog(
|
// // 항상 앱 내 About 다이얼로그를 우선 표시 (현재 미사용)
|
||||||
context: context,
|
// showAboutDialog(
|
||||||
applicationName: AppLocalizations.of(context).appTitle,
|
// context: context,
|
||||||
applicationVersion: '1.0.0',
|
// applicationName: AppLocalizations.of(context).appTitle,
|
||||||
applicationIcon: const FlutterLogo(size: 50),
|
// applicationVersion: '1.0.0',
|
||||||
children: [
|
// applicationIcon: const FlutterLogo(size: 50),
|
||||||
Text(AppLocalizations.of(context).appDescription),
|
// children: [
|
||||||
const SizedBox(height: 8),
|
// Text(AppLocalizations.of(context).appDescription),
|
||||||
Text(
|
// const SizedBox(height: 8),
|
||||||
'${AppLocalizations.of(context).developer}: Julian Sul'),
|
// Text(
|
||||||
const SizedBox(height: 12),
|
// '${AppLocalizations.of(context).developer}: Julian Sul'),
|
||||||
Builder(builder: (ctx) {
|
// const SizedBox(height: 12),
|
||||||
return TextButton.icon(
|
// Builder(builder: (ctx) {
|
||||||
icon: const Icon(Icons.open_in_new),
|
// return TextButton.icon(
|
||||||
label: Text(AppLocalizations.of(ctx).openStore),
|
// icon: const Icon(Icons.open_in_new),
|
||||||
onPressed: () async {
|
// label: Text(AppLocalizations.of(ctx).openStore),
|
||||||
try {
|
// onPressed: () async {
|
||||||
if (Platform.isAndroid) {
|
// try {
|
||||||
// 우선 Play 스토어 앱 시도
|
// if (Platform.isAndroid) {
|
||||||
const pkg =
|
// // 우선 Play 스토어 앱 시도
|
||||||
'com.naturebridgeai.digitalrentmanager';
|
// const pkg =
|
||||||
final marketUri =
|
// 'com.naturebridgeai.digitalrentmanager';
|
||||||
Uri.parse('market://details?id=$pkg');
|
// final marketUri =
|
||||||
final webUri = Uri.parse(
|
// Uri.parse('market://details?id=$pkg');
|
||||||
'https://play.google.com/store/apps/details?id=$pkg');
|
// final webUri = Uri.parse(
|
||||||
final ok = await launchUrl(marketUri,
|
// 'https://play.google.com/store/apps/details?id=$pkg');
|
||||||
mode: LaunchMode.externalApplication);
|
// final ok = await launchUrl(marketUri,
|
||||||
if (!ok) {
|
// mode: LaunchMode.externalApplication);
|
||||||
await launchUrl(webUri,
|
// if (!ok) {
|
||||||
mode: LaunchMode.externalApplication);
|
// await launchUrl(webUri,
|
||||||
}
|
// mode: LaunchMode.externalApplication);
|
||||||
} else if (Platform.isIOS) {
|
// }
|
||||||
final uri = Uri.parse(
|
// } else if (Platform.isIOS) {
|
||||||
'https://apps.apple.com/app/id123456789');
|
// final uri = Uri.parse(
|
||||||
await launchUrl(uri,
|
// 'https://apps.apple.com/app/id123456789');
|
||||||
mode: LaunchMode.externalApplication);
|
// await launchUrl(uri,
|
||||||
}
|
// mode: LaunchMode.externalApplication);
|
||||||
} catch (e) {
|
// }
|
||||||
if (ctx.mounted) {
|
// } catch (e) {
|
||||||
AppSnackBar.showError(
|
// if (ctx.mounted) {
|
||||||
context: ctx,
|
// AppSnackBar.showError(
|
||||||
message: AppLocalizations.of(ctx)
|
// context: ctx,
|
||||||
.cannotOpenStore,
|
// message: AppLocalizations.of(ctx)
|
||||||
);
|
// .cannotOpenStore,
|
||||||
}
|
// );
|
||||||
}
|
// }
|
||||||
},
|
// }
|
||||||
);
|
// },
|
||||||
}),
|
// );
|
||||||
],
|
// }),
|
||||||
);
|
// ],
|
||||||
},
|
// );
|
||||||
|
// },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// FloatingNavigationBar를 위한 충분한 하단 여백
|
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../l10n/app_localizations.dart';
|
|||||||
import '../widgets/payment_card/payment_card_form_sheet.dart';
|
import '../widgets/payment_card/payment_card_form_sheet.dart';
|
||||||
import '../routes/app_routes.dart';
|
import '../routes/app_routes.dart';
|
||||||
import '../models/payment_card_suggestion.dart';
|
import '../models/payment_card_suggestion.dart';
|
||||||
|
import '../theme/ui_constants.dart';
|
||||||
|
|
||||||
class SmsScanScreen extends StatefulWidget {
|
class SmsScanScreen extends StatefulWidget {
|
||||||
const SmsScanScreen({super.key});
|
const SmsScanScreen({super.key});
|
||||||
@@ -46,7 +47,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
_controller.initializeWebsiteUrl();
|
_controller.initializeWebsiteUrl(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent() {
|
Widget _buildContent() {
|
||||||
@@ -56,7 +57,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
|
|
||||||
if (_controller.scannedSubscriptions.isEmpty) {
|
if (_controller.scannedSubscriptions.isEmpty) {
|
||||||
return ScanInitialWidget(
|
return ScanInitialWidget(
|
||||||
onScanPressed: () => _controller.scanSms(context),
|
onScanPressed: () => _controller.startScan(context),
|
||||||
errorMessage: _controller.errorMessage,
|
errorMessage: _controller.errorMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,7 +76,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return ScanInitialWidget(
|
return ScanInitialWidget(
|
||||||
onScanPressed: () => _controller.scanSms(context),
|
onScanPressed: () => _controller.startScan(context),
|
||||||
errorMessage: _controller.errorMessage,
|
errorMessage: _controller.errorMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,7 +105,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
onPaymentCardChanged: _controller.setSelectedPaymentCardId,
|
onPaymentCardChanged: _controller.setSelectedPaymentCardId,
|
||||||
enableServiceNameEditing: _controller.isServiceNameEditable,
|
enableServiceNameEditing: _controller.isServiceNameEditable,
|
||||||
onServiceNameChanged: _controller.isServiceNameEditable
|
onServiceNameChanged: _controller.isServiceNameEditable
|
||||||
? _controller.updateCurrentServiceName
|
? (value) => _controller.updateCurrentServiceName(context, value)
|
||||||
: null,
|
: null,
|
||||||
onAddCard: () async {
|
onAddCard: () async {
|
||||||
final newCardId = await PaymentCardFormSheet.show(context);
|
final newCardId = await PaymentCardFormSheet.show(context);
|
||||||
@@ -160,21 +161,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// 로딩 중일 때는 화면 정중앙에 표시
|
||||||
|
if (_controller.isLoading) {
|
||||||
|
return const ScanLoadingWidget();
|
||||||
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: Column(
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
|
||||||
// toolbar 높이 추가
|
child: Column(
|
||||||
SizedBox(
|
children: [
|
||||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
_buildContent(),
|
||||||
),
|
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||||
_buildContent(),
|
SizedBox(
|
||||||
// FloatingNavigationBar를 위한 충분한 하단 여백
|
height: 120 + MediaQuery.of(context).padding.bottom,
|
||||||
SizedBox(
|
),
|
||||||
height: 120 + MediaQuery.of(context).padding.bottom,
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
try {
|
||||||
final expirationDate = subscription.nextBillingDate;
|
final expirationDate = subscription.nextBillingDate;
|
||||||
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
||||||
|
final ctx = navigatorKey.currentContext;
|
||||||
|
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// tz.local 초기화 확인 및 재시도
|
||||||
tz.Location location;
|
tz.Location location;
|
||||||
@@ -656,8 +658,9 @@ class NotificationService {
|
|||||||
|
|
||||||
await _notifications.zonedSchedule(
|
await _notifications.zonedSchedule(
|
||||||
('${subscription.id}_expiration').hashCode,
|
('${subscription.id}_expiration').hashCode,
|
||||||
'구독 만료 예정 알림',
|
loc?.expirationReminder ?? _paymentReminderTitle(_getLocaleCode()),
|
||||||
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
|
loc?.expirationReminderBody(subscription.serviceName, 7) ??
|
||||||
|
'${subscription.serviceName} subscription expires in 7 days.',
|
||||||
tz.TZDateTime.from(reminderDate, location),
|
tz.TZDateTime.from(reminderDate, location),
|
||||||
const NotificationDetails(
|
const NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
@@ -849,11 +852,14 @@ class NotificationService {
|
|||||||
if (_isWeb || !_initialized) return;
|
if (_isWeb || !_initialized) return;
|
||||||
try {
|
try {
|
||||||
final locale = _getLocaleCode();
|
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 =
|
final amountText =
|
||||||
await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale);
|
await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale);
|
||||||
|
|
||||||
final body = '테스트 구독 • $amountText';
|
final body = loc?.testSubscriptionBody(amountText) ??
|
||||||
|
'Test subscription • $amountText';
|
||||||
|
|
||||||
await _notifications.show(
|
await _notifications.show(
|
||||||
DateTime.now().millisecondsSinceEpoch.remainder(1 << 31),
|
DateTime.now().millisecondsSinceEpoch.remainder(1 << 31),
|
||||||
@@ -880,7 +886,11 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String getNotificationBody(String serviceName, double amount) {
|
static String getNotificationBody(String serviceName, double amount) {
|
||||||
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
|
final ctx = navigatorKey.currentContext;
|
||||||
|
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
|
||||||
|
final amountText = amount.toStringAsFixed(0);
|
||||||
|
return loc?.paymentChargeNotification(serviceName, amountText) ??
|
||||||
|
'$serviceName subscription charge $amountText was completed.';
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String> _buildPaymentBody(
|
static Future<String> _buildPaymentBody(
|
||||||
@@ -925,6 +935,10 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String _paymentReminderTitle(String locale) {
|
static String _paymentReminderTitle(String locale) {
|
||||||
|
final ctx = navigatorKey.currentContext;
|
||||||
|
if (ctx != null) {
|
||||||
|
return AppLocalizations.of(ctx).paymentReminder;
|
||||||
|
}
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
case 'ko':
|
case 'ko':
|
||||||
return '결제 예정 알림';
|
return '결제 예정 알림';
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import '../utils/platform_helper.dart';
|
|||||||
import '../utils/business_day_util.dart';
|
import '../utils/business_day_util.dart';
|
||||||
import '../services/sms_scan/sms_scan_result.dart';
|
import '../services/sms_scan/sms_scan_result.dart';
|
||||||
import '../models/payment_card_suggestion.dart';
|
import '../models/payment_card_suggestion.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../navigator_key.dart';
|
||||||
|
|
||||||
class SmsScanner {
|
class SmsScanner {
|
||||||
final SmsQuery _query = SmsQuery();
|
final SmsQuery _query = SmsQuery();
|
||||||
@@ -82,7 +84,9 @@ class SmsScanner {
|
|||||||
return subscriptions;
|
return subscriptions;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.e('SmsScanner: 예외 발생', e);
|
Log.e('SmsScanner: 예외 발생', e);
|
||||||
throw Exception('SMS 스캔 중 오류 발생: $e');
|
final loc = _loc();
|
||||||
|
throw Exception(loc?.smsScanErrorWithMessage(e.toString()) ??
|
||||||
|
'Error occurred during SMS scan: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +120,13 @@ class SmsScanner {
|
|||||||
|
|
||||||
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||||
try {
|
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 monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
|
||||||
final billingCycle = SubscriptionModel.normalizeBillingCycle(
|
final billingCycle = SubscriptionModel.normalizeBillingCycle(
|
||||||
sms['billingCycle'] as String? ?? 'monthly');
|
sms['billingCycle'] as String? ?? 'monthly');
|
||||||
@@ -196,8 +206,9 @@ class SmsScanner {
|
|||||||
if (issuer == null && last4 == null) {
|
if (issuer == null && last4 == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
final loc = _loc();
|
||||||
return PaymentCardSuggestion(
|
return PaymentCardSuggestion(
|
||||||
issuerName: issuer ?? '결제수단',
|
issuerName: issuer ?? loc?.paymentCard ?? 'Payment card',
|
||||||
last4: last4,
|
last4: last4,
|
||||||
source: 'sms',
|
source: 'sms',
|
||||||
);
|
);
|
||||||
@@ -366,6 +377,12 @@ class SmsScanner {
|
|||||||
// 기본값은 원화
|
// 기본값은 원화
|
||||||
return 'KRW';
|
return 'KRW';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppLocalizations? _loc() {
|
||||||
|
final ctx = navigatorKey.currentContext;
|
||||||
|
if (ctx == null) return null;
|
||||||
|
return AppLocalizations.of(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const List<String> _paymentLikeKeywords = [
|
const List<String> _paymentLikeKeywords = [
|
||||||
@@ -501,7 +518,8 @@ String _isoExtractServiceName(String body, String sender) {
|
|||||||
|
|
||||||
String _isoExtractServiceNameFromSender(String sender) {
|
String _isoExtractServiceNameFromSender(String sender) {
|
||||||
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
||||||
return '알 수 없는 서비스';
|
// Isolate에서 실행되므로 하드코딩 사용 (Flutter 바인딩 접근 불가)
|
||||||
|
return 'Unknown service';
|
||||||
}
|
}
|
||||||
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
|
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 address = (sms['address'] as String?)?.trim();
|
||||||
final sender = (sms['sender'] as String?)?.trim();
|
final sender = (sms['sender'] as String?)?.trim();
|
||||||
|
|
||||||
|
final unknownLabel = _unknownServiceLabel();
|
||||||
String key = (serviceName != null &&
|
String key = (serviceName != null &&
|
||||||
serviceName.isNotEmpty &&
|
serviceName.isNotEmpty &&
|
||||||
serviceName != '알 수 없는 서비스')
|
serviceName != unknownLabel)
|
||||||
? serviceName
|
? serviceName
|
||||||
: (address?.isNotEmpty == true
|
: (address?.isNotEmpty == true
|
||||||
? address!
|
? address!
|
||||||
: (sender?.isNotEmpty == true ? sender! : 'unknown'));
|
: (sender?.isNotEmpty == true ? sender! : unknownLabel));
|
||||||
|
|
||||||
groups.putIfAbsent(key, () => []).add(sms);
|
groups.putIfAbsent(key, () => []).add(sms);
|
||||||
}
|
}
|
||||||
@@ -602,6 +621,12 @@ class _RepeatDetectionResult {
|
|||||||
|
|
||||||
enum _MatchType { none, monthly, yearly, identical }
|
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 {
|
class _MatchedPair {
|
||||||
_MatchedPair(this.first, this.second, this.type);
|
_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:flutter/services.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||||
import '../utils/platform_helper.dart';
|
import '../utils/platform_helper.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../navigator_key.dart';
|
||||||
|
|
||||||
class SMSService {
|
class SMSService {
|
||||||
static const platform = MethodChannel('com.submanager/sms');
|
static const platform = MethodChannel('com.submanager/sms');
|
||||||
@@ -37,14 +39,24 @@ class SMSService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!await hasSMSPermission()) {
|
if (!await hasSMSPermission()) {
|
||||||
throw Exception('SMS 권한이 없습니다.');
|
final loc = _loc();
|
||||||
|
throw Exception(
|
||||||
|
loc?.smsPermissionRequired ?? 'SMS permission required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<dynamic> result =
|
final List<dynamic> result =
|
||||||
await platform.invokeMethod('scanSubscriptions');
|
await platform.invokeMethod('scanSubscriptions');
|
||||||
return result.map((item) => item as Map<String, dynamic>).toList();
|
return result.map((item) => item as Map<String, dynamic>).toList();
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
throw Exception('SMS 스캔 중 오류 발생: ${e.message}');
|
final loc = _loc();
|
||||||
|
throw Exception(loc?.smsScanErrorWithMessage(e.message ?? '') ??
|
||||||
|
'Error occurred during SMS scan: ${e.message}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static AppLocalizations? _loc() {
|
||||||
|
final ctx = navigatorKey.currentContext;
|
||||||
|
if (ctx == null) return null;
|
||||||
|
return AppLocalizations.of(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
class UIConstants {
|
class UIConstants {
|
||||||
static const double pageHorizontalPadding = 16;
|
static const double pageHorizontalPadding = 16;
|
||||||
static const double adVerticalPadding = 12;
|
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 cardRadius = 16;
|
||||||
static const double cardOutlineAlpha = 0.5; // for outline color alpha
|
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 'package:flutter/material.dart';
|
||||||
import '../../controllers/add_subscription_controller.dart';
|
import '../../controllers/add_subscription_controller.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
// import '../../theme/app_colors.dart';
|
// import '../../theme/app_colors.dart';
|
||||||
@@ -72,23 +73,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final locale = Localizations.localeOf(context);
|
final loc = AppLocalizations.of(context);
|
||||||
String titleText;
|
|
||||||
switch (locale.languageCode) {
|
|
||||||
case 'ko':
|
|
||||||
titleText = '이벤트 가격';
|
|
||||||
break;
|
|
||||||
case 'ja':
|
|
||||||
titleText = 'イベント価格';
|
|
||||||
break;
|
|
||||||
case 'zh':
|
|
||||||
titleText = '活动价格';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
titleText = 'Event Price';
|
|
||||||
}
|
|
||||||
return Text(
|
return Text(
|
||||||
titleText,
|
loc.eventPrice,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -157,23 +144,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final locale =
|
final loc = AppLocalizations.of(context);
|
||||||
Localizations.localeOf(context);
|
final infoText = loc.eventPriceHint;
|
||||||
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';
|
|
||||||
}
|
|
||||||
return Text(
|
return Text(
|
||||||
infoText,
|
infoText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -195,26 +167,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
// 이벤트 기간
|
// 이벤트 기간
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final locale = Localizations.localeOf(context);
|
final loc = AppLocalizations.of(context);
|
||||||
String startLabel;
|
final startLabel = loc.startDate;
|
||||||
String endLabel;
|
final endLabel = loc.endDate;
|
||||||
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';
|
|
||||||
}
|
|
||||||
return DateRangePickerField(
|
return DateRangePickerField(
|
||||||
startDate: controller.eventStartDate,
|
startDate: controller.eventStartDate,
|
||||||
endDate: controller.eventEndDate,
|
endDate: controller.eventEndDate,
|
||||||
@@ -245,37 +200,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
// 이벤트 가격
|
// 이벤트 가격
|
||||||
Builder(
|
Builder(
|
||||||
builder: (BuildContext innerContext) {
|
builder: (BuildContext innerContext) {
|
||||||
// 현재 로케일 확인
|
final loc = AppLocalizations.of(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';
|
|
||||||
}
|
|
||||||
|
|
||||||
return CurrencyInputField(
|
return CurrencyInputField(
|
||||||
controller: controller.eventPriceController,
|
controller: controller.eventPriceController,
|
||||||
currency: controller.currency,
|
currency: controller.currency,
|
||||||
label: eventPriceLabel,
|
label: loc.eventPrice,
|
||||||
hintText: eventPriceHint,
|
hintText: loc.eventPriceHint,
|
||||||
enabled: controller.isEventActive,
|
enabled: controller.isEventActive,
|
||||||
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
|
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
|
||||||
validator:
|
validator:
|
||||||
|
|||||||
@@ -233,56 +233,66 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ThemedText.headline(
|
Expanded(
|
||||||
text: AppLocalizations.of(context)
|
child: ThemedText.headline(
|
||||||
.subscriptionServiceRatio,
|
text: AppLocalizations.of(context)
|
||||||
style: const TextStyle(
|
.subscriptionServiceRatio,
|
||||||
fontSize: 18,
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FutureBuilder<String>(
|
Flexible(
|
||||||
future: CurrencyUtil.getExchangeRateInfoForLocale(
|
child: FutureBuilder<String>(
|
||||||
context
|
future: CurrencyUtil.getExchangeRateInfoForLocale(
|
||||||
.watch<LocaleProvider>()
|
context
|
||||||
.locale
|
.watch<LocaleProvider>()
|
||||||
.languageCode),
|
.locale
|
||||||
builder: (context, snapshot) {
|
.languageCode),
|
||||||
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
|
builder: (context, snapshot) {
|
||||||
return Container(
|
if (snapshot.hasData &&
|
||||||
padding: const EdgeInsets.symmetric(
|
snapshot.data!.isNotEmpty) {
|
||||||
horizontal: 8,
|
return Padding(
|
||||||
vertical: 4,
|
padding: const EdgeInsets.only(left: 12),
|
||||||
),
|
child: Align(
|
||||||
decoration: BoxDecoration(
|
alignment: Alignment.topRight,
|
||||||
color: Theme.of(context)
|
child: Container(
|
||||||
.colorScheme
|
padding: const EdgeInsets.symmetric(
|
||||||
.primary
|
horizontal: 8,
|
||||||
.withValues(alpha: 0.08),
|
vertical: 4,
|
||||||
borderRadius: BorderRadius.circular(4),
|
),
|
||||||
border: Border.all(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.primary
|
.primary
|
||||||
.withValues(alpha: 0.3),
|
.withValues(alpha: 0.08),
|
||||||
width: 1,
|
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)
|
return const SizedBox.shrink();
|
||||||
.exchangeRateFormat(snapshot.data!),
|
},
|
||||||
style: TextStyle(
|
),
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color:
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -265,23 +265,39 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
final loc = AppLocalizations.of(context);
|
final loc = AppLocalizations.of(context);
|
||||||
switch (cycle.toLowerCase()) {
|
switch (cycle.toLowerCase()) {
|
||||||
case '매월':
|
case '매월':
|
||||||
|
case '월간':
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
case '毎月':
|
case '毎月':
|
||||||
|
case '月付':
|
||||||
|
case '月間':
|
||||||
case '每月':
|
case '每月':
|
||||||
return loc.billingCycleMonthly;
|
return loc.billingCycleMonthly;
|
||||||
case '분기별':
|
case '분기별':
|
||||||
|
case '분기':
|
||||||
case 'quarterly':
|
case 'quarterly':
|
||||||
|
case 'quarter':
|
||||||
|
case '季付':
|
||||||
|
case '季度付':
|
||||||
case '四半期':
|
case '四半期':
|
||||||
case '每季度':
|
case '每季度':
|
||||||
return loc.billingCycleQuarterly;
|
return loc.billingCycleQuarterly;
|
||||||
case '반기별':
|
case '반기별':
|
||||||
case 'half-yearly':
|
case 'half-yearly':
|
||||||
|
case 'half yearly':
|
||||||
|
case 'semiannual':
|
||||||
|
case 'semi-annual':
|
||||||
|
case '半年付':
|
||||||
|
case '半年払い':
|
||||||
case '半年ごと':
|
case '半年ごと':
|
||||||
case '每半年':
|
case '每半年':
|
||||||
return loc.billingCycleHalfYearly;
|
return loc.billingCycleHalfYearly;
|
||||||
case '매년':
|
case '매년':
|
||||||
|
case '연간':
|
||||||
case 'yearly':
|
case 'yearly':
|
||||||
|
case 'annual':
|
||||||
|
case 'annually':
|
||||||
case '年間':
|
case '年間':
|
||||||
|
case '年付':
|
||||||
case '每年':
|
case '每年':
|
||||||
return loc.billingCycleYearly;
|
return loc.billingCycleYearly;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
// Material 3 기반 다이얼로그
|
// Material 3 기반 다이얼로그
|
||||||
import '../common/buttons/primary_button.dart';
|
import '../common/buttons/primary_button.dart';
|
||||||
import '../common/buttons/secondary_button.dart';
|
import '../common/buttons/secondary_button.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// 삭제 확인 다이얼로그
|
/// 삭제 확인 다이얼로그
|
||||||
/// 글래스모피즘 스타일의 삭제 확인 다이얼로그입니다.
|
/// 글래스모피즘 스타일의 삭제 확인 다이얼로그입니다.
|
||||||
@@ -15,8 +16,20 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Dialog(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: textThemeColor.surface,
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -44,11 +57,11 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
|||||||
|
|
||||||
// 타이틀
|
// 타이틀
|
||||||
Text(
|
Text(
|
||||||
'구독 삭제',
|
loc.deleteSubscriptionTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
color: textThemeColor.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -57,22 +70,12 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
|||||||
RichText(
|
RichText(
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
style: TextStyle(
|
style: baseMessageStyle,
|
||||||
fontSize: 16,
|
children: _buildLocalizedMessageSpans(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
loc.deleteSubscriptionMessageTemplate,
|
||||||
height: 1.5,
|
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),
|
const SizedBox(height: 8),
|
||||||
@@ -84,14 +87,10 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
|||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: textThemeColor.error.withValues(alpha: 0.05),
|
||||||
Theme.of(context).colorScheme.error.withValues(alpha: 0.05),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context)
|
color: textThemeColor.error.withValues(alpha: 0.2),
|
||||||
.colorScheme
|
|
||||||
.error
|
|
||||||
.withValues(alpha: 0.2),
|
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -100,18 +99,15 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.warning_amber_rounded,
|
Icons.warning_amber_rounded,
|
||||||
color: Theme.of(context)
|
color: textThemeColor.error.withValues(alpha: 0.8),
|
||||||
.colorScheme
|
|
||||||
.error
|
|
||||||
.withValues(alpha: 0.8),
|
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'이 작업은 되돌릴 수 없습니다',
|
loc.deleteIrreversibleWarning,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: textThemeColor.error,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -125,7 +121,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SecondaryButton(
|
child: SecondaryButton(
|
||||||
text: '취소',
|
text: loc.cancel,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop(false);
|
Navigator.of(context).pop(false);
|
||||||
},
|
},
|
||||||
@@ -134,12 +130,12 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: PrimaryButton(
|
child: PrimaryButton(
|
||||||
text: '삭제',
|
text: loc.delete,
|
||||||
icon: Icons.delete_rounded,
|
icon: Icons.delete_rounded,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop(true);
|
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;
|
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 '../utils/subscription_grouping_helper.dart';
|
||||||
import '../widgets/empty_state_widget.dart';
|
import '../widgets/empty_state_widget.dart';
|
||||||
import '../widgets/main_summary_card.dart';
|
import '../widgets/main_summary_card.dart';
|
||||||
import '../widgets/native_ad_widget.dart';
|
import '../theme/ui_constants.dart';
|
||||||
import '../widgets/subscription_list_widget.dart';
|
import '../widgets/subscription_list_widget.dart';
|
||||||
|
|
||||||
class HomeContent extends StatefulWidget {
|
class HomeContent extends StatefulWidget {
|
||||||
@@ -115,13 +115,8 @@ class _HomeContentState extends State<HomeContent> {
|
|||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SizedBox(
|
|
||||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: NativeAdWidget(key: ValueKey('home_ad')),
|
child: SizedBox(height: UIConstants.pageTopPadding),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SlideTransition(
|
child: SlideTransition(
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ import '../theme/ui_constants.dart';
|
|||||||
/// SRP에 따라 광고 전용 위젯으로 분리
|
/// SRP에 따라 광고 전용 위젯으로 분리
|
||||||
class NativeAdWidget extends StatefulWidget {
|
class NativeAdWidget extends StatefulWidget {
|
||||||
final bool useOuterPadding; // true이면 외부에서 페이지 패딩을 제공
|
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
|
@override
|
||||||
State<NativeAdWidget> createState() => _NativeAdWidgetState();
|
State<NativeAdWidget> createState() => _NativeAdWidgetState();
|
||||||
@@ -58,10 +67,14 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
|||||||
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
|
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
|
||||||
// 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다.
|
// 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다.
|
||||||
nativeTemplateStyle: NativeTemplateStyle(
|
nativeTemplateStyle: NativeTemplateStyle(
|
||||||
templateType: TemplateType.small,
|
templateType: widget.templateTypeOverride ?? TemplateType.medium,
|
||||||
mainBackgroundColor: const Color(0x00000000),
|
mainBackgroundColor: const Color(0x00000000),
|
||||||
cornerRadius: 12,
|
cornerRadius: 12,
|
||||||
),
|
),
|
||||||
|
nativeAdOptions: NativeAdOptions(
|
||||||
|
mediaAspectRatio:
|
||||||
|
widget.mediaAspectRatioOverride ?? MediaAspectRatio.square,
|
||||||
|
),
|
||||||
request: const AdRequest(),
|
request: const AdRequest(),
|
||||||
listener: NativeAdListener(
|
listener: NativeAdListener(
|
||||||
onAdLoaded: (ad) {
|
onAdLoaded: (ad) {
|
||||||
@@ -129,12 +142,19 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
|||||||
super.dispose();
|
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(
|
return Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal:
|
horizontal: horizontalPadding,
|
||||||
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
|
|
||||||
vertical: UIConstants.adVerticalPadding,
|
vertical: UIConstants.adVerticalPadding,
|
||||||
),
|
),
|
||||||
child: Card(
|
child: Card(
|
||||||
@@ -143,7 +163,7 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
|||||||
borderRadius: BorderRadius.zero,
|
borderRadius: BorderRadius.zero,
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: UIConstants.adCardHeight,
|
height: slotHeight,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -232,43 +252,54 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 웹 환경인 경우 플레이스홀더 표시
|
return LayoutBuilder(
|
||||||
if (kIsWeb) {
|
builder: (context, constraints) {
|
||||||
return _buildWebPlaceholder();
|
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)) {
|
if (kIsWeb) {
|
||||||
return const SizedBox.shrink();
|
return _buildWebPlaceholder(slotHeight, horizontalPadding);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_error != null) {
|
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
|
||||||
// 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지
|
if (!(Platform.isAndroid || Platform.isIOS)) {
|
||||||
return _buildWebPlaceholder();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isLoaded) {
|
if (_error != null) {
|
||||||
// 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지
|
// 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지
|
||||||
return _buildWebPlaceholder();
|
return _buildWebPlaceholder(slotHeight, horizontalPadding);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 광고 정상 노출
|
if (!_isLoaded) {
|
||||||
return Padding(
|
// 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지
|
||||||
padding: EdgeInsets.symmetric(
|
return _buildWebPlaceholder(slotHeight, horizontalPadding);
|
||||||
horizontal:
|
}
|
||||||
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
|
|
||||||
vertical: UIConstants.adVerticalPadding,
|
// 광고 정상 노출
|
||||||
),
|
return Padding(
|
||||||
child: Card(
|
padding: EdgeInsets.symmetric(
|
||||||
elevation: 1,
|
horizontal: horizontalPadding,
|
||||||
shape: const RoundedRectangleBorder(
|
vertical: UIConstants.adVerticalPadding,
|
||||||
borderRadius: BorderRadius.zero,
|
),
|
||||||
),
|
child: Card(
|
||||||
child: SizedBox(
|
elevation: 1,
|
||||||
height: UIConstants.adCardHeight,
|
shape: const RoundedRectangleBorder(
|
||||||
child: AdWidget(ad: _nativeAd!),
|
borderRadius: BorderRadius.zero,
|
||||||
),
|
),
|
||||||
),
|
child: SizedBox(
|
||||||
|
height: slotHeight,
|
||||||
|
child: AdWidget(ad: _nativeAd!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ class ScanInitialWidget extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// 광고 위젯 추가
|
|
||||||
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
|
|
||||||
const SizedBox(height: 48),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
child: Column(
|
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 'package:flutter/material.dart';
|
||||||
import '../../widgets/native_ad_widget.dart';
|
|
||||||
import '../../widgets/themed_text.dart';
|
import '../../widgets/themed_text.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -8,33 +7,31 @@ class ScanLoadingWidget extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Center(
|
||||||
children: [
|
child: Padding(
|
||||||
const NativeAdWidget(key: ValueKey('sms_scan_loading_ad')),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
const SizedBox(height: 48),
|
child: Column(
|
||||||
Padding(
|
mainAxisSize: MainAxisSize.min,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
children: [
|
||||||
child: Column(
|
CircularProgressIndicator(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
children: [
|
),
|
||||||
CircularProgressIndicator(
|
const SizedBox(height: 16),
|
||||||
color: Theme.of(context).colorScheme.primary,
|
ThemedText(
|
||||||
),
|
AppLocalizations.of(context).scanningMessages,
|
||||||
const SizedBox(height: 16),
|
forceDark: true,
|
||||||
ThemedText(
|
textAlign: TextAlign.center,
|
||||||
AppLocalizations.of(context).scanningMessages,
|
),
|
||||||
forceDark: true,
|
const SizedBox(height: 8),
|
||||||
),
|
ThemedText(
|
||||||
const SizedBox(height: 8),
|
AppLocalizations.of(context).findingSubscriptions,
|
||||||
ThemedText(
|
opacity: 0.7,
|
||||||
AppLocalizations.of(context).findingSubscriptions,
|
forceDark: true,
|
||||||
opacity: 0.7,
|
textAlign: TextAlign.center,
|
||||||
forceDark: true,
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/base_text_field.dart';
|
||||||
import '../../widgets/common/form_fields/category_selector.dart';
|
import '../../widgets/common/form_fields/category_selector.dart';
|
||||||
import '../../widgets/common/snackbar/app_snackbar.dart';
|
import '../../widgets/common/snackbar/app_snackbar.dart';
|
||||||
import '../../widgets/native_ad_widget.dart';
|
|
||||||
import '../../widgets/payment_card/payment_card_selector.dart';
|
import '../../widgets/payment_card/payment_card_selector.dart';
|
||||||
import '../../services/currency_util.dart';
|
import '../../services/currency_util.dart';
|
||||||
import '../../utils/sms_scan/date_formatter.dart';
|
import '../../utils/sms_scan/date_formatter.dart';
|
||||||
@@ -87,9 +86,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// 광고 위젯 추가
|
|
||||||
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (_hasRawSmsMessage)
|
if (_hasRawSmsMessage)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import './common/snackbar/app_snackbar.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
import '../utils/subscription_grouping_helper.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 {
|
class SubscriptionListWidget extends StatelessWidget {
|
||||||
@@ -28,134 +30,132 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sections = groups;
|
final sections = groups;
|
||||||
|
int itemCounter = 0;
|
||||||
|
final List<Widget> children = [];
|
||||||
|
|
||||||
return SliverList(
|
for (final group in sections) {
|
||||||
delegate: SliverChildBuilderDelegate(
|
final subscriptions = group.subscriptions;
|
||||||
(context, index) {
|
final List<Widget> subscriptionItems = [];
|
||||||
final group = sections[index];
|
|
||||||
final subscriptions = group.subscriptions;
|
|
||||||
|
|
||||||
return Padding(
|
for (var subIndex = 0; subIndex < subscriptions.length; subIndex++) {
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
|
||||||
child: Column(
|
final delay = 0.05 * subIndex;
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const animationBegin = 0.2;
|
||||||
children: [
|
const animationEnd = 1.0;
|
||||||
SubscriptionGroupHeader(
|
final intervalStart = delay;
|
||||||
group: group,
|
final intervalEnd = intervalStart + 0.4;
|
||||||
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;
|
|
||||||
|
|
||||||
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
|
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
|
||||||
final intervalStartNormalized =
|
final intervalStartNormalized = intervalStart.clamp(0.0, 0.9);
|
||||||
intervalStart.clamp(0.0, 0.9);
|
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
|
||||||
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
|
|
||||||
|
|
||||||
return FadeTransition(
|
subscriptionItems.add(
|
||||||
opacity: Tween<double>(
|
FadeTransition(
|
||||||
begin: animationBegin, end: animationEnd)
|
opacity: Tween<double>(begin: animationBegin, end: animationEnd)
|
||||||
.animate(CurvedAnimation(
|
.animate(CurvedAnimation(
|
||||||
parent: fadeController,
|
parent: fadeController,
|
||||||
curve: Interval(intervalStartNormalized,
|
curve: Interval(
|
||||||
intervalEndNormalized,
|
intervalStartNormalized, intervalEndNormalized,
|
||||||
curve: Curves.easeOut))),
|
curve: Curves.easeOut))),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12.0),
|
padding:
|
||||||
child: StaggeredAnimationItem(
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 6.0),
|
||||||
index: subIndex,
|
child: StaggeredAnimationItem(
|
||||||
delay: const Duration(milliseconds: 50),
|
index: subIndex,
|
||||||
child: RepaintBoundary(
|
delay: const Duration(milliseconds: 50),
|
||||||
child: SwipeableSubscriptionCard(
|
child: RepaintBoundary(
|
||||||
subscription: subscriptions[subIndex],
|
child: SwipeableSubscriptionCard(
|
||||||
keepAlive: true,
|
subscription: subscriptions[subIndex],
|
||||||
onTap: () {
|
keepAlive: true,
|
||||||
Log.d(
|
onTap: () {
|
||||||
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
Log.d(
|
||||||
AppNavigator.toDetail(
|
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
||||||
context, subscriptions[subIndex]);
|
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||||
},
|
},
|
||||||
onDelete: () async {
|
onDelete: () async {
|
||||||
// 현재 로케일에 맞는 서비스명 가져오기
|
// 현재 로케일에 맞는 서비스명 가져오기
|
||||||
final localeProvider =
|
final localeProvider = Provider.of<LocaleProvider>(
|
||||||
Provider.of<LocaleProvider>(
|
context,
|
||||||
context,
|
listen: false,
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
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
|
name: submanager
|
||||||
description: A new Flutter project.
|
description: A new Flutter project.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.0+1
|
version: 1.0.1+3
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||