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]
This commit is contained in:
@@ -30,6 +30,26 @@ Future<bool> esPremiumPersistido({SharedPreferences? prefs}) async {
|
||||
return resueltas.getBool(_keyPremium) ?? false;
|
||||
}
|
||||
|
||||
/// User-facing, non-error-text outcomes [EstadoEntitlement] can expose (FIX
|
||||
/// 3, code review): the UI layer (`hoja_premium.dart`) has no BuildContext
|
||||
/// here, so this file never carries localized/user-facing STRINGS itself —
|
||||
/// only this typed signal, mapped to a localized message by the widget.
|
||||
/// Cleared back to `null` once consumed ([EstadoEntitlement.consumirResultadoUsuario]).
|
||||
enum ResultadoEntitlementUsuario {
|
||||
/// A purchase or restore attempt failed (network, billing error, product
|
||||
/// not yet available in the store, etc). This NEVER carries the raw
|
||||
/// exception/developer string from [EventoCompra.mensaje] — the UI maps
|
||||
/// this enum value to ONE generic localized message, never the internal
|
||||
/// diagnostic text.
|
||||
error,
|
||||
|
||||
/// [EstadoEntitlement.restaurar] completed successfully but found nothing
|
||||
/// to restore. Distinct from [error]: an expected, non-error outcome
|
||||
/// (Spec "Restore Purchases" — "finds nothing -> stays free tier with a
|
||||
/// clear non-error result").
|
||||
restauracionSinCompras,
|
||||
}
|
||||
|
||||
/// Cross-cutting entitlement notifier (Design ADR-1), idiomatic
|
||||
/// `EstadoIdioma`-shaped `ChangeNotifier`: UI layers `context.watch`/`read`
|
||||
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
|
||||
@@ -56,10 +76,24 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
|
||||
bool _esPremium = false;
|
||||
bool _compraEnCurso = false;
|
||||
ResultadoEntitlementUsuario? _resultadoUsuario;
|
||||
|
||||
bool get esPremium => _esPremium;
|
||||
bool get compraEnCurso => _compraEnCurso;
|
||||
|
||||
/// FIX 3 (code review): the user-facing signal for a failed purchase/
|
||||
/// restore, or a restore that found nothing. `null` when there is nothing
|
||||
/// to show — see [consumirResultadoUsuario].
|
||||
ResultadoEntitlementUsuario? get resultadoUsuario => _resultadoUsuario;
|
||||
|
||||
/// Clears [resultadoUsuario] once the UI has consumed/displayed it.
|
||||
/// A no-op (no extra notification) if there is nothing to clear.
|
||||
void consumirResultadoUsuario() {
|
||||
if (_resultadoUsuario == null) return;
|
||||
_resultadoUsuario = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _cargar() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final premium = prefs.getBool(_keyPremium) ?? false;
|
||||
@@ -80,6 +114,10 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
final compras = _compras;
|
||||
if (compras == null) return;
|
||||
_compraEnCurso = true;
|
||||
// FIX 3 (code review): a fresh attempt clears any stale result left over
|
||||
// from a previous failed attempt, so the UI never shows an outdated
|
||||
// error/confirmation across two unrelated attempts.
|
||||
_resultadoUsuario = null;
|
||||
notifyListeners();
|
||||
await compras.comprar();
|
||||
}
|
||||
@@ -89,6 +127,7 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
final compras = _compras;
|
||||
if (compras == null) return;
|
||||
_compraEnCurso = true;
|
||||
_resultadoUsuario = null;
|
||||
notifyListeners();
|
||||
await compras.restaurar();
|
||||
}
|
||||
@@ -99,17 +138,31 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
case TipoEventoCompra.restaurada:
|
||||
await _desbloquear();
|
||||
case TipoEventoCompra.cancelada:
|
||||
case TipoEventoCompra.noEncontrada:
|
||||
// Spec "Purchase cancelled or failed" / "Restore finds nothing":
|
||||
// stays free tier, no error surfaced — just stop the in-flight
|
||||
// spinner.
|
||||
// Spec "Purchase cancelled or failed": a user-INITIATED cancel
|
||||
// stays free tier with no error surfaced — just stop the in-flight
|
||||
// spinner. Not a failure, so no [resultadoUsuario] either.
|
||||
_compraEnCurso = false;
|
||||
notifyListeners();
|
||||
case TipoEventoCompra.noEncontrada:
|
||||
// FIX 3 (code review): "Restore finds nothing" is an expected,
|
||||
// NON-error outcome (Spec "Restore Purchases") but `hoja_premium.dart`
|
||||
// had zero feedback for it — the spinner just stopped with no
|
||||
// confirmation. Distinct signal from [TipoEventoCompra.error].
|
||||
_compraEnCurso = false;
|
||||
_resultadoUsuario = ResultadoEntitlementUsuario.restauracionSinCompras;
|
||||
notifyListeners();
|
||||
case TipoEventoCompra.error:
|
||||
// Fail-open (Design ADR-2): an error NEVER writes `false` over an
|
||||
// already-premium flag, and never invents a `true` for a free user
|
||||
// either — the persisted flag from `_cargar()` is left untouched.
|
||||
//
|
||||
// FIX 3 (code review): [EventoCompra.mensaje] (raw exception/
|
||||
// developer text, e.g. "Producto no encontrado en Play Console") is
|
||||
// DELIBERATELY discarded here — only the typed enum crosses into
|
||||
// [resultadoUsuario], never the raw string. `hoja_premium.dart` maps
|
||||
// it to ONE generic localized message.
|
||||
_compraEnCurso = false;
|
||||
_resultadoUsuario = ResultadoEntitlementUsuario.error;
|
||||
notifyListeners();
|
||||
case TipoEventoCompra.pendiente:
|
||||
_compraEnCurso = true;
|
||||
|
||||
Reference in New Issue
Block a user