From 640b23804f4a9e0f11b59559b37158a347c8f781 Mon Sep 17 00:00:00 2001 From: airkjw Date: Sat, 13 Jun 2026 14:23:45 +0900 Subject: [PATCH] fix(ads): banner retries load when SDK becomes ready On a cold start the consent flow is still running when home first builds, so createBanner returns null and the slot stayed empty until a rebuild. AdService now exposes an isReady ValueNotifier; BannerAdSlot listens and retries its load once MobileAds finishes initializing. Verified: analyze clean, 169 tests green. --- lib/services/ad_service.dart | 11 ++++++++++- lib/ui/widgets/banner_ad_slot.dart | 21 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/services/ad_service.dart b/lib/services/ad_service.dart index c0bb2ea..392b3aa 100644 --- a/lib/services/ad_service.dart +++ b/lib/services/ad_service.dart @@ -1,6 +1,7 @@ // lib/services/ad_service.dart import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'ad_config.dart'; @@ -29,11 +30,18 @@ class AdService { RewardedAd? _rewarded; bool _initialized = false; - /// Called by ConsentService once the SDK is initialized. Preloads ads. + /// Flips true once the SDK is initialized. Banner slots that were built + /// before consent finished listen to this and retry their load — otherwise + /// the banner would stay empty until the screen is rebuilt. + final ValueNotifier isReady = ValueNotifier(false); + + /// Called by ConsentService once the SDK is initialized. Preloads ads and + /// notifies any waiting banner slots. void onSdkReady() { _initialized = true; _loadInterstitial(); _loadRewarded(); + isReady.value = true; } // ---- lifecycle hooks the game calls ---- @@ -137,5 +145,6 @@ class AdService { void dispose() { _interstitial?.dispose(); _rewarded?.dispose(); + isReady.dispose(); } } diff --git a/lib/ui/widgets/banner_ad_slot.dart b/lib/ui/widgets/banner_ad_slot.dart index b81dc5a..bd484ae 100644 --- a/lib/ui/widgets/banner_ad_slot.dart +++ b/lib/ui/widgets/banner_ad_slot.dart @@ -3,11 +3,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; +import '../../services/ad_service.dart'; import '../../state/providers.dart'; /// A self-loading 320x50 banner for the home/map screens. Renders nothing /// when ads are removed or the banner has not loaded — never reserves blank /// space and never appears on the game screen. +/// +/// If the slot is built before the AdMob SDK finished initializing (common on +/// a cold start while the consent flow is still running), the first create +/// returns null; it then retries once the [AdService.isReady] notifier flips. class BannerAdSlot extends ConsumerStatefulWidget { const BannerAdSlot({super.key}); @@ -18,13 +23,22 @@ class BannerAdSlot extends ConsumerStatefulWidget { class _BannerAdSlotState extends ConsumerState { BannerAd? _ad; bool _loaded = false; + AdService? _adService; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_ad != null) return; + if (_adService == null) { + _adService = ref.read(adServiceProvider); + _adService!.isReady.addListener(_tryCreate); + } + _tryCreate(); + } + + void _tryCreate() { + if (_ad != null || !mounted) return; if (ref.read(adsRemovedProvider)) return; - final ad = ref.read(adServiceProvider).createBanner( + final ad = _adService!.createBanner( listener: BannerAdListener( onAdLoaded: (_) { if (mounted) setState(() => _loaded = true); @@ -32,12 +46,13 @@ class _BannerAdSlotState extends ConsumerState { onAdFailedToLoad: (ad, _) => ad.dispose(), ), ); - if (ad == null) return; + if (ad == null) return; // SDK not ready yet; isReady will retry. _ad = ad; } @override void dispose() { + _adService?.isReady.removeListener(_tryCreate); _ad?.dispose(); super.dispose(); }