From 3f659432e957310d1ace6a62190ee6364e9a09f2 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 3 Dec 2025 18:26:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(ad):=20=EC=A0=84=EB=A9=B4=20=EA=B4=91?= =?UTF-8?q?=EA=B3=A0=20=EB=AA=B0=EC=9E=85=20=EB=AA=A8=EB=93=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/constants/app_constants.dart | 4 +- lib/core/services/ad_service.dart | 219 ++++++++++++-------------- lib/core/utils/ad_helper.dart | 7 + 3 files changed, 114 insertions(+), 116 deletions(-) diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index a0a4e3d..ff441cf 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -14,11 +14,11 @@ class AppConstants { static const String naverMapApiKey = 'YOUR_NAVER_MAP_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 iosAdAppId = 'ca-app-pub-3940256099942544~1458002511'; static const String interstitialAdUnitId = - 'ca-app-pub-3940256099942544/1033173712'; + 'ca-app-pub-6691216385521068/6006297260'; static const String androidNativeAdUnitId = 'ca-app-pub-6691216385521068/7939870622'; static const String iosNativeAdUnitId = diff --git a/lib/core/services/ad_service.dart b/lib/core/services/ad_service.dart index fa83c17..0f0fb95 100644 --- a/lib/core/services/ad_service.dart +++ b/lib/core/services/ad_service.dart @@ -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? _loadingCompleter; + + /// 광고를 로드하고 재생한 뒤 완료 여부를 반환한다. Future showInterstitialAd(BuildContext context) async { - final result = await showDialog( + 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(); + 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 _enterImmersiveMode() async { + try { + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.immersiveSticky, + overlays: [], + ); + } catch (_) {} + } + + Future _restoreSystemUi() async { + try { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } catch (_) {} + } + + VoidCallback _showLoadingOverlay(BuildContext context) { + final navigator = Navigator.of(context, rootNavigator: true); + showDialog( 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 _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(); + _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(); } } diff --git a/lib/core/utils/ad_helper.dart b/lib/core/utils/ad_helper.dart index 1f57674..21ffccb 100644 --- a/lib/core/utils/ad_helper.dart +++ b/lib/core/utils/ad_helper.dart @@ -9,6 +9,13 @@ class AdHelper { 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 { if (!isMobilePlatform) { throw UnsupportedError('Native ads are only supported on mobile.');