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]
194 lines
7.8 KiB
Dart
194 lines
7.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../servicios/servicio_audio.dart' show notificarDesbloqueoAuto;
|
|
import '../servicios/servicio_compras.dart';
|
|
|
|
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
|
|
/// premium unlock. Older builds that predate this key simply never read it —
|
|
/// no migration needed (Rollout "Versioned key ... is ignored by older
|
|
/// builds").
|
|
const _keyPremium = 'compra_premium_v1';
|
|
|
|
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
|
|
/// Entitlement Read"): resolves the persisted premium flag directly from
|
|
/// prefs, with NO `BuildContext`/`Provider` dependency. Mirrors
|
|
/// `FuenteMusicaLocalAutoImpl._resolverPrefs()`'s
|
|
/// inject-or-`getInstance()` convention (`musica_local_auto.dart:163`) —
|
|
/// this is what `PluriWaveAudioHandler` calls, since it registers before
|
|
/// `runApp` and no widget tree (therefore no `Provider`) exists yet.
|
|
///
|
|
/// Absent key = free tier (Rollout "Additive and prefs-backed; absent key =
|
|
/// free"). Never throws — a `SharedPreferences.getInstance()` failure would
|
|
/// propagate here exactly like the persisted read failing, which the caller
|
|
/// (Design ADR-2 "fail-open") must treat as "trust the last known state",
|
|
/// not this function's job to catch.
|
|
Future<bool> esPremiumPersistido({SharedPreferences? prefs}) async {
|
|
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
|
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,
|
|
/// since no `Provider` exists on that path.
|
|
class EstadoEntitlement extends ChangeNotifier {
|
|
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
|
|
: _prefs = prefs,
|
|
_compras = compras {
|
|
final flujo = _compras;
|
|
if (flujo != null) {
|
|
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
|
|
}
|
|
_cargar();
|
|
}
|
|
|
|
/// The single non-consumable product id (Design "Interfaces / Contracts"),
|
|
/// re-exported here so UI/paywall code depends on ONE canonical constant
|
|
/// rather than reaching into `servicio_compras.dart` for it.
|
|
static const idProducto = ServicioComprasPlayBilling.idProducto;
|
|
|
|
final SharedPreferences? _prefs;
|
|
final PuertoCompras? _compras;
|
|
StreamSubscription<EventoCompra>? _comprasSub;
|
|
|
|
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;
|
|
if (premium != _esPremium) {
|
|
_esPremium = premium;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<SharedPreferences> _resolverPrefs() async =>
|
|
_prefs ?? SharedPreferences.getInstance();
|
|
|
|
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
|
|
/// already premium (Spec "Already-purchased attempt is idempotent") — no
|
|
/// duplicate charge is even attempted.
|
|
Future<void> comprar() async {
|
|
if (_esPremium) return;
|
|
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();
|
|
}
|
|
|
|
/// Re-queries Play Billing for a prior purchase (Spec "Restore Purchases").
|
|
Future<void> restaurar() async {
|
|
final compras = _compras;
|
|
if (compras == null) return;
|
|
_compraEnCurso = true;
|
|
_resultadoUsuario = null;
|
|
notifyListeners();
|
|
await compras.restaurar();
|
|
}
|
|
|
|
Future<void> _alRecibirEvento(EventoCompra evento) async {
|
|
switch (evento.tipo) {
|
|
case TipoEventoCompra.comprada:
|
|
case TipoEventoCompra.restaurada:
|
|
await _desbloquear();
|
|
case TipoEventoCompra.cancelada:
|
|
// 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;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> _desbloquear() async {
|
|
final yaEraPremium = _esPremium;
|
|
_esPremium = true;
|
|
_compraEnCurso = false;
|
|
final prefs = await _resolverPrefs();
|
|
await prefs.setBool(_keyPremium, true);
|
|
notifyListeners();
|
|
if (!yaEraPremium) {
|
|
// Orchestrator-resolved open question (design.md): actively
|
|
// invalidate the Android Auto browse cache on the free -> premium
|
|
// transition, rather than waiting for the head unit's own re-bind.
|
|
notificarDesbloqueoAuto();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_comprasSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|