Files
pluriwave/lib/widgets/banner_anuncio_superior.dart
FreeTLab 9cfa5ac17d
Build & Deploy PluriWave / Análisis de código (push) Successful in 42s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 4m23s
fix(iap): address code review defects in freemium/IAP change
Fixes 9 of 10 review findings (10th requires a manual Play Console
step, no code change):

1. app.dart/banner_anuncio_superior.dart: move the top SafeArea inside
   BannerAnuncioSuperior so it only reserves status-bar height when an
   ad actually renders, restoring edge-to-edge layout for premium and
   free-unloaded users.
2. servicio_anuncios.dart: bound every interstitial await (load,
   presentation, and the injected implementation itself) with
   injectable timeouts so a callback that never fires can no longer
   hang a caller.
3. estado_entitlement.dart/hoja_premium.dart: expose a typed
   resultadoUsuario signal for purchase/restore failures and
   restore-found-nothing, with dedicated localized messages
   (compraError, restauracionSinCompras) across all 13 locales --
   never the raw developer/exception string.
4. main.dart/servicio_consentimiento.dart: add a GDPR/UMP consent flow
   (ConsentInformation/ConsentForm) that gates Mobile Ads SDK init on
   canRequestAds(); premium users never see a consent form; failures
   degrade to no ads instead of crashing or blocking startup.
6. servicio_anuncios.dart: track real ad presentation
   (onAdShowedFullScreenContent) so a failed-to-show interstitial no
   longer consumes a session cap slot.
7. banner_anuncio_superior.dart: add an explicit load-attempted guard
   so repeated didChangeDependencies (e.g. entitlement notifyListeners
   during a purchase) can only ever trigger one banner load attempt.
8. servicio_anuncios.dart: make esPremium a required constructor
   parameter, matching the hardened contract already applied to
   EstadoAlarmas/EstadoGrabacion/EstadoRadio.
9. hoja_premium.dart: add a dedicated premiumActivo localized string
   instead of reusing the equalizer's equalizerActive translation,
   across all 13 locales.

All fixes implemented RED-first (failing test before production
code). Full suite: 1261 passed, 2 pre-existing skips, 0 failures.
flutter analyze: 5 pre-existing issues only, 0 new.

[version set]
2026-08-12 16:10:46 +02:00

123 lines
4.9 KiB
Dart

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<BannerAnuncioSuperior> createState() => _BannerAnuncioSuperiorState();
}
class _BannerAnuncioSuperiorState extends State<BannerAnuncioSuperior> {
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<ServicioAnuncios>();
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<EstadoEntitlement>();
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),
),
);
}
}