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

View File

@@ -1,21 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import 'dart:io' show Platform;
import '../services/sms_scanner.dart'; import '../services/sms_scanner.dart';
import '../models/subscription.dart'; import '../services/ad_service.dart';
import '../models/payment_card_suggestion.dart';
import '../services/sms_scan/subscription_converter.dart'; import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart'; import '../services/sms_scan/subscription_filter.dart';
import '../services/sms_scan/sms_scan_result.dart'; import '../services/sms_scan/sms_scan_result.dart';
import '../models/subscription.dart';
import '../models/payment_card_suggestion.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/logger.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/payment_card_provider.dart'; import '../providers/payment_card_provider.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import '../l10n/app_localizations.dart';
import 'dart:io' show Platform; import '../utils/logger.dart';
class SmsScanController extends ChangeNotifier { class SmsScanController extends ChangeNotifier {
// 상태 관리 // 상태 관리
@@ -47,10 +48,9 @@ class SmsScanController extends ChangeNotifier {
final SmsScanner _smsScanner = SmsScanner(); final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter(); final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter(); final SubscriptionFilter _filter = SubscriptionFilter();
final AdService _adService = AdService();
bool _forceServiceNameEditing = false; bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing; bool get isServiceNameEditable => _forceServiceNameEditing;
bool _isAdInProgress = false;
bool get isAdInProgress => _isAdInProgress;
@override @override
void dispose() { void dispose() {
@@ -87,69 +87,26 @@ class SmsScanController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// SMS 스캔 시작 (전면 광고 표시 후 스캔 진행)
Future<void> startScan(BuildContext context) async { Future<void> startScan(BuildContext context) async {
if (_isLoading) return; if (_isLoading) return;
_isAdInProgress = true;
notifyListeners();
// 웹/비지원 플랫폼은 바로 스캔 // 웹/비지원 플랫폼은 바로 스캔
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) { if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
_isAdInProgress = false;
notifyListeners();
await scanSms(context); await scanSms(context);
return; return;
} }
// 전면 광고 로드 및 노출 후 스캔 진행 // 광고 표시 (완료까지 대기)
try { // 광고 실패해도 스캔 진행 (사용자 경험 우선)
await InterstitialAd.load( await _adService.showInterstitialAd(context);
adUnitId: _interstitialAdUnitId(),
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
_startSmsScanIfMounted(context);
},
onAdFailedToShowFullScreenContent: (ad, error) {
ad.dispose();
_fallbackAfterDelay(context);
},
);
ad.show();
},
onAdFailedToLoad: (error) {
_fallbackAfterDelay(context);
},
),
);
} catch (e) {
Log.e('전면 광고 로드 중 오류, 바로 스캔 진행', e);
if (!context.mounted) return;
_fallbackAfterDelay(context);
}
}
String _interstitialAdUnitId() {
if (Platform.isAndroid || Platform.isIOS) {
return 'ca-app-pub-6691216385521068~6638409932';
}
return '';
}
Future<void> _startSmsScanIfMounted(BuildContext context) async {
if (!context.mounted) return; if (!context.mounted) return;
_isAdInProgress = false;
notifyListeners(); // 광고 완료 후 SMS 스캔 실행
await scanSms(context); await scanSms(context);
} }
Future<void> _fallbackAfterDelay(BuildContext context) async {
await Future.delayed(const Duration(seconds: 5));
if (!context.mounted) return;
await _startSmsScanIfMounted(context);
}
Future<void> scanSms(BuildContext context) async { Future<void> scanSms(BuildContext context) async {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@@ -157,6 +114,11 @@ class SmsScanController extends ChangeNotifier {
_currentIndex = 0; _currentIndex = 0;
notifyListeners(); notifyListeners();
await _performSmsScan(context);
}
/// 실제 SMS 스캔 수행 (로딩 상태는 이미 설정되어 있음)
Future<void> _performSmsScan(BuildContext context) async {
try { try {
// Android에서 SMS 권한 확인 및 요청 // Android에서 SMS 권한 확인 및 요청
final ctx = context; final ctx = context;
@@ -399,13 +361,14 @@ class SmsScanController extends ChangeNotifier {
return otherCategory.id; return otherCategory.id;
} }
void initializeWebsiteUrl() { void initializeWebsiteUrl(BuildContext context) {
if (_currentIndex < _scannedSubscriptions.length) { if (_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex]; final currentSub = _scannedSubscriptions[_currentIndex];
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!; websiteUrlController.text = currentSub.websiteUrl!;
} }
if (_shouldEnableServiceNameEditing(currentSub)) { final unknownLabel = _unknownServiceLabel(context);
if (_shouldEnableServiceNameEditing(currentSub, unknownLabel)) {
if (serviceNameController.text != currentSub.serviceName) { if (serviceNameController.text != currentSub.serviceName) {
serviceNameController.clear(); serviceNameController.clear();
} }

View File

@@ -47,7 +47,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_controller.initializeWebsiteUrl(); _controller.initializeWebsiteUrl(context);
} }
Widget _buildContent() { Widget _buildContent() {
@@ -161,9 +161,12 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( // 로딩 중일 때는 화면 정중앙에 표시
children: [ if (_controller.isLoading) {
SingleChildScrollView( return const ScanLoadingWidget();
}
return SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: Padding( child: Padding(
@@ -178,66 +181,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
], ],
), ),
), ),
),
if (_controller.isAdInProgress)
Positioned.fill(
child: IgnorePointer(
child: Stack(
children: [
Container(
color: Theme.of(context)
.colorScheme
.surface
.withValues(alpha: 0.4),
),
Center(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).scanningMessages,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
color:
Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
],
),
),
),
],
); );
} }
} }

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

@@ -518,7 +518,8 @@ String _isoExtractServiceName(String body, String sender) {
String _isoExtractServiceNameFromSender(String sender) { String _isoExtractServiceNameFromSender(String sender) {
if (RegExp(r'^\d+$').hasMatch(sender)) { if (RegExp(r'^\d+$').hasMatch(sender)) {
return _unknownServiceLabel(); // Isolate에서 실행되므로 하드코딩 사용 (Flutter 바인딩 접근 불가)
return 'Unknown service';
} }
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
} }

View File

@@ -7,12 +7,11 @@ class ScanLoadingWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox.expand( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
CircularProgressIndicator( CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,

View File

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