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; } /// 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; bool get esPremium => _esPremium; bool get compraEnCurso => _compraEnCurso; 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; 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; notifyListeners(); await compras.restaurar(); } Future _alRecibirEvento(EventoCompra evento) async { switch (evento.tipo) { case TipoEventoCompra.comprada: 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. _compraEnCurso = false; 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. _compraEnCurso = false; 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(); } }