From 83c43fb61fd1e7de462d7be4bad9801fe3118c79 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 8 Dec 2025 18:14:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SMS=20=EC=8A=A4=EC=BA=94=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=EA=B4=91=EA=B3=A0=20=EB=B0=8F=20Isolate=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 전면 광고 (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 --- .claude/skills/admob/SKILL.md | 89 +++++ .claude/skills/flutter-build/SKILL.md | 39 ++ .claude/skills/hive-model/SKILL.md | 55 +++ .claude/skills/release-deploy/SKILL.md | 64 +++ .claude/skills/sms-scanner/SKILL.md | 60 +++ CLAUDE.md | 377 ++++-------------- lib/controllers/sms_scan_controller.dart | 89 ++--- lib/screens/sms_scan_screen.dart | 95 +---- lib/services/ad_service.dart | 195 +++++++++ lib/services/sms_scanner.dart | 3 +- lib/widgets/sms_scan/scan_loading_widget.dart | 5 +- pubspec.yaml | 2 +- 12 files changed, 639 insertions(+), 434 deletions(-) create mode 100644 .claude/skills/admob/SKILL.md create mode 100644 .claude/skills/flutter-build/SKILL.md create mode 100644 .claude/skills/hive-model/SKILL.md create mode 100644 .claude/skills/release-deploy/SKILL.md create mode 100644 .claude/skills/sms-scanner/SKILL.md create mode 100644 lib/services/ad_service.dart diff --git a/.claude/skills/admob/SKILL.md b/.claude/skills/admob/SKILL.md new file mode 100644 index 0000000..d284317 --- /dev/null +++ b/.claude/skills/admob/SKILL.md @@ -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 showInterstitialAd(BuildContext context) async { + final completer = Completer(); + + 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() +``` diff --git a/.claude/skills/flutter-build/SKILL.md b/.claude/skills/flutter-build/SKILL.md new file mode 100644 index 0000000..d67adce --- /dev/null +++ b/.claude/skills/flutter-build/SKILL.md @@ -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` | diff --git a/.claude/skills/hive-model/SKILL.md b/.claude/skills/hive-model/SKILL.md new file mode 100644 index 0000000..7f6f8cd --- /dev/null +++ b/.claude/skills/hive-model/SKILL.md @@ -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('newModelBox'); +``` diff --git a/.claude/skills/release-deploy/SKILL.md b/.claude/skills/release-deploy/SKILL.md new file mode 100644 index 0000000..3daefc6 --- /dev/null +++ b/.claude/skills/release-deploy/SKILL.md @@ -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 생성 완료 diff --git a/.claude/skills/sms-scanner/SKILL.md b/.claude/skills/sms-scanner/SKILL.md new file mode 100644 index 0000000..2927a07 --- /dev/null +++ b/.claude/skills/sms-scanner/SKILL.md @@ -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 데이터 정의됨. diff --git a/CLAUDE.md b/CLAUDE.md index 775a272..42e28dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,314 +1,111 @@ -# Claude 프로젝트 컨텍스트 +# CLAUDE.md -## 언어 설정 -- 모든 답변은 한국어로 제공 -- 기술 용어는 영어와 한국어 병기 가능 +프로젝트별 가이드. 일반 규칙은 `~/.claude/CLAUDE.md` 참조. -## 프로젝트 정보 -- Flutter 기반 구독 관리 앱 (SubManager) +## Project Overview -## 현재 작업 -- 구독카드가 클릭이 되지 않아서 문제를 찾는 중. +**SubManager** - 구독 관리 앱 (Flutter 3.x) -## 🎯 Mandatory Response Format +| 항목 | 기술 | +|------|------| +| DB | Hive (로컬 전용) | +| 상태관리 | Provider + ChangeNotifier | +| 디자인 | Material 3 | +| 광고 | google_mobile_ads | -Before starting any task, you MUST respond in the following format: +**버전**: 1.0.1+3 -``` -[Model Name]. I have reviewed all the following rules: [rule file list or categories]. Proceeding with the task. Master! +## Quick Commands + +```bash +# Hive 모델 생성 +dart run build_runner build --delete-conflicting-outputs + +# 빌드 +flutter build apk --release # APK +flutter build appbundle --release # AAB (Play Store) + +# 버전업 후 디바이스 설치 +flutter install --release ``` -**Examples:** +## Architecture -- `Claude Sonnet 4. I have reviewed all the following rules: development guidelines, class structure, testing rules. Proceeding with the task. Master!` -- For extensive rules: `coding style, class design, exception handling, testing rules` (categorized summary) - -## 🚀 Mandatory 3-Phase Task Process - -### Phase 1: Codebase Exploration & Analysis - -**Required Actions:** - -- Systematically discover ALL relevant files, directories, modules -- Search for related keywords, functions, classes, patterns -- Thoroughly examine each identified file -- Document coding conventions and style guidelines -- Identify framework/library usage patterns - -### Phase 2: Implementation Planning - -**Required Actions:** - -- Create detailed implementation roadmap based on Phase 1 findings -- Define specific task lists and acceptance criteria per module -- Specify performance/quality requirements - -### Phase 3: Implementation Execution - -**Required Actions:** - -- Implement each module following Phase 2 plan -- Verify ALL acceptance criteria before proceeding -- Ensure adherence to conventions identified in Phase 1 - -## ✅ Core Development Principles - -### Language & Type Rules - -- **Write ALL code, variables, and names in English** -- **Write ALL comments, documentation, prompts, and responses in Korean** -- **Always declare types explicitly** for variables, parameters, and return values -- Avoid `any`, `dynamic`, or loosely typed declarations (except when strictly necessary) -- Define **custom types** when needed -- Extract magic numbers and literals into named constants or enums - -### Naming Conventions - -|Element|Style|Example| -|---|---|---| -|Classes|`PascalCase`|`UserService`, `DataRepository`| -|Variables/Methods|`camelCase`|`userName`, `calculateTotal`| -|Files/Folders|`under_score_case`|`user_service.dart`, `data_models/`| -|Environment Variables|`UPPERCASE`|`API_URL`, `DATABASE_PASSWORD`| - -**Critical Rules:** - -- **Boolean variables must be verb-based**: `isReady`, `hasError`, `canDelete` -- **Function/method names start with verbs**: `executeLogin`, `saveUser` -- Use meaningful, descriptive names -- Avoid abbreviations unless widely accepted: `i`, `j`, `err`, `ctx`, `API`, `URL` - -## 🔧 Function & Method Design - -### Function Structure Principles - -- **Keep functions short and focused** (≤20 lines recommended) -- **Avoid blank lines inside functions** -- **Follow Single Responsibility Principle** -- **Use verb + object format** for naming: - - Boolean return: `isValid`, `canRetry`, `hasPermission` - - Void return: `executeLogin`, `saveUser`, `startAnimation` - -### Function Optimization Techniques - -- Use **early returns** to avoid nested logic -- Extract logic into helper functions -- Prefer **arrow functions** for short expressions (≤3 lines) -- Use **named functions** for complex logic -- Minimize null checks by using **default values** -- Minimize parameters using **RO-RO pattern** (Receive Object – Return Object) - -## 📦 Data & Class Design - -### Class Design Principles - -- **Strictly follow Single Responsibility Principle (SRP)** -- **Favor composition over inheritance** -- **Define interfaces/abstract classes** to establish contracts -- **Prefer immutable data structures** (use `readonly`, `const`) - -### File Size Management - -- **Split by responsibility when exceeding 200 lines** (responsibility-based, not line-based) -- ✅ **May remain as-is if**: - - Has **single clear responsibility** - - Is **easy to maintain** -- 🔁 **Must split when**: - - Contains **multiple concerns** - - Requires **excessive scrolling** - - Patterns repeat across files - - Difficult for new developer onboarding - -### Class Recommendations - -- ≤ 200 lines (not mandatory) -- ≤ 10 public methods -- ≤ 10 properties - -### Data Model Design - -- Avoid excessive use of primitives — use **composite types or classes** -- Move **validation logic inside data models** (not in business logic) - -## ❗ Exception Handling - -### Exception Usage Principles - -- Use exceptions only for **truly unexpected or critical issues** -- **Catch exceptions only to**: - - Handle known failure scenarios - - Add useful context -- Otherwise, let global handlers manage them - -## 🧪 Testing - -### Test Structure - -- Follow **Arrange–Act–Assert** pattern -- Clear test variable naming: `inputX`, `mockX`, `actualX`, `expectedX` -- **Write unit tests for every public method** - -### Test Doubles Usage - -- Use **test doubles** (mock/fake/stub) for dependencies -- Exception: allow real use of **lightweight third-party libraries** - -### Integration Testing - -- Write **integration tests per module** -- Follow **Given–When–Then** structure -- Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures - -## 📝 Git Commit Guidelines - -### Commit Message Format - -- **Use clear, descriptive commit messages in Korean** -- **Follow conventional commit format**: `type: description` -- **Keep commit messages concise and focused** -- **DO NOT include Claude Code attribution or co-author tags** - -### Commit Message Structure - -``` -type: brief description in Korean - -Optional detailed explanation if needed +```text +lib/ +├── controllers/ # 비즈니스 로직 (3개) +│ ├── add_subscription_controller.dart +│ ├── detail_screen_controller.dart +│ └── sms_scan_controller.dart +├── models/ # Hive 모델 (@HiveType) +│ ├── subscription_model.dart (typeId: 0) +│ ├── category_model.dart (typeId: 1) +│ └── payment_card_model.dart (typeId: 2) +├── providers/ # ChangeNotifier 상태관리 +├── screens/ # 화면 위젯 +├── services/ # 외부 서비스 연동 +├── widgets/ # 재사용 컴포넌트 +├── utils/ # 유틸리티 헬퍼 +├── routes/ # 라우팅 정의 +├── theme/ # 테마/색상 +└── l10n/ # 다국어 (ko/en/ja/zh) ``` -### Commit Types +## Key Services -- `feat`: 새로운 기능 추가 -- `fix`: 버그 수정 -- `refactor`: 코드 리팩토링 -- `style`: 코드 스타일 변경 (formatting, missing semi-colons, etc) -- `docs`: 문서 변경 -- `test`: 테스트 코드 추가 또는 수정 -- `chore`: 빌드 프로세스 또는 보조 도구 변경 +| Service | 역할 | +|---------|------| +| `AdService` | 전면 광고 (Completer 패턴) | +| `SmsScanner` | SMS 파싱 → 구독 자동 감지 (Isolate 사용) | +| `NotificationService` | 로컬 알림 | +| `ExchangeRateService` | 환율 조회 | +| `url_matcher/` | 서비스명 → URL 매칭 | -### Examples +## Routes -✅ **Good Examples:** -- `feat: 월별 차트 다국어 지원 추가` -- `fix: 분석화면 총지출 금액 불일치 문제 해결` -- `refactor: 통화 변환 로직 모듈화` +| Path | Screen | +|------|--------| +| `/` | MainScreen | +| `/add-subscription` | AddSubscriptionScreen | +| `/subscription-detail` | DetailScreen (requires SubscriptionModel) | +| `/sms-scanner` | SmsScanScreen | +| `/analysis` | AnalysisScreen | +| `/settings` | SettingsScreen | +| `/payment-card-management` | PaymentCardManagementScreen | -❌ **Avoid These:** -- Including "🤖 Generated with [Claude Code](https://claude.ai/code)" -- Including "Co-Authored-By: Claude " -- 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(); +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 \ No newline at end of file +```text +[모델명]. I have reviewed all the following rules: [규칙]. Proceeding with the task. Master! +``` diff --git a/lib/controllers/sms_scan_controller.dart b/lib/controllers/sms_scan_controller.dart index 4ba0b07..7fb8587 100644 --- a/lib/controllers/sms_scan_controller.dart +++ b/lib/controllers/sms_scan_controller.dart @@ -1,21 +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 'package:google_mobile_ads/google_mobile_ads.dart'; -import 'dart:io' show Platform; +import '../l10n/app_localizations.dart'; +import '../utils/logger.dart'; class SmsScanController extends ChangeNotifier { // 상태 관리 @@ -47,10 +48,9 @@ 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; - bool _isAdInProgress = false; - bool get isAdInProgress => _isAdInProgress; @override void dispose() { @@ -87,69 +87,26 @@ class SmsScanController extends ChangeNotifier { notifyListeners(); } + /// SMS 스캔 시작 (전면 광고 표시 후 스캔 진행) Future startScan(BuildContext context) async { if (_isLoading) return; - _isAdInProgress = true; - notifyListeners(); + // 웹/비지원 플랫폼은 바로 스캔 if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) { - _isAdInProgress = false; - notifyListeners(); await scanSms(context); return; } - // 전면 광고 로드 및 노출 후 스캔 진행 - try { - await InterstitialAd.load( - 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); - } - } + // 광고 표시 (완료까지 대기) + // 광고 실패해도 스캔 진행 (사용자 경험 우선) + await _adService.showInterstitialAd(context); - String _interstitialAdUnitId() { - if (Platform.isAndroid || Platform.isIOS) { - return 'ca-app-pub-6691216385521068~6638409932'; - } - return ''; - } - - Future _startSmsScanIfMounted(BuildContext context) async { if (!context.mounted) return; - _isAdInProgress = false; - notifyListeners(); + + // 광고 완료 후 SMS 스캔 실행 await scanSms(context); } - Future _fallbackAfterDelay(BuildContext context) async { - await Future.delayed(const Duration(seconds: 5)); - if (!context.mounted) return; - await _startSmsScanIfMounted(context); - } - Future scanSms(BuildContext context) async { _isLoading = true; _errorMessage = null; @@ -157,6 +114,11 @@ class SmsScanController extends ChangeNotifier { _currentIndex = 0; notifyListeners(); + await _performSmsScan(context); + } + + /// 실제 SMS 스캔 수행 (로딩 상태는 이미 설정되어 있음) + Future _performSmsScan(BuildContext context) async { try { // Android에서 SMS 권한 확인 및 요청 final ctx = context; @@ -399,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(); } diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index af8873f..c05b70d 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -47,7 +47,7 @@ class _SmsScanScreenState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - _controller.initializeWebsiteUrl(); + _controller.initializeWebsiteUrl(context); } Widget _buildContent() { @@ -161,83 +161,26 @@ class _SmsScanScreenState extends State { @override Widget build(BuildContext context) { - return Stack( - children: [ - SingleChildScrollView( - controller: _scrollController, - padding: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.only(top: UIConstants.pageTopPadding), - child: Column( - children: [ - _buildContent(), - // FloatingNavigationBar를 위한 충분한 하단 여백 - SizedBox( - height: 120 + MediaQuery.of(context).padding.bottom, - ), - ], + // 로딩 중일 때는 화면 정중앙에 표시 + if (_controller.isLoading) { + return const ScanLoadingWidget(); + } + + return SingleChildScrollView( + controller: _scrollController, + padding: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.only(top: UIConstants.pageTopPadding), + child: Column( + children: [ + _buildContent(), + // FloatingNavigationBar를 위한 충분한 하단 여백 + SizedBox( + height: 120 + MediaQuery.of(context).padding.bottom, ), - ), + ], ), - 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( - 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, - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - ], + ), ); } } diff --git a/lib/services/ad_service.dart b/lib/services/ad_service.dart new file mode 100644 index 0000000..9a7e9c1 --- /dev/null +++ b/lib/services/ad_service.dart @@ -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? _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 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(); + + 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( + 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 _enterImmersiveMode() async { + try { + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.immersiveSticky, + overlays: [], + ); + } catch (_) {} + } + + /// UI 복구 + Future _restoreSystemUi() async { + try { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } catch (_) {} + } + + /// 광고 로드 보장 (이미 로드된 경우 즉시 반환) + Future _ensureAdLoaded() async { + if (_interstitialAd != null) return true; + + if (_loadingCompleter != null) { + return _loadingCompleter!.future; + } + + final completer = Completer(); + _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(); + } +} diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index c2353a4..ae2c2a3 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -518,7 +518,8 @@ String _isoExtractServiceName(String body, String sender) { String _isoExtractServiceNameFromSender(String sender) { if (RegExp(r'^\d+$').hasMatch(sender)) { - return _unknownServiceLabel(); + // Isolate에서 실행되므로 하드코딩 사용 (Flutter 바인딩 접근 불가) + return 'Unknown service'; } return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); } diff --git a/lib/widgets/sms_scan/scan_loading_widget.dart b/lib/widgets/sms_scan/scan_loading_widget.dart index 5b71705..fffddab 100644 --- a/lib/widgets/sms_scan/scan_loading_widget.dart +++ b/lib/widgets/sms_scan/scan_loading_widget.dart @@ -7,12 +7,11 @@ class ScanLoadingWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox.expand( + return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator( color: Theme.of(context).colorScheme.primary, diff --git a/pubspec.yaml b/pubspec.yaml index 0b2711c..f007c53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: submanager description: A new Flutter project. publish_to: 'none' -version: 1.0.1+2 +version: 1.0.1+3 environment: sdk: '>=3.0.0 <4.0.0'