fix(ad): 전면 광고 몰입 모드 적용

This commit is contained in:
JiWoong Sul
2025-12-03 18:26:34 +09:00
parent a4c7f55fc0
commit 3f659432e9
3 changed files with 114 additions and 116 deletions

View File

@@ -1,133 +1,124 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:lunchpick/core/utils/ad_helper.dart';
/// 간단한 전면 광고(Interstitial Ad) 모의 서비스
/// 실제 구글 전면 광고(Interstitial Ad) 서비스.
class AdService {
/// 임시 광고 다이얼로그를 표시하고 사용자가 끝까지 시청했는지 여부를 반환한다.
InterstitialAd? _interstitialAd;
Completer<bool>? _loadingCompleter;
/// 광고를 로드하고 재생한 뒤 완료 여부를 반환한다.
Future<bool> showInterstitialAd(BuildContext context) async {
final result = await showDialog<bool>(
if (!AdHelper.isMobilePlatform) return true;
final closeLoading = _showLoadingOverlay(context);
await _enterImmersiveMode();
final loaded = await _ensureAdLoaded();
closeLoading();
if (!loaded) {
await _restoreSystemUi();
return false;
}
final ad = _interstitialAd;
if (ad == null) {
await _restoreSystemUi();
return false;
}
_interstitialAd = null;
final completer = Completer<bool>();
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
_preload();
unawaited(_restoreSystemUi());
completer.complete(true);
},
onAdFailedToShowFullScreenContent: (ad, error) {
ad.dispose();
_preload();
unawaited(_restoreSystemUi());
completer.complete(false);
},
);
// 상하단 여백 없이 전체 화면으로 표시하도록 immersive 모드 설정.
ad.setImmersiveMode(true);
try {
ad.show();
} catch (_) {
unawaited(_restoreSystemUi());
completer.complete(false);
}
return completer.future;
}
Future<void> _enterImmersiveMode() async {
try {
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
overlays: [],
);
} catch (_) {}
}
Future<void> _restoreSystemUi() async {
try {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} catch (_) {}
}
VoidCallback _showLoadingOverlay(BuildContext context) {
final navigator = Navigator.of(context, rootNavigator: true);
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const _MockInterstitialAdDialog(),
barrierColor: Colors.black.withOpacity(0.35),
builder: (_) => const Center(child: CircularProgressIndicator()),
);
return result ?? false;
}
}
class _MockInterstitialAdDialog extends StatefulWidget {
const _MockInterstitialAdDialog();
@override
State<_MockInterstitialAdDialog> createState() =>
_MockInterstitialAdDialogState();
}
class _MockInterstitialAdDialogState extends State<_MockInterstitialAdDialog> {
static const int _adDurationSeconds = 4;
late Timer _timer;
int _elapsedSeconds = 0;
bool _completed = false;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return;
setState(() {
_elapsedSeconds++;
});
if (_elapsedSeconds >= _adDurationSeconds && !_completed) {
_completed = true;
_timer.cancel();
Navigator.of(context).pop(true);
return () {
if (navigator.mounted && navigator.canPop()) {
navigator.pop();
}
});
};
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
Future<bool> _ensureAdLoaded() async {
if (_interstitialAd != null) return true;
bool get _canClose => _elapsedSeconds >= _adDurationSeconds;
if (_loadingCompleter != null) {
return _loadingCompleter!.future;
}
double get _progress => (_elapsedSeconds / _adDurationSeconds).clamp(0, 1);
final completer = Completer<bool>();
_loadingCompleter = completer;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 80),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.ondemand_video,
size: 56,
color: Colors.deepPurple,
),
const SizedBox(height: 12),
Text(
'광고 시청 중...',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 8),
Text(
_canClose ? '광고가 완료되었습니다.' : '잠시만 기다려 주세요.',
style: TextStyle(
color: isDark ? Colors.white70 : Colors.black54,
),
),
const SizedBox(height: 24),
LinearProgressIndicator(
value: _progress,
minHeight: 6,
borderRadius: BorderRadius.circular(999),
backgroundColor: Colors.grey.withValues(alpha: 0.2),
color: Colors.deepPurple,
),
const SizedBox(height: 12),
Text(
_canClose
? '광고가 완료되었어요. 자동으로 계속합니다.'
: '남은 시간: ${_adDurationSeconds - _elapsedSeconds}',
style: TextStyle(
color: isDark ? Colors.white70 : Colors.black54,
),
),
const SizedBox(height: 12),
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: const Text('닫기'),
),
],
),
),
Positioned(
right: 8,
top: 8,
child: IconButton(
onPressed: () => Navigator.of(context).pop(false),
icon: const Icon(Icons.close),
),
),
],
InterstitialAd.load(
adUnitId: AdHelper.interstitialAdUnitId,
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
_interstitialAd = ad;
completer.complete(true);
_loadingCompleter = null;
},
onAdFailedToLoad: (error) {
completer.complete(false);
_loadingCompleter = null;
},
),
);
return completer.future;
}
void _preload() {
if (_interstitialAd != null || _loadingCompleter != null) return;
_ensureAdLoaded();
}
}