Files
pluriwave/lib/servicios/servicio_anuncios.dart
T
FreeTLab 94f354a7c1 feat(iap): wire real AdMob app id, banner and interstitial units
App id always uses the real value (SDK init only, no ad-serving risk).
Banner/interstitial pick the real unit id in release builds and Google's
test unit id everywhere else, so debug/profile builds can never serve
(or accidentally tap) a real ad.
2026-08-12 12:53:34 +02:00

133 lines
5.2 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint, kReleaseMode;
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// Official Google TEST ad unit ids. ALWAYS used outside release builds —
/// tapping your own real ad unit during development/testing is invalid
/// traffic and AdMob suspends accounts for it, so this is not optional.
const bannerAdUnitIdPrueba = 'ca-app-pub-3940256099942544/6300978111';
const interstitialAdUnitIdPrueba = 'ca-app-pub-3940256099942544/1033173712';
/// Real banner unit id, provisioned in the AdMob console (iap-freemium-unlock).
const _bannerAdUnitIdReal = 'ca-app-pub-6038935671414339/5658618378';
/// Real interstitial unit id, provisioned in the AdMob console (iap-freemium-unlock).
const _interstitialAdUnitIdReal = 'ca-app-pub-6038935671414339/4189478248';
/// Real id in release builds only; test id everywhere else (debug/profile,
/// including internal-testing-track builds run via `flutter run --release`
/// on a personal device — see the "never tap your own ads" note above).
const bannerAdUnitId = kReleaseMode
? _bannerAdUnitIdReal
: bannerAdUnitIdPrueba;
const interstitialAdUnitId = kReleaseMode
? _interstitialAdUnitIdReal
: interstitialAdUnitIdPrueba;
/// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns
/// the entitlement gate for both surfaces, the interstitial's session
/// frequency cap, and is the ONLY `google_mobile_ads` call site besides
/// `banner_anuncio_superior.dart`'s `BannerAd` widget wrapper. The frequency
/// cap and premium gating are pure/injectable (`ahora`,
/// `mostrarInterstitialImpl`) so they are unit-testable with a fake clock
/// and zero AdMob platform channels (Design Testing Strategy).
class ServicioAnuncios {
ServicioAnuncios({
bool Function()? esPremium,
DateTime Function()? ahora,
Future<bool> Function()? mostrarInterstitialImpl,
}) : _esPremium = esPremium ?? (() => false),
_ahora = ahora ?? DateTime.now,
_mostrarInterstitialImpl =
mostrarInterstitialImpl ?? _mostrarInterstitialAdMob;
/// Session-scoped cap (ad-display spec "Interstitial Frequency Cap"): at
/// most 2 interstitials per process lifetime.
static const maxInterstitialsPorSesion = 2;
/// Minimum spacing between two interstitials (ad-display spec, same
/// requirement).
static const separacionMinima = Duration(minutes: 3);
final bool Function() _esPremium;
final DateTime Function() _ahora;
final Future<bool> Function() _mostrarInterstitialImpl;
int _mostrados = 0;
DateTime? _ultimoMostrado;
/// Ad-display spec "Persistent Top Banner": absent entirely for premium.
bool get debeMostrarBanner => !_esPremium();
bool _dentroDelCap() {
if (_esPremium()) return false;
if (_mostrados >= maxInterstitialsPorSesion) return false;
final ultimo = _ultimoMostrado;
if (ultimo != null && _ahora().difference(ultimo) < separacionMinima) {
return false;
}
return true;
}
/// Attempts to show an interstitial for one of the two allowed CTAs (add
/// station manually, add alarm). Callers are responsible for the ADR-6
/// ordering invariant themselves (cap-check-before-interstitial for
/// add-alarm, so a refusal is never preceded by an ad) — this method only
/// owns entitlement + frequency-cap gating, never the caller's own
/// business-rule ordering.
///
/// Returns whether an interstitial actually rendered. A failed/aborted ad
/// load (network, no fill) does NOT consume the session cap — only a
/// genuinely SHOWN ad does (Spec intent: the cap limits driver-facing
/// interruptions, not load attempts).
Future<bool> intentarInterstitial() async {
if (!_dentroDelCap()) return false;
final mostrado = await _mostrarInterstitialImpl();
if (mostrado) {
_mostrados++;
_ultimoMostrado = _ahora();
}
return mostrado;
}
static Future<bool> _mostrarInterstitialAdMob() async {
try {
final cargaCompleter = Completer<InterstitialAd?>();
await InterstitialAd.load(
adUnitId: interstitialAdUnitId,
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
if (!cargaCompleter.isCompleted) cargaCompleter.complete(ad);
},
onAdFailedToLoad: (error) {
debugPrint('[PluriWave][anuncios] interstitial load ERROR $error');
if (!cargaCompleter.isCompleted) cargaCompleter.complete(null);
},
),
);
final cargado = await cargaCompleter.future;
if (cargado == null) return false;
final cierreCompleter = Completer<void>();
cargado.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
if (!cierreCompleter.isCompleted) cierreCompleter.complete();
},
onAdFailedToShowFullScreenContent: (ad, error) {
ad.dispose();
if (!cierreCompleter.isCompleted) cierreCompleter.complete();
},
);
await cargado.show();
await cierreCompleter.future;
return true;
} catch (e) {
debugPrint('[PluriWave][anuncios] interstitial ERROR $e');
return false;
}
}
}