import 'package:flutter/material.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:provider/provider.dart'; import '../estado/estado_entitlement.dart'; import '../servicios/servicio_anuncios.dart'; /// Entitlement-aware top-banner slot (Design ADR-6, ad-display spec /// "Persistent Top Banner, Never Overlapping Content"). Collapses to /// `SizedBox.shrink()` — zero reserved space, zero layout impact — whenever /// the user is premium OR no ad has finished loading yet; only a /// successfully loaded [BannerAd] renders a sized box around an [AdWidget]. /// Callers place this as a plain sibling in a `Column` ABOVE the existing /// body (`app.dart`'s `_PaginaPrincipalState.build`) — this widget itself /// never wraps its parent in a `Stack`/overlay. class BannerAnuncioSuperior extends StatefulWidget { const BannerAnuncioSuperior({super.key, this.alIntentarCargar}); /// Test-only hook (FIX 7, code review): fires exactly once per REAL load /// ATTEMPT (`BannerAd(...).load()` call), independent of the load's /// eventual outcome — lets a widget test count load attempts without a /// real AdMob platform channel. Always `null` in production. @visibleForTesting final VoidCallback? alIntentarCargar; @override State createState() => _BannerAnuncioSuperiorState(); } class _BannerAnuncioSuperiorState extends State { BannerAd? _bannerAd; bool _cargado = false; /// FIX 7 (code review): explicit "load already attempted" flag. Before /// this, the guard was `_bannerAd == null`, which stays `null` until a /// load actually SUCCEEDS — so every `notifyListeners()` from ANY /// provider this widget watches (`EstadoEntitlement` during a /// purchase/restore in progress) plus theme/locale/`MediaQuery` changes /// re-ran `didChangeDependencies` and spawned ANOTHER `BannerAd` + /// `load()` call. Only the LAST loaded ad was ever disposed, leaking /// every in-flight duplicate before it. /// /// Retry policy (documented decision): a FAILED load is never retried /// automatically — this flag is set once and never reset. Retrying on /// every rebuild is exactly the bug this flag fixes; the next natural /// retry opportunity is a fresh app session, which is an adequate cadence /// for a non-critical, collapse-to-nothing UI element. bool _cargaIntentada = false; @override void didChangeDependencies() { super.didChangeDependencies(); final servicio = context.read(); if (!_cargaIntentada && servicio.debeMostrarBanner) { _cargaIntentada = true; _cargarBanner(); } } void _cargarBanner() { widget.alIntentarCargar?.call(); // Fire-and-forget: a failure (no plugin channel in `flutter test`, no // fill, offline) leaves `_bannerAd` `null` forever, which keeps this // widget collapsed — exactly the same degrade-to-shrink path a genuine // load failure takes in production. Never throws out of this method. final anuncio = BannerAd( size: AdSize.banner, adUnitId: bannerAdUnitId, request: const AdRequest(), listener: BannerAdListener( onAdLoaded: (ad) { if (!mounted) { ad.dispose(); return; } setState(() { _bannerAd = ad as BannerAd; _cargado = true; }); }, onAdFailedToLoad: (ad, error) { ad.dispose(); }, ), ); anuncio.load().catchError((_) {}); } @override void dispose() { _bannerAd?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final entitlement = context.watch(); if (entitlement.esPremium) return const SizedBox.shrink(); // Instant vanish-on-purchase (ad-display spec "Ads Vanish Immediately // On Purchase"): even a banner that finished loading BEFORE this // transition is dropped, never shown to a now-premium user. if (!_cargado || _bannerAd == null) return const SizedBox.shrink(); final ad = _bannerAd!; // FIX 1 (code review): the top-inset `SafeArea` now lives HERE, applied // ONLY when an ad is actually about to render. `SafeArea` reserves // `MediaQuery.padding.top` regardless of its child's own size — even a // zero-size `SizedBox.shrink()` child — so the OLD unconditional // `app.dart`-level `SafeArea(bottom: false, child: BannerAnuncioSuperior())` // wrapper left a permanent blank status-bar-height strip both for // premium users and for free users before the first ad finished // loading. Collapsing (the two early returns above) now returns a // TRULY zero-height widget, including no reserved padding. return SafeArea( bottom: false, child: SizedBox( width: ad.size.width.toDouble(), height: ad.size.height.toDouble(), child: AdWidget(ad: ad), ), ); } }