fix(ad): 전면 광고 몰입 모드 적용
This commit is contained in:
@@ -14,11 +14,11 @@ class AppConstants {
|
|||||||
static const String naverMapApiKey = 'YOUR_NAVER_MAP_API_KEY';
|
static const String naverMapApiKey = 'YOUR_NAVER_MAP_API_KEY';
|
||||||
static const String weatherApiKey = 'YOUR_WEATHER_API_KEY';
|
static const String weatherApiKey = 'YOUR_WEATHER_API_KEY';
|
||||||
|
|
||||||
// AdMob IDs (Test IDs - Replace with real IDs in production)
|
// AdMob IDs (Real)
|
||||||
static const String androidAdAppId = 'ca-app-pub-3940256099942544~3347511713';
|
static const String androidAdAppId = 'ca-app-pub-3940256099942544~3347511713';
|
||||||
static const String iosAdAppId = 'ca-app-pub-3940256099942544~1458002511';
|
static const String iosAdAppId = 'ca-app-pub-3940256099942544~1458002511';
|
||||||
static const String interstitialAdUnitId =
|
static const String interstitialAdUnitId =
|
||||||
'ca-app-pub-3940256099942544/1033173712';
|
'ca-app-pub-6691216385521068/6006297260';
|
||||||
static const String androidNativeAdUnitId =
|
static const String androidNativeAdUnitId =
|
||||||
'ca-app-pub-6691216385521068/7939870622';
|
'ca-app-pub-6691216385521068/7939870622';
|
||||||
static const String iosNativeAdUnitId =
|
static const String iosNativeAdUnitId =
|
||||||
|
|||||||
@@ -1,133 +1,124 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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 {
|
class AdService {
|
||||||
/// 임시 광고 다이얼로그를 표시하고 사용자가 끝까지 시청했는지 여부를 반환한다.
|
InterstitialAd? _interstitialAd;
|
||||||
|
Completer<bool>? _loadingCompleter;
|
||||||
|
|
||||||
|
/// 광고를 로드하고 재생한 뒤 완료 여부를 반환한다.
|
||||||
Future<bool> showInterstitialAd(BuildContext context) async {
|
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,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (_) => const _MockInterstitialAdDialog(),
|
barrierColor: Colors.black.withOpacity(0.35),
|
||||||
|
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
return result ?? false;
|
return () {
|
||||||
|
if (navigator.mounted && navigator.canPop()) {
|
||||||
|
navigator.pop();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MockInterstitialAdDialog extends StatefulWidget {
|
Future<bool> _ensureAdLoaded() async {
|
||||||
const _MockInterstitialAdDialog();
|
if (_interstitialAd != null) return true;
|
||||||
|
|
||||||
@override
|
if (_loadingCompleter != null) {
|
||||||
State<_MockInterstitialAdDialog> createState() =>
|
return _loadingCompleter!.future;
|
||||||
_MockInterstitialAdDialogState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MockInterstitialAdDialogState extends State<_MockInterstitialAdDialog> {
|
final completer = Completer<bool>();
|
||||||
static const int _adDurationSeconds = 4;
|
_loadingCompleter = completer;
|
||||||
|
|
||||||
late Timer _timer;
|
InterstitialAd.load(
|
||||||
int _elapsedSeconds = 0;
|
adUnitId: AdHelper.interstitialAdUnitId,
|
||||||
bool _completed = false;
|
request: const AdRequest(),
|
||||||
|
adLoadCallback: InterstitialAdLoadCallback(
|
||||||
@override
|
onAdLoaded: (ad) {
|
||||||
void initState() {
|
_interstitialAd = ad;
|
||||||
super.initState();
|
completer.complete(true);
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
_loadingCompleter = null;
|
||||||
if (!mounted) return;
|
},
|
||||||
setState(() {
|
onAdFailedToLoad: (error) {
|
||||||
_elapsedSeconds++;
|
completer.complete(false);
|
||||||
});
|
_loadingCompleter = null;
|
||||||
if (_elapsedSeconds >= _adDurationSeconds && !_completed) {
|
|
||||||
_completed = true;
|
|
||||||
_timer.cancel();
|
|
||||||
Navigator.of(context).pop(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_timer.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _canClose => _elapsedSeconds >= _adDurationSeconds;
|
|
||||||
|
|
||||||
double get _progress => (_elapsedSeconds / _adDurationSeconds).clamp(0, 1);
|
|
||||||
|
|
||||||
@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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _preload() {
|
||||||
|
if (_interstitialAd != null || _loadingCompleter != null) return;
|
||||||
|
_ensureAdLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ class AdHelper {
|
|||||||
defaultTargetPlatform == TargetPlatform.iOS;
|
defaultTargetPlatform == TargetPlatform.iOS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String get interstitialAdUnitId {
|
||||||
|
if (!isMobilePlatform) {
|
||||||
|
throw UnsupportedError('Interstitial ads are only supported on mobile.');
|
||||||
|
}
|
||||||
|
return AppConstants.interstitialAdUnitId;
|
||||||
|
}
|
||||||
|
|
||||||
static String get nativeAdUnitId {
|
static String get nativeAdUnitId {
|
||||||
if (!isMobilePlatform) {
|
if (!isMobilePlatform) {
|
||||||
throw UnsupportedError('Native ads are only supported on mobile.');
|
throw UnsupportedError('Native ads are only supported on mobile.');
|
||||||
|
|||||||
Reference in New Issue
Block a user