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:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user