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 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? _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 _cargar() async { final prefs = await _resolverPrefs(); final premium = prefs.getBool(_keyPremium) ?? false; if (premium != _esPremium) { _esPremium = premium; } notifyListeners(); } Future _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 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 restaurar() async { final compras = _compras; if (compras == null) return; _compraEnCurso = true; _resultadoUsuario = null; notifyListeners(); await compras.restaurar(); } Future _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 _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(); } }