6 Commits

Author SHA1 Message Date
JiWoong Sul
83c43fb61f feat: SMS 스캔 전면광고 및 Isolate 버그 수정
## 전면 광고 (AdService)
- AdService 클래스 신규 생성 (lunchpick 패턴 참조)
- Completer 패턴으로 광고 완료 대기 구현
- 로딩 오버레이로 앱 foreground 상태 유지
- 몰입형 모드 (immersiveSticky) 적용
- iOS 테스트 광고 ID 설정

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

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

## 버전
- 1.0.1+2 → 1.0.1+3
2025-12-08 18:14:52 +09:00
JiWoong Sul
bac4acf9a3 i8n과 광고 수정 2025-12-07 21:14:54 +09:00
JiWoong Sul
64da0c5fd3 스토어등록용 이미지 및 앱아이콘 2025-11-17 19:28:51 +09:00
JiWoong Sul
d9435bbee5 앱 키 설정 및 버전업 처리 2025-11-17 19:28:33 +09:00
JiWoong Sul
b018e5eb2f 옵션창 정보팝업 단절 처리 2025-11-17 19:26:46 +09:00
JiWoong Sul
b22df5daf3 i8n누락 사항 추가 적용 2025-11-17 19:26:14 +09:00
65 changed files with 1465 additions and 818 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

377
CLAUDE.md
View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View File

@@ -11,6 +11,9 @@
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"deleteSubscriptionTitle": "Delete Subscription",
"deleteSubscriptionMessage": "Are you sure you want to delete @ subscription?",
"deleteIrreversibleWarning": "This action cannot be undone",
"edit": "Edit",
"totalSubscriptions": "Total Subscriptions",
"totalMonthlyExpense": "Total Monthly Expense",
@@ -25,10 +28,12 @@
"selectIcon": "Select Icon",
"addCategory": "Add Category",
"settings": "Settings",
"theme": "Theme",
"darkMode": "Dark Mode",
"language": "Language",
"notifications": "Notifications",
"appLock": "App Lock",
"appLocked": "App is locked",
"paymentCard": "Payment Card",
"paymentCardManagement": "Payment Card Management",
"paymentCardManagementDescription": "Manage saved cards for subscriptions",
@@ -64,6 +69,7 @@
"dailyReminderEnabled": "Receive daily notifications until payment date",
"dailyReminderDisabled": "Receive notification @ day(s) before payment",
"notificationPermissionDenied": "Notification permission denied",
"permissionGranted": "Permission granted.",
"appInfo": "App Info",
"version": "Version",
"appDescription": "Digital Rent Management App",
@@ -83,6 +89,7 @@
"twoDaysBefore": "2 days before",
"threeDaysBefore": "3 days before",
"requiredFieldsError": "Please fill in all required fields",
"categoryNameRequired": "Please enter category name",
"subscriptionUpdated": "Subscription information has been updated",
"subscriptionDeleted": "@ subscription has been deleted",
"officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.",
@@ -111,6 +118,7 @@
"appLockDesc": "App lock with biometric authentication",
"unlockWithBiometric": "Unlock with biometric authentication",
"authenticationFailed": "Authentication failed. Please try again.",
"nextBillingDateAdjusted": "Saved as the next billing date",
"totalExpenseCopied": "Total expense copied: @",
"smsPermissionRequired": "SMS permission required",
"noSubscriptionSmsFound": "No subscription related SMS found",
@@ -155,6 +163,7 @@
"latestSmsMessage": "Latest SMS message",
"smsDetectedDate": "Detected on @",
"serviceName": "Service Name",
"unknownService": "Unknown service",
"nextBillingDateLabel": "Next Billing Date",
"category": "Category",
"websiteUrlAuto": "Website URL (Auto-extracted)",
@@ -242,8 +251,12 @@
"subscriptionDetail": "Subscription Detail",
"enterAmount": "Enter amount",
"invalidAmount": "Please enter a valid amount",
"featureComingSoon": "This feature is coming soon"
,
"featureComingSoon": "This feature is coming soon",
"exactAlarmPermission": "Exact alarm permission (Alarms & Reminders)",
"exactAlarmPermissionDesc": "We need permission to guarantee precise alarms.",
"allowAlarmsInSettings": "Please allow \"Alarms & reminders\" in Settings.",
"testNotification": "Test notification",
"testSubscriptionBody": "Test subscription • @",
"smsPermissionTitle": "Request SMS Permission",
"smsPermissionReasonTitle": "Why",
"smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.",
@@ -253,7 +266,11 @@
"openSettings": "Open Settings",
"later": "Later",
"requesting": "Requesting...",
"smsPermissionLabel": "SMS Permission"
"smsPermissionLabel": "SMS Permission",
"expirationReminderBody": "@ subscription expires in # days.",
"eventEndNotificationTitle": "Event end notification",
"eventEndNotificationBody": "@'s discount event has ended.",
"paymentChargeNotification": "@ subscription charge @ was completed."
},
"ko": {
"appTitle": "디지털 월세 관리자",
@@ -267,6 +284,9 @@
"save": "저장",
"cancel": "취소",
"delete": "삭제",
"deleteSubscriptionTitle": "구독 삭제",
"deleteSubscriptionMessage": "정말로 @ 구독을 삭제하시겠습니까?",
"deleteIrreversibleWarning": "이 작업은 되돌릴 수 없습니다",
"edit": "수정",
"totalSubscriptions": "총 구독",
"totalMonthlyExpense": "이번 달 총 지출",
@@ -281,10 +301,12 @@
"selectIcon": "아이콘 선택",
"addCategory": "카테고리 추가",
"settings": "설정",
"theme": "테마",
"darkMode": "다크 모드",
"language": "언어",
"notifications": "알림",
"appLock": "앱 잠금",
"appLocked": "앱이 잠겨 있습니다",
"paymentCard": "결제수단",
"paymentCardManagement": "결제수단 관리",
"paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다",
@@ -320,6 +342,7 @@
"dailyReminderEnabled": "결제일까지 매일 알림을 받습니다",
"dailyReminderDisabled": "결제 @일 전에 알림을 받습니다",
"notificationPermissionDenied": "알림 권한이 거부되었습니다",
"permissionGranted": "권한이 허용되었습니다.",
"appInfo": "앱 정보",
"version": "버전",
"appDescription": "디지털 월세 관리 앱",
@@ -339,6 +362,7 @@
"twoDaysBefore": "2일 전",
"threeDaysBefore": "3일 전",
"requiredFieldsError": "필수 항목을 모두 입력해주세요",
"categoryNameRequired": "카테고리 이름을 입력하세요",
"subscriptionUpdated": "구독 정보가 업데이트되었습니다.",
"subscriptionDeleted": "@ 구독이 삭제되었습니다.",
"officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.",
@@ -367,6 +391,7 @@
"appLockDesc": "생체 인증으로 앱 잠금",
"unlockWithBiometric": "생체 인증으로 잠금 해제",
"authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.",
"nextBillingDateAdjusted": "다음 결제 예정일로 저장됨",
"totalExpenseCopied": "총 지출액이 복사되었습니다: @",
"smsPermissionRequired": "SMS 권한이 필요합니다.",
"noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.",
@@ -411,6 +436,7 @@
"latestSmsMessage": "최신 SMS 메시지",
"smsDetectedDate": "SMS 수신일: @",
"serviceName": "서비스명",
"unknownService": "알 수 없는 서비스",
"nextBillingDateLabel": "다음 결제일",
"category": "카테고리",
"websiteUrlAuto": "웹사이트 URL (자동 추출됨)",
@@ -498,8 +524,12 @@
"subscriptionDetail": "구독 상세",
"enterAmount": "금액을 입력하세요",
"invalidAmount": "올바른 금액을 입력해주세요",
"featureComingSoon": "이 기능은 곧 출시됩니다"
,
"featureComingSoon": "이 기능은 곧 출시됩니다",
"exactAlarmPermission": "정확 알람 권한(알람 및 리마인더)",
"exactAlarmPermissionDesc": "정확한 시각에 알림을 보장하려면 권한이 필요합니다.",
"allowAlarmsInSettings": "설정에서 \"알람 및 리마인더\"를 허용해 주세요.",
"testNotification": "테스트 알림",
"testSubscriptionBody": "테스트 구독 • @",
"smsPermissionTitle": "SMS 권한 요청",
"smsPermissionReasonTitle": "이유",
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
@@ -509,7 +539,11 @@
"openSettings": "설정 열기",
"later": "나중에 하기",
"requesting": "요청 중...",
"smsPermissionLabel": "SMS 권한"
"smsPermissionLabel": "SMS 권한",
"expirationReminderBody": "@ 구독이 #일 후 만료됩니다.",
"eventEndNotificationTitle": "이벤트 종료 알림",
"eventEndNotificationBody": "@의 할인 이벤트가 종료되었습니다.",
"paymentChargeNotification": "@ 구독료 @이 결제되었습니다."
},
"ja": {
"appTitle": "デジタル月額管理者",
@@ -523,6 +557,9 @@
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"deleteSubscriptionTitle": "サブスクリプション削除",
"deleteSubscriptionMessage": "本当に@のサブスクリプションを削除しますか?",
"deleteIrreversibleWarning": "この操作は取り消せません",
"edit": "編集",
"totalSubscriptions": "総サブスクリプション",
"totalMonthlyExpense": "今月の総支出",
@@ -537,10 +574,12 @@
"selectIcon": "アイコンを選択",
"addCategory": "カテゴリー追加",
"settings": "設定",
"theme": "テーマ",
"darkMode": "ダークモード",
"language": "言語",
"notifications": "通知",
"appLock": "アプリロック",
"appLocked": "アプリがロックされています",
"paymentCard": "支払いカード",
"paymentCardManagement": "支払いカード管理",
"paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します",
@@ -576,6 +615,7 @@
"dailyReminderEnabled": "支払い日まで毎日通知を受け取ります",
"dailyReminderDisabled": "支払い@日前に通知を受け取ります",
"notificationPermissionDenied": "通知権限が拒否されました",
"permissionGranted": "権限が許可されました。",
"appInfo": "アプリ情報",
"version": "バージョン",
"appDescription": "デジタル月額管理アプリ",
@@ -595,6 +635,7 @@
"twoDaysBefore": "2日前",
"threeDaysBefore": "3日前",
"requiredFieldsError": "すべての必須項目を入力してください",
"categoryNameRequired": "カテゴリ名を入力してください",
"subscriptionUpdated": "サブスクリプション情報が更新されました",
"subscriptionDeleted": "@サブスクリプションが削除されました",
"officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。",
@@ -623,6 +664,7 @@
"appLockDesc": "生体認証でアプリをロック",
"unlockWithBiometric": "生体認証でロック解除",
"authenticationFailed": "認証に失敗しました。もう一度お試しください。",
"nextBillingDateAdjusted": "次回請求日に保存しました",
"totalExpenseCopied": "総支出がコピーされました:@",
"smsPermissionRequired": "SMS権限が必要です",
"noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません",
@@ -667,6 +709,7 @@
"latestSmsMessage": "最新のSMSメッセージ",
"smsDetectedDate": "SMS受信日: @",
"serviceName": "サービス名",
"unknownService": "不明なサービス",
"nextBillingDateLabel": "次回請求日",
"category": "カテゴリー",
"websiteUrlAuto": "ウェブサイトURL自動抽出",
@@ -754,7 +797,16 @@
"subscriptionDetail": "サブスクリプション詳細",
"enterAmount": "金額を入力してください",
"invalidAmount": "正しい金額を入力してください",
"featureComingSoon": "この機能は近日公開予定です"
"featureComingSoon": "この機能は近日公開予定です",
"exactAlarmPermission": "正確なアラーム権限(アラームとリマインダー)",
"exactAlarmPermissionDesc": "正確な時刻に通知するには権限が必要です。",
"allowAlarmsInSettings": "設定で「アラームとリマインダー」を許可してください。",
"testNotification": "テスト通知",
"testSubscriptionBody": "テストサブスクリプション • @",
"expirationReminderBody": "@ のサブスクリプションは #日後に期限切れになります。",
"eventEndNotificationTitle": "イベント終了通知",
"eventEndNotificationBody": "@ の割引イベントが終了しました。",
"paymentChargeNotification": "@ の購読料 @ が請求されました。"
},
"zh": {
"appTitle": "数字月租管理器",
@@ -768,6 +820,9 @@
"save": "保存",
"cancel": "取消",
"delete": "删除",
"deleteSubscriptionTitle": "删除订阅",
"deleteSubscriptionMessage": "确定要删除@订阅吗?",
"deleteIrreversibleWarning": "此操作无法撤销",
"edit": "编辑",
"totalSubscriptions": "订阅总数",
"totalMonthlyExpense": "本月总支出",
@@ -782,10 +837,12 @@
"selectIcon": "选择图标",
"addCategory": "添加分类",
"settings": "设置",
"theme": "主题",
"darkMode": "深色模式",
"language": "语言",
"notifications": "通知",
"appLock": "应用锁定",
"appLocked": "应用已锁定",
"paymentCard": "支付卡",
"paymentCardManagement": "支付卡管理",
"paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)",
@@ -821,6 +878,7 @@
"dailyReminderEnabled": "直到付款日期每天接收通知",
"dailyReminderDisabled": "在付款@天前接收通知",
"notificationPermissionDenied": "通知权限被拒绝",
"permissionGranted": "已获得权限。",
"appInfo": "应用信息",
"version": "版本",
"appDescription": "数字月租管理应用",
@@ -840,6 +898,7 @@
"twoDaysBefore": "2天前",
"threeDaysBefore": "3天前",
"requiredFieldsError": "请填写所有必填项",
"categoryNameRequired": "请输入分类名称",
"subscriptionUpdated": "订阅信息已更新",
"subscriptionDeleted": "@订阅已删除",
"officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。",
@@ -868,6 +927,7 @@
"appLockDesc": "使用生物识别锁定应用",
"unlockWithBiometric": "使用生物识别解锁",
"authenticationFailed": "认证失败。请重试。",
"nextBillingDateAdjusted": "已保存为下一次账单日",
"totalExpenseCopied": "总支出已复制:@",
"smsPermissionRequired": "需要短信权限",
"noSubscriptionSmsFound": "未找到订阅相关的短信",
@@ -912,6 +972,7 @@
"latestSmsMessage": "最新短信内容",
"smsDetectedDate": "短信接收日期:@",
"serviceName": "服务名称",
"unknownService": "未知服务",
"nextBillingDateLabel": "下次付款日期",
"category": "类别",
"websiteUrlAuto": "网站URL自动提取",
@@ -999,6 +1060,15 @@
"subscriptionDetail": "订阅详情",
"enterAmount": "请输入金额",
"invalidAmount": "请输入有效的金额",
"featureComingSoon": "此功能即将推出"
"featureComingSoon": "此功能即将推出",
"exactAlarmPermission": "精确闹钟权限(闹钟和提醒)",
"exactAlarmPermissionDesc": "需要权限以确保在准确时间发送提醒。",
"allowAlarmsInSettings": "请在设置中允许“闹钟和提醒”。",
"testNotification": "测试通知",
"testSubscriptionBody": "测试订阅 • @",
"expirationReminderBody": "@ 订阅将在 # 天后到期。",
"eventEndNotificationTitle": "活动结束通知",
"eventEndNotificationBody": "@ 的优惠活动已结束。",
"paymentChargeNotification": "@ 订阅费用 @ 已扣款。"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

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

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

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

Binary file not shown.

View File

@@ -525,7 +525,7 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
message: AppLocalizations.of(context).nextBillingDateAdjusted,
);
}
}

View File

@@ -454,7 +454,7 @@ class DetailScreenController extends ChangeNotifier {
if (adjustedNext.isAfter(originalDateOnly)) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
message: AppLocalizations.of(context).nextBillingDateAdjusted,
);
}

View File

@@ -1,19 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import 'dart:io' show Platform;
import '../services/sms_scanner.dart';
import '../models/subscription.dart';
import '../models/payment_card_suggestion.dart';
import '../services/ad_service.dart';
import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../models/subscription.dart';
import '../models/payment_card_suggestion.dart';
import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/logger.dart';
import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/payment_card_provider.dart';
import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
class SmsScanController extends ChangeNotifier {
// 상태 관리
@@ -45,6 +48,7 @@ class SmsScanController extends ChangeNotifier {
final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter();
final AdService _adService = AdService();
bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing;
@@ -73,15 +77,36 @@ class SmsScanController extends ChangeNotifier {
serviceNameController.text = '';
}
void updateCurrentServiceName(String value) {
void updateCurrentServiceName(BuildContext context, String value) {
if (_currentIndex >= _scannedSubscriptions.length) return;
final trimmed = value.trim();
final unknownLabel = _unknownServiceLabel(context);
final updated = _scannedSubscriptions[_currentIndex]
.copyWith(serviceName: trimmed.isEmpty ? '알 수 없는 서비스' : trimmed);
.copyWith(serviceName: trimmed.isEmpty ? unknownLabel : trimmed);
_scannedSubscriptions[_currentIndex] = updated;
notifyListeners();
}
/// SMS 스캔 시작 (전면 광고 표시 후 스캔 진행)
Future<void> startScan(BuildContext context) async {
if (_isLoading) return;
// 웹/비지원 플랫폼은 바로 스캔
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
await scanSms(context);
return;
}
// 광고 표시 (완료까지 대기)
// 광고 실패해도 스캔 진행 (사용자 경험 우선)
await _adService.showInterstitialAd(context);
if (!context.mounted) return;
// 광고 완료 후 SMS 스캔 실행
await scanSms(context);
}
Future<void> scanSms(BuildContext context) async {
_isLoading = true;
_errorMessage = null;
@@ -89,6 +114,11 @@ class SmsScanController extends ChangeNotifier {
_currentIndex = 0;
notifyListeners();
await _performSmsScan(context);
}
/// 실제 SMS 스캔 수행 (로딩 상태는 이미 설정되어 있음)
Future<void> _performSmsScan(BuildContext context) async {
try {
// Android에서 SMS 권한 확인 및 요청
final ctx = context;
@@ -331,13 +361,14 @@ class SmsScanController extends ChangeNotifier {
return otherCategory.id;
}
void initializeWebsiteUrl() {
void initializeWebsiteUrl(BuildContext context) {
if (_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!;
}
if (_shouldEnableServiceNameEditing(currentSub)) {
final unknownLabel = _unknownServiceLabel(context);
if (_shouldEnableServiceNameEditing(currentSub, unknownLabel)) {
if (serviceNameController.text != currentSub.serviceName) {
serviceNameController.clear();
}
@@ -366,8 +397,10 @@ class SmsScanController extends ChangeNotifier {
}
final current = _scannedSubscriptions[_currentIndex];
_forceServiceNameEditing = _shouldEnableServiceNameEditing(current);
if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') {
final unknownLabel = _unknownServiceLabel(context);
_forceServiceNameEditing =
_shouldEnableServiceNameEditing(current, unknownLabel);
if (_forceServiceNameEditing && current.serviceName == unknownLabel) {
serviceNameController.clear();
} else {
serviceNameController.text = current.serviceName;
@@ -429,8 +462,13 @@ class SmsScanController extends ChangeNotifier {
return null;
}
bool _shouldEnableServiceNameEditing(Subscription subscription) {
bool _shouldEnableServiceNameEditing(
Subscription subscription, String unknownLabel) {
final name = subscription.serviceName.trim();
return name.isEmpty || name == '알 수 없는 서비스';
return name.isEmpty || name == unknownLabel;
}
String _unknownServiceLabel(BuildContext context) {
return AppLocalizations.of(context).unknownService;
}
}

View File

@@ -36,6 +36,16 @@ class AppLocalizations {
String get save => _localizedStrings['save'] ?? 'Save';
String get cancel => _localizedStrings['cancel'] ?? 'Cancel';
String get delete => _localizedStrings['delete'] ?? 'Delete';
String get deleteSubscriptionTitle =>
_localizedStrings['deleteSubscriptionTitle'] ?? 'Delete Subscription';
String get deleteSubscriptionMessageTemplate =>
_localizedStrings['deleteSubscriptionMessage'] ??
'Are you sure you want to delete @ subscription?';
String deleteSubscriptionMessage(String serviceName) =>
deleteSubscriptionMessageTemplate.replaceAll('@', serviceName);
String get deleteIrreversibleWarning =>
_localizedStrings['deleteIrreversibleWarning'] ??
'This action cannot be undone';
String get edit => _localizedStrings['edit'] ?? 'Edit';
String get totalSubscriptions =>
_localizedStrings['totalSubscriptions'] ?? 'Total Subscriptions';
@@ -58,11 +68,13 @@ class AppLocalizations {
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
String get settings => _localizedStrings['settings'] ?? 'Settings';
String get theme => _localizedStrings['theme'] ?? 'Theme';
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
String get language => _localizedStrings['language'] ?? 'Language';
String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
String get appLocked => _localizedStrings['appLocked'] ?? 'App is locked';
String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card';
String get paymentCardManagement =>
_localizedStrings['paymentCardManagement'] ?? 'Payment Card Management';
@@ -163,6 +175,8 @@ class AppLocalizations {
String get notificationPermissionDenied =>
_localizedStrings['notificationPermissionDenied'] ??
'Notification permission denied';
String get permissionGranted =>
_localizedStrings['permissionGranted'] ?? 'Permission granted.';
// 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version';
@@ -197,6 +211,8 @@ class AppLocalizations {
String get requiredFieldsError =>
_localizedStrings['requiredFieldsError'] ??
'Please fill in all required fields';
String get categoryNameRequired =>
_localizedStrings['categoryNameRequired'] ?? 'Please enter category name';
String get subscriptionUpdated =>
_localizedStrings['subscriptionUpdated'] ??
'Subscription information has been updated';
@@ -249,6 +265,9 @@ class AppLocalizations {
String get authenticationFailed =>
_localizedStrings['authenticationFailed'] ??
'Authentication failed. Please try again.';
String get nextBillingDateAdjusted =>
_localizedStrings['nextBillingDateAdjusted'] ??
'Saved as the next billing date';
String get smsPermissionRequired =>
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
String get noSubscriptionSmsFound =>
@@ -457,6 +476,8 @@ class AppLocalizations {
String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get unknownService =>
_localizedStrings['unknownService'] ?? 'Unknown service';
String get latestSmsMessage =>
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
String smsDetectedDate(String date) {
@@ -659,6 +680,49 @@ class AppLocalizations {
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon =>
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
String get exactAlarmPermission =>
_localizedStrings['exactAlarmPermission'] ??
'Exact alarm permission (Alarms & Reminders)';
String get exactAlarmPermissionDesc =>
_localizedStrings['exactAlarmPermissionDesc'] ??
'We need permission to guarantee precise alarms.';
String get allowAlarmsInSettings =>
_localizedStrings['allowAlarmsInSettings'] ??
'Please allow "Alarms & reminders" in Settings.';
String get testNotification =>
_localizedStrings['testNotification'] ?? 'Test notification';
String testSubscriptionBody(String amountText) {
final template =
_localizedStrings['testSubscriptionBody'] ?? 'Test subscription • @';
return template.replaceAll('@', amountText);
}
String expirationReminderBody(String serviceName, int days) {
final template = _localizedStrings['expirationReminderBody'] ??
'@ subscription expires in # days.';
return template
.replaceAll('@', serviceName)
.replaceAll('#', days.toString());
}
String get eventEndNotificationTitle =>
_localizedStrings['eventEndNotificationTitle'] ??
'Event end notification';
String eventEndNotificationBody(String serviceName) {
final template = _localizedStrings['eventEndNotificationBody'] ??
"@'s discount event has ended.";
return template.replaceAll('@', serviceName);
}
String paymentChargeNotification(String serviceName, String amountText) {
final template = _localizedStrings['paymentChargeNotification'] ??
'@ subscription charge @ was completed.';
return template
.replaceFirst('@', serviceName)
.replaceFirst('@', amountText);
}
// 결제 주기를 키값으로 변환하여 번역된 이름 반환
String getBillingCycleName(String billingCycleKey) {

View File

@@ -6,6 +6,8 @@ import '../services/notification_service.dart';
import '../providers/subscription_provider.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class AppLockProvider extends ChangeNotifier {
final Box<bool> _appLockBox;
@@ -72,8 +74,11 @@ class AppLockProvider extends ChangeNotifier {
return true;
}
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
final authenticated = await _localAuth.authenticate(
localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.',
localizedReason:
loc?.unlockWithBiometric ?? 'Unlock with biometric authentication.',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: true,

View File

@@ -8,6 +8,8 @@ import '../services/notification_service.dart';
import '../services/exchange_rate_service.dart';
import '../services/currency_util.dart';
import 'category_provider.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SubscriptionProvider extends ChangeNotifier {
late Box<SubscriptionModel> _subscriptionBox;
@@ -239,10 +241,13 @@ class SubscriptionProvider extends ChangeNotifier {
SubscriptionModel subscription) async {
if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
await NotificationService.scheduleNotification(
id: '${subscription.id}_event_end'.hashCode,
title: '이벤트 종료 알림',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
title: loc?.eventEndNotificationTitle ?? 'Event end notification',
body: loc?.eventEndNotificationBody(subscription.serviceName) ??
"${subscription.serviceName}'s discount event has ended.",
scheduledDate: subscription.eventEndDate!,
channelId: NotificationService.expirationChannelId,
);

View File

@@ -9,6 +9,7 @@ import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/screens/sms_permission_screen.dart';
import 'package:submanager/models/subscription_model.dart';
import 'package:submanager/screens/payment_card_management_screen.dart';
import '../l10n/app_localizations.dart';
class AppRoutes {
static const String splash = '/splash';
@@ -81,9 +82,9 @@ class AppRoutes {
static Route<dynamic> _errorRoute() {
return MaterialPageRoute(
builder: (_) => const Scaffold(
builder: (context) => Scaffold(
body: Center(
child: Text('페이지를 찾을 수 없습니다'),
child: Text(AppLocalizations.of(context).pageNotFound),
),
),
);

View File

@@ -13,6 +13,7 @@ import '../widgets/analysis/subscription_pie_chart_card.dart';
import '../widgets/analysis/total_expense_summary_card.dart';
import '../widgets/analysis/monthly_expense_chart_card.dart';
import '../widgets/analysis/event_analysis_card.dart';
import '../theme/ui_constants.dart';
enum AnalysisCardFilterType { all, unassigned, card }
@@ -324,21 +325,11 @@ class _AnalysisScreenState extends State<AnalysisScreen>
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: <Widget>[
SliverToBoxAdapter(
child: SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
SliverPadding(
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
sliver: _buildCardFilterSection(context, cardProvider),
),
// 네이티브 광고 위젯
SliverToBoxAdapter(
child: _buildAnimatedAd(),
),
const AnalysisScreenSpacer(),
_buildCardFilterSection(context, cardProvider),
const AnalysisScreenSpacer(),
// 1. 구독 비율 파이 차트
@@ -349,6 +340,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
const AnalysisScreenSpacer(),
// 네이티브 광고 위젯 (구독 비율 차트 하단)
SliverToBoxAdapter(
child: _buildAnimatedAd(),
),
const AnalysisScreenSpacer(),
// 2. 총 지출 요약 카드
TotalExpenseSummaryCard(
key: ValueKey('total_expense_$_lastDataHash'),

View File

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

View File

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

View File

@@ -4,10 +4,8 @@ import 'package:provider/provider.dart';
import '../providers/notification_provider.dart';
import 'dart:io';
import '../services/notification_service.dart';
import 'package:url_launcher/url_launcher.dart';
// import '../widgets/glassmorphism_card.dart';
// import '../theme/app_colors.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart';
@@ -18,6 +16,7 @@ import '../theme/adaptive_theme.dart';
import '../widgets/common/layout/page_container.dart';
import '../theme/color_scheme_ext.dart';
import '../widgets/app_navigator.dart';
import '../theme/ui_constants.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -87,23 +86,16 @@ class SettingsScreen extends StatelessWidget {
child: PageContainer(
padding: EdgeInsets.zero,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.fromLTRB(
16,
UIConstants.pageTopPadding,
16,
0,
),
children: [
// toolbar 높이 추가
SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
// 광고 위젯 추가
const NativeAdWidget(
key: ValueKey('settings_ad'),
useOuterPadding: true,
),
const SizedBox(height: 16),
// 테마 모드 설정
Card(
margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
@@ -192,7 +184,7 @@ class SettingsScreen extends StatelessWidget {
leading: Icon(Icons.color_lens,
color: cs.onSurfaceVariant),
title: Text(
'테마',
loc.theme,
style: TextStyle(color: cs.onSurface),
),
),
@@ -361,14 +353,14 @@ class SettingsScreen extends StatelessWidget {
.colorScheme
.onSurfaceVariant),
title: Text(
'정확 알람 권한(알람 및 리마인더)',
loc.exactAlarmPermission,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
),
subtitle: Text(
'정확한 시각에 알림을 보장하려면 권한이 필요합니다.',
loc.exactAlarmPermissionDesc,
style: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -386,19 +378,19 @@ class SettingsScreen extends StatelessWidget {
if (ok || recheck) {
AppSnackBar.showSuccess(
context: context,
message: '권한이 허용되었습니다.',
message: loc.permissionGranted,
);
} else {
AppSnackBar.showInfo(
context: context,
message:
'설정에서 "알람 및 리마인더"를 허용해 주세요.',
loc.allowAlarmsInSettings,
);
}
(context as Element).markNeedsBuild();
}
},
child: const Text('허용 요청'),
child: Text(loc.requestPermission),
),
);
},
@@ -748,8 +740,8 @@ class SettingsScreen extends StatelessWidget {
child: OutlinedButton.icon(
icon: const Icon(Icons
.notifications_active),
label:
const Text('테스트 알림'),
label: Text(
loc.testNotification),
onPressed: () {
NotificationService
.showTestPaymentNotification();
@@ -907,60 +899,61 @@ class SettingsScreen extends StatelessWidget {
),
leading: Icon(Icons.info,
color: Theme.of(context).colorScheme.onSurfaceVariant),
onTap: () async {
// 항상 앱 내 About 다이얼로그를 우선 표시
showAboutDialog(
context: context,
applicationName: AppLocalizations.of(context).appTitle,
applicationVersion: '1.0.0',
applicationIcon: const FlutterLogo(size: 50),
children: [
Text(AppLocalizations.of(context).appDescription),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context).developer}: Julian Sul'),
const SizedBox(height: 12),
Builder(builder: (ctx) {
return TextButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(AppLocalizations.of(ctx).openStore),
onPressed: () async {
try {
if (Platform.isAndroid) {
// 우선 Play 스토어 앱 시도
const pkg =
'com.naturebridgeai.digitalrentmanager';
final marketUri =
Uri.parse('market://details?id=$pkg');
final webUri = Uri.parse(
'https://play.google.com/store/apps/details?id=$pkg');
final ok = await launchUrl(marketUri,
mode: LaunchMode.externalApplication);
if (!ok) {
await launchUrl(webUri,
mode: LaunchMode.externalApplication);
}
} else if (Platform.isIOS) {
final uri = Uri.parse(
'https://apps.apple.com/app/id123456789');
await launchUrl(uri,
mode: LaunchMode.externalApplication);
}
} catch (e) {
if (ctx.mounted) {
AppSnackBar.showError(
context: ctx,
message: AppLocalizations.of(ctx)
.cannotOpenStore,
);
}
}
},
);
}),
],
);
},
onTap: null,
// onTap: () async {
// // 항상 앱 내 About 다이얼로그를 우선 표시 (현재 미사용)
// showAboutDialog(
// context: context,
// applicationName: AppLocalizations.of(context).appTitle,
// applicationVersion: '1.0.0',
// applicationIcon: const FlutterLogo(size: 50),
// children: [
// Text(AppLocalizations.of(context).appDescription),
// const SizedBox(height: 8),
// Text(
// '${AppLocalizations.of(context).developer}: Julian Sul'),
// const SizedBox(height: 12),
// Builder(builder: (ctx) {
// return TextButton.icon(
// icon: const Icon(Icons.open_in_new),
// label: Text(AppLocalizations.of(ctx).openStore),
// onPressed: () async {
// try {
// if (Platform.isAndroid) {
// // 우선 Play 스토어 앱 시도
// const pkg =
// 'com.naturebridgeai.digitalrentmanager';
// final marketUri =
// Uri.parse('market://details?id=$pkg');
// final webUri = Uri.parse(
// 'https://play.google.com/store/apps/details?id=$pkg');
// final ok = await launchUrl(marketUri,
// mode: LaunchMode.externalApplication);
// if (!ok) {
// await launchUrl(webUri,
// mode: LaunchMode.externalApplication);
// }
// } else if (Platform.isIOS) {
// final uri = Uri.parse(
// 'https://apps.apple.com/app/id123456789');
// await launchUrl(uri,
// mode: LaunchMode.externalApplication);
// }
// } catch (e) {
// if (ctx.mounted) {
// AppSnackBar.showError(
// context: ctx,
// message: AppLocalizations.of(ctx)
// .cannotOpenStore,
// );
// }
// }
// },
// );
// }),
// ],
// );
// },
),
),
// FloatingNavigationBar를 위한 충분한 하단 여백

View File

@@ -9,6 +9,7 @@ import '../l10n/app_localizations.dart';
import '../widgets/payment_card/payment_card_form_sheet.dart';
import '../routes/app_routes.dart';
import '../models/payment_card_suggestion.dart';
import '../theme/ui_constants.dart';
class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key});
@@ -46,7 +47,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
_controller.initializeWebsiteUrl();
_controller.initializeWebsiteUrl(context);
}
Widget _buildContent() {
@@ -56,7 +57,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (_controller.scannedSubscriptions.isEmpty) {
return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context),
onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage,
);
}
@@ -75,7 +76,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
}
});
return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context),
onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage,
);
}
@@ -104,7 +105,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
onPaymentCardChanged: _controller.setSelectedPaymentCardId,
enableServiceNameEditing: _controller.isServiceNameEditable,
onServiceNameChanged: _controller.isServiceNameEditable
? _controller.updateCurrentServiceName
? (value) => _controller.updateCurrentServiceName(context, value)
: null,
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
@@ -160,21 +161,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override
Widget build(BuildContext context) {
// 로딩 중일 때는 화면 정중앙에 표시
if (_controller.isLoading) {
return const ScanLoadingWidget();
}
return SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.zero,
child: Column(
children: [
// toolbar 높이 추가
SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
_buildContent(),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
],
child: Padding(
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
child: Column(
children: [
_buildContent(),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
],
),
),
);
}

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

View File

@@ -635,6 +635,8 @@ class NotificationService {
try {
final expirationDate = subscription.nextBillingDate;
final reminderDate = expirationDate.subtract(const Duration(days: 7));
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
// tz.local 초기화 확인 및 재시도
tz.Location location;
@@ -656,8 +658,9 @@ class NotificationService {
await _notifications.zonedSchedule(
('${subscription.id}_expiration').hashCode,
'구독 만료 예정 알림',
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
loc?.expirationReminder ?? _paymentReminderTitle(_getLocaleCode()),
loc?.expirationReminderBody(subscription.serviceName, 7) ??
'${subscription.serviceName} subscription expires in 7 days.',
tz.TZDateTime.from(reminderDate, location),
const NotificationDetails(
android: AndroidNotificationDetails(
@@ -849,11 +852,14 @@ class NotificationService {
if (_isWeb || !_initialized) return;
try {
final locale = _getLocaleCode();
final title = _paymentReminderTitle(locale);
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
final title = loc?.paymentReminder ?? _paymentReminderTitle(locale);
final amountText =
await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale);
final body = '테스트 구독 • $amountText';
final body = loc?.testSubscriptionBody(amountText) ??
'Test subscription • $amountText';
await _notifications.show(
DateTime.now().millisecondsSinceEpoch.remainder(1 << 31),
@@ -880,7 +886,11 @@ class NotificationService {
}
static String getNotificationBody(String serviceName, double amount) {
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
final amountText = amount.toStringAsFixed(0);
return loc?.paymentChargeNotification(serviceName, amountText) ??
'$serviceName subscription charge $amountText was completed.';
}
static Future<String> _buildPaymentBody(
@@ -925,6 +935,10 @@ class NotificationService {
}
static String _paymentReminderTitle(String locale) {
final ctx = navigatorKey.currentContext;
if (ctx != null) {
return AppLocalizations.of(ctx).paymentReminder;
}
switch (locale) {
case 'ko':
return '결제 예정 알림';

View File

@@ -10,6 +10,8 @@ import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../models/payment_card_suggestion.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SmsScanner {
final SmsQuery _query = SmsQuery();
@@ -82,7 +84,9 @@ class SmsScanner {
return subscriptions;
} catch (e) {
Log.e('SmsScanner: 예외 발생', e);
throw Exception('SMS 스캔 중 오류 발생: $e');
final loc = _loc();
throw Exception(loc?.smsScanErrorWithMessage(e.toString()) ??
'Error occurred during SMS scan: $e');
}
}
@@ -116,7 +120,13 @@ class SmsScanner {
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final loc = _loc();
final unknownLabel = loc?.unknownService ?? 'Unknown service';
final serviceNameRaw = sms['serviceName'] as String?;
final serviceName =
(serviceNameRaw == null || serviceNameRaw.trim().isEmpty)
? unknownLabel
: serviceNameRaw;
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
final billingCycle = SubscriptionModel.normalizeBillingCycle(
sms['billingCycle'] as String? ?? 'monthly');
@@ -196,8 +206,9 @@ class SmsScanner {
if (issuer == null && last4 == null) {
return null;
}
final loc = _loc();
return PaymentCardSuggestion(
issuerName: issuer ?? '결제수단',
issuerName: issuer ?? loc?.paymentCard ?? 'Payment card',
last4: last4,
source: 'sms',
);
@@ -366,6 +377,12 @@ class SmsScanner {
// 기본값은 원화
return 'KRW';
}
AppLocalizations? _loc() {
final ctx = navigatorKey.currentContext;
if (ctx == null) return null;
return AppLocalizations.of(ctx);
}
}
const List<String> _paymentLikeKeywords = [
@@ -501,7 +518,8 @@ String _isoExtractServiceName(String body, String sender) {
String _isoExtractServiceNameFromSender(String sender) {
if (RegExp(r'^\d+$').hasMatch(sender)) {
return '알 수 없는 서비스';
// Isolate에서 실행되므로 하드코딩 사용 (Flutter 바인딩 접근 불가)
return 'Unknown service';
}
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
}
@@ -576,13 +594,14 @@ Map<String, List<Map<String, dynamic>>> _groupMessagesByIdentifier(
final address = (sms['address'] as String?)?.trim();
final sender = (sms['sender'] as String?)?.trim();
final unknownLabel = _unknownServiceLabel();
String key = (serviceName != null &&
serviceName.isNotEmpty &&
serviceName != '알 수 없는 서비스')
serviceName != unknownLabel)
? serviceName
: (address?.isNotEmpty == true
? address!
: (sender?.isNotEmpty == true ? sender! : 'unknown'));
: (sender?.isNotEmpty == true ? sender! : unknownLabel));
groups.putIfAbsent(key, () => []).add(sms);
}
@@ -602,6 +621,12 @@ class _RepeatDetectionResult {
enum _MatchType { none, monthly, yearly, identical }
String _unknownServiceLabel() {
final ctx = navigatorKey.currentContext;
if (ctx == null) return 'Unknown service';
return AppLocalizations.of(ctx).unknownService;
}
class _MatchedPair {
_MatchedPair(this.first, this.second, this.type);

View File

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

View File

@@ -1,7 +1,10 @@
class UIConstants {
static const double pageHorizontalPadding = 16;
static const double adVerticalPadding = 12;
static const double adCardHeight = 88;
static const double nativeAdWidth = 320;
static const double nativeAdHeight = 300;
static const double nativeAdAspectRatio = nativeAdWidth / nativeAdHeight;
static const double pageTopPadding = 40;
static const double cardRadius = 16;
static const double cardOutlineAlpha = 0.5; // for outline color alpha
}

View File

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

View File

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

View File

@@ -233,56 +233,66 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText.headline(
text: AppLocalizations.of(context)
.subscriptionServiceRatio,
style: const TextStyle(
fontSize: 18,
Expanded(
child: ThemedText.headline(
text: AppLocalizations.of(context)
.subscriptionServiceRatio,
style: const TextStyle(
fontSize: 18,
),
),
),
FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfoForLocale(
context
.watch<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
width: 1,
Flexible(
child: FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfoForLocale(
context
.watch<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) {
if (snapshot.hasData &&
snapshot.data!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(left: 12),
child: Align(
alignment: Alignment.topRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
width: 1,
),
),
child: ThemedText(
AppLocalizations.of(context)
.exchangeRateFormat(snapshot.data!),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.right,
),
),
),
),
child: Text(
AppLocalizations.of(context)
.exchangeRateFormat(snapshot.data!),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color:
Theme.of(context).colorScheme.primary,
),
),
);
}
return const SizedBox.shrink();
},
);
}
return const SizedBox.shrink();
},
),
),
],
),

View File

@@ -265,23 +265,39 @@ class DetailHeaderSection extends StatelessWidget {
final loc = AppLocalizations.of(context);
switch (cycle.toLowerCase()) {
case '매월':
case '월간':
case 'monthly':
case '毎月':
case '月付':
case '月間':
case '每月':
return loc.billingCycleMonthly;
case '분기별':
case '분기':
case 'quarterly':
case 'quarter':
case '季付':
case '季度付':
case '四半期':
case '每季度':
return loc.billingCycleQuarterly;
case '반기별':
case 'half-yearly':
case 'half yearly':
case 'semiannual':
case 'semi-annual':
case '半年付':
case '半年払い':
case '半年ごと':
case '每半年':
return loc.billingCycleHalfYearly;
case '매년':
case '연간':
case 'yearly':
case 'annual':
case 'annually':
case '年間':
case '年付':
case '每年':
return loc.billingCycleYearly;
default:

View File

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

View File

@@ -9,7 +9,7 @@ import '../providers/subscription_provider.dart';
import '../utils/subscription_grouping_helper.dart';
import '../widgets/empty_state_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/native_ad_widget.dart';
import '../theme/ui_constants.dart';
import '../widgets/subscription_list_widget.dart';
class HomeContent extends StatefulWidget {
@@ -115,13 +115,8 @@ class _HomeContentState extends State<HomeContent> {
controller: widget.scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
),
const SliverToBoxAdapter(
child: NativeAdWidget(key: ValueKey('home_ad')),
child: SizedBox(height: UIConstants.pageTopPadding),
),
SliverToBoxAdapter(
child: SlideTransition(

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ import '../../widgets/common/buttons/secondary_button.dart';
import '../../widgets/common/form_fields/base_text_field.dart';
import '../../widgets/common/form_fields/category_selector.dart';
import '../../widgets/common/snackbar/app_snackbar.dart';
import '../../widgets/native_ad_widget.dart';
import '../../widgets/payment_card/payment_card_selector.dart';
import '../../services/currency_util.dart';
import '../../utils/sms_scan/date_formatter.dart';
@@ -87,9 +86,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16),
if (_hasRawSmsMessage)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),

View File

@@ -13,6 +13,8 @@ import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
import '../utils/subscription_grouping_helper.dart';
import 'native_ad_widget.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget {
@@ -28,134 +30,132 @@ class SubscriptionListWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sections = groups;
int itemCounter = 0;
final List<Widget> children = [];
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final group = sections[index];
final subscriptions = group.subscriptions;
for (final group in sections) {
final subscriptions = group.subscriptions;
final List<Widget> subscriptionItems = [];
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SubscriptionGroupHeader(
group: group,
subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
),
// 카테고리별 구독 목록
FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: fadeController, curve: Curves.easeIn)),
child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
cacheExtent: 500,
itemCount: subscriptions.length,
itemBuilder: (context, subIndex) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
final delay = 0.05 * subIndex;
const animationBegin = 0.2;
const animationEnd = 1.0;
final intervalStart = delay;
final intervalEnd = intervalStart + 0.4;
for (var subIndex = 0; subIndex < subscriptions.length; subIndex++) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
final delay = 0.05 * subIndex;
const animationBegin = 0.2;
const animationEnd = 1.0;
final intervalStart = delay;
final intervalEnd = intervalStart + 0.4;
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
final intervalStartNormalized =
intervalStart.clamp(0.0, 0.9);
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
final intervalStartNormalized = intervalStart.clamp(0.0, 0.9);
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
return FadeTransition(
opacity: Tween<double>(
begin: animationBegin, end: animationEnd)
.animate(CurvedAnimation(
parent: fadeController,
curve: Interval(intervalStartNormalized,
intervalEndNormalized,
curve: Curves.easeOut))),
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: StaggeredAnimationItem(
index: subIndex,
delay: const Duration(milliseconds: 50),
child: RepaintBoundary(
child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex],
keepAlive: true,
onTap: () {
Log.d(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(
context, subscriptions[subIndex]);
},
onDelete: () async {
// 현재 로케일에 맞는 서비스명 가져오기
final localeProvider =
Provider.of<LocaleProvider>(
context,
listen: false,
);
final locale =
localeProvider.locale.languageCode;
final displayName =
await SubscriptionUrlMatcher
.getServiceDisplayName(
serviceName:
subscriptions[subIndex].serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시
if (!context.mounted) return;
final shouldDelete =
await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
if (!context.mounted) return;
if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행
final provider =
Provider.of<SubscriptionProvider>(
context,
listen: false,
);
await provider.deleteSubscription(
subscriptions[subIndex].id,
);
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context)
.subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded,
);
}
}
},
),
),
),
),
subscriptionItems.add(
FadeTransition(
opacity: Tween<double>(begin: animationBegin, end: animationEnd)
.animate(CurvedAnimation(
parent: fadeController,
curve: Interval(
intervalStartNormalized, intervalEndNormalized,
curve: Curves.easeOut))),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6.0),
child: StaggeredAnimationItem(
index: subIndex,
delay: const Duration(milliseconds: 50),
child: RepaintBoundary(
child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex],
keepAlive: true,
onTap: () {
Log.d(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(context, subscriptions[subIndex]);
},
onDelete: () async {
// 현재 로케일에 맞는 서비스명 가져오기
final localeProvider = Provider.of<LocaleProvider>(
context,
listen: false,
);
final locale = localeProvider.locale.languageCode;
final displayName =
await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscriptions[subIndex].serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시
if (!context.mounted) return;
final shouldDelete = await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
if (!context.mounted) return;
if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행
final provider = Provider.of<SubscriptionProvider>(
context,
listen: false,
);
await provider.deleteSubscription(
subscriptions[subIndex].id,
);
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context)
.subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded,
);
}
}
},
),
),
],
),
),
),
);
itemCounter++;
if ((itemCounter - 1) % 10 == 0) {
subscriptionItems.add(
NativeAdWidget(
key: ValueKey('home_list_ad_$itemCounter'),
aspectRatioOverride: 320 / 80,
mediaAspectRatioOverride: MediaAspectRatio.landscape,
templateTypeOverride: TemplateType.small,
),
);
},
childCount: sections.length,
),
}
}
children.add(
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SubscriptionGroupHeader(
group: group,
subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
),
...subscriptionItems,
],
),
),
);
}
return SliverList(
delegate: SliverChildListDelegate(children),
);
}

View File

@@ -1,7 +1,7 @@
name: submanager
description: A new Flutter project.
publish_to: 'none'
version: 1.0.0+1
version: 1.0.1+3
environment:
sdk: '>=3.0.0 <4.0.0'