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:
89
.claude/skills/admob/SKILL.md
Normal file
89
.claude/skills/admob/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: admob
|
||||
description: AdMob 전면 광고 구현 및 디버깅. 광고 표시, 로드 실패, foreground 이슈 시 사용.
|
||||
allowed-tools: Read, Edit, Grep
|
||||
---
|
||||
|
||||
# AdMob Integration
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `lib/services/ad_service.dart` | 전면 광고 서비스 |
|
||||
|
||||
## 광고 ID
|
||||
|
||||
| 플랫폼 | ID | 비고 |
|
||||
|--------|-----|------|
|
||||
| Android | `ca-app-pub-6691216385521068/5281562472` | 프로덕션 |
|
||||
| iOS | `ca-app-pub-3940256099942544/1033173712` | 테스트 |
|
||||
|
||||
## Completer 패턴 (필수)
|
||||
|
||||
광고 완료를 기다리려면 Completer 사용:
|
||||
|
||||
```dart
|
||||
Future<bool> showInterstitialAd(BuildContext context) async {
|
||||
final completer = Completer<bool>();
|
||||
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
ad.dispose();
|
||||
completer.complete(true);
|
||||
},
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
ad.dispose();
|
||||
completer.complete(false);
|
||||
},
|
||||
);
|
||||
|
||||
ad.show();
|
||||
return completer.future;
|
||||
}
|
||||
```
|
||||
|
||||
## "App not in foreground" 해결
|
||||
|
||||
광고 로드 중 앱이 백그라운드로 가면 오류 발생.
|
||||
|
||||
**해결책: 로딩 오버레이**
|
||||
|
||||
```dart
|
||||
// 광고 로드 전 다이얼로그 표시 → 앱이 foreground 유지
|
||||
final closeLoading = _showLoadingOverlay(context);
|
||||
await _enterImmersiveMode();
|
||||
final loaded = await _ensureAdLoaded();
|
||||
closeLoading();
|
||||
```
|
||||
|
||||
## 몰입형 모드
|
||||
|
||||
```dart
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
overlays: [],
|
||||
);
|
||||
```
|
||||
|
||||
## 흐름도
|
||||
|
||||
```
|
||||
startScan()
|
||||
↓
|
||||
showLoadingOverlay() ← 앱 foreground 유지
|
||||
↓
|
||||
enterImmersiveMode()
|
||||
↓
|
||||
loadAd()
|
||||
↓
|
||||
closeOverlay()
|
||||
↓
|
||||
ad.show()
|
||||
↓
|
||||
await Completer.future ← 광고 완료 대기
|
||||
↓
|
||||
restoreSystemUI()
|
||||
↓
|
||||
continueWithScan()
|
||||
```
|
||||
39
.claude/skills/flutter-build/SKILL.md
Normal file
39
.claude/skills/flutter-build/SKILL.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: flutter-build
|
||||
description: Flutter 빌드 및 분석 수행. 앱 빌드, 릴리즈 APK/AAB 생성, 코드 분석 시 사용.
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
# Flutter Build
|
||||
|
||||
## 빌드 명령어
|
||||
|
||||
```bash
|
||||
# 코드 분석
|
||||
flutter analyze
|
||||
|
||||
# 디버그 빌드
|
||||
flutter run
|
||||
|
||||
# 릴리즈 APK (디바이스 테스트용)
|
||||
flutter build apk --release
|
||||
|
||||
# 릴리즈 AAB (Play Store용)
|
||||
flutter build appbundle --release
|
||||
|
||||
# 디바이스 설치
|
||||
flutter install --release
|
||||
```
|
||||
|
||||
## 빌드 전 체크리스트
|
||||
|
||||
1. `flutter analyze` 통과 확인
|
||||
2. pubspec.yaml 버전 확인
|
||||
3. 필요시 `flutter clean` 실행
|
||||
|
||||
## 출력 위치
|
||||
|
||||
| 타입 | 경로 |
|
||||
|------|------|
|
||||
| APK | `build/app/outputs/flutter-apk/app-release.apk` |
|
||||
| AAB | `build/app/outputs/bundle/release/app-release.aab` |
|
||||
55
.claude/skills/hive-model/SKILL.md
Normal file
55
.claude/skills/hive-model/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: hive-model
|
||||
description: Hive 데이터 모델 생성 및 수정. 새 모델 추가, 필드 변경, typeId 관리 시 사용.
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob
|
||||
---
|
||||
|
||||
# Hive Model Management
|
||||
|
||||
## 현재 모델 (typeId)
|
||||
|
||||
| Model | typeId | 파일 |
|
||||
|-------|--------|------|
|
||||
| SubscriptionModel | 0 | `lib/models/subscription_model.dart` |
|
||||
| CategoryModel | 1 | `lib/models/category_model.dart` |
|
||||
| PaymentCardModel | 2 | `lib/models/payment_card_model.dart` |
|
||||
|
||||
## 새 모델 생성 시
|
||||
|
||||
```dart
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'new_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 3) // 다음 사용 가능한 typeId
|
||||
class NewModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String name;
|
||||
|
||||
NewModel({required this.id, required this.name});
|
||||
}
|
||||
```
|
||||
|
||||
## 코드 생성
|
||||
|
||||
모델 변경 후 반드시 실행:
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **typeId 충돌 금지**: 기존 typeId 재사용 불가
|
||||
2. **HiveField 순서**: 기존 필드 인덱스 변경 금지 (데이터 손실)
|
||||
3. **마이그레이션**: 필드 추가는 안전, 삭제/변경은 마이그레이션 필요
|
||||
|
||||
## main.dart 등록
|
||||
|
||||
```dart
|
||||
Hive.registerAdapter(NewModelAdapter());
|
||||
await Hive.openBox<NewModel>('newModelBox');
|
||||
```
|
||||
64
.claude/skills/release-deploy/SKILL.md
Normal file
64
.claude/skills/release-deploy/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: release-deploy
|
||||
description: 앱 릴리즈 및 배포 프로세스. 버전업, 빌드, 디바이스 설치, Play Store AAB 생성 시 사용.
|
||||
allowed-tools: Bash, Read, Edit
|
||||
---
|
||||
|
||||
# Release & Deploy
|
||||
|
||||
## 버전 관리
|
||||
|
||||
pubspec.yaml 버전 형식: `major.minor.patch+buildNumber`
|
||||
|
||||
```yaml
|
||||
version: 1.0.1+3
|
||||
# │ │ │ └─ 빌드번호 (내부 버전, 매 빌드마다 증가)
|
||||
# │ │ └─── 패치 (버그 수정)
|
||||
# │ └───── 마이너 (기능 추가)
|
||||
# └─────── 메이저 (대규모 변경)
|
||||
```
|
||||
|
||||
## 릴리즈 프로세스
|
||||
|
||||
### 1. 버전 업데이트
|
||||
|
||||
```bash
|
||||
# pubspec.yaml에서 버전 수정
|
||||
# 예: 1.0.1+3 → 1.0.1+4
|
||||
```
|
||||
|
||||
### 2. 릴리즈 빌드
|
||||
|
||||
```bash
|
||||
# APK (테스트용)
|
||||
flutter build apk --release
|
||||
|
||||
# AAB (Play Store용)
|
||||
flutter build appbundle --release
|
||||
```
|
||||
|
||||
### 3. 디바이스 설치
|
||||
|
||||
```bash
|
||||
flutter install --release
|
||||
```
|
||||
|
||||
## 전체 자동화
|
||||
|
||||
```bash
|
||||
# 버전업 + APK 빌드 + 설치 + AAB 빌드
|
||||
flutter build apk --release && flutter install --release && flutter build appbundle --release
|
||||
```
|
||||
|
||||
## Play Store 업로드
|
||||
|
||||
1. AAB 파일: `build/app/outputs/bundle/release/app-release.aab`
|
||||
2. Google Play Console에서 업로드
|
||||
3. 릴리즈 노트 작성 (한국어/영어/일본어/중국어)
|
||||
|
||||
## 체크리스트
|
||||
|
||||
- [ ] 버전 번호 증가
|
||||
- [ ] `flutter analyze` 통과
|
||||
- [ ] 테스트 디바이스에서 확인
|
||||
- [ ] AAB 생성 완료
|
||||
60
.claude/skills/sms-scanner/SKILL.md
Normal file
60
.claude/skills/sms-scanner/SKILL.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: sms-scanner
|
||||
description: SMS 스캔 기능 개발 및 디버깅. SMS 파싱, 구독 감지, Isolate 관련 이슈 시 사용.
|
||||
allowed-tools: Read, Edit, Grep, Glob
|
||||
---
|
||||
|
||||
# SMS Scanner
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `lib/services/sms_scanner.dart` | SMS 파싱 로직 (Isolate) |
|
||||
| `lib/controllers/sms_scan_controller.dart` | 스캔 플로우 제어 |
|
||||
| `lib/screens/sms_scan_screen.dart` | UI |
|
||||
| `lib/services/sms_scan/` | 보조 클래스 |
|
||||
|
||||
## Isolate 주의사항
|
||||
|
||||
`compute()` 내부에서 실행되는 함수는 별도 Isolate에서 실행됨.
|
||||
|
||||
**접근 불가 항목:**
|
||||
|
||||
- Flutter 바인딩 (`WidgetsBinding`)
|
||||
- `BuildContext`
|
||||
- `Provider`
|
||||
- `navigatorKey`
|
||||
- `AppLocalizations`
|
||||
|
||||
**올바른 패턴:**
|
||||
|
||||
```dart
|
||||
// Isolate 내부 함수 (접두사: _iso)
|
||||
String _isoExtractServiceName(String sender) {
|
||||
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
||||
return 'Unknown service'; // 하드코딩 사용
|
||||
}
|
||||
return sender;
|
||||
}
|
||||
```
|
||||
|
||||
**잘못된 패턴:**
|
||||
|
||||
```dart
|
||||
String _isoExtractServiceName(String sender) {
|
||||
// 오류: Isolate에서 Context 접근 불가
|
||||
return AppLocalizations.of(context).unknownService;
|
||||
}
|
||||
```
|
||||
|
||||
## 디버깅
|
||||
|
||||
```bash
|
||||
# SMS 스캔 로그 확인
|
||||
flutter logs | grep -i "sms\|scan\|isolate"
|
||||
```
|
||||
|
||||
## 테스트 데이터
|
||||
|
||||
`lib/temp/test_sms_data.dart`에 테스트용 SMS 데이터 정의됨.
|
||||
377
CLAUDE.md
377
CLAUDE.md
@@ -1,314 +1,111 @@
|
||||
# Claude 프로젝트 컨텍스트
|
||||
# CLAUDE.md
|
||||
|
||||
## 언어 설정
|
||||
- 모든 답변은 한국어로 제공
|
||||
- 기술 용어는 영어와 한국어 병기 가능
|
||||
프로젝트별 가이드. 일반 규칙은 `~/.claude/CLAUDE.md` 참조.
|
||||
|
||||
## 프로젝트 정보
|
||||
- Flutter 기반 구독 관리 앱 (SubManager)
|
||||
## Project Overview
|
||||
|
||||
## 현재 작업
|
||||
- 구독카드가 클릭이 되지 않아서 문제를 찾는 중.
|
||||
**SubManager** - 구독 관리 앱 (Flutter 3.x)
|
||||
|
||||
## 🎯 Mandatory Response Format
|
||||
| 항목 | 기술 |
|
||||
|------|------|
|
||||
| DB | Hive (로컬 전용) |
|
||||
| 상태관리 | Provider + ChangeNotifier |
|
||||
| 디자인 | Material 3 |
|
||||
| 광고 | google_mobile_ads |
|
||||
|
||||
Before starting any task, you MUST respond in the following format:
|
||||
**버전**: 1.0.1+3
|
||||
|
||||
```
|
||||
[Model Name]. I have reviewed all the following rules: [rule file list or categories]. Proceeding with the task. Master!
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Hive 모델 생성
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
# 빌드
|
||||
flutter build apk --release # APK
|
||||
flutter build appbundle --release # AAB (Play Store)
|
||||
|
||||
# 버전업 후 디바이스 설치
|
||||
flutter install --release
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
## Architecture
|
||||
|
||||
- `Claude Sonnet 4. I have reviewed all the following rules: development guidelines, class structure, testing rules. Proceeding with the task. Master!`
|
||||
- For extensive rules: `coding style, class design, exception handling, testing rules` (categorized summary)
|
||||
|
||||
## 🚀 Mandatory 3-Phase Task Process
|
||||
|
||||
### Phase 1: Codebase Exploration & Analysis
|
||||
|
||||
**Required Actions:**
|
||||
|
||||
- Systematically discover ALL relevant files, directories, modules
|
||||
- Search for related keywords, functions, classes, patterns
|
||||
- Thoroughly examine each identified file
|
||||
- Document coding conventions and style guidelines
|
||||
- Identify framework/library usage patterns
|
||||
|
||||
### Phase 2: Implementation Planning
|
||||
|
||||
**Required Actions:**
|
||||
|
||||
- Create detailed implementation roadmap based on Phase 1 findings
|
||||
- Define specific task lists and acceptance criteria per module
|
||||
- Specify performance/quality requirements
|
||||
|
||||
### Phase 3: Implementation Execution
|
||||
|
||||
**Required Actions:**
|
||||
|
||||
- Implement each module following Phase 2 plan
|
||||
- Verify ALL acceptance criteria before proceeding
|
||||
- Ensure adherence to conventions identified in Phase 1
|
||||
|
||||
## ✅ Core Development Principles
|
||||
|
||||
### Language & Type Rules
|
||||
|
||||
- **Write ALL code, variables, and names in English**
|
||||
- **Write ALL comments, documentation, prompts, and responses in Korean**
|
||||
- **Always declare types explicitly** for variables, parameters, and return values
|
||||
- Avoid `any`, `dynamic`, or loosely typed declarations (except when strictly necessary)
|
||||
- Define **custom types** when needed
|
||||
- Extract magic numbers and literals into named constants or enums
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
|Element|Style|Example|
|
||||
|---|---|---|
|
||||
|Classes|`PascalCase`|`UserService`, `DataRepository`|
|
||||
|Variables/Methods|`camelCase`|`userName`, `calculateTotal`|
|
||||
|Files/Folders|`under_score_case`|`user_service.dart`, `data_models/`|
|
||||
|Environment Variables|`UPPERCASE`|`API_URL`, `DATABASE_PASSWORD`|
|
||||
|
||||
**Critical Rules:**
|
||||
|
||||
- **Boolean variables must be verb-based**: `isReady`, `hasError`, `canDelete`
|
||||
- **Function/method names start with verbs**: `executeLogin`, `saveUser`
|
||||
- Use meaningful, descriptive names
|
||||
- Avoid abbreviations unless widely accepted: `i`, `j`, `err`, `ctx`, `API`, `URL`
|
||||
|
||||
## 🔧 Function & Method Design
|
||||
|
||||
### Function Structure Principles
|
||||
|
||||
- **Keep functions short and focused** (≤20 lines recommended)
|
||||
- **Avoid blank lines inside functions**
|
||||
- **Follow Single Responsibility Principle**
|
||||
- **Use verb + object format** for naming:
|
||||
- Boolean return: `isValid`, `canRetry`, `hasPermission`
|
||||
- Void return: `executeLogin`, `saveUser`, `startAnimation`
|
||||
|
||||
### Function Optimization Techniques
|
||||
|
||||
- Use **early returns** to avoid nested logic
|
||||
- Extract logic into helper functions
|
||||
- Prefer **arrow functions** for short expressions (≤3 lines)
|
||||
- Use **named functions** for complex logic
|
||||
- Minimize null checks by using **default values**
|
||||
- Minimize parameters using **RO-RO pattern** (Receive Object – Return Object)
|
||||
|
||||
## 📦 Data & Class Design
|
||||
|
||||
### Class Design Principles
|
||||
|
||||
- **Strictly follow Single Responsibility Principle (SRP)**
|
||||
- **Favor composition over inheritance**
|
||||
- **Define interfaces/abstract classes** to establish contracts
|
||||
- **Prefer immutable data structures** (use `readonly`, `const`)
|
||||
|
||||
### File Size Management
|
||||
|
||||
- **Split by responsibility when exceeding 200 lines** (responsibility-based, not line-based)
|
||||
- ✅ **May remain as-is if**:
|
||||
- Has **single clear responsibility**
|
||||
- Is **easy to maintain**
|
||||
- 🔁 **Must split when**:
|
||||
- Contains **multiple concerns**
|
||||
- Requires **excessive scrolling**
|
||||
- Patterns repeat across files
|
||||
- Difficult for new developer onboarding
|
||||
|
||||
### Class Recommendations
|
||||
|
||||
- ≤ 200 lines (not mandatory)
|
||||
- ≤ 10 public methods
|
||||
- ≤ 10 properties
|
||||
|
||||
### Data Model Design
|
||||
|
||||
- Avoid excessive use of primitives — use **composite types or classes**
|
||||
- Move **validation logic inside data models** (not in business logic)
|
||||
|
||||
## ❗ Exception Handling
|
||||
|
||||
### Exception Usage Principles
|
||||
|
||||
- Use exceptions only for **truly unexpected or critical issues**
|
||||
- **Catch exceptions only to**:
|
||||
- Handle known failure scenarios
|
||||
- Add useful context
|
||||
- Otherwise, let global handlers manage them
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Structure
|
||||
|
||||
- Follow **Arrange–Act–Assert** pattern
|
||||
- Clear test variable naming: `inputX`, `mockX`, `actualX`, `expectedX`
|
||||
- **Write unit tests for every public method**
|
||||
|
||||
### Test Doubles Usage
|
||||
|
||||
- Use **test doubles** (mock/fake/stub) for dependencies
|
||||
- Exception: allow real use of **lightweight third-party libraries**
|
||||
|
||||
### Integration Testing
|
||||
|
||||
- Write **integration tests per module**
|
||||
- Follow **Given–When–Then** structure
|
||||
- Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures
|
||||
|
||||
## 📝 Git Commit Guidelines
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
- **Use clear, descriptive commit messages in Korean**
|
||||
- **Follow conventional commit format**: `type: description`
|
||||
- **Keep commit messages concise and focused**
|
||||
- **DO NOT include Claude Code attribution or co-author tags**
|
||||
|
||||
### Commit Message Structure
|
||||
|
||||
```
|
||||
type: brief description in Korean
|
||||
|
||||
Optional detailed explanation if needed
|
||||
```text
|
||||
lib/
|
||||
├── controllers/ # 비즈니스 로직 (3개)
|
||||
│ ├── add_subscription_controller.dart
|
||||
│ ├── detail_screen_controller.dart
|
||||
│ └── sms_scan_controller.dart
|
||||
├── models/ # Hive 모델 (@HiveType)
|
||||
│ ├── subscription_model.dart (typeId: 0)
|
||||
│ ├── category_model.dart (typeId: 1)
|
||||
│ └── payment_card_model.dart (typeId: 2)
|
||||
├── providers/ # ChangeNotifier 상태관리
|
||||
├── screens/ # 화면 위젯
|
||||
├── services/ # 외부 서비스 연동
|
||||
├── widgets/ # 재사용 컴포넌트
|
||||
├── utils/ # 유틸리티 헬퍼
|
||||
├── routes/ # 라우팅 정의
|
||||
├── theme/ # 테마/색상
|
||||
└── l10n/ # 다국어 (ko/en/ja/zh)
|
||||
```
|
||||
|
||||
### Commit Types
|
||||
## Key Services
|
||||
|
||||
- `feat`: 새로운 기능 추가
|
||||
- `fix`: 버그 수정
|
||||
- `refactor`: 코드 리팩토링
|
||||
- `style`: 코드 스타일 변경 (formatting, missing semi-colons, etc)
|
||||
- `docs`: 문서 변경
|
||||
- `test`: 테스트 코드 추가 또는 수정
|
||||
- `chore`: 빌드 프로세스 또는 보조 도구 변경
|
||||
| Service | 역할 |
|
||||
|---------|------|
|
||||
| `AdService` | 전면 광고 (Completer 패턴) |
|
||||
| `SmsScanner` | SMS 파싱 → 구독 자동 감지 (Isolate 사용) |
|
||||
| `NotificationService` | 로컬 알림 |
|
||||
| `ExchangeRateService` | 환율 조회 |
|
||||
| `url_matcher/` | 서비스명 → URL 매칭 |
|
||||
|
||||
### Examples
|
||||
## Routes
|
||||
|
||||
✅ **Good Examples:**
|
||||
- `feat: 월별 차트 다국어 지원 추가`
|
||||
- `fix: 분석화면 총지출 금액 불일치 문제 해결`
|
||||
- `refactor: 통화 변환 로직 모듈화`
|
||||
| Path | Screen |
|
||||
|------|--------|
|
||||
| `/` | MainScreen |
|
||||
| `/add-subscription` | AddSubscriptionScreen |
|
||||
| `/subscription-detail` | DetailScreen (requires SubscriptionModel) |
|
||||
| `/sms-scanner` | SmsScanScreen |
|
||||
| `/analysis` | AnalysisScreen |
|
||||
| `/settings` | SettingsScreen |
|
||||
| `/payment-card-management` | PaymentCardManagementScreen |
|
||||
|
||||
❌ **Avoid These:**
|
||||
- Including "🤖 Generated with [Claude Code](https://claude.ai/code)"
|
||||
- Including "Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
- Vague messages like "update code" or "fix stuff"
|
||||
- English commit messages (use Korean)
|
||||
## Project Rules
|
||||
|
||||
### Critical Rules
|
||||
1. **로컬 전용**: 서버/Firebase/외부 DB 금지
|
||||
2. **권한 거부 시**: 수동 입력 폴백 (SMS), 기능 비활성화 (알림)
|
||||
3. **외부 API**: Clearbit Logo API만 허용
|
||||
4. **Isolate 주의**: `compute()` 내부에서 Flutter 바인딩/Context 접근 불가
|
||||
|
||||
- **NEVER include AI tool attribution in commit messages**
|
||||
- **Focus on what was changed and why**
|
||||
- **Use present tense and imperative mood**
|
||||
- **Keep the first line under 50 characters when possible**
|
||||
## Known Patterns
|
||||
|
||||
## 🧠 Error Analysis & Rule Documentation
|
||||
### 전면 광고 (AdService)
|
||||
|
||||
### Mandatory Process When Errors Occur
|
||||
|
||||
1. **Analyze root cause in detail**
|
||||
2. **Document preventive rule in `.cursor/rules/error_analysis.mdc`**
|
||||
3. **Write in English including**:
|
||||
- Error description and context
|
||||
- Cause and reproducibility steps
|
||||
- Resolution approach
|
||||
- Rule for preventing future recurrences
|
||||
- Sample code and references to related rules
|
||||
|
||||
### Rule Writing Standards
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Clear, one-line description of what the rule enforces
|
||||
globs: path/to/files/*.ext, other/path/**/*
|
||||
alwaysApply: boolean
|
||||
---
|
||||
|
||||
**Main Points in Bold**
|
||||
- Sub-points with details
|
||||
- Examples and explanations
|
||||
```dart
|
||||
// Completer 패턴으로 광고 완료 대기
|
||||
final completer = Completer<bool>();
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
completer.complete(true);
|
||||
},
|
||||
);
|
||||
ad.show();
|
||||
return completer.future;
|
||||
```
|
||||
|
||||
## 🏗️ Architectural Guidelines
|
||||
### SMS 스캔 (Isolate)
|
||||
|
||||
### Clean Architecture Compliance
|
||||
```dart
|
||||
// Isolate 내부에서는 하드코딩 사용
|
||||
// Flutter 바인딩, Context, Provider 접근 불가
|
||||
return 'Unknown service'; // AppLocalizations 사용 불가
|
||||
```
|
||||
|
||||
- **Layered structure**: `modules`, `controllers`, `services`, `repositories`, `entities`
|
||||
- Apply **Repository Pattern** for data abstraction
|
||||
- Use **Dependency Injection** (`getIt`, `inject`, etc.)
|
||||
- Controllers handle business logic (not view processing)
|
||||
## Response Format
|
||||
|
||||
### Code Structuring
|
||||
|
||||
- **One export or public declaration per file**
|
||||
- Centralize constants, error messages, and configuration
|
||||
- Make **all shared logic reusable** and place in dedicated helper modules
|
||||
|
||||
## 🌲 UI Structure & Component Design
|
||||
|
||||
### UI Optimization Principles
|
||||
|
||||
- **Avoid deeply nested widget/component trees**:
|
||||
- Flatten hierarchy for **better performance and readability**
|
||||
- Easier **state management and testability**
|
||||
- **Split large components into small, focused widgets/components**
|
||||
- Use `const` constructors (or equivalents) for performance optimization
|
||||
- Apply clear **naming and separation** between view, logic, and data layers
|
||||
|
||||
## 📈 Continuous Rule Improvement
|
||||
|
||||
### Rule Improvement Triggers
|
||||
|
||||
- New code patterns not covered by existing rules
|
||||
- Repeated similar implementations across files
|
||||
- Common error patterns that could be prevented
|
||||
- New libraries or tools being used consistently
|
||||
- Emerging best practices in the codebase
|
||||
|
||||
### Rule Update Criteria
|
||||
|
||||
**Add New Rules When:**
|
||||
|
||||
- A new technology/pattern is used in 3+ files
|
||||
- Common bugs could be prevented by a rule
|
||||
- Code reviews repeatedly mention the same feedback
|
||||
|
||||
**Modify Existing Rules When:**
|
||||
|
||||
- Better examples exist in the codebase
|
||||
- Additional edge cases are discovered
|
||||
- Related rules have been updated
|
||||
|
||||
## ✅ Quality Validation Checklist
|
||||
|
||||
Before completing any task, confirm:
|
||||
|
||||
- ✅ All three phases completed sequentially
|
||||
- ✅ Each phase output meets specified format requirements
|
||||
- ✅ Implementation satisfies all acceptance criteria
|
||||
- ✅ Code quality meets professional standards
|
||||
- ✅ Started with mandatory response format
|
||||
- ✅ All naming conventions followed
|
||||
- ✅ Type safety ensured
|
||||
- ✅ Single Responsibility Principle adhered to
|
||||
|
||||
## 🎯 Success Validation Framework
|
||||
|
||||
### Expert-Level Standards Verification
|
||||
|
||||
- **Minimalistic Approach**: High-quality, clean solutions without unnecessary complexity
|
||||
- **Professional Standards**: Every output meets industry-standard software engineering practices
|
||||
- **Concrete Results**: Specific, actionable details at each step
|
||||
|
||||
### Final Quality Gates
|
||||
|
||||
- [ ] All acceptance criteria validated
|
||||
- [ ] Code follows established conventions
|
||||
- [ ] Minimalistic approach maintained
|
||||
- [ ] Expert-level implementation standards met
|
||||
- [ ] Korean comments and documentation provided
|
||||
- [ ] English code and variable names used consistently
|
||||
```text
|
||||
[모델명]. I have reviewed all the following rules: [규칙]. Proceeding with the task. Master!
|
||||
```
|
||||
|
||||
@@ -1,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<void> 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<void> _startSmsScanIfMounted(BuildContext context) async {
|
||||
if (!context.mounted) return;
|
||||
_isAdInProgress = false;
|
||||
notifyListeners();
|
||||
|
||||
// 광고 완료 후 SMS 스캔 실행
|
||||
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 {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
@@ -157,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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_controller.initializeWebsiteUrl();
|
||||
_controller.initializeWebsiteUrl(context);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
@@ -161,83 +161,26 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
|
||||
@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<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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
195
lib/services/ad_service.dart
Normal file
195
lib/services/ad_service.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// 전면 광고(Interstitial Ad) 서비스
|
||||
/// lunchpick 프로젝트의 AdService 패턴을 참조하여 구현
|
||||
class AdService {
|
||||
InterstitialAd? _interstitialAd;
|
||||
Completer<bool>? _loadingCompleter;
|
||||
|
||||
/// 모바일 플랫폼 여부 확인
|
||||
bool get _isMobilePlatform {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isAndroid || Platform.isIOS;
|
||||
}
|
||||
|
||||
/// 전면 광고 Unit ID 반환
|
||||
String get _interstitialAdUnitId {
|
||||
if (Platform.isAndroid) {
|
||||
return 'ca-app-pub-6691216385521068/5281562472';
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS 테스트 광고 ID
|
||||
return 'ca-app-pub-3940256099942544/1033173712';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 광고를 로드하고 표시한 뒤 완료 여부를 반환
|
||||
/// true: 광고 시청 완료 또는 미지원 플랫폼
|
||||
/// false: 광고 로드/표시 실패
|
||||
Future<bool> showInterstitialAd(BuildContext context) async {
|
||||
if (!_isMobilePlatform) return true;
|
||||
|
||||
Log.i('광고 표시 시작');
|
||||
|
||||
// 1. 로딩 오버레이 표시 (앱이 foreground 상태 유지)
|
||||
final closeLoading = _showLoadingOverlay(context);
|
||||
|
||||
// 2. 몰입형 모드 진입
|
||||
await _enterImmersiveMode();
|
||||
|
||||
// 3. 광고 로드
|
||||
final loaded = await _ensureAdLoaded();
|
||||
|
||||
// 4. 로딩 오버레이 닫기
|
||||
closeLoading();
|
||||
|
||||
if (!loaded) {
|
||||
Log.w('광고 로드 실패, 건너뜀');
|
||||
await _restoreSystemUi();
|
||||
return false;
|
||||
}
|
||||
|
||||
final ad = _interstitialAd;
|
||||
if (ad == null) {
|
||||
Log.w('광고 인스턴스 없음, 건너뜀');
|
||||
await _restoreSystemUi();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 현재 광고를 null로 설정 (다음 광고 미리로드)
|
||||
_interstitialAd = null;
|
||||
|
||||
final completer = Completer<bool>();
|
||||
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdShowedFullScreenContent: (ad) {
|
||||
Log.i('전면 광고 표시됨 (콜백)');
|
||||
},
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
Log.i('전면 광고 닫힘 (콜백)');
|
||||
ad.dispose();
|
||||
_preload();
|
||||
unawaited(_restoreSystemUi());
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(true);
|
||||
}
|
||||
},
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
Log.e('전면 광고 표시 실패 (콜백)', error);
|
||||
ad.dispose();
|
||||
_preload();
|
||||
unawaited(_restoreSystemUi());
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 전체 화면으로 표시하도록 immersive 모드 설정
|
||||
ad.setImmersiveMode(true);
|
||||
|
||||
Log.i('ad.show() 호출 직전');
|
||||
try {
|
||||
ad.show();
|
||||
Log.i('ad.show() 호출 완료');
|
||||
} catch (e) {
|
||||
Log.e('광고 show() 호출 실패', e);
|
||||
unawaited(_restoreSystemUi());
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 타임아웃 설정 (15초 후 자동 건너뜀)
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
Log.w('광고 표시 타임아웃, 건너뜀');
|
||||
unawaited(_restoreSystemUi());
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 로딩 오버레이 표시 (앱이 foreground 상태 유지)
|
||||
VoidCallback _showLoadingOverlay(BuildContext context) {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.35),
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
return () {
|
||||
if (navigator.mounted && navigator.canPop()) {
|
||||
navigator.pop();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// 몰입형 모드 진입 (상하단 시스템 UI 숨김)
|
||||
Future<void> _enterImmersiveMode() async {
|
||||
try {
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
overlays: [],
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// UI 복구
|
||||
Future<void> _restoreSystemUi() async {
|
||||
try {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 광고 로드 보장 (이미 로드된 경우 즉시 반환)
|
||||
Future<bool> _ensureAdLoaded() async {
|
||||
if (_interstitialAd != null) return true;
|
||||
|
||||
if (_loadingCompleter != null) {
|
||||
return _loadingCompleter!.future;
|
||||
}
|
||||
|
||||
final completer = Completer<bool>();
|
||||
_loadingCompleter = completer;
|
||||
|
||||
Log.i('전면 광고 로드 시작: $_interstitialAdUnitId');
|
||||
|
||||
InterstitialAd.load(
|
||||
adUnitId: _interstitialAdUnitId,
|
||||
request: const AdRequest(),
|
||||
adLoadCallback: InterstitialAdLoadCallback(
|
||||
onAdLoaded: (ad) {
|
||||
Log.i('전면 광고 로드 성공');
|
||||
_interstitialAd = ad;
|
||||
completer.complete(true);
|
||||
_loadingCompleter = null;
|
||||
},
|
||||
onAdFailedToLoad: (error) {
|
||||
Log.e('전면 광고 로드 실패', error);
|
||||
completer.complete(false);
|
||||
_loadingCompleter = null;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// 다음 광고 미리로드
|
||||
void _preload() {
|
||||
if (_interstitialAd != null || _loadingCompleter != null) return;
|
||||
_ensureAdLoaded();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user