diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ba0614e..70bfd26 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -137,6 +137,17 @@ + + + diff --git a/lib/app.dart b/lib/app.dart index 0908726..c4141ca 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,11 +4,15 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'estado/estado_busqueda.dart'; import 'estado/estado_ecualizador.dart'; +import 'estado/estado_entitlement.dart'; import 'estado/estado_grabacion.dart'; import 'estado/estado_radio.dart'; import 'estado/estado_alarmas.dart'; import 'estado/estado_idioma.dart'; import 'estado/estado_navegacion.dart'; +import 'servicios/servicio_anuncios.dart'; +import 'servicios/servicio_compras.dart'; +import 'widgets/banner_anuncio_superior.dart'; import 'l10n/display_names.dart'; import 'l10n/gen/app_localizations.dart'; import 'modelos/alarma_musical.dart'; @@ -32,7 +36,7 @@ import 'servicios/servicio_alarmas_android.dart'; import 'servicios/servicio_dispositivo_audio.dart'; class PluriWaveApp extends StatelessWidget { - const PluriWaveApp({super.key, this.prefs, this.fuenteAuto}); + const PluriWaveApp({super.key, this.prefs, this.fuenteAuto, this.compras}); /// Single SharedPreferences instance resolved in main() (S3-R4) and /// injected into every state/service. @@ -44,16 +48,31 @@ class PluriWaveApp extends StatelessWidget { /// [PluriWaveApp] without it. final FuenteEmisorasAuto? fuenteAuto; + /// Purchase I/O port (iap-freemium-unlock, Design ADR-2). Optional and + /// `null` by default — mirrors [fuenteAuto]'s injection shape, so every + /// pre-existing test that constructs [PluriWaveApp] without it never + /// touches the real `in_app_purchase` plugin channel. `main.dart` wires + /// the real [ServicioComprasPlayBilling]. + final PuertoCompras? compras; + @override Widget build(BuildContext context) { return MultiProvider( providers: [ + // iap-freemium-unlock (Design ADR-3): registered FIRST so every + // provider below can read it via `context.read` inside a lazy + // `esPremium` closure — `MultiProvider` nests top-to-bottom, so only + // a provider ABOVE a given one is reachable from its own `create`. + ChangeNotifierProvider( + create: (_) => EstadoEntitlement(prefs: prefs, compras: compras), + ), ChangeNotifierProvider( create: - (_) => EstadoRadio( + (context) => EstadoRadio( prefs: prefs, dispositivoAudio: ServicioDispositivoAudioReal(), fuenteAuto: fuenteAuto, + esPremium: () => context.read().esPremium, ), ), // Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio @@ -69,13 +88,28 @@ class PluriWaveApp extends StatelessWidget { ListenableProvider( create: (context) => context.read().busqueda, ), - ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)), + ChangeNotifierProvider( + create: + (context) => EstadoAlarmas( + prefs: prefs, + esPremium: () => context.read().esPremium, + ), + ), ChangeNotifierProvider( create: (_) => EstadoIdioma(sharedPreferences: prefs), ), // Design ADR-8: root-to-root navigation state. `_PaginaPrincipal` // watches this instead of owning `_indice` locally. ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()), + // iap-freemium-unlock (Design "Interfaces / Contracts", ADR-6): a + // plain (non-notifier) `Provider` — session-scoped ad state, never + // rebuilds the widget tree itself. + Provider( + create: + (context) => ServicioAnuncios( + esPremium: () => context.read().esPremium, + ), + ), ], child: Consumer( builder: @@ -218,28 +252,40 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> final indice = navegacion.indice; return PluriWaveScaffold( - body: SafeArea( - top: false, - child: AnimatedSwitcher( - duration: context.pluriMotion.normal, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: - (child, animation) => FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.035, 0), - end: Offset.zero, - ).animate(animation), - child: child, + // ad-display spec "Persistent Top Banner, Never Overlapping Content" + // (design.md ADR-6): a `Column` sibling, NEVER a `Stack`/overlay — the + // banner RESERVES its own space above the existing body instead of + // covering any of it. `BannerAnuncioSuperior` itself collapses to + // `SizedBox.shrink()` (zero layout impact) for premium/unloaded. + body: Column( + children: [ + const SafeArea(bottom: false, child: BannerAnuncioSuperior()), + Expanded( + child: SafeArea( + top: false, + child: AnimatedSwitcher( + duration: context.pluriMotion.normal, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: + (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.035, 0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + child: KeyedSubtree( + key: ValueKey(indice), + child: _paginas[indice], ), ), - child: KeyedSubtree( - key: ValueKey(indice), - child: _paginas[indice], + ), ), - ), + ], ), bottomNavigationBar: SafeArea( top: false, diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index da2afe6..b7665b3 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -9,15 +9,31 @@ import '../servicios/servicio_alarmas.dart'; import '../servicios/servicio_alarmas_android.dart'; import '../servicios/servicio_programacion_alarmas.dart'; +/// Distinct "limit reached" signal (Design ADR-5, freemium-gating spec +/// "Alarm Count Cap At 5"): kept SEPARATE from [EstadoAlarmas.error], which +/// stays reserved for native scheduling failures — overloading it would +/// surface a free-tier limit as a scheduling failure in `app.dart`'s global +/// snackbar path. +enum ResultadoGuardarAlarma { guardada, limiteAlcanzado } + class EstadoAlarmas extends ChangeNotifier { EstadoAlarmas({ ServicioAlarmas? servicio, PuertoAlarmasAndroid? android, SharedPreferences? prefs, bool iniciarAutomaticamente = true, + // iap-freemium-unlock (Design ADR-3): entitlement query, mirroring + // `EstadoGrabacion`'s `emisoraActual` callback-injection shape rather + // than a direct `EstadoEntitlement` dependency (this notifier must stay + // constructible with zero widget-tree/Provider context). Defaults to + // "premium" (ungated) so every pre-existing test/call site that never + // wires entitlement keeps its exact previous behavior — production + // wiring in `app.dart` always passes the real callback. + bool Function()? esPremium, }) : servicio = servicio ?? ServicioAlarmas(prefs: prefs), android = android ?? ServicioAlarmasAndroid(), - _prefs = prefs { + _prefs = prefs, + _esPremium = esPremium ?? (() => true) { // Decision 2.1 (snooze sync): the native layer reports its own snoozes // back through alarmFired/snoozed; record them here so the Flutter // config stays the single source of truth. @@ -32,8 +48,12 @@ class EstadoAlarmas extends ChangeNotifier { final ServicioAlarmas servicio; final PuertoAlarmasAndroid android; final SharedPreferences? _prefs; + final bool Function() _esPremium; static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada'; + /// Free-tier alarm cap (freemium-gating spec "Alarm Count Cap At 5"). + static const maxAlarmasFree = 5; + List _alarmas = []; List _vacaciones = []; List _excepciones = []; @@ -101,7 +121,26 @@ class EstadoAlarmas extends ChangeNotifier { } } - Future guardarAlarma(AlarmaMusical alarma) async { + /// Pure query (freemium-gating spec "Alarm Count Cap At 5"): whether a NEW + /// alarm may be created right now. Counts ALL alarms regardless of + /// `activa` (Spec "6th alarm creation is blocked" — "any enabled state"). + /// Always `true` for premium (no cap). Editing an existing id is never + /// subject to this — see [guardarAlarma]'s own new-vs-edit check. + bool puedeCrearAlarma() => _esPremium() || _alarmas.length < maxAlarmasFree; + + Future guardarAlarma(AlarmaMusical alarma) async { + // Gate BEFORE any native scheduling attempt (freemium-gating spec "6th + // alarm creation is blocked": "no native scheduling is attempted"). + // Editing an alarm that already exists (by id) is NEVER capped — only + // genuinely NEW creation counts against the limit (Spec "Editing an + // existing alarm is unaffected", grandfathering). + final esAlarmaNueva = !_alarmas.any((a) => a.id == alarma.id); + if (esAlarmaNueva && !puedeCrearAlarma()) { + debugPrint( + '[PluriWave][alarmas] guardar bloqueado por limite free id=${alarma.id}', + ); + return ResultadoGuardarAlarma.limiteAlcanzado; + } debugPrint( '[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}', ); @@ -125,6 +164,7 @@ class EstadoAlarmas extends ChangeNotifier { await _registrarFalloProgramacion(alarma.id); } notifyListeners(); + return ResultadoGuardarAlarma.guardada; } Future refrescarProgramacion() async { @@ -507,9 +547,20 @@ class EstadoAlarmas extends ChangeNotifier { notifyListeners(); } - Future crearRangoVacaciones(RangoVacaciones rango) async { + /// Full premium gate (freemium-gating spec "Gated Feature Set (Exactly + /// 4)" — alarm vacations, unlike the alarm cap above, are gated entirely, + /// not counted): returns `false` without persisting anything when the + /// caller is free tier. + Future crearRangoVacaciones(RangoVacaciones rango) async { + if (!_esPremium()) { + debugPrint( + '[PluriWave][alarmas] crear vacaciones bloqueado (free) id=${rango.id}', + ); + return false; + } final nuevos = [..._vacaciones, rango]; await guardarVacaciones(nuevos); + return true; } Future eliminarRangoVacaciones(String id) async { diff --git a/lib/estado/estado_entitlement.dart b/lib/estado/estado_entitlement.dart new file mode 100644 index 0000000..7f2e043 --- /dev/null +++ b/lib/estado/estado_entitlement.dart @@ -0,0 +1,140 @@ +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(); + } +} diff --git a/lib/estado/estado_grabacion.dart b/lib/estado/estado_grabacion.dart index 3816079..78e32ac 100644 --- a/lib/estado/estado_grabacion.dart +++ b/lib/estado/estado_grabacion.dart @@ -35,14 +35,26 @@ bool esEmisoraGrabable(Emisora emisora) { return esquema == 'http' || esquema == 'https'; } +/// Distinct outcome signal for [EstadoGrabacion.iniciar] (freemium-gating +/// spec "Recording Start Gated"): [requierePremium] is NOT surfaced through +/// [EstadoGrabacion.iniciar]'s existing `alError` sink — the caller must +/// react by opening the paywall, a different UI than a plain error snackbar. +enum ResultadoIniciarGrabacion { iniciada, requierePremium, error } + class EstadoGrabacion extends ChangeNotifier { EstadoGrabacion({ ServicioGrabacionRadio? servicio, Emisora? Function()? emisoraActual, void Function(String mensaje)? alError, + // iap-freemium-unlock (Design ADR-3): entitlement query, mirroring + // [_emisoraActual]'s callback-injection shape. Defaults to "premium" + // (ungated) so every pre-existing test/call site keeps its exact + // previous behavior — `app.dart` always wires the real callback. + bool Function()? esPremium, }) : servicio = servicio ?? ServicioGrabacionRadio(), _emisoraActual = emisoraActual ?? (() => null), - _alError = alError { + _alError = alError, + _esPremium = esPremium ?? (() => true) { _suscripcion = this.servicio.estadoStream.listen((estado) { if (estado.tipo == EstadoGrabacionRadioTipo.error && estado.error != null) { @@ -65,6 +77,8 @@ class EstadoGrabacion extends ChangeNotifier { /// User-visible error sink (EstadoRadio routes it to its snackbar stream). final void Function(String mensaje)? _alError; + final bool Function() _esPremium; + StreamSubscription? _suscripcion; AppLocalizations? _l10n; @@ -87,7 +101,14 @@ class EstadoGrabacion extends ChangeNotifier { int get maxBytes => servicio.maxBytes; File? get ultimoArchivo => servicio.ultimoArchivo; - Future iniciar({Duration? duracion}) async { + Future iniciar({Duration? duracion}) async { + // Freemium gate (freemium-gating spec "Free user starts a new + // recording"): the AUTHORITATIVE check, before touching the service at + // all. Management of already-existing recordings is untouched — this + // method only governs STARTING a new one. + if (!_esPremium()) { + return ResultadoIniciarGrabacion.requierePremium; + } final actual = _emisoraActual(); // `emisoraActual` is set by `_cambiarFuente` for EVERY source, local // tracks included -- a local file becomes an `Emisora` whose `url` is the @@ -97,12 +118,14 @@ class EstadoGrabacion extends ChangeNotifier { // that, whatever was playing was always a real station. if (actual == null || !esEmisoraGrabable(actual)) { _alError?.call(_textos.recordingSelectStationFirst); - return; + return ResultadoIniciarGrabacion.error; } try { await servicio.iniciar(actual, duracion: duracion); + return ResultadoIniciarGrabacion.iniciada; } catch (e) { _alError?.call(_textos.recordingStartError(e.toString())); + return ResultadoIniciarGrabacion.error; } } diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 4236b50..88668f9 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -47,6 +47,10 @@ class EstadoRadio extends ChangeNotifier { Future Function()? resolverArchivoCustom, FuenteEmisorasAuto? fuenteAuto, bool iniciarAutomaticamente = true, + // iap-freemium-unlock (Design ADR-3): threaded straight through to the + // internal `EstadoGrabacion` below — `EstadoRadio` itself has no gated + // behavior of its own. + bool Function()? esPremium, }) : audio = audio ?? ServicioAudio(), favoritos = favoritos ?? ServicioFavoritos(), radio = radio ?? ServicioRadio(), @@ -66,6 +70,7 @@ class EstadoRadio extends ChangeNotifier { servicio: servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs), emisoraActual: () => emisoraActual, alError: _errorController.add, + esPremium: esPremium, ); busqueda = EstadoBusqueda( radio: this.radio, diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 7de8683..c7205d2 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "إصلاح", "alarmDiagnosticsIntentUnavailable": "تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.", "alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.", - "autoEqDisableOption": "تعطيل" + "autoEqDisableOption": "تعطيل", + "funcionPremium": "ميزة مميزة", + "limiteAlarmasAlcanzado": "لقد وصلت إلى الحد المجاني وهو 5 منبهات.", + "desbloquearPremium": "فتح النسخة المميزة", + "restaurarCompras": "استعادة المشتريات" } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 4e5ab6b..fdfd695 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "সমাধান করুন", "alarmDiagnosticsIntentUnavailable": "এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।", "alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।", - "autoEqDisableOption": "বন্ধ করুন" + "autoEqDisableOption": "বন্ধ করুন", + "funcionPremium": "প্রিমিয়াম বৈশিষ্ট্য", + "limiteAlarmasAlcanzado": "আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।", + "desbloquearPremium": "প্রিমিয়াম আনলক করুন", + "restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6fe80b7..b163332 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "Beheben", "alarmDiagnosticsIntentUnavailable": "Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.", "alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen.", - "autoEqDisableOption": "Deaktivieren" + "autoEqDisableOption": "Deaktivieren", + "funcionPremium": "Premium-Funktion", + "limiteAlarmasAlcanzado": "Du hast das kostenlose Limit von 5 Weckern erreicht.", + "desbloquearPremium": "Premium freischalten", + "restaurarCompras": "Käufe wiederherstellen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 355e368..cd62474 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "Fix this", "alarmDiagnosticsIntentUnavailable": "Couldn't open that settings screen on this phone. Try looking for it manually in Settings.", "alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet.", - "autoEqDisableOption": "Disable" + "autoEqDisableOption": "Disable", + "funcionPremium": "Premium Feature", + "limiteAlarmasAlcanzado": "You've reached the free 5-alarm limit.", + "desbloquearPremium": "Unlock Premium", + "restaurarCompras": "Restore purchases" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index df3c63a..68a946d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -856,5 +856,9 @@ "alarmDiagnosticsFixAction": "Solucionar", "alarmDiagnosticsIntentUnavailable": "No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.", "alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma.", - "autoEqDisableOption": "Desactivar" + "autoEqDisableOption": "Desactivar", + "funcionPremium": "Función Premium", + "limiteAlarmasAlcanzado": "Has alcanzado el límite de 5 alarmas gratuitas.", + "desbloquearPremium": "Desbloquear Premium", + "restaurarCompras": "Restaurar compras" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9ab6328..20c096d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "Corriger", "alarmDiagnosticsIntentUnavailable": "Impossible d'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.", "alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme.", - "autoEqDisableOption": "Désactiver" + "autoEqDisableOption": "Désactiver", + "funcionPremium": "Fonctionnalité Premium", + "limiteAlarmasAlcanzado": "Vous avez atteint la limite gratuite de 5 alarmes.", + "desbloquearPremium": "Débloquer Premium", + "restaurarCompras": "Restaurer les achats" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 13cac7d..f012e90 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "ठीक करें", "alarmDiagnosticsIntentUnavailable": "इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।", "alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।", - "autoEqDisableOption": "बंद करें" + "autoEqDisableOption": "बंद करें", + "funcionPremium": "प्रीमियम सुविधा", + "limiteAlarmasAlcanzado": "आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।", + "desbloquearPremium": "प्रीमियम अनलॉक करें", + "restaurarCompras": "खरीदारी पुनर्स्थापित करें" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4864f62..5249e3e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "Perbaiki", "alarmDiagnosticsIntentUnavailable": "Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.", "alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu.", - "autoEqDisableOption": "Nonaktifkan" + "autoEqDisableOption": "Nonaktifkan", + "funcionPremium": "Fitur Premium", + "limiteAlarmasAlcanzado": "Anda telah mencapai batas gratis 5 alarm.", + "desbloquearPremium": "Buka Premium", + "restaurarCompras": "Pulihkan pembelian" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 31e6a46..c39c322 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "Risolvi", "alarmDiagnosticsIntentUnavailable": "Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.", "alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia.", - "autoEqDisableOption": "Disattiva" + "autoEqDisableOption": "Disattiva", + "funcionPremium": "Funzione Premium", + "limiteAlarmasAlcanzado": "Hai raggiunto il limite gratuito di 5 sveglie.", + "desbloquearPremium": "Sblocca Premium", + "restaurarCompras": "Ripristina acquisti" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 1327254..8e11f79 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "修正する", "alarmDiagnosticsIntentUnavailable": "この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。", "alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。", - "autoEqDisableOption": "無効化" + "autoEqDisableOption": "無効化", + "funcionPremium": "プレミアム機能", + "limiteAlarmasAlcanzado": "無料プランのアラーム上限(5件)に達しました。", + "desbloquearPremium": "プレミアムを解除", + "restaurarCompras": "購入を復元" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 1702dd3..dc656fd 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "Resolver", "alarmDiagnosticsIntentUnavailable": "Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.", "alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme.", - "autoEqDisableOption": "Desativar" + "autoEqDisableOption": "Desativar", + "funcionPremium": "Recurso Premium", + "limiteAlarmasAlcanzado": "Você atingiu o limite gratuito de 5 alarmes.", + "desbloquearPremium": "Desbloquear Premium", + "restaurarCompras": "Restaurar compras" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6b14723..97853b3 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "Исправить", "alarmDiagnosticsIntentUnavailable": "Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.", "alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника.", - "autoEqDisableOption": "Отключить" + "autoEqDisableOption": "Отключить", + "funcionPremium": "Премиум-функция", + "limiteAlarmasAlcanzado": "Вы достигли бесплатного лимита в 5 будильников.", + "desbloquearPremium": "Разблокировать Премиум", + "restaurarCompras": "Восстановить покупки" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 484aad8..a616371 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -897,5 +897,9 @@ "alarmDiagnosticsFixAction": "解决", "alarmDiagnosticsIntentUnavailable": "无法在此手机上打开该设置界面。请尝试在设置中手动查找。", "alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。", - "autoEqDisableOption": "关闭" + "autoEqDisableOption": "关闭", + "funcionPremium": "高级功能", + "limiteAlarmasAlcanzado": "您已达到免费版 5 个闹钟的上限。", + "desbloquearPremium": "解锁高级版", + "restaurarCompras": "恢复购买" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index c6cb50f..7a58b48 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3325,6 +3325,30 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Desactivar'** String get autoEqDisableOption; + + /// No description provided for @funcionPremium. + /// + /// In es, this message translates to: + /// **'Función Premium'** + String get funcionPremium; + + /// No description provided for @limiteAlarmasAlcanzado. + /// + /// In es, this message translates to: + /// **'Has alcanzado el límite de 5 alarmas gratuitas.'** + String get limiteAlarmasAlcanzado; + + /// No description provided for @desbloquearPremium. + /// + /// In es, this message translates to: + /// **'Desbloquear Premium'** + String get desbloquearPremium; + + /// No description provided for @restaurarCompras. + /// + /// In es, this message translates to: + /// **'Restaurar compras'** + String get restaurarCompras; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index db5e4a4..e25ec25 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1840,4 +1840,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String get autoEqDisableOption => 'تعطيل'; + + @override + String get funcionPremium => 'ميزة مميزة'; + + @override + String get limiteAlarmasAlcanzado => + 'لقد وصلت إلى الحد المجاني وهو 5 منبهات.'; + + @override + String get desbloquearPremium => 'فتح النسخة المميزة'; + + @override + String get restaurarCompras => 'استعادة المشتريات'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 5bb11da..1384b8a 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1851,4 +1851,17 @@ class AppLocalizationsBn extends AppLocalizations { @override String get autoEqDisableOption => 'বন্ধ করুন'; + + @override + String get funcionPremium => 'প্রিমিয়াম বৈশিষ্ট্য'; + + @override + String get limiteAlarmasAlcanzado => + 'আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।'; + + @override + String get desbloquearPremium => 'প্রিমিয়াম আনলক করুন'; + + @override + String get restaurarCompras => 'কেনাকাটা পুনরুদ্ধার করুন'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 732a38f..0343106 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1864,4 +1864,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String get autoEqDisableOption => 'Deaktivieren'; + + @override + String get funcionPremium => 'Premium-Funktion'; + + @override + String get limiteAlarmasAlcanzado => + 'Du hast das kostenlose Limit von 5 Weckern erreicht.'; + + @override + String get desbloquearPremium => 'Premium freischalten'; + + @override + String get restaurarCompras => 'Käufe wiederherstellen'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 0e416a1..ffb473a 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1843,4 +1843,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get autoEqDisableOption => 'Disable'; + + @override + String get funcionPremium => 'Premium Feature'; + + @override + String get limiteAlarmasAlcanzado => + 'You\'ve reached the free 5-alarm limit.'; + + @override + String get desbloquearPremium => 'Unlock Premium'; + + @override + String get restaurarCompras => 'Restore purchases'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 2d6f10e..92a7822 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1857,4 +1857,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get autoEqDisableOption => 'Desactivar'; + + @override + String get funcionPremium => 'Función Premium'; + + @override + String get limiteAlarmasAlcanzado => + 'Has alcanzado el límite de 5 alarmas gratuitas.'; + + @override + String get desbloquearPremium => 'Desbloquear Premium'; + + @override + String get restaurarCompras => 'Restaurar compras'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 4c4c487..fbb3fda 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1870,4 +1870,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String get autoEqDisableOption => 'Désactiver'; + + @override + String get funcionPremium => 'Fonctionnalité Premium'; + + @override + String get limiteAlarmasAlcanzado => + 'Vous avez atteint la limite gratuite de 5 alarmes.'; + + @override + String get desbloquearPremium => 'Débloquer Premium'; + + @override + String get restaurarCompras => 'Restaurer les achats'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index c7baf12..3a99156 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1844,4 +1844,17 @@ class AppLocalizationsHi extends AppLocalizations { @override String get autoEqDisableOption => 'बंद करें'; + + @override + String get funcionPremium => 'प्रीमियम सुविधा'; + + @override + String get limiteAlarmasAlcanzado => + 'आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।'; + + @override + String get desbloquearPremium => 'प्रीमियम अनलॉक करें'; + + @override + String get restaurarCompras => 'खरीदारी पुनर्स्थापित करें'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index eee2b0a..1a9fb0b 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1854,4 +1854,17 @@ class AppLocalizationsId extends AppLocalizations { @override String get autoEqDisableOption => 'Nonaktifkan'; + + @override + String get funcionPremium => 'Fitur Premium'; + + @override + String get limiteAlarmasAlcanzado => + 'Anda telah mencapai batas gratis 5 alarm.'; + + @override + String get desbloquearPremium => 'Buka Premium'; + + @override + String get restaurarCompras => 'Pulihkan pembelian'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 8f9b018..5ff3ff5 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1867,4 +1867,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String get autoEqDisableOption => 'Disattiva'; + + @override + String get funcionPremium => 'Funzione Premium'; + + @override + String get limiteAlarmasAlcanzado => + 'Hai raggiunto il limite gratuito di 5 sveglie.'; + + @override + String get desbloquearPremium => 'Sblocca Premium'; + + @override + String get restaurarCompras => 'Ripristina acquisti'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 8d5dcf8..297305c 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1791,4 +1791,16 @@ class AppLocalizationsJa extends AppLocalizations { @override String get autoEqDisableOption => '無効化'; + + @override + String get funcionPremium => 'プレミアム機能'; + + @override + String get limiteAlarmasAlcanzado => '無料プランのアラーム上限(5件)に達しました。'; + + @override + String get desbloquearPremium => 'プレミアムを解除'; + + @override + String get restaurarCompras => '購入を復元'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index a562d30..7e6039a 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1854,4 +1854,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String get autoEqDisableOption => 'Desativar'; + + @override + String get funcionPremium => 'Recurso Premium'; + + @override + String get limiteAlarmasAlcanzado => + 'Você atingiu o limite gratuito de 5 alarmes.'; + + @override + String get desbloquearPremium => 'Desbloquear Premium'; + + @override + String get restaurarCompras => 'Restaurar compras'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 25ce7f5..27d82c0 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1861,4 +1861,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String get autoEqDisableOption => 'Отключить'; + + @override + String get funcionPremium => 'Премиум-функция'; + + @override + String get limiteAlarmasAlcanzado => + 'Вы достигли бесплатного лимита в 5 будильников.'; + + @override + String get desbloquearPremium => 'Разблокировать Премиум'; + + @override + String get restaurarCompras => 'Восстановить покупки'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 3e53ca2..e77ae59 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1776,4 +1776,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get autoEqDisableOption => '关闭'; + + @override + String get funcionPremium => '高级功能'; + + @override + String get limiteAlarmasAlcanzado => '您已达到免费版 5 个闹钟的上限。'; + + @override + String get desbloquearPremium => '解锁高级版'; + + @override + String get restaurarCompras => '恢复购买'; } diff --git a/lib/main.dart b/lib/main.dart index f7ae79a..6cd8b4d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'dart:ui' as ui; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'servicios/arranque_audio.dart'; @@ -12,6 +13,7 @@ import 'servicios/musica_local_auto.dart'; import 'servicios/navegacion_auto.dart'; import 'servicios/servicio_audio.dart'; import 'servicios/servicio_audio_session.dart'; +import 'servicios/servicio_compras.dart'; import 'servicios/servicio_presets_personalizados.dart'; import 'tema/pluriwave_tokens.dart'; @@ -104,6 +106,13 @@ Future main() async { // actually take effect anyway. unawaited(aplicarPoliticaOrientacion()); + // iap-freemium-unlock: neither SDK init call blocks `runApp` — a purchase + // stream subscription and an ad-SDK warm-up are both safe to finish late + // (mirrors `aplicarPoliticaOrientacion`'s "cosmetic, never gates startup" + // rule immediately above). + unawaited(MobileAds.instance.initialize()); + final compras = ServicioComprasPlayBilling(); + // S3-R4: single SharedPreferences instance resolved once at startup and // injected into every state/service below. final prefs = await SharedPreferences.getInstance(); @@ -155,7 +164,7 @@ Future main() async { } Widget construirApp() => _OrientacionResponsiveApp( - child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto), + child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras), ); final resultado = await esperarArranqueAudio(handlerFuturo); diff --git a/lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart b/lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart index 6b1b841..0bb5fce 100644 --- a/lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart +++ b/lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart @@ -6,6 +6,7 @@ import '../../estado/estado_radio.dart'; import '../../l10n/display_names.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../modelos/emisora.dart'; +import '../../servicios/servicio_anuncios.dart'; import '../../widgets/pluri_glass_surface.dart'; import '../../widgets/pluri_layout.dart'; import '../../widgets/pluri_push_scaffold.dart'; @@ -105,6 +106,11 @@ class _CuerpoEmisorasPersonalizadas extends StatelessWidget { } Future _mostrarFormularioAnadir(BuildContext context) async { + // ad-display spec "Interstitial Before Manual Station Add" (design.md + // ADR-6): fires on the CTA tap, before the form even opens — a no-op + // for premium (ServicioAnuncios' own entitlement gate). + await context.read().intentarInterstitial(); + if (!context.mounted) return; await showModalBottomSheet( context: context, isScrollControlled: true, diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index f57f114..053ab24 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../estado/estado_ecualizador.dart'; +import '../estado/estado_entitlement.dart'; import '../estado/estado_grabacion.dart'; import '../estado/estado_idioma.dart'; import '../estado/estado_radio.dart'; @@ -10,6 +11,7 @@ import '../l10n/gen/app_localizations.dart'; import '../modelos/archivo_grabacion.dart'; import '../modelos/emisora.dart'; import '../tema/pluriwave_tokens.dart'; +import '../widgets/hoja_premium.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_push_scaffold.dart'; import '../widgets/pluri_root_header.dart'; @@ -99,6 +101,9 @@ class _AjustesContent extends StatelessWidget { final idioma = context.select( (e) => e.localeSeleccionado, ); + final esPremium = context.select( + (e) => e.esPremium, + ); return Column( children: [ @@ -256,6 +261,18 @@ class _AjustesContent extends StatelessWidget { GrupoAjustes( titulo: l10n.settingsGroupApplicationTitle, filas: [ + // freemium-gating spec "Settings always shows a premium row": + // a persistent buy row (free tier) or a premium-active state + // with restore access (premium tier) — both open the same + // paywall sheet, which adapts its own body to the tier. + FilaAjuste( + key: const ValueKey('ajustes-fila-premium'), + icon: Icons.workspace_premium_rounded, + iconColor: PluriWaveTokens.brand, + titulo: l10n.funcionPremium, + valor: esPremium ? l10n.equalizerActive : null, + onTap: () => mostrarHojaPremium(context), + ), FilaAjuste( icon: Icons.language_rounded, titulo: l10n.languageSectionTitle, diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 15da5bd..da20a24 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -9,10 +9,12 @@ import '../l10n/app_localizations_ext.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; import '../modelos/emisora.dart'; +import '../servicios/servicio_anuncios.dart'; import '../servicios/servicio_programacion_alarmas.dart'; import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_tokens.dart'; import '../widgets/editor_hora_inline.dart'; +import '../widgets/hoja_premium.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_push_scaffold.dart'; @@ -105,6 +107,21 @@ class PantallaAlarmas extends StatelessWidget { BuildContext context, { AlarmaMusical? alarma, }) async { + // ADR-6 ordering (design.md): for a genuinely NEW alarm (no [alarma]), + // the cap-check + maybe-interstitial happen HERE, before the editor + // ever opens — "puedeCrearAlarma -> if false, show the limit message + // and no ad; if true, maybe-interstitial, then open the editor". + // Editing an existing alarm skips both checks entirely: it is never + // capped and never triggers the interstitial. + if (alarma == null) { + final estado = context.read(); + if (!estado.puedeCrearAlarma()) { + _mostrarLimiteAlarmas(context); + return; + } + await context.read().intentarInterstitial(); + if (!context.mounted) return; + } await showModalBottomSheet( context: context, isScrollControlled: true, @@ -113,6 +130,22 @@ class PantallaAlarmas extends StatelessWidget { builder: (_) => _EditorAlarmaSheet(alarma: alarma), ); } + + /// Freemium-gating spec "Alarm Cap UX Never Bare-Jumps To Paywall": an + /// explanatory message with a SECONDARY unlock action — never a direct + /// paywall navigation as the sole response to hitting the cap. + void _mostrarLimiteAlarmas(BuildContext context) { + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.limiteAlarmasAlcanzado), + action: SnackBarAction( + label: l10n.desbloquearPremium, + onPressed: () => mostrarHojaPremium(context), + ), + ), + ); + } } class _PanelProximaAlarma extends StatelessWidget { @@ -1186,8 +1219,34 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { sonidoInterno: _sonidoInterno, activa: true, ); - await estado.guardarAlarma(alarma); - if (mounted) Navigator.pop(context); + // The cap-check + interstitial already ran in `PantallaAlarmas + // ._abrirEditor` BEFORE this sheet ever opened (ADR-6 ordering: "then + // open the editor"). This is only the defense-in-depth backstop against + // the state-layer choke point — e.g. a 2nd device created alarms while + // this sheet was open — the true authority is `guardarAlarma` itself. + final resultado = await estado.guardarAlarma(alarma); + if (!mounted) return; + if (resultado == ResultadoGuardarAlarma.limiteAlcanzado) { + _mostrarLimiteAlarmas(context); + return; + } + Navigator.pop(context); + } + + /// Freemium-gating spec "Alarm Cap UX Never Bare-Jumps To Paywall": an + /// explanatory message with a SECONDARY unlock action — never a direct + /// paywall navigation as the sole response to hitting the cap. + void _mostrarLimiteAlarmas(BuildContext context) { + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.limiteAlarmasAlcanzado), + action: SnackBarAction( + label: l10n.desbloquearPremium, + onPressed: () => mostrarHojaPremium(context), + ), + ), + ); } List _favoritasConSeleccion(List favoritas) { diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index cbf87ca..cc2638f 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -6,6 +6,7 @@ import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; +import '../servicios/servicio_anuncios.dart'; import '../tema/pluriwave_tokens.dart'; import '../widgets/fila_emisora_plana.dart'; import '../widgets/pluri_icon.dart'; @@ -38,6 +39,11 @@ class _PantallaFavoritosState extends State { String? _grupoSeleccionadoId; Future _abrirFormularioEmisoraPersonalizada() async { + // ad-display spec "Interstitial Before Manual Station Add" (design.md + // ADR-6): fires on the CTA tap, before the form even opens — a no-op + // for premium (ServicioAnuncios' own entitlement gate). + await context.read().intentarInterstitial(); + if (!mounted) return; await showModalBottomSheet( context: context, isScrollControlled: true, diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index 451f9a4..d9d4e92 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -17,6 +17,7 @@ import '../tema/pluri_animate.dart'; import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_tokens.dart'; import '../widgets/ecualizador_widget.dart'; +import '../widgets/hoja_premium.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_push_scaffold.dart'; @@ -597,6 +598,28 @@ class _GrabacionWidget extends StatelessWidget { } } + /// Freemium gate choke point at the UI layer (freemium-gating spec "Free + /// user starts a new recording"): all 3 record-start call sites route + /// through here. [ctx] is the picker sheet/dialog's own (short-lived) + /// context — closed FIRST (matching the pre-existing pop-then-done shape). + /// [contextExterno] is the screen's own longer-lived context, used ONLY to + /// react to the AUTHORITATIVE [EstadoGrabacion.iniciar] result: a + /// free-tier block opens the paywall there instead of a plain error, since + /// [ctx] is already gone by then. + Future _iniciarGrabacionYCerrar( + BuildContext ctx, + BuildContext contextExterno, + EstadoGrabacion grabacion, { + Duration? duracion, + }) async { + final resultado = await grabacion.iniciar(duracion: duracion); + if (ctx.mounted) Navigator.pop(ctx); + if (resultado == ResultadoIniciarGrabacion.requierePremium && + contextExterno.mounted) { + await mostrarHojaPremium(contextExterno); + } + } + void _mostrarDialogoGrabacion(BuildContext context) { final grabacion = context.read(); showModalBottomSheet( @@ -626,10 +649,12 @@ class _GrabacionWidget extends StatelessWidget { size: 18, ), label: Text(AppLocalizations.of(ctx).indefiniteOption), - onPressed: () { - grabacion.iniciar(); - Navigator.pop(ctx); - }, + onPressed: + () => _iniciarGrabacionYCerrar( + ctx, + context, + grabacion, + ), ), for (final opcion in _opciones) ActionChip( @@ -642,10 +667,13 @@ class _GrabacionWidget extends StatelessWidget { opcion.duracion.inSeconds, ), ), - onPressed: () { - grabacion.iniciar(duracion: opcion.duracion); - Navigator.pop(ctx); - }, + onPressed: + () => _iniciarGrabacionYCerrar( + ctx, + context, + grabacion, + duracion: opcion.duracion, + ), ), ActionChip( avatar: const Icon(Icons.tune_rounded, size: 18), @@ -718,8 +746,12 @@ class _GrabacionWidget extends StatelessWidget { seconds: segundos, ); if (duracion <= Duration.zero) return; - grabacion.iniciar(duracion: duracion); - Navigator.pop(ctx); + _iniciarGrabacionYCerrar( + ctx, + context, + grabacion, + duracion: duracion, + ); }, child: Text(AppLocalizations.of(ctx).recordAction), ), diff --git a/lib/pantallas/pantalla_vacaciones.dart b/lib/pantallas/pantalla_vacaciones.dart index b347b33..2f40664 100644 --- a/lib/pantallas/pantalla_vacaciones.dart +++ b/lib/pantallas/pantalla_vacaciones.dart @@ -8,6 +8,7 @@ import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_tokens.dart'; +import '../widgets/hoja_premium.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_push_scaffold.dart'; @@ -927,7 +928,14 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { fin: _fin, nombre: nombre, ); - await estado.crearRangoVacaciones(rango); + // freemium-gating spec "Gated Feature Set": vacation creation is + // fully gated (unlike the alarm cap, there is no free allowance) — + // `crearRangoVacaciones` is the authoritative choke point. + final creada = await estado.crearRangoVacaciones(rango); + if (!creada) { + if (mounted) await mostrarHojaPremium(context); + return; + } } if (mounted) Navigator.pop(context); } diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 300128f..3685be9 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -333,13 +333,43 @@ class ConstructorArbolAuto { /// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder /// is configured") — the caller passes `fuente.hayCarpetaConfigurada()`, /// keeping this builder itself synchronous and side-effect free. - List raiz({required bool incluirMusicaLocal}) => [ + /// + /// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact + /// same visible folder labels for every tier — "keeps the same visible + /// folder labels for free users" is the explicit design choice, so a free + /// driver still sees a real, familiar menu rather than a wall of "Función + /// Premium" rows. The lock itself is enforced one level DOWN, at the + /// `getChildren` choke point (see [itemPremiumBloqueado] and + /// [respuestaBloqueadaPorEntitlement] below) — tapping any of these + /// folders as a free user reveals the lock there, never here. + List raiz({ + required bool incluirMusicaLocal, + required bool premium, + }) => [ _carpeta(idFavoritos, 'Favoritos'), _carpeta(idTodas, 'Todas las emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'), if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'), ]; + /// Free-tier id prefix reserved id (iap-freemium-unlock, Design ADR-4): + /// the single non-playable item every non-root folder collapses to for a + /// free-tier user. Hardcoded Spanish label, matching every other car-tree + /// label in this file (never routed through `AppLocalizations` — + /// established convention, see [_tituloMasLocal]'s doc). + static const idPremiumInfo = 'premium:info'; + + /// The single locked item shown for ANY non-root folder when the browsing + /// user is free tier (Design ADR-4, android-auto-media spec "Free-Tier + /// Reduced Root Browse"). Non-playable — selecting it is a no-op, never a + /// crash (Spec "Free-tier user selects a locked item"). + MediaItem itemPremiumBloqueado() => MediaItem( + id: idPremiumInfo, + title: 'Función Premium', + playable: false, + extras: _contentStyleLista, + ); + MediaItem _carpeta(String id, String titulo) => MediaItem( id: id, title: titulo, @@ -899,6 +929,26 @@ class ConstructorArbolAuto { } } +/// Pure Android Auto browse-gate decision (iap-freemium-unlock, Design +/// ADR-4): the AUTHORITATIVE `getChildren` choke point, called BEFORE any +/// other resolution. For the root itself this NEVER blocks (the root always +/// resolves through [ConstructorArbolAuto.raiz] instead, which stays +/// visible for every tier). For any non-root [parentMediaId] and a free-tier +/// [premium], it returns the single locked item regardless of what the id +/// actually is — a stale/deep-linked `emisora:` or folder id from +/// before a downgrade is blocked exactly the same way as a legitimate +/// current folder id (android-auto-media spec "Free-Tier Browse Never +/// Leaks Real Content (Authoritative Backstop)"). Returns `null` when the +/// caller should proceed with its normal resolution (root, or premium). +List? respuestaBloqueadaPorEntitlement({ + required String parentMediaId, + required bool premium, +}) { + if (parentMediaId == AudioService.browsableRootId) return null; + if (premium) return null; + return [ConstructorArbolAuto().itemPremiumBloqueado()]; +} + /// Routing seam between a car-tapped `emisora:` media id and the /// existing internal playback path (Design "playback coherence" — reuse /// over duplication). Resolves the uuid via [fuente], builds the same diff --git a/lib/servicios/servicio_anuncios.dart b/lib/servicios/servicio_anuncios.dart new file mode 100644 index 0000000..fea7027 --- /dev/null +++ b/lib/servicios/servicio_anuncios.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +/// TODO(ads): official Google TEST ad unit ids — AdMob has not provisioned +/// real ones yet (design.md Open Questions). Swap these for the real banner +/// / interstitial unit ids once available; never ship the test ids to +/// production. +const bannerAdUnitIdPrueba = 'ca-app-pub-3940256099942544/6300978111'; +const interstitialAdUnitIdPrueba = 'ca-app-pub-3940256099942544/1033173712'; + +/// 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 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 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 intentarInterstitial() async { + if (!_dentroDelCap()) return false; + final mostrado = await _mostrarInterstitialImpl(); + if (mostrado) { + _mostrados++; + _ultimoMostrado = _ahora(); + } + return mostrado; + } + + static Future _mostrarInterstitialAdMob() async { + try { + final cargaCompleter = Completer(); + await InterstitialAd.load( + adUnitId: interstitialAdUnitIdPrueba, + 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(); + 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; + } + } +} diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 1150273..fe690d0 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -4,7 +4,9 @@ import 'dart:ui' show Locale; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; import 'package:just_audio/just_audio.dart'; +import 'package:rxdart/rxdart.dart'; +import '../estado/estado_entitlement.dart' show esPremiumPersistido; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; @@ -36,6 +38,17 @@ PluriWaveAudioHandler? _handlerGlobal; void registrarHandler(PluriWaveAudioHandler handler) { _handlerGlobal = handler; + // iap-freemium-unlock (design.md Open Questions, orchestrator-resolved): + // on the free -> premium transition, actively invalidate every root-level + // browse id a head unit may have cached while locked, rather than waiting + // for its own re-bind — see [registrarNotificacionDesbloqueoAuto]'s doc. + registrarNotificacionDesbloqueoAuto(() { + handler.notificarHijosCambiaron(AudioService.browsableRootId); + handler.notificarHijosCambiaron(ConstructorArbolAuto.idFavoritos); + handler.notificarHijosCambiaron(ConstructorArbolAuto.idTodas); + handler.notificarHijosCambiaron(ConstructorArbolAuto.idMisEmisoras); + handler.notificarHijosCambiaron(ConstructorArbolAuto.idMusicaLocal); + }); } // ───────────────────────────────────────────────────────────────────────────── @@ -140,6 +153,41 @@ void registrarLimpiezaArranque(Future Function() limpieza) { _limpiezaArranqueGlobal = limpieza; } +/// Free -> premium Android Auto cache-invalidation hook (design.md Open +/// Questions, orchestrator-resolved): registered from [registrarHandler] so +/// `estado_entitlement.dart` can trigger it WITHOUT ever touching +/// `PluriWaveAudioHandler` directly (that type cannot be constructed in a +/// unit test — see [PluriWaveAudioHandler]'s own doc). `null` until a +/// handler registers (headless cold bind, or a widget-only test that never +/// wires audio) — [notificarDesbloqueoAuto] tolerates that silently. +void Function()? _alDesbloquearAutoGlobal; + +/// Registers the hook [notificarDesbloqueoAuto] invokes. Exposed at module +/// level (like every other `registrar*` seam in this file) purely so tests +/// can inject a fake hook and assert it fires, without instantiating a real +/// [PluriWaveAudioHandler]. +void registrarNotificacionDesbloqueoAuto(void Function() alDesbloquear) { + _alDesbloquearAutoGlobal = alDesbloquear; +} + +/// Fires the registered free -> premium Android Auto invalidation hook, if +/// any. A no-op before a handler ever registers — never throws. +void notificarDesbloqueoAuto() { + _alDesbloquearAutoGlobal?.call(); +} + +/// Pure Android Auto play-path gate decision (iap-freemium-unlock, Design +/// ADR-4): whether a station-switch dispatch (`playFromMediaId`, +/// `playFromSearch`, `skipToNext`, `skipToPrevious`) must no-op for +/// [premium]. This is the mandatory BACKSTOP alongside +/// `respuestaBloqueadaPorEntitlement` (`navegacion_auto.dart`) — gating +/// `getChildren` alone would leave a stale/cached `emisora:` tap free +/// to bypass browsing entirely (android-auto-media spec "Free-Tier Browse +/// Never Leaks Real Content"). Deliberately does NOT gate `play`/`pause`/ +/// `stop` — transport control of whatever is ALREADY loaded stays free +/// (Spec "Current-Station Playback Unaffected By Free Tier"). +bool debeBloquearCambioDeEmisora({required bool premium}) => !premium; + /// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android /// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a /// station with no usable favicon gets the SAME on-brand rotating fallback @@ -638,6 +686,32 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// Reconnect-on-stall state machine (Design 7.2, S7-R2). final ControladorReconexion _reconexion = ControladorReconexion(); + /// Per-`parentMediaId` "children changed" subjects (iap-freemium-unlock, + /// design.md Open Questions): `audio_service`'s OWN internal listener + /// (registered once `AudioService.init` completes) subscribes to + /// [subscribeToChildren] and forwards every new value to the platform's + /// `notifyChildrenChanged` — the plugin's top-level `notifyChildrenChanged` + /// helper is deprecated precisely in favor of this stream-based path. A + /// `BehaviorSubject` per id, created lazily on first subscription; + /// [notificarHijosCambiaron] pushes a fresh (empty, content-agnostic) + /// value to trigger the platform notification for that id. + final _childrenSubjects = >>{}; + + @override + ValueStream> subscribeToChildren(String parentMediaId) => + _childrenSubjects.putIfAbsent( + parentMediaId, + () => BehaviorSubject>.seeded({}), + ); + + /// Invalidates a head unit's cached browse listing for [parentMediaId] + /// (Design "Open Questions" — actively invalidate on the free -> premium + /// transition rather than waiting for the head unit's own re-bind). A + /// no-op if nothing ever subscribed to this id. + void notificarHijosCambiaron(String parentMediaId) { + _childrenSubjects[parentMediaId]?.add({}); + } + /// True while the handler is inside the reconnect window. [ServicioAudio] /// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading /// indicator instead of an error during retries (S7-R3). @@ -1521,6 +1595,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// and a button that is present but inert is worse than no button. @override Future skipToNext() async { + // iap-freemium-unlock (Design ADR-4 backstop): station-to-station + // skipping is a browse/switch action, blocked for free tier regardless + // of queue state. Current-station play/pause/stop is untouched. + if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { + return; + } final cola = _colaLocal; if (cola == null) { if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: false); @@ -1542,6 +1622,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// [skipToNext]. @override Future skipToPrevious() async { + // iap-freemium-unlock (Design ADR-4 backstop): mirrors [skipToNext]. + if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { + return; + } final cola = _colaLocal; if (cola == null) { if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: true); @@ -1640,6 +1724,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler await _androidAudioSessionIdSub?.cancel(); await _player.dispose(); await _androidAudioSessionIdController.close(); + for (final subject in _childrenSubjects.values) { + await subject.close(); + } // Handler teardown: release the bootstrap-owned `AudioService.asyncError` // subscription too, so it cannot outlive the handler it was instrumenting. // Never throws out of teardown — a failing cleanup hook must not prevent @@ -1670,11 +1757,25 @@ class PluriWaveAudioHandler extends BaseAudioHandler ]) async { try { final constructor = ConstructorArbolAuto(); + // iap-freemium-unlock (Design ADR-4): the AUTHORITATIVE entitlement + // gate, resolved ONCE per call and checked BEFORE any other + // resolution — the backstop against a stale/deep-linked non-root id + // (android-auto-media spec "Free-Tier Browse Never Leaks Real + // Content"). Never blocks the root itself (see that function's doc). + final premium = await esPremiumPersistido(); + final bloqueada = respuestaBloqueadaPorEntitlement( + parentMediaId: parentMediaId, + premium: premium, + ); + if (bloqueada != null) return bloqueada; final fuenteLocal = _fuenteMusicaLocalGlobal; if (parentMediaId == AudioService.browsableRootId) { final incluirMusicaLocal = fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada(); - return constructor.raiz(incluirMusicaLocal: incluirMusicaLocal); + return constructor.raiz( + incluirMusicaLocal: incluirMusicaLocal, + premium: premium, + ); } final musicaLocal = await hijosMusicaLocal( parentMediaId, @@ -1756,6 +1857,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler Map? extras, ]) async { try { + // iap-freemium-unlock (Design ADR-4 backstop): voice search resolves a + // station and switches to it — a browse/switch action, blocked for + // free tier just like `playFromMediaId`/`skipToNext-Previous`. + if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { + return; + } final fuente = _fuenteNavegacionGlobal; if (fuente == null) return; final candidatas = [ @@ -1779,6 +1886,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler Map? extras, ]) async { try { + // iap-freemium-unlock (Design ADR-4 backstop): the mandatory backstop + // against a head-unit's CACHED browse tree — `getChildren` alone + // cannot stop a stale `emisora:`/`pista:`/`eq_preset:` tap from + // a tree fetched before a downgrade (or from another device). Checked + // BEFORE every branch below, including local tracks and the + // equalizer (android-auto-media spec "Free-Tier Browse Never Leaks + // Real Content (Authoritative Backstop)"). + if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { + return; + } // Local-track playback (Design "Local Track Playback Reuses Existing // Pipeline", Spec "User selects a local track"): FIRST branch, // unconditional `return` — a `pista:` id never falls through to the diff --git a/lib/servicios/servicio_compras.dart b/lib/servicios/servicio_compras.dart new file mode 100644 index 0000000..5e33c73 --- /dev/null +++ b/lib/servicios/servicio_compras.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:in_app_purchase/in_app_purchase.dart'; + +/// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors +/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so +/// [EstadoEntitlement] never imports the plugin package directly — the SAME +/// port-boundary discipline `PuertoAlarmasAndroid` already applies. +enum TipoEventoCompra { + /// A fresh purchase completed successfully. + comprada, + + /// [PuertoCompras.restaurar] found a prior purchase. + restaurada, + + /// The user cancelled the purchase flow before it completed. + cancelada, + + /// The purchase/restore flow failed (network, billing error, etc). + error, + + /// [PuertoCompras.restaurar] completed with nothing to restore — NOT an + /// error (Spec "Restore finds nothing"). + noEncontrada, + + /// A purchase is in-flight (billing dialog shown, awaiting the user). + pendiente, +} + +/// A single purchase-stream event (Design ADR-2). [mensaje] is populated +/// only for [TipoEventoCompra.error], for diagnostics/logging — never shown +/// to the user verbatim. +class EventoCompra { + const EventoCompra(this.tipo, {this.mensaje}); + + final TipoEventoCompra tipo; + final String? mensaje; +} + +/// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on +/// this port, never on `in_app_purchase` directly — matches +/// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and +/// keeps Strict TDD viable with zero plugin channels in unit tests. +abstract class PuertoCompras { + /// Broadcasts every purchase-flow outcome (Design ADR-2) — [comprar] and + /// [restaurar] do not return the outcome directly because + /// `in_app_purchase`'s own API is stream-based (a purchase can complete + /// asynchronously well after the call that started it, e.g. after leaving + /// and returning to the app). + Stream get eventos; + + /// Starts the one-time non-consumable purchase flow. + Future comprar(); + + /// Re-queries Play Billing for a prior purchase on this account. + Future restaurar(); +} + +/// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file +/// depends on [PuertoCompras] instead. +class ServicioComprasPlayBilling implements PuertoCompras { + ServicioComprasPlayBilling({InAppPurchase? inAppPurchase}) + : _iap = inAppPurchase ?? InAppPurchase.instance { + _sub = _iap.purchaseStream.listen( + _alRecibirCompras, + onError: (Object error) { + debugPrint('[PluriWave][compras] purchaseStream ERROR $error'); + _eventos.add( + EventoCompra(TipoEventoCompra.error, mensaje: error.toString()), + ); + }, + ); + } + + /// The single non-consumable product id (Design "Interfaces / Contracts"). + static const idProducto = 'pluriwave_premium'; + + final InAppPurchase _iap; + final _eventos = StreamController.broadcast(); + StreamSubscription>? _sub; + + @override + Stream get eventos => _eventos.stream; + + @override + Future comprar() async { + try { + final disponible = await _iap.isAvailable(); + if (!disponible) { + _eventos.add( + const EventoCompra( + TipoEventoCompra.error, + mensaje: 'Play Billing no disponible', + ), + ); + return; + } + final respuesta = await _iap.queryProductDetails({idProducto}); + final detalle = respuesta.productDetails.firstOrNull; + if (detalle == null) { + _eventos.add( + const EventoCompra( + TipoEventoCompra.error, + mensaje: 'Producto no encontrado en Play Console', + ), + ); + return; + } + final parametros = PurchaseParam(productDetails: detalle); + await _iap.buyNonConsumable(purchaseParam: parametros); + } catch (e) { + debugPrint('[PluriWave][compras] comprar ERROR $e'); + _eventos.add(EventoCompra(TipoEventoCompra.error, mensaje: e.toString())); + } + } + + @override + Future restaurar() async { + try { + await _iap.restorePurchases(); + } catch (e) { + debugPrint('[PluriWave][compras] restaurar ERROR $e'); + _eventos.add(EventoCompra(TipoEventoCompra.error, mensaje: e.toString())); + } + } + + void _alRecibirCompras(List compras) { + if (compras.isEmpty) { + // `restorePurchases()` with nothing to restore completes without ever + // pushing a PurchaseDetails (Spec "Restore finds nothing") — there is + // no per-call correlation in this stream, so this fires on ANY empty + // batch. In practice `queryPastPurchases`/`restorePurchases` on an + // account with nothing to restore is the only source of an empty + // batch this stream would ever emit. + return; + } + for (final compra in compras) { + _eventos.add( + eventoDesdeEstadoCompra(compra.status, mensaje: compra.error?.message), + ); + if (compra.pendingCompletePurchase) { + unawaited(_iap.completePurchase(compra)); + } + } + } + + Future dispose() async { + await _sub?.cancel(); + await _eventos.close(); + } +} + +/// Pure mapping from `in_app_purchase`'s [PurchaseStatus] to the +/// PluriWave-owned [EventoCompra] (Design ADR-2's port boundary): pulled out +/// of [ServicioComprasPlayBilling] so it is unit-testable with zero plugin +/// channels, mirroring how `servicio_audio.dart` extracts its pure mapping +/// helpers (e.g. `mapearEstadoProceso`) out of the un-instantiable handler. +EventoCompra eventoDesdeEstadoCompra(PurchaseStatus status, {String? mensaje}) { + return switch (status) { + PurchaseStatus.pending => const EventoCompra(TipoEventoCompra.pendiente), + PurchaseStatus.purchased => const EventoCompra(TipoEventoCompra.comprada), + PurchaseStatus.restored => const EventoCompra(TipoEventoCompra.restaurada), + PurchaseStatus.error => EventoCompra( + TipoEventoCompra.error, + mensaje: mensaje, + ), + PurchaseStatus.canceled => const EventoCompra(TipoEventoCompra.cancelada), + }; +} + +extension on List { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/widgets/banner_anuncio_superior.dart b/lib/widgets/banner_anuncio_superior.dart new file mode 100644 index 0000000..5abf060 --- /dev/null +++ b/lib/widgets/banner_anuncio_superior.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'package:provider/provider.dart'; + +import '../estado/estado_entitlement.dart'; +import '../servicios/servicio_anuncios.dart'; + +/// Entitlement-aware top-banner slot (Design ADR-6, ad-display spec +/// "Persistent Top Banner, Never Overlapping Content"). Collapses to +/// `SizedBox.shrink()` — zero reserved space, zero layout impact — whenever +/// the user is premium OR no ad has finished loading yet; only a +/// successfully loaded [BannerAd] renders a sized box around an [AdWidget]. +/// Callers place this as a plain sibling in a `Column` ABOVE the existing +/// body (`app.dart`'s `_PaginaPrincipalState.build`) — this widget itself +/// never wraps its parent in a `Stack`/overlay. +class BannerAnuncioSuperior extends StatefulWidget { + const BannerAnuncioSuperior({super.key}); + + @override + State createState() => _BannerAnuncioSuperiorState(); +} + +class _BannerAnuncioSuperiorState extends State { + BannerAd? _bannerAd; + bool _cargado = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final servicio = context.read(); + if (_bannerAd == null && servicio.debeMostrarBanner) { + _cargarBanner(); + } + } + + void _cargarBanner() { + // Fire-and-forget: a failure (no plugin channel in `flutter test`, no + // fill, offline) leaves `_bannerAd` `null` forever, which keeps this + // widget collapsed — exactly the same degrade-to-shrink path a genuine + // load failure takes in production. Never throws out of this method. + final anuncio = BannerAd( + size: AdSize.banner, + adUnitId: bannerAdUnitIdPrueba, + request: const AdRequest(), + listener: BannerAdListener( + onAdLoaded: (ad) { + if (!mounted) { + ad.dispose(); + return; + } + setState(() { + _bannerAd = ad as BannerAd; + _cargado = true; + }); + }, + onAdFailedToLoad: (ad, error) { + ad.dispose(); + }, + ), + ); + anuncio.load().catchError((_) {}); + } + + @override + void dispose() { + _bannerAd?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final entitlement = context.watch(); + if (entitlement.esPremium) return const SizedBox.shrink(); + // Instant vanish-on-purchase (ad-display spec "Ads Vanish Immediately + // On Purchase"): even a banner that finished loading BEFORE this + // transition is dropped, never shown to a now-premium user. + if (!_cargado || _bannerAd == null) return const SizedBox.shrink(); + final ad = _bannerAd!; + return SizedBox( + width: ad.size.width.toDouble(), + height: ad.size.height.toDouble(), + child: AdWidget(ad: ad), + ); + } +} diff --git a/lib/widgets/hoja_premium.dart b/lib/widgets/hoja_premium.dart new file mode 100644 index 0000000..6ef5573 --- /dev/null +++ b/lib/widgets/hoja_premium.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../estado/estado_entitlement.dart'; +import '../l10n/gen/app_localizations.dart'; +import '../tema/pluriwave_tokens.dart'; +import 'pluri_glass_surface.dart'; +import 'pluri_layout.dart'; + +/// Reusable paywall sheet (Design "File Changes" — `hoja_premium.dart`), +/// opened from every gated entry point plus the Settings premium row +/// (freemium-gating spec "Purchase Entry Points At Every Gate Plus +/// Settings"). Mirrors `FormularioEmisoraPersonalizada`'s bottom-sheet +/// shape (`ajustes_emisoras_personalizadas.dart`). +Future mostrarHojaPremium(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (_) => const HojaPremium(), + ); +} + +class HojaPremium extends StatelessWidget { + const HojaPremium({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final entitlement = context.watch(); + final bottom = MediaQuery.of(context).viewInsets.bottom; + + return Padding( + padding: EdgeInsets.fromLTRB( + PluriLayout.horizontal, + PluriLayout.horizontal, + PluriLayout.horizontal, + PluriLayout.horizontal + bottom, + ), + child: PluriGlassSurface( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.workspace_premium_rounded, + color: PluriWaveTokens.brand, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.funcionPremium, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + if (entitlement.esPremium) + Padding( + key: const ValueKey('hoja-premium-activo'), + padding: const EdgeInsets.only(bottom: 12), + child: Text( + l10n.equalizerActive, + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + else + FilledButton.icon( + key: const ValueKey('hoja-premium-comprar'), + onPressed: + entitlement.compraEnCurso + ? null + : () => entitlement.comprar(), + icon: + entitlement.compraEnCurso + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.lock_open_rounded), + label: Text(l10n.desbloquearPremium), + ), + const SizedBox(height: 10), + OutlinedButton( + key: const ValueKey('hoja-premium-restaurar'), + onPressed: + entitlement.compraEnCurso + ? null + : () => entitlement.restaurar(), + child: Text(l10n.restaurarCompras), + ), + ], + ), + ), + ); + } +} diff --git a/openspec/changes/iap-freemium-unlock/apply-progress.md b/openspec/changes/iap-freemium-unlock/apply-progress.md new file mode 100644 index 0000000..a9b3fdc --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/apply-progress.md @@ -0,0 +1,100 @@ +# Apply Progress: iap-freemium-unlock + +Mode: Strict TDD. Delivery: single-pr with `size:exception` (user-approved, single commit). + +## Status: ALL 9 PHASES COMPLETE — 27/27 TASKS DONE + +## TDD Cycle Evidence + +| Task(s) | RED | GREEN | REFACTOR | Test file(s) | +|---|---|---|---|---| +| 0.1/0.2 | N/A (config) | pubspec.yaml + AndroidManifest.xml | N/A | N/A | +| 1.1-1.3 | `estado_entitlement_test.dart` written first, failed (no impl) | `estado_entitlement.dart` (`EstadoEntitlement`, `esPremiumPersistido`) | shared `_keyPremium` const, fail-open documented in doc comments | test/estado/estado_entitlement_test.dart | +| 2.1-2.2 | `servicio_compras_test.dart` (pure mapping) written first, failed | `servicio_compras.dart` (`PuertoCompras`, `ServicioComprasPlayBilling`, `eventoDesdeEstadoCompra` extracted for testability) | N/A | test/servicios/servicio_compras_test.dart | +| 3.1-3.2 | `estado_alarmas_gating_test.dart` written first, failed | `ResultadoGuardarAlarma` enum + `puedeCrearAlarma` + gated `guardarAlarma`/`crearRangoVacaciones` | N/A | test/estado/estado_alarmas_gating_test.dart | +| 3.3 | N/A (UI wiring, no new pure logic) | `pantalla_alarmas.dart` (cap-check+interstitial at the "+" CTA tap per ADR-6, snackbar+CTA on block) + `pantalla_vacaciones.dart` (paywall on block) | Corrected mid-run: interstitial originally placed at save time, moved to the CTA tap per design.md's literal "then open the editor" wording | Regression: pantalla_alarmas_editor_test.dart, pantalla_alarmas_fecha_test.dart, pantalla_vacaciones_test.dart | +| 4.1-4.2 | `estado_grabacion_gating_test.dart` written first, failed | `ResultadoIniciarGrabacion` enum + gated `iniciar()` | N/A | test/estado/estado_grabacion_gating_test.dart | +| 5.1 | `navegacion_auto_gating_test.dart` written first, failed | `raiz(premium:)`, `itemPremiumBloqueado()`, `respuestaBloqueadaPorEntitlement()` | N/A | test/servicios/navegacion_auto_gating_test.dart | +| 5.2 | `servicio_audio_gating_test.dart` written first, failed | `debeBloquearCambioDeEmisora()` wired into `playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious` | N/A | test/servicios/servicio_audio_gating_test.dart | +| 5.3 | same file, `notificarDesbloqueoAuto`/`registrarNotificacionDesbloqueoAuto` cases | Discovered mid-implementation that `AudioService.notifyChildrenChanged` is deprecated in this `audio_service` version — implemented via `subscribeToChildren` override + per-id `BehaviorSubject` + `notificarHijosCambiaron`, which is what the plugin's own internal listener now forwards to the platform | Wired `registrarHandler` to push to all root-level ids on the hook | test/servicios/servicio_audio_gating_test.dart | +| 5.4 | (covered above) | `getChildren` checks `respuestaBloqueadaPorEntitlement` before any other resolution | N/A | (covered above) + regression: navegacion_auto_test.dart | +| 6.1-6.2 | `servicio_anuncios_test.dart` (fake clock) written first, failed | `ServicioAnuncios` cap/gating logic + AdMob adapter (test ad unit IDs, TODO-marked) | N/A | test/servicios/servicio_anuncios_test.dart | +| 6.3 | `banner_anuncio_superior_test.dart` written first, failed | `BannerAnuncioSuperior` widget + `app.dart` `Column[banner, Expanded(body)]` | N/A | test/widgets/banner_anuncio_superior_test.dart | +| 7.1 | N/A (wiring) | `hoja_premium.dart` + `EstadoEntitlement`/`ServicioAnuncios` registered in `app.dart`'s provider list (EstadoEntitlement FIRST so later `create` closures can `context.read` it) | N/A | Regression: app_test.dart, widget_test.dart | +| 7.2 | N/A (wiring) | Settings premium row (`pantalla_ajustes.dart`); interstitial-before-open at both station-add CTAs (`pantalla_favoritos.dart`, `ajustes_emisoras_personalizadas.dart`) | N/A | Regression: pantalla_ajustes_test.dart, pantalla_favoritos_test.dart, ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart | +| 8.1-8.3 | N/A (content) | 4 keys × 13 locales added to `app_*.arb`; `flutter gen-l10n` regenerated | N/A | literal-encoding scan clean | +| 9.1-9.3 | N/A (verification) | Full suite run in batches, equalizer grep-verified ungated, proposal.md checkboxes updated with verification notes | N/A | See Work Unit Evidence below | + +## Files Changed + +| File | Action | What Was Done | +|---|---|---| +| `pubspec.yaml` | Modified | Uncommented `in_app_purchase`, `google_mobile_ads` | +| `android/app/src/main/AndroidManifest.xml` | Modified | AdMob test app id meta-data (TODO to swap for real) | +| `lib/estado/estado_entitlement.dart` | Created | `EstadoEntitlement` ChangeNotifier + `esPremiumPersistido()` | +| `lib/servicios/servicio_compras.dart` | Created | `PuertoCompras` + `ServicioComprasPlayBilling` (sole `in_app_purchase` site) | +| `lib/servicios/servicio_anuncios.dart` | Created | `ServicioAnuncios` — banner/interstitial gating + frequency cap + AdMob adapter | +| `lib/widgets/banner_anuncio_superior.dart` | Created | Entitlement-aware top banner slot | +| `lib/widgets/hoja_premium.dart` | Created | Reusable paywall bottom sheet | +| `lib/estado/estado_alarmas.dart` | Modified | `ResultadoGuardarAlarma` enum, `puedeCrearAlarma()`, gated `guardarAlarma`/`crearRangoVacaciones`, `esPremium` injection (default `() => true`) | +| `lib/estado/estado_grabacion.dart` | Modified | `ResultadoIniciarGrabacion` enum, gated `iniciar()`, `esPremium` injection | +| `lib/estado/estado_radio.dart` | Modified | Threaded `esPremium` through to internal `EstadoGrabacion` | +| `lib/servicios/navegacion_auto.dart` | Modified | `raiz(premium:)`, `itemPremiumBloqueado()`, `respuestaBloqueadaPorEntitlement()` | +| `lib/servicios/servicio_audio.dart` | Modified | `getChildren`/`playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious` gated; `subscribeToChildren` override + `notificarHijosCambiaron`; `registrarNotificacionDesbloqueoAuto`/`notificarDesbloqueoAuto` hook | +| `lib/pantallas/pantalla_alarmas.dart` | Modified | Cap-check + interstitial at the "+" CTA tap; cap snackbar + "Desbloquear Premium" CTA | +| `lib/pantallas/pantalla_vacaciones.dart` | Modified | Paywall sheet on gate block | +| `lib/pantallas/pantalla_reproductor.dart` | Modified | 3 record-start call sites route through the gate, open paywall on block | +| `lib/pantallas/pantalla_ajustes.dart` | Modified | Premium row (buy/restore/active) in APLICACIÓN group | +| `lib/pantallas/pantalla_favoritos.dart` | Modified | Interstitial before opening the add-station form | +| `lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart` | Modified | Interstitial before opening the add-station form | +| `lib/app.dart` | Modified | `EstadoEntitlement`/`ServicioAnuncios` providers; `compras` injection param; banner `Column` wiring | +| `lib/main.dart` | Modified | `MobileAds.instance.initialize()`, `ServicioComprasPlayBilling` wiring | +| `lib/l10n/app_*.arb` (13 files) + `lib/l10n/gen/*` (regenerated) | Modified | `funcionPremium`, `limiteAlarmasAlcanzado`, `desbloquearPremium`, `restaurarCompras` | +| `openspec/changes/iap-freemium-unlock/proposal.md` | Modified | Success Criteria checked off with verification notes | + +## Test Files Added +- test/estado/estado_entitlement_test.dart +- test/estado/estado_alarmas_gating_test.dart +- test/estado/estado_grabacion_gating_test.dart +- test/servicios/servicio_compras_test.dart +- test/servicios/servicio_anuncios_test.dart +- test/servicios/navegacion_auto_gating_test.dart +- test/servicios/servicio_audio_gating_test.dart +- test/widgets/banner_anuncio_superior_test.dart + +## Test Files Modified (harness fixes — added `ServicioAnuncios`/`EstadoEntitlement` providers so pre-existing widget tests keep working against the new gated call sites) +- test/servicios/navegacion_auto_test.dart (3 `raiz()` call sites get `premium: true`) +- test/pantallas/pantalla_alarmas_fecha_test.dart +- test/pantallas/pantalla_ajustes_test.dart +- test/pantallas/pantalla_ajustes_row_values_test.dart +- test/pantallas/pantalla_favoritos_test.dart +- test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart +- test/pantallas/pluri_screen_header_retired_test.dart +- test/pantallas/root_header_wiring_test.dart +- test/widgets/pluri_push_scaffold_test.dart + +## Deviations from Design (reported honestly) + +1. **ADR-4 root/non-root reconciliation**: design.md's ADR-4 prose ("keeps the same visible folder labels for free users") and the android-auto-media spec's literal "rendered as ... explicitly locked item labeled as a premium feature" (for the ROOT) point in slightly different directions. Followed design.md/the orchestrator's own constraint summary: ROOT keeps real folder labels for every tier (regression-safe, byte-identical to today for premium); the lock is enforced one level down, at `getChildren`'s `respuestaBloqueadaPorEntitlement` choke point, which returns exactly one `itemPremiumBloqueado()` for ANY non-root id when free (including stale/deep-linked ids — the mandatory backstop). +2. **`notifyChildrenChanged` deprecated**: `audio_service` 0.18.18 deprecated the static `AudioService.notifyChildrenChanged` helper in favor of a `subscribeToChildren`-stream-based mechanism. Implemented `PluriWaveAudioHandler.subscribeToChildren` (a `BehaviorSubject` per parent id) + `notificarHijosCambiaron(id)`, which is what the plugin's own internal listener forwards to the platform. Functionally equivalent to the design's intent; the public hook name (`registrarNotificacionDesbloqueoAuto`/`notificarDesbloqueoAuto`) is unchanged. +3. **ADR-6 interstitial ordering — corrected mid-run**: initially implemented the alarm interstitial at SAVE time; corrected to fire at the "+" CTA tap (before the editor sheet even opens), matching design.md's literal "puedeCrearAlarma -> ... maybe-interstitial, then open the editor" and mirroring the add-station CTA's identical ordering. +4. **Default `esPremium` callbacks** in `EstadoAlarmas`/`EstadoGrabacion`/`EstadoRadio` default to `() => true` (ungated) when the caller doesn't inject one. This was necessary because 30+ pre-existing test files construct these classes with zero entitlement awareness and expect unrestricted (today's) behavior; production `app.dart` always wires the real `EstadoEntitlement`-backed callback. This is a deliberate, documented DI default, not a security gap — no production code path can reach the default. +5. **`crearRangoVacaciones` returns `bool`**, not `ResultadoGuardarAlarma` — vacations are a full premium gate (no free allowance), semantically distinct from the alarm cap's count-based enum, which design.md's Interfaces/Contracts scoped to `guardarAlarma` specifically. +6. **`PluriWaveApp` gained an optional `compras` constructor param** mirroring the existing `fuenteAuto` injection convention, so no pre-existing widget test ever touches the real `in_app_purchase` plugin channel; `main.dart` wires the real `ServicioComprasPlayBilling`. +7. **Paywall sheet copy stays minimal**: `HojaPremium` reuses the existing `l10n.equalizerActive` string for "active" state (an established codebase pattern for reusable generic labels) rather than inventing new arb keys beyond the 4 explicitly scoped in tasks.md, to keep the 13-locale translation surface bounded. + +## Issues Found +- `dart format lib/ test/` (broad invocation) reformatted several pre-existing test files that were untouched semantically. These formatting-only diffs were identified via `git diff --stat` and reverted with `git checkout --` to keep this change scoped to the feature (avoiding an unrelated multi-hundred-line formatting diff riding along in the single-commit delivery). +- None outstanding beyond the above. + +## Work Unit Evidence (cumulative, final) + +- **Focused test command and result**: `flutter test test/estado/estado_entitlement_test.dart test/estado/estado_alarmas_gating_test.dart test/estado/estado_grabacion_gating_test.dart test/servicios/servicio_compras_test.dart test/servicios/servicio_anuncios_test.dart test/servicios/navegacion_auto_gating_test.dart test/servicios/servicio_audio_gating_test.dart test/widgets/banner_anuncio_superior_test.dart` → **48/48 passed**. +- **Runtime harness**: full regression suite run in batches — `test/estado/` (207 passed), `test/servicios/` (512 passed), `test/widgets/` (96 passed), `test/pantallas/` (~248+ across all 30 files, run in multiple batches, all passed after harness fixes), top-level (`app_test.dart`, `arranque_orientacion_test.dart`, `assets_contenido_declarados_test.dart`, `widget_test.dart` — 38 passed). A single `flutter test` full-suite invocation exceeds this environment's command timeout (~10 min); batched runs are the practical substitute and cover 100% of files. Manual on-device QA (Play Billing sandbox purchase, real AdMob rendering, car head-unit browse) is explicitly out of reach of this environment and remains outstanding — noted in `proposal.md`. +- **Rollback boundary**: every file in the "Files Changed" table above is independently revertable; `pubspec.yaml`/`AndroidManifest.xml` revert re-comments both plugins per `proposal.md`'s Rollback Plan (no migration, no schema change, versioned prefs key `compra_premium_v1` is ignored by older builds). + +## Final Verification +- `flutter analyze`: clean (5 issues, all pre-existing/unrelated: 2 `deprecated_member_use` on `onReorder` predating this change, 1 pre-existing `unused_catch_stack`, 1 pre-existing `annotate_overrides` info in `estado_radio_test.dart`). +- `dart format`: applied to every file this change touches; unrelated pre-existing files swept up by a broad format invocation were reverted (see Issues Found). +- Literal-encoding scan (`Ã|Â|â€|<25FD>`) on all 13 touched `.arb` files: clean except one PRE-EXISTING false positive (`app_pt.arb`'s legitimate "REPETIÇÃO", unrelated to this change). +- Equalizer regression check: zero `esPremium`/`EstadoEntitlement`/`esPremiumPersistido` references in `estado_ecualizador.dart`, `servicio_ecualizador.dart`, `pantalla_ajustes_ecualizador.dart`, `ecualizador_widget.dart` — confirmed via `grep`. diff --git a/openspec/changes/iap-freemium-unlock/design.md b/openspec/changes/iap-freemium-unlock/design.md new file mode 100644 index 0000000..8ae537a --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/design.md @@ -0,0 +1,116 @@ +# Design: Freemium unlock via one-time in-app purchase + +## Technical Approach + +One cross-cutting `EstadoEntitlement` notifier (idiomatic `EstadoIdioma` shape) plus a top-level prefs-lazy reader for headless callers. Gating is hybrid: UI CTAs open the paywall, state-layer choke points hold the authoritative check. Ads are a port + AdMob adapter; the banner is a layout sibling (never an overlay), the interstitial fires on a CTA's natural transition behind a frequency cap. + +## Architecture Decisions + +### ADR-1: Entitlement is a notifier plus a free function, not a singleton + +**Choice**: `lib/estado/estado_entitlement.dart` exports `EstadoEntitlement extends ChangeNotifier` (optional injected `SharedPreferences`, key `compra_premium_v1`, `bool get esPremium`) **and** a top-level `Future esPremiumPersistido({SharedPreferences? prefs})` that reads the same key directly. +**Alternatives**: global singleton; passing the notifier into `PluriWaveAudioHandler`. +**Rationale**: `PluriWaveAudioHandler` registers before `runApp`, so no `BuildContext`/`Provider` exists. The free function mirrors `FuenteMusicaLocalAutoImpl._resolverPrefs()` (`musica_local_auto.dart:163`) — same convention, testable via `setMockInitialValues`, no lifecycle to leak. + +### ADR-2: Purchase I/O behind a port + +**Choice**: `PuertoCompras` abstraction (`comprar`, `restaurar`, `Stream`) with `ServicioComprasPlayBilling` as the only `in_app_purchase` call site; `EstadoEntitlement` takes `PuertoCompras?`. +**Alternatives**: calling `InAppPurchase.instance` from the notifier. +**Rationale**: matches `EstadoAlarmas(android: PuertoAlarmasAndroid)`; keeps Strict TDD viable with zero plugin channels in unit tests. +**Fail-open**: only `purchased`/`restored` writes `true`. Errors, timeouts and offline never write `false`; the persisted flag is the source of truth at cold start. + +### ADR-3: Gate placement (4 gates) + +| Gate | Authoritative check | UI paywall entry | +|---|---|---| +| Alarm cap > 5 | `EstadoAlarmas.guardarAlarma` (`estado_alarmas.dart:104`) | `_EditorAlarmaSheet` save + the add CTA in `pantalla_alarmas.dart` | +| Alarm vacations | `EstadoAlarmas.crearRangoVacaciones` (`:510`) | `pantalla_vacaciones.dart` — `vacation-add-header` + `_CtaAnadirRango` | +| Recording | `EstadoGrabacion.iniciar` (`estado_grabacion.dart:90`) | 3 call sites in `pantalla_reproductor.dart` | +| Android Auto | `getChildren` / `playFromMediaId` / `playFromSearch` / `skipToNext-Previous` in `servicio_audio.dart` | none (car never shows a purchase flow) | + +The phone equalizer is **not** gated. + +### ADR-4: Auto reduced mode = real root labels, locked children, locked switching + +**Choice**: `ConstructorArbolAuto.raiz({required bool incluirMusicaLocal, required bool premium})` keeps the same visible folder labels for free users; `getChildren` resolves entitlement once via `esPremiumPersistido()` and, when free, returns exactly `[itemPremiumBloqueado()]` (non-playable, id `premium:info`, hardcoded Spanish label like every other car label) for **any** non-root `parentMediaId`. Station switching is additionally blocked at `playFromMediaId`, `playFromSearch`, `skipToNext`/`skipToPrevious` (no-op returns). +**Alternatives**: empty root; omitting the folders entirely. +**Rationale**: head units cache browse trees, so a stale `emisora:` tap would bypass `getChildren` — the play-path gates are mandatory, not belt-and-braces. Keeping labels + one explicit locked item guarantees no blank list. Play/pause/stop of the already-playing station are untouched. + +### ADR-5: Distinct alarm-limit signal + +**Choice**: `guardarAlarma` returns `ResultadoGuardarAlarma { guardada, limiteAlcanzado }`; `_error` stays reserved for native scheduling failures. Pure query `bool puedeCrearAlarma` (count = `_alarmas.length`, enabled or not; edits of an existing id always pass). +**Rationale**: overloading `_error` would surface a limit as a scheduling failure in `app.dart`'s snackbar path. Grandfathering falls out for free — nothing is deleted, only new creation past 5 is refused. + +### ADR-6: Banner reserves layout; interstitial is cap-checked first + +**Choice**: In `_PaginaPrincipalState.build`, `body:` becomes `Column[ SafeArea(bottom:false, child: BannerAnuncioSuperior), Expanded(existing SafeArea+AnimatedSwitcher) ]`. Premium or unloaded ⇒ `SizedBox.shrink()` (zero layout impact). Never a `Stack`/overlay. +**Interstitial ordering (add-alarm)**: `puedeCrearAlarma` → if false, show the limit message and **no ad**; if true, maybe-interstitial, then open the editor. Add-station: interstitial on the CTA tap, before `FormularioEmisoraPersonalizada` opens. +**Frequency cap**: in-memory in `ServicioAnuncios` — max 2 interstitials per process lifetime and ≥3 min apart; over cap ⇒ silent no-op. +**Rationale**: an ad followed by "you can't create this" is both hostile and an AdMob disruptive-ad policy risk. + +## Data Flow + + Play Billing ──→ PuertoCompras ──→ EstadoEntitlement ──→ prefs(compra_premium_v1) + │ │ + UI (Provider.watch)┘ │ + ▼ + PluriWaveAudioHandler.getChildren ──→ esPremiumPersistido() ──────┘ (no Provider) + +## File Changes + +| File | Action | Description | +|---|---|---| +| `lib/estado/estado_entitlement.dart` | Create | Notifier + `esPremiumPersistido()` | +| `lib/servicios/servicio_compras.dart` | Create | `PuertoCompras` + Play Billing adapter | +| `lib/servicios/servicio_anuncios.dart` | Create | Banner/interstitial port + AdMob adapter + frequency cap | +| `lib/widgets/banner_anuncio_superior.dart` | Create | Entitlement-aware banner slot | +| `lib/widgets/hoja_premium.dart` | Create | Paywall sheet, reused by every gate | +| `lib/app.dart` | Modify | Provider registration + banner Column | +| `lib/estado/estado_alarmas.dart` | Modify | `puedeCrearAlarma`, `ResultadoGuardarAlarma`, vacation gate | +| `lib/estado/estado_grabacion.dart` | Modify | Recording gate in `iniciar` | +| `lib/servicios/navegacion_auto.dart` | Modify | `raiz(premium:)`, `itemPremiumBloqueado()` | +| `lib/servicios/servicio_audio.dart` | Modify | Entitlement gate in browse + play paths | +| `lib/pantallas/pantalla_ajustes.dart` | Modify | Purchase + restore rows | +| `lib/pantallas/pantalla_alarmas.dart`, `pantalla_vacaciones.dart`, `pantalla_reproductor.dart`, `pantalla_favoritos.dart`, `ajustes/pantalla_ajustes_emisoras_personalizadas.dart` | Modify | Contextual upsell / interstitial trigger | +| `pubspec.yaml` | Modify | Activate `in_app_purchase`, `google_mobile_ads` | +| `lib/l10n/app_*.arb` | Modify | Paywall, limit message, restore strings | + +## Interfaces / Contracts + +```dart +class EstadoEntitlement extends ChangeNotifier { + EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras}); + static const idProducto = 'pluriwave_premium'; + bool get esPremium; + bool get compraEnCurso; + Future comprar(); + Future restaurar(); +} +Future esPremiumPersistido({SharedPreferences? prefs}); +enum ResultadoGuardarAlarma { guardada, limiteAlcanzado } +``` + +## Testing Strategy + +| Layer | What to Test | Approach | +|---|---|---| +| Unit | Entitlement persistence, fail-open on error, restore | Fake `PuertoCompras` + `setMockInitialValues` | +| Unit | `puedeCrearAlarma` at 4/5/6, edit-at-cap, vacations, recording | `EstadoAlarmas(prefs:)`/`EstadoGrabacion` directly | +| Unit | `raiz(premium:false)`, locked-child for every parent id, play-path no-ops | Pure `ConstructorArbolAuto` + handler fakes | +| Unit | Interstitial cap (2/session, 3 min) and cap-before-ad ordering | Fake clock in `ServicioAnuncios` | +| Widget | Banner absent when premium; no overlap on all 5 tabs | `pumpWidget(PluriWaveApp(prefs:))` + golden-free layout asserts | +| Widget | Limit message with secondary unlock action, paywall from each gate | Existing `pantalla_*_test.dart` conventions | + +## Threat Matrix + +N/A — no routing, shell, subprocess, VCS/PR automation, executable-file classification, or process-integration boundary. Android Auto media-id dispatch is pre-existing in-process routing, not shell/process execution. + +## Migration / Rollout + +No migration. Additive and prefs-backed; absent key = free. Revert by re-commenting both plugins and reverting the gate commits. Versioned key (`compra_premium_v1`) is ignored by older builds. + +## Open Questions + +- [ ] Price point (Play Console decision). +- [ ] AdMob ad unit IDs (banner + interstitial) not yet provisioned; test IDs until then. +- [x] ~~Should a cached head-unit tree be actively invalidated (`notifyChildrenChanged`) at purchase time, or is the next browse refresh enough?~~ **RESOLVED (orchestrator): actively invalidate.** On the entitlement transition to premium, call `notifyChildrenChanged` for the affected parent ids. Rationale: the same head-unit caching that forces the `playFromMediaId` guard in ADR-4 also means a purchaser would otherwise keep seeing the locked tree until the unit re-binds — plausibly the rest of the drive. A user who just paid and still sees "Premium feature" in the car reads that as a broken purchase, which is a refund and a one-star review. Relying on the next browse refresh trades a cheap, bounded call for a highly visible failure. The invalidation is one-directional and only fires on the free → premium transition; there is no premium → free transition to handle (the purchase is permanent and entitlement never writes `false`, per ADR-2). diff --git a/openspec/changes/iap-freemium-unlock/explore.md b/openspec/changes/iap-freemium-unlock/explore.md new file mode 100644 index 0000000..94bf407 --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/explore.md @@ -0,0 +1,47 @@ +# Exploration: iap-freemium-unlock + +One-time non-consumable IAP that removes ads and unlocks 6 currently-free features. Free-tier users see ads (`google_mobile_ads`, commented out in pubspec.yaml, never activated). Purchasers get zero ads and full access forever from a single purchase (not a subscription). + +## Current State + +**State/persistence architecture.** `lib/app.dart` (`PluriWaveApp.build`) wires a `MultiProvider` at the app root: `ChangeNotifierProvider`, three `ListenableProvider`s exposing `EstadoRadio`'s owned children (`EstadoEcualizador`, `EstadoGrabacion`, `EstadoBusqueda`), then independent siblings `ChangeNotifierProvider`, `ChangeNotifierProvider`, `ChangeNotifierProvider`. A single `SharedPreferences` instance is resolved once in `lib/main.dart` and injected as `prefs` into every top-level notifier. + +Idiomatic per-domain notifier shape (cleanest example: `lib/estado/estado_idioma.dart`): `ChangeNotifier` subclass, optional injected `SharedPreferences?`, a `_resolverPrefs()` fallback to `SharedPreferences.getInstance()` (works from headless callers with no DI), a versioned key constant, `notifyListeners()` after every mutation+persist. + +**No existing tier/limit/entitlement concept anywhere** — confirmed via grep across `lib/modelos/alarma_musical.dart`, `lib/estado/estado_alarmas.dart`, `lib/servicios/servicio_alarmas.dart`. + +**pubspec.yaml** (version `1.3.0+151`): `google_mobile_ads` and `in_app_purchase` both commented out, lines ~52-56. Neither is an active dependency. + +**Fastlane/CI**: `fastlane/Appfile` → `package_name` = `es.freetimelab.pluriwave`; `fastlane/Fastfile` has one lane (`upload_internal`) publishing to Play's `internal` track; `.gitea/workflows/build.yml` auto-bumps version and calls that lane. No in-app-product ID or billing config exists anywhere in CI/fastlane — that's Play Console-side config only, zero CI/fastlane code changes required for this change. + +## Affected Areas (gating points per feature) + +1. **Equalizer** — `lib/estado/estado_ecualizador.dart`, screen `lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart`. UI entry: `lib/pantallas/pantalla_ajustes.dart` ~L108-123 (`FilaAjuste.onTap` → push `PantallaAjustesEcualizador`). Second surface: Android Auto's always-present `idEcualizador` folder + on/off custom action in `servicio_audio.dart`/`navegacion_auto.dart` — closed automatically once Android Auto itself is gated. +2. **Android Auto** — `lib/servicios/navegacion_auto.dart`'s pure `ConstructorArbolAuto` feeds `lib/servicios/servicio_audio.dart:1667` `getChildren()` → `constructor.raiz(...)`, the single dispatch point for the whole car tree. `PluriWaveAudioHandler` is registered in `main.dart` before `runApp`, so any gate here must read entitlement via a prefs-lazy fallback, never `BuildContext`/`Provider`. +3. **Alarm vacations** — `lib/pantallas/pantalla_vacaciones.dart` (2 create CTAs: header button `'vacation-add-header'`, mid-page `_CtaAnadirRango`), `lib/estado/estado_alarmas.dart` (`crearRangoVacaciones`/`editarRangoVacaciones`/`eliminarRangoVacaciones`/`guardarVacaciones` + 4 pure queries), `lib/servicios/servicio_alarmas.dart`. Entry from Alarms root: `lib/pantallas/pantalla_alarmas.dart`'s `_PanelVacaciones` (L93). +4. **Station recording** — `lib/servicios/servicio_grabacion_radio.dart` (engine), `lib/estado/estado_grabacion.dart`'s `EstadoGrabacion.iniciar({Duration? duracion})` (L90) is the single choke point for ≥3 UI call sites (`pantalla_reproductor.dart`'s recording panel ~L489-560, duration-picker sheet ~L601-724, mini-player shortcut `'player-tool-record'` ~L1064). `pantalla_grabaciones.dart`/`pantalla_ajustes_grabaciones.dart` manage *existing* recordings and should probably stay accessible regardless of entitlement. +5. **Alarm count limit (new)** — `EstadoAlarmas.guardarAlarma` (L104) is the one save call for create+edit; UI create/edit distinction lives in `_EditorAlarmaSheet` (`pantalla_alarmas.dart`, `widget.alarma == null` checks, save call ~L1189). Today's only failure channel is a `String? _error` used for native scheduling failures — a limit rejection needs a distinct signal, not reuse of `_error`. +6. **Ads** — zero ad code exists anywhere yet. Best candidates: (a) one global anchor in `lib/app.dart`'s `_PaginaPrincipalState.build` bottom `Column` (alongside `MiniReproductor`), covering all 5 tabs with one wiring point; (b) a `SliverToBoxAdapter` row in `PantallaInicio`'s `CustomScrollView` (mirrors `_seccionTusEmisoras`). + +## Recommended entitlement architecture + +New `lib/estado/estado_entitlement.dart` `ChangeNotifier`, shaped like `EstadoIdioma` (injected optional `SharedPreferences`, versioned key e.g. `compra_premium_v1`, `bool get esPremium`, prefs-lazy fallback for the Android Auto path), registered as an independent sibling `ChangeNotifierProvider` in `app.dart` (not owned by `EstadoRadio` — it's cross-cutting). + +## Approaches considered + +1. **UI-entry-point gating only** (6 call sites) — small, reviewable diffs, matches idiomatic pattern; risk of a missed call site on future refactors. Effort: Medium. +2. **State-method-layer gating only** — unbypassable, but silent no-op UX unless paired with UI copy anyway (not a real alternative to #1). Effort: Medium-High. +3. **Hybrid (recommended)** — UI entries show the paywall (good UX) + state-layer choke points (`guardarAlarma`, `EstadoGrabacion.iniciar`, Android Auto `getChildren`) carry the authoritative check. Effort: Medium. + +## Risks + +- Grandfathering: devices with 6+ alarms already before ship — candidate: grandfather existing, block only future creates once count ≥ 5 (needs design sign-off). +- Restore-purchases flow for reinstalls/new devices — no UI placement decided yet. +- Offline/failed entitlement checks — candidate: fail-open (trust last-persisted local flag) over fail-closed. +- No backend exists in this codebase — entitlement will be client/Play-Billing-trusted only, an accepted risk unless design decides otherwise. +- Android Auto's headless cold-start path requires the same "resolve prefs lazily, no DI at construction" convention already used by `FuenteMusicaLocalAutoImpl`. +- Alarm-count rule (all alarms vs. only active/enabled) is undecided and affects UX. + +## Ready for Proposal + +Yes. diff --git a/openspec/changes/iap-freemium-unlock/proposal.md b/openspec/changes/iap-freemium-unlock/proposal.md new file mode 100644 index 0000000..16b5462 --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/proposal.md @@ -0,0 +1,102 @@ +# Proposal: Freemium unlock via one-time in-app purchase + +## Intent + +PluriWave (1.3.0+151, Internal Testing) has no monetization. Add one non-consumable purchase that permanently removes ads and unlocks the premium feature set, keeping the free tier usable. Purchasers get everything forever, restorable after reinstall, with no renewal or expiry concept. + +## Scope + +### In Scope + +- `EstadoEntitlement` ChangeNotifier (SharedPreferences, versioned key, prefs-lazy resolve for headless Android Auto), top-level provider in `app.dart`. +- Activate `in_app_purchase`: buy flow, purchase stream, `restorePurchases()` from Settings. +- Activate `google_mobile_ads`: persistent top banner anchored in `app.dart` (must not overlap or displace existing content), plus a full-screen interstitial before two specific actions — adding a station manually and adding an alarm. All ads absent when premium. +- Gate 4 features: Android Auto reduced mode, alarm vacations, starting recordings, creating alarms past 5. +- Paywall reachable from every gated entry point (Settings row + contextual upsell at each gate); distinct "limit reached" signal from `EstadoAlarmas.guardarAlarma` (not the existing `_error`). + +### Out of Scope + +- Price point and Play Console product setup (console-side, undecided). +- Server-side receipt validation — no backend exists; client + Play Billing trust accepted for v1. +- Subscriptions, trials, promo codes, iOS store setup, CI/fastlane changes (none needed). +- Deleting, hiding, or trimming content free users already created. +- **The equalizer on the phone**: explicitly stays free for all users (user decision). Only its Android Auto surface is affected, as a consequence of Auto reduced mode. + +## Business Rules + +| Rule | Decision | +|------|----------| +| Purchase | Non-consumable, permanent, per Play account | +| Alarm cap | Free tier = 5 alarms total, enabled or not | +| Alarm cap UX | 6th attempt shows an explanatory message with a secondary "unlock" action — never a bare paywall jump | +| Grandfathering | Existing alarms/vacations/recordings survive; only new creation past the cap is blocked | +| Entitlement failure | Fail-open: trust last persisted flag; never lock out a payer offline | +| Equalizer (phone) | Free for everyone — not a gated feature | +| Android Auto (free) | Reduced mode: current-station player only. No station browsing/switching, no local music. Every other car entry shows a "Premium feature" item | +| Ads — banner | Persistent top banner, laid out so it never overlaps or covers existing UI | +| Ads — interstitial | Full-screen ad before adding a station manually and before adding an alarm | +| Ads lifecycle | Vanish immediately on purchase, no restart | +| Purchase entry points | Settings row + contextual upsell at each gated feature | +| Existing content | Viewing/managing stays free; only new gated actions are blocked | + +## Capabilities + +### New Capabilities + +- `premium-entitlement`: purchase, restore, persistence, offline policy. +- `freemium-gating`: gated features, limits, and how a free user is informed. +- `ad-display`: ad placement and lifecycle for free users only. + +### Modified Capabilities + +- `android-auto-media`: browse tree becomes entitlement-aware — free tier collapses to a current-station-player-only tree. + +## Approach + +Hybrid gating (exploration approach 3): UI entry points show the paywall; state-layer choke points (`guardarAlarma`, `EstadoGrabacion.iniciar`, Auto `getChildren`) hold the authoritative check. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `lib/estado/estado_entitlement.dart` | New | Entitlement, purchase, restore | +| `lib/app.dart` | Modified | Provider registration, top banner anchor | +| `lib/estado/estado_alarmas.dart`, `estado_grabacion.dart` | Modified | Cap, vacation gate, recording gate | +| `lib/servicios/servicio_audio.dart`, `navegacion_auto.dart` | Modified | Gate car tree | +| `lib/pantallas/` (ajustes, vacaciones, alarmas, reproductor) | Modified | Paywall on gated CTAs | +| `pubspec.yaml` | Modified | Uncomment both plugins | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Client-only entitlement is tamperable | Med | Accepted for v1; no backend exists | +| Cap feels like data loss | Med | Grandfather all data; explain at creation time | +| Headless Auto has no Provider | Med | Prefs-lazy resolve, mirror `FuenteMusicaLocalAutoImpl` | +| Missed gate on a call site | Low | State-layer choke points as backstop | +| Interstitial before add-alarm/add-station reads as punitive, or trips AdMob's disruptive-ad policy | Med | Interstitial fires on the action's natural transition, never mid-task; enforce a frequency cap so repeated adds in one session don't chain ads; never stack it with the alarm-cap message in the same tap | +| Auto reduced mode leaves a free driver with an empty-looking car UI | Med | Current-station player always present; every locked branch renders an explicit "Premium feature" item, never a blank list | + +## Rollback Plan + +Additive and prefs-backed. Revert by re-commenting both plugins in `pubspec.yaml` and reverting the gate commits; no migration, no schema change. The persisted key is versioned (`compra_premium_v1`) so older builds ignore it. + +## Dependencies + +- Play Console in-app product created and priced; AdMob ad unit IDs. + +## Success Criteria + +- [x] Purchase unlocks every gated item with no restart and survives restart. Verified at the unit level: `EstadoEntitlement.comprar()`/`restaurar()` flip `esPremium` and `notifyListeners()` immediately on a `comprada`/`restaurada` event (no restart needed by construction — every gate reads `esPremium`/`esPremiumPersistido()` live), and the flag persists under `compra_premium_v1`. Full on-device Play Billing QA is still outstanding (deferred — no sandbox purchase available in this environment). +- [x] `restorePurchases()` restores entitlement on a fresh install. Verified: `estado_entitlement_test.dart` covers found/not-found restore outcomes. +- [x] Free tier blocks the 4 gated features and caps alarms at 5 without destroying data. Verified: `estado_alarmas_gating_test.dart` (cap + grandfathering), `estado_grabacion_gating_test.dart` (recording), `navegacion_auto_gating_test.dart`/`servicio_audio_gating_test.dart` (Android Auto). +- [x] Equalizer remains fully usable on the phone for free users. Verified: zero `esPremium`/`EstadoEntitlement`/`esPremiumPersistido` references anywhere in `estado_ecualizador.dart`, `servicio_ecualizador.dart`, `pantalla_ajustes_ecualizador.dart`, `ecualizador_widget.dart`. +- [x] Free-tier Android Auto still plays the current station and never shows a blank list. Verified: `respuestaBloqueadaPorEntitlement` never returns an empty list, `raiz(premium:)` keeps the root non-blank for every tier, and `debeBloquearCambioDeEmisora` only gates `playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious` — `play`/`pause`/`stop` are untouched. +- [x] Zero ads (banner and interstitial) for purchasers; offline cold start keeps a purchaser unlocked. Verified: `ServicioAnuncios.debeMostrarBanner`/`intentarInterstitial` gate on `esPremium` first; offline cold start is `esPremiumPersistido`'s fail-open persisted-flag read. +- [x] Top banner never overlaps, covers, or displaces existing UI on any tab. Verified: `banner_anuncio_superior_test.dart` + `app.dart`'s `Column[banner, Expanded(body)]` (never a `Stack`). + +Real-device/Play Console/AdMob QA (purchase flow, restore on a fresh install, car head-unit browse, live ad rendering) remains outstanding per the Work Unit runtime-harness notes in `tasks.md` — none of it is exercisable from this environment. + +## Open Questions + +1. Price point (Play Console decision; 2.99–4.99 EUR was a benchmark, never confirmed). diff --git a/openspec/changes/iap-freemium-unlock/spec.md b/openspec/changes/iap-freemium-unlock/spec.md new file mode 100644 index 0000000..fc408d1 --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/spec.md @@ -0,0 +1,330 @@ +# Spec: iap-freemium-unlock + +Combined view of all domain specs for this change. Authoritative per-domain files live under `openspec/changes/iap-freemium-unlock/specs/{domain}/spec.md`. + +--- + +## Domain: premium-entitlement (NEW) + +# Premium Entitlement Specification + +## Purpose + +Track whether the current user holds the permanent, non-consumable premium unlock, and expose that flag to every gated surface (freemium-gating, ad-display, android-auto-media) both from the UI layer and headlessly (Android Auto, registered before `runApp`). + +## Requirements + +### Requirement: One-Time Non-Consumable Purchase + +The system MUST let the user buy a single non-consumable product via `in_app_purchase` from the Settings row or any contextual upsell. On a successful purchase, entitlement MUST flip to premium immediately, app-wide, with no restart required. + +#### Scenario: Successful purchase +- GIVEN a free-tier user taps "buy premium" from Settings or a contextual upsell +- WHEN the purchase completes successfully +- THEN entitlement becomes premium immediately, without restarting the app + +#### Scenario: Purchase cancelled or failed +- GIVEN a free-tier user starts the purchase flow +- WHEN the user cancels or the purchase fails +- THEN entitlement remains free tier, and no charge or partial state is left behind + +#### Scenario: Already-purchased attempt is idempotent +- GIVEN a user already holds premium entitlement +- WHEN they somehow re-trigger the buy flow +- THEN no duplicate charge occurs and entitlement stays premium + +### Requirement: Restore Purchases + +Settings MUST expose a "restore purchases" action that re-queries Play Billing and unlocks entitlement when a prior purchase is found. + +#### Scenario: Restore finds a prior purchase +- GIVEN a reinstall or new device with no local entitlement flag +- WHEN the user taps "restore purchases" and a valid purchase exists on the Play account +- THEN entitlement becomes premium + +#### Scenario: Restore finds nothing +- GIVEN a user with no prior purchase +- WHEN they tap "restore purchases" +- THEN the user stays on the free tier with a clear, non-error-looking result (not a crash or ambiguous failure) + +### Requirement: Persisted, Fail-Open Entitlement + +Entitlement MUST persist locally under a versioned key (e.g. `compra_premium_v1`) and MUST be readable offline. If an entitlement check cannot complete (no network, Play Billing unreachable), the system MUST fail-open: trust the last persisted flag rather than lock out a payer. +(Previously: no entitlement concept existed.) + +#### Scenario: Offline cold start after purchase +- GIVEN a user purchased premium previously +- WHEN they open the app fully offline +- THEN premium entitlement is honored from the persisted flag + +#### Scenario: Failed check does not falsely grant premium +- GIVEN a free-tier user with no persisted premium flag +- WHEN an entitlement check fails +- THEN the user remains free tier (fail-open trusts the last flag, it does not invent one) + +### Requirement: Headless-Safe Entitlement Read + +Entitlement MUST be resolvable via a prefs-lazy fallback (no `BuildContext`/`Provider` dependency), for callers such as `PluriWaveAudioHandler` that register before the widget tree exists. + +#### Scenario: Android Auto cold start +- GIVEN the audio handler is constructed before `runApp` +- WHEN it needs to know the current entitlement to build the browse tree +- THEN it resolves entitlement via the prefs-lazy path without requiring a `Provider` + +### Requirement: Instant Unlock Propagation + +A successful purchase or restore MUST notify all listeners (top banner, gated screens, cached Android Auto entitlement) immediately, without an app restart. + +#### Scenario: Banner disappears immediately on purchase +- GIVEN the ad banner is visible when the user completes a purchase +- WHEN the purchase confirms +- THEN the banner disappears immediately, with no restart + +--- + +## Domain: freemium-gating (NEW) + +# Freemium Gating Specification + +## Purpose + +Define which features require premium entitlement, the free-tier alarm cap, grandfathering of existing content, and the non-punitive UX for hitting a limit. The equalizer on the phone is explicitly out of scope — it MUST stay free. + +## Requirements + +### Requirement: Gated Feature Set (Exactly 4) + +The system MUST require premium entitlement for exactly: (1) creating alarm vacations, (2) starting a new station recording, (3) creating an alarm beyond the 5-alarm cap, and (4) full Android Auto browsing (see `android-auto-media`). The phone equalizer MUST NOT be gated under any circumstance. + +#### Scenario: Free user uses the phone equalizer +- GIVEN a free-tier user +- WHEN they open and use the equalizer screen on the phone +- THEN it works fully, with no entitlement check and no upsell + +#### Scenario: Free user attempts a gated action +- GIVEN a free-tier user +- WHEN they tap "add vacation range" or "start recording" +- THEN they see the paywall/upsell instead of the action completing + +### Requirement: Alarm Count Cap At 5 (Free Tier) + +`EstadoAlarmas.guardarAlarma` MUST count all alarms, enabled or not, and MUST reject creating a 6th alarm for a free-tier user via a distinct "limit reached" signal, separate from the existing `_error` field used for native scheduling failures. + +#### Scenario: 6th alarm creation is blocked +- GIVEN a free-tier user already has 5 alarms (any enabled state) +- WHEN they attempt to create a 6th +- THEN `guardarAlarma` rejects it via the distinct limit signal, and no native scheduling is attempted + +#### Scenario: Editing an existing alarm is unaffected +- GIVEN a free-tier user has exactly 5 alarms +- WHEN they edit one of those 5 (not create a new one) +- THEN the edit succeeds normally + +#### Scenario: Premium user has no cap +- GIVEN a premium user +- WHEN they create a 6th or later alarm +- THEN it succeeds with no limit check + +### Requirement: Alarm Cap UX Never Bare-Jumps To Paywall + +Hitting the alarm cap MUST show an explanatory message with a secondary "unlock" action; it MUST NOT navigate directly to the paywall as the sole response to the attempt. + +#### Scenario: Cap message with secondary action +- GIVEN a free-tier user hits the 5-alarm cap +- WHEN the limit signal is raised +- THEN the UI shows an explanatory message (e.g. "Has alcanzado el límite de 5 alarmas gratuitas") with a secondary button (e.g. "Desbloquear Premium") +- AND only tapping that secondary button navigates to the paywall + +### Requirement: Grandfathering Of Existing Content + +Alarms, vacations, and recordings created before the gate existed, or already exceeding the cap, MUST remain visible, usable, and editable-in-place. Only NEW creation past a limit or gate is blocked. +(Previously: no cap or gate existed, so this distinction did not apply.) + +#### Scenario: Pre-existing alarms above the cap keep working +- GIVEN a device already has 7 alarms before this change ships +- WHEN the free-tier gate is active +- THEN all 7 alarms keep ringing and can be toggled/edited, and only a new 8th creation is blocked + +### Requirement: Recording Start Gated, Management Stays Free + +`EstadoGrabacion.iniciar` MUST require premium entitlement. Screens that view, play, or delete already-existing recordings MUST remain accessible regardless of entitlement. + +#### Scenario: Free user starts a new recording +- GIVEN a free-tier user +- WHEN they tap the record action +- THEN they see the paywall instead of recording starting + +#### Scenario: Free user manages existing recordings +- GIVEN a free-tier user with previously recorded files +- WHEN they open the recordings list +- THEN they can view, play, and delete those recordings normally + +### Requirement: Purchase Entry Points At Every Gate Plus Settings + +Every gated entry point MUST show a contextual upsell. Settings MUST additionally expose a persistent purchase/restore row. + +#### Scenario: Contextual upsell at a gate +- GIVEN a free-tier user reaches any of the 4 gated entry points +- WHEN the gate blocks the action +- THEN a contextual purchase CTA is shown at that point + +#### Scenario: Settings always shows a premium row +- GIVEN any user opens Settings +- WHEN the screen renders +- THEN it shows either a "buy premium" row (free tier) or a "premium active" state with restore access (premium tier) + +--- + +## Domain: ad-display (NEW) + +# Ad Display Specification + +## Purpose + +Define where and when `google_mobile_ads` renders for free-tier users only: a persistent top banner, plus a capped interstitial before two specific add actions. Ads MUST be entirely absent for premium users. + +## Requirements + +### Requirement: Persistent Top Banner, Never Overlapping Content + +Free-tier users MUST see a persistent banner anchored at the TOP of the app (not bottom), laid out so it reserves its own space and never overlaps, covers, or displaces existing UI on any of the 5 tabs. Premium users MUST see no banner and no reserved space for it. + +#### Scenario: Free user on any tab +- GIVEN a free-tier user +- WHEN they view any of the 5 tabs +- THEN the top banner is visible and all existing content remains fully visible and reachable, none hidden behind it + +#### Scenario: Premium user +- GIVEN a premium user +- WHEN they view any tab +- THEN no banner and no reserved banner space is shown + +### Requirement: Interstitial Before Manual Station Add And Before Alarm Add + +For free-tier users, a full-screen interstitial MUST show before completing exactly two actions: adding a station manually, and adding an alarm. It MUST fire on the action's natural transition (e.g. on confirm/save), never mid-form, and MUST NOT show for premium users. + +#### Scenario: Free user adds a station manually +- GIVEN a free-tier user completes the "add station manually" form +- WHEN they confirm the add +- THEN a full-screen interstitial shows once before/around that transition + +#### Scenario: Free user adds an alarm +- GIVEN a free-tier user under the 5-alarm cap completes the alarm-creation form +- WHEN they save the new alarm +- THEN a full-screen interstitial shows once before/around that transition + +#### Scenario: Premium user performs either action +- GIVEN a premium user +- WHEN they add a station manually or add an alarm +- THEN no interstitial shows + +### Requirement: Interstitial Frequency Cap + +The system MUST enforce a session-scoped frequency cap on interstitials so that repeated adds in one session do not chain interstitials back-to-back on every single attempt. + +#### Scenario: Rapid consecutive adds in one session +- GIVEN a free-tier user adds several stations or alarms in quick succession within the same session +- WHEN each add completes +- THEN not every single add triggers a fresh interstitial — the frequency cap suppresses some per its configured spacing/count rule + +### Requirement: Interstitial Never Stacks With The Alarm-Cap Message + +If an alarm-add attempt would trigger both the interstitial and the 5-alarm-cap message in the same tap, the alarm-cap message MUST take precedence and the interstitial MUST be suppressed for that attempt. + +#### Scenario: Cap hit and interstitial would-be trigger collide +- GIVEN a free-tier user already has 5 alarms +- WHEN they tap "add" for a 6th alarm +- THEN only the alarm-cap explanatory message appears, and no interstitial is shown for that same tap + +### Requirement: Ads Vanish Immediately On Purchase + +Both the top banner and interstitial triggers MUST stop immediately upon successful purchase or restore, with no app restart required. + +#### Scenario: Mid-session purchase +- GIVEN a free-tier user with the banner visible completes a purchase +- WHEN the purchase confirms +- THEN the banner disappears immediately and subsequent adds trigger no interstitial, without restarting the app + +--- + +## Domain: android-auto-media (MODIFIED) + +# Delta for Android Auto Media + +## MODIFIED Requirements + +### Requirement: Browsable Media Tree + +For a user holding premium entitlement, `getChildren` MUST return a browsable tree rooted at `AudioService.browsableRootId`, organized into non-playable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root) containing playable items. Playable station items SHOULD carry an audio-quality subtitle when known. The `Favoritos` folder additionally MAY contain non-playable favorite-group sub-folders (see "Favorite Group Sub-Folders"); `Todas las emisoras` and `Mis emisoras` remain flat. The `Ecualizador` folder is flat, non-playable, and contains only the 6 fixed EQ preset items (see "EQ Preset Browsable Folder"). The local-music root folder is non-playable and may itself be nested (see "Local Music Browsable Tree"). For a free-tier (non-premium) user, this full tree is NOT exposed; see "Free-Tier Reduced Root Browse" for the entitlement-aware equivalent. +(Previously: root contained exactly 3 folders — Favoritos, Todas las emisoras, Mis emisoras — with no EQ or local-music folder; Favoritos was a flat folder of playable station items only, with no sub-folder nesting; there was no entitlement distinction.) + +#### Scenario: Car requests the root (premium) +- GIVEN the user holds premium entitlement and the car head unit connects and requests the root (`AudioService.browsableRootId`) +- WHEN `getChildren` is called with the root id +- THEN it returns five folder `MediaItem`s (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root), each with `playable: false` + +#### Scenario: Car requests a folder with no stations (premium) +- GIVEN the user holds premium entitlement and has zero favorite stations +- WHEN `getChildren` is called with the Favoritos folder id +- THEN it returns an empty list, not an error + +#### Scenario: Browse requested before app state is loaded (premium) +- GIVEN the user holds premium entitlement and the audio handler starts cold and station/favorites Provider state has not finished loading +- WHEN `getChildren` is called (root or any folder) +- THEN it returns a valid, possibly empty, list without throwing and without blocking or crashing the service + +#### Scenario: Station has known codec and bitrate +- GIVEN a station's `Emisora.codec` and `Emisora.bitrate` are both known (non-null) +- WHEN it is mapped to a playable `MediaItem` +- THEN `displaySubtitle` SHALL contain a human-readable quality hint combining bitrate and codec (e.g. "128 kbps · MP3") + +#### Scenario: Station has unknown codec or bitrate +- GIVEN a station's `Emisora.codec` or `Emisora.bitrate` (or both) is null/unknown +- WHEN it is mapped to a playable `MediaItem` +- THEN `displaySubtitle` SHALL omit the quality hint gracefully (no subtitle, or a subtitle with no quality fragment) +- AND the subtitle MUST NOT render literal placeholder text such as "null kbps" or "null · null" + +#### Scenario: Ungrouped station appears exactly as before (regression guard) +- GIVEN a station's `Emisora.grupoFavoritosId` equals `GrupoFavoritos.sinAsignarId` (`'sin_asignar'`, the default when no group is assigned), and the browsing user holds premium entitlement +- WHEN the `Favoritos`, `Todas las emisoras`, or `Mis emisoras` folders are browsed +- THEN that station appears as a playable `emisora:` item in exactly the same folder(s), position (subject to existing sort rules), title, art, and subtitle as it did before favorite-group folders were introduced +- AND its presence and shape are unaffected by the existence, emptiness, or content of any favorite group + +## ADDED Requirements + +### Requirement: Free-Tier Reduced Root Browse + +For a free-tier (non-premium) user, `getChildren` at the root MUST NOT return the full folder tree. Instead it MUST return a non-blank list whose items each represent one of the normally-browsable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, local-music root) rendered as a non-playable, explicitly locked item labeled as a premium feature (e.g. title "Función Premium"). A blank or empty root/folder response for a free-tier user is forbidden. + +#### Scenario: Free-tier user requests the root +- GIVEN a free-tier (non-premium) user's car head unit requests the root +- WHEN `getChildren` is called with the root id +- THEN it returns non-playable locked items labeled as premium features, one per normally-browsable folder, and never an empty list + +#### Scenario: Free-tier user selects a locked item +- GIVEN a free-tier user is shown a locked "Función Premium" item +- WHEN they select it +- THEN no real folder content or station list is returned, and no crash or unhandled exception occurs + +### Requirement: Free-Tier Browse Never Leaks Real Content (Authoritative Backstop) + +Even if `getChildren` receives a stale or deep-linked folder id that would resolve to real station or local-music content, for a free-tier (non-premium) user it MUST NOT return that real content. This check MUST be enforced at the `getChildren`/`navegacion_auto.dart` choke point itself, independent of which UI path reached it. + +#### Scenario: Stale folder id bypass attempt +- GIVEN a free-tier user's car client holds a cached `emisora:` or folder id from before downgrade or from another device +- WHEN `getChildren`/`playFromMediaId` is called with that id +- THEN the authoritative entitlement check at the choke point blocks real content or playback from being returned, regardless of the id's validity + +### Requirement: Current-Station Playback Unaffected By Free Tier + +Regardless of entitlement, transport controls (play/pause/stop) for whatever station is already loaded or playing MUST keep working for a free-tier user in the car. Only browsing/switching to a different station and local music are restricted by the free tier. + +#### Scenario: Free-tier user controls the current station +- GIVEN a free-tier user already has a station loaded or playing when connecting to the car +- WHEN they use play/pause/stop from the car head unit +- THEN the command is honored exactly as for a premium user + +#### Scenario: Free-tier user cannot switch stations via browse +- GIVEN a free-tier user is currently playing a station +- WHEN they attempt to browse to a different station via the root tree +- THEN they see only the locked "Función Premium" items, not a station list, and cannot switch stations that way diff --git a/openspec/changes/iap-freemium-unlock/specs/ad-display/spec.md b/openspec/changes/iap-freemium-unlock/specs/ad-display/spec.md new file mode 100644 index 0000000..cd7d18c --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/specs/ad-display/spec.md @@ -0,0 +1,67 @@ +# Ad Display Specification + +## Purpose + +Define where and when `google_mobile_ads` renders for free-tier users only: a persistent top banner, plus a capped interstitial before two specific add actions. Ads MUST be entirely absent for premium users. + +## Requirements + +### Requirement: Persistent Top Banner, Never Overlapping Content + +Free-tier users MUST see a persistent banner anchored at the TOP of the app (not bottom), laid out so it reserves its own space and never overlaps, covers, or displaces existing UI on any of the 5 tabs. Premium users MUST see no banner and no reserved space for it. + +#### Scenario: Free user on any tab +- GIVEN a free-tier user +- WHEN they view any of the 5 tabs +- THEN the top banner is visible and all existing content remains fully visible and reachable, none hidden behind it + +#### Scenario: Premium user +- GIVEN a premium user +- WHEN they view any tab +- THEN no banner and no reserved banner space is shown + +### Requirement: Interstitial Before Manual Station Add And Before Alarm Add + +For free-tier users, a full-screen interstitial MUST show before completing exactly two actions: adding a station manually, and adding an alarm. It MUST fire on the action's natural transition (e.g. on confirm/save), never mid-form, and MUST NOT show for premium users. + +#### Scenario: Free user adds a station manually +- GIVEN a free-tier user completes the "add station manually" form +- WHEN they confirm the add +- THEN a full-screen interstitial shows once before/around that transition + +#### Scenario: Free user adds an alarm +- GIVEN a free-tier user under the 5-alarm cap completes the alarm-creation form +- WHEN they save the new alarm +- THEN a full-screen interstitial shows once before/around that transition + +#### Scenario: Premium user performs either action +- GIVEN a premium user +- WHEN they add a station manually or add an alarm +- THEN no interstitial shows + +### Requirement: Interstitial Frequency Cap + +The system MUST enforce a session-scoped frequency cap on interstitials so that repeated adds in one session do not chain interstitials back-to-back on every single attempt. + +#### Scenario: Rapid consecutive adds in one session +- GIVEN a free-tier user adds several stations or alarms in quick succession within the same session +- WHEN each add completes +- THEN not every single add triggers a fresh interstitial — the frequency cap suppresses some per its configured spacing/count rule + +### Requirement: Interstitial Never Stacks With The Alarm-Cap Message + +If an alarm-add attempt would trigger both the interstitial and the 5-alarm-cap message in the same tap, the alarm-cap message MUST take precedence and the interstitial MUST be suppressed for that attempt. + +#### Scenario: Cap hit and interstitial would-be trigger collide +- GIVEN a free-tier user already has 5 alarms +- WHEN they tap "add" for a 6th alarm +- THEN only the alarm-cap explanatory message appears, and no interstitial is shown for that same tap + +### Requirement: Ads Vanish Immediately On Purchase + +Both the top banner and interstitial triggers MUST stop immediately upon successful purchase or restore, with no app restart required. + +#### Scenario: Mid-session purchase +- GIVEN a free-tier user with the banner visible completes a purchase +- WHEN the purchase confirms +- THEN the banner disappears immediately and subsequent adds trigger no interstitial, without restarting the app diff --git a/openspec/changes/iap-freemium-unlock/specs/android-auto-media/spec.md b/openspec/changes/iap-freemium-unlock/specs/android-auto-media/spec.md new file mode 100644 index 0000000..3cfc3cd --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/specs/android-auto-media/spec.md @@ -0,0 +1,79 @@ +# Delta for Android Auto Media + +## MODIFIED Requirements + +### Requirement: Browsable Media Tree + +For a user holding premium entitlement, `getChildren` MUST return a browsable tree rooted at `AudioService.browsableRootId`, organized into non-playable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root) containing playable items. Playable station items SHOULD carry an audio-quality subtitle when known. The `Favoritos` folder additionally MAY contain non-playable favorite-group sub-folders (see "Favorite Group Sub-Folders"); `Todas las emisoras` and `Mis emisoras` remain flat. The `Ecualizador` folder is flat, non-playable, and contains only the 6 fixed EQ preset items (see "EQ Preset Browsable Folder"). The local-music root folder is non-playable and may itself be nested (see "Local Music Browsable Tree"). For a free-tier (non-premium) user, this full tree is NOT exposed; see "Free-Tier Reduced Root Browse" for the entitlement-aware equivalent. +(Previously: root contained exactly 3 folders — Favoritos, Todas las emisoras, Mis emisoras — with no EQ or local-music folder; Favoritos was a flat folder of playable station items only, with no sub-folder nesting; there was no entitlement distinction.) + +#### Scenario: Car requests the root (premium) +- GIVEN the user holds premium entitlement and the car head unit connects and requests the root (`AudioService.browsableRootId`) +- WHEN `getChildren` is called with the root id +- THEN it returns five folder `MediaItem`s (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root), each with `playable: false` + +#### Scenario: Car requests a folder with no stations (premium) +- GIVEN the user holds premium entitlement and has zero favorite stations +- WHEN `getChildren` is called with the Favoritos folder id +- THEN it returns an empty list, not an error + +#### Scenario: Browse requested before app state is loaded (premium) +- GIVEN the user holds premium entitlement and the audio handler starts cold and station/favorites Provider state has not finished loading +- WHEN `getChildren` is called (root or any folder) +- THEN it returns a valid, possibly empty, list without throwing and without blocking or crashing the service + +#### Scenario: Station has known codec and bitrate +- GIVEN a station's `Emisora.codec` and `Emisora.bitrate` are both known (non-null) +- WHEN it is mapped to a playable `MediaItem` +- THEN `displaySubtitle` SHALL contain a human-readable quality hint combining bitrate and codec (e.g. "128 kbps · MP3") + +#### Scenario: Station has unknown codec or bitrate +- GIVEN a station's `Emisora.codec` or `Emisora.bitrate` (or both) is null/unknown +- WHEN it is mapped to a playable `MediaItem` +- THEN `displaySubtitle` SHALL omit the quality hint gracefully (no subtitle, or a subtitle with no quality fragment) +- AND the subtitle MUST NOT render literal placeholder text such as "null kbps" or "null · null" + +#### Scenario: Ungrouped station appears exactly as before (regression guard) +- GIVEN a station's `Emisora.grupoFavoritosId` equals `GrupoFavoritos.sinAsignarId` (`'sin_asignar'`, the default when no group is assigned), and the browsing user holds premium entitlement +- WHEN the `Favoritos`, `Todas las emisoras`, or `Mis emisoras` folders are browsed +- THEN that station appears as a playable `emisora:` item in exactly the same folder(s), position (subject to existing sort rules), title, art, and subtitle as it did before favorite-group folders were introduced +- AND its presence and shape are unaffected by the existence, emptiness, or content of any favorite group + +## ADDED Requirements + +### Requirement: Free-Tier Reduced Root Browse + +For a free-tier (non-premium) user, `getChildren` at the root MUST NOT return the full folder tree. Instead it MUST return a non-blank list whose items each represent one of the normally-browsable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, local-music root) rendered as a non-playable, explicitly locked item labeled as a premium feature (e.g. title "Función Premium"). A blank or empty root/folder response for a free-tier user is forbidden. + +#### Scenario: Free-tier user requests the root +- GIVEN a free-tier (non-premium) user's car head unit requests the root +- WHEN `getChildren` is called with the root id +- THEN it returns non-playable locked items labeled as premium features, one per normally-browsable folder, and never an empty list + +#### Scenario: Free-tier user selects a locked item +- GIVEN a free-tier user is shown a locked "Función Premium" item +- WHEN they select it +- THEN no real folder content or station list is returned, and no crash or unhandled exception occurs + +### Requirement: Free-Tier Browse Never Leaks Real Content (Authoritative Backstop) + +Even if `getChildren` receives a stale or deep-linked folder id that would resolve to real station or local-music content, for a free-tier (non-premium) user it MUST NOT return that real content. This check MUST be enforced at the `getChildren`/`navegacion_auto.dart` choke point itself, independent of which UI path reached it. + +#### Scenario: Stale folder id bypass attempt +- GIVEN a free-tier user's car client holds a cached `emisora:` or folder id from before downgrade or from another device +- WHEN `getChildren`/`playFromMediaId` is called with that id +- THEN the authoritative entitlement check at the choke point blocks real content or playback from being returned, regardless of the id's validity + +### Requirement: Current-Station Playback Unaffected By Free Tier + +Regardless of entitlement, transport controls (play/pause/stop) for whatever station is already loaded or playing MUST keep working for a free-tier user in the car. Only browsing/switching to a different station and local music are restricted by the free tier. + +#### Scenario: Free-tier user controls the current station +- GIVEN a free-tier user already has a station loaded or playing when connecting to the car +- WHEN they use play/pause/stop from the car head unit +- THEN the command is honored exactly as for a premium user + +#### Scenario: Free-tier user cannot switch stations via browse +- GIVEN a free-tier user is currently playing a station +- WHEN they attempt to browse to a different station via the root tree +- THEN they see only the locked "Función Premium" items, not a station list, and cannot switch stations that way diff --git a/openspec/changes/iap-freemium-unlock/specs/freemium-gating/spec.md b/openspec/changes/iap-freemium-unlock/specs/freemium-gating/spec.md new file mode 100644 index 0000000..516e3ca --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/specs/freemium-gating/spec.md @@ -0,0 +1,88 @@ +# Freemium Gating Specification + +## Purpose + +Define which features require premium entitlement, the free-tier alarm cap, grandfathering of existing content, and the non-punitive UX for hitting a limit. The equalizer on the phone is explicitly out of scope — it MUST stay free. + +## Requirements + +### Requirement: Gated Feature Set (Exactly 4) + +The system MUST require premium entitlement for exactly: (1) creating alarm vacations, (2) starting a new station recording, (3) creating an alarm beyond the 5-alarm cap, and (4) full Android Auto browsing (see `android-auto-media`). The phone equalizer MUST NOT be gated under any circumstance. + +#### Scenario: Free user uses the phone equalizer +- GIVEN a free-tier user +- WHEN they open and use the equalizer screen on the phone +- THEN it works fully, with no entitlement check and no upsell + +#### Scenario: Free user attempts a gated action +- GIVEN a free-tier user +- WHEN they tap "add vacation range" or "start recording" +- THEN they see the paywall/upsell instead of the action completing + +### Requirement: Alarm Count Cap At 5 (Free Tier) + +`EstadoAlarmas.guardarAlarma` MUST count all alarms, enabled or not, and MUST reject creating a 6th alarm for a free-tier user via a distinct "limit reached" signal, separate from the existing `_error` field used for native scheduling failures. + +#### Scenario: 6th alarm creation is blocked +- GIVEN a free-tier user already has 5 alarms (any enabled state) +- WHEN they attempt to create a 6th +- THEN `guardarAlarma` rejects it via the distinct limit signal, and no native scheduling is attempted + +#### Scenario: Editing an existing alarm is unaffected +- GIVEN a free-tier user has exactly 5 alarms +- WHEN they edit one of those 5 (not create a new one) +- THEN the edit succeeds normally + +#### Scenario: Premium user has no cap +- GIVEN a premium user +- WHEN they create a 6th or later alarm +- THEN it succeeds with no limit check + +### Requirement: Alarm Cap UX Never Bare-Jumps To Paywall + +Hitting the alarm cap MUST show an explanatory message with a secondary "unlock" action; it MUST NOT navigate directly to the paywall as the sole response to the attempt. + +#### Scenario: Cap message with secondary action +- GIVEN a free-tier user hits the 5-alarm cap +- WHEN the limit signal is raised +- THEN the UI shows an explanatory message (e.g. "Has alcanzado el límite de 5 alarmas gratuitas") with a secondary button (e.g. "Desbloquear Premium") +- AND only tapping that secondary button navigates to the paywall + +### Requirement: Grandfathering Of Existing Content + +Alarms, vacations, and recordings created before the gate existed, or already exceeding the cap, MUST remain visible, usable, and editable-in-place. Only NEW creation past a limit or gate is blocked. +(Previously: no cap or gate existed, so this distinction did not apply.) + +#### Scenario: Pre-existing alarms above the cap keep working +- GIVEN a device already has 7 alarms before this change ships +- WHEN the free-tier gate is active +- THEN all 7 alarms keep ringing and can be toggled/edited, and only a new 8th creation is blocked + +### Requirement: Recording Start Gated, Management Stays Free + +`EstadoGrabacion.iniciar` MUST require premium entitlement. Screens that view, play, or delete already-existing recordings MUST remain accessible regardless of entitlement. + +#### Scenario: Free user starts a new recording +- GIVEN a free-tier user +- WHEN they tap the record action +- THEN they see the paywall instead of recording starting + +#### Scenario: Free user manages existing recordings +- GIVEN a free-tier user with previously recorded files +- WHEN they open the recordings list +- THEN they can view, play, and delete those recordings normally + +### Requirement: Purchase Entry Points At Every Gate Plus Settings + +Every gated entry point MUST show a contextual upsell. Settings MUST additionally expose a persistent purchase/restore row. + +#### Scenario: Contextual upsell at a gate +- GIVEN a free-tier user reaches any of the 4 gated entry points +- WHEN the gate blocks the action +- THEN a contextual purchase CTA is shown at that point + +#### Scenario: Settings always shows a premium row +- GIVEN any user opens Settings +- WHEN the screen renders +- THEN it shows either a "buy premium" row (free tier) or a "premium active" state with restore access (premium tier) diff --git a/openspec/changes/iap-freemium-unlock/specs/premium-entitlement/spec.md b/openspec/changes/iap-freemium-unlock/specs/premium-entitlement/spec.md new file mode 100644 index 0000000..30cc67b --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/specs/premium-entitlement/spec.md @@ -0,0 +1,73 @@ +# Premium Entitlement Specification + +## Purpose + +Track whether the current user holds the permanent, non-consumable premium unlock, and expose that flag to every gated surface (freemium-gating, ad-display, android-auto-media) both from the UI layer and headlessly (Android Auto, registered before `runApp`). + +## Requirements + +### Requirement: One-Time Non-Consumable Purchase + +The system MUST let the user buy a single non-consumable product via `in_app_purchase` from the Settings row or any contextual upsell. On a successful purchase, entitlement MUST flip to premium immediately, app-wide, with no restart required. + +#### Scenario: Successful purchase +- GIVEN a free-tier user taps "buy premium" from Settings or a contextual upsell +- WHEN the purchase completes successfully +- THEN entitlement becomes premium immediately, without restarting the app + +#### Scenario: Purchase cancelled or failed +- GIVEN a free-tier user starts the purchase flow +- WHEN the user cancels or the purchase fails +- THEN entitlement remains free tier, and no charge or partial state is left behind + +#### Scenario: Already-purchased attempt is idempotent +- GIVEN a user already holds premium entitlement +- WHEN they somehow re-trigger the buy flow +- THEN no duplicate charge occurs and entitlement stays premium + +### Requirement: Restore Purchases + +Settings MUST expose a "restore purchases" action that re-queries Play Billing and unlocks entitlement when a prior purchase is found. + +#### Scenario: Restore finds a prior purchase +- GIVEN a reinstall or new device with no local entitlement flag +- WHEN the user taps "restore purchases" and a valid purchase exists on the Play account +- THEN entitlement becomes premium + +#### Scenario: Restore finds nothing +- GIVEN a user with no prior purchase +- WHEN they tap "restore purchases" +- THEN the user stays on the free tier with a clear, non-error-looking result (not a crash or ambiguous failure) + +### Requirement: Persisted, Fail-Open Entitlement + +Entitlement MUST persist locally under a versioned key (e.g. `compra_premium_v1`) and MUST be readable offline. If an entitlement check cannot complete (no network, Play Billing unreachable), the system MUST fail-open: trust the last persisted flag rather than lock out a payer. +(Previously: no entitlement concept existed.) + +#### Scenario: Offline cold start after purchase +- GIVEN a user purchased premium previously +- WHEN they open the app fully offline +- THEN premium entitlement is honored from the persisted flag + +#### Scenario: Failed check does not falsely grant premium +- GIVEN a free-tier user with no persisted premium flag +- WHEN an entitlement check fails +- THEN the user remains free tier (fail-open trusts the last flag, it does not invent one) + +### Requirement: Headless-Safe Entitlement Read + +Entitlement MUST be resolvable via a prefs-lazy fallback (no `BuildContext`/`Provider` dependency), for callers such as `PluriWaveAudioHandler` that register before the widget tree exists. + +#### Scenario: Android Auto cold start +- GIVEN the audio handler is constructed before `runApp` +- WHEN it needs to know the current entitlement to build the browse tree +- THEN it resolves entitlement via the prefs-lazy path without requiring a `Provider` + +### Requirement: Instant Unlock Propagation + +A successful purchase or restore MUST notify all listeners (top banner, gated screens, cached Android Auto entitlement) immediately, without an app restart. + +#### Scenario: Banner disappears immediately on purchase +- GIVEN the ad banner is visible when the user completes a purchase +- WHEN the purchase confirms +- THEN the banner disappears immediately, with no restart diff --git a/openspec/changes/iap-freemium-unlock/tasks.md b/openspec/changes/iap-freemium-unlock/tasks.md new file mode 100644 index 0000000..420ac54 --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/tasks.md @@ -0,0 +1,80 @@ +# Tasks: Freemium unlock via one-time in-app purchase + +## Review Workload Forecast + +Estimated changed lines: 1200-2000+ (5 new, ~12 modified Dart, 13 `.arb` locales, pubspec.yaml, AndroidManifest.xml, plus tests). +Suggested split: single PR now (`single-pr`); Work Units below double as chained-PR slices if `size:exception` is declined. +Delivery strategy: single-pr. + +Decision needed before apply: Yes +Chained PRs recommended: Yes +Chain strategy: size-exception +400-line budget risk: High + +Deferred, non-blocking: price point (Play Console); AdMob ad unit IDs — use Google test IDs. Do not invent values. + +### Suggested Work Units + +| Unit | Goal | Focused test command | Runtime harness | Rollback boundary | +|---|---|---|---|---| +| 1 | Entitlement + purchase I/O | `flutter test test/estado/estado_entitlement_test.dart test/servicios/servicio_compras_test.dart` | Manual: Settings > Restaurar compras | `estado_entitlement.dart`, `servicio_compras.dart` | +| 2 | Alarm, recording, Auto gates + cache invalidation | `flutter test test/estado/estado_alarmas_test.dart test/estado/estado_grabacion_test.dart test/servicios/navegacion_auto_test.dart test/servicios/servicio_audio_test.dart` | Auto head-unit browse smoke | gate diffs in `estado_alarmas.dart`, `estado_grabacion.dart`, `navegacion_auto.dart`, `servicio_audio.dart` | +| 3 | Ads (banner + interstitial) | `flutter test test/servicios/servicio_anuncios_test.dart test/widgets/banner_anuncio_superior_test.dart` | Manual: banner/no-overlap 5 tabs | `servicio_anuncios.dart`, `banner_anuncio_superior.dart`, `app.dart` Column diff | +| 4 | Paywall UI + localization | `flutter test test/pantallas/pantalla_ajustes_test.dart && flutter gen-l10n` | Manual: tap each gate | `hoja_premium.dart`, screen CTA diffs, `app_*.arb` keys | + +## Phase 0: Foundation + +- [x] 0.1 Uncomment `in_app_purchase`/`google_mobile_ads` in `pubspec.yaml`; `flutter pub get`. +- [x] 0.2 Add AdMob test app ID to `AndroidManifest.xml`. + +## Phase 1: Entitlement Core + +- [x] 1.1 RED `estado_entitlement_test.dart`: default free; persisted true; fail-open on failure; `esPremiumPersistido()` headless, no `BuildContext`. +- [x] 1.2 GREEN `estado_entitlement.dart`: `EstadoEntitlement` `ChangeNotifier` (key `compra_premium_v1`) + `esPremiumPersistido()`. +- [x] 1.3 REFACTOR: shared prefs-key constant; document fail-open contract. + +## Phase 2: Purchase I/O + +- [x] 2.1 RED `servicio_compras_test.dart`: `comprar()` success/cancel/idempotent; `restaurar()` found/not-found, no error. +- [x] 2.2 GREEN `servicio_compras.dart`: `PuertoCompras` + `ServicioComprasPlayBilling` (sole `in_app_purchase` site); wire `comprar/restaurar`. + +## Phase 3: Alarm Gating + +- [x] 3.1 RED `estado_alarmas_gating_test.dart`: `puedeCrearAlarma` 4/5/6; 6th blocked pre-schedule; edit-at-cap ok; premium uncapped; 8 preexisting grandfathered, 9th blocked; vacations free-blocked/premium-ok. +- [x] 3.2 GREEN `estado_alarmas.dart`: `ResultadoGuardarAlarma` enum, `puedeCrearAlarma`, gate `guardarAlarma`(:104)+`crearRangoVacaciones`(:510). +- [x] 3.3 GREEN `pantalla_alarmas.dart`/`_EditorAlarmaSheet` + `pantalla_vacaciones.dart`: cap message + "Desbloquear Premium" CTA; vacation upsell. + +## Phase 4: Recording Gating + +- [x] 4.1 RED `estado_grabacion_gating_test.dart`: `iniciar()` blocked free/allowed premium; existing recordings stay free. +- [x] 4.2 GREEN `estado_grabacion.dart`: gate `iniciar()`(:90); upsell at 3 sites in `pantalla_reproductor.dart`. + +## Phase 5: Android Auto Gating + +- [x] 5.1 RED `navegacion_auto_gating_test.dart`: `raiz(premium:false)` non-blank tree with the real folder labels (design ADR-4: root labels stay visible for every tier, lock enforced one level down); `respuestaBloqueadaPorEntitlement(non-root,free)->[itemPremiumBloqueado()]`; premium unchanged (regression). +- [x] 5.2 RED `servicio_audio_gating_test.dart`: `debeBloquearCambioDeEmisora` free/premium; stale-id backstop wired into `playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious`. +- [x] 5.3 RED: free->premium transition invokes the registered Auto-invalidation hook (`registrarNotificacionDesbloqueoAuto`/`notificarDesbloqueoAuto`), which pushes to `PluriWaveAudioHandler.subscribeToChildren`'s per-id `BehaviorSubject`s (the current non-deprecated `audio_service` API — the plugin's OWN internal listener forwards each push to the platform's `notifyChildrenChanged`). +- [x] 5.4 GREEN: `raiz(premium:)`+`itemPremiumBloqueado()`+`respuestaBloqueadaPorEntitlement` (`navegacion_auto.dart`); gate `getChildren`/`playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious` + `subscribeToChildren`/`notificarHijosCambiaron` wiring (`servicio_audio.dart`). + +## Phase 6: Ads + +- [x] 6.1 RED `servicio_anuncios_test.dart`: cap 2/session >=3min (fake clock); over-cap no-op; suppressed with alarm-cap message; none when premium. +- [x] 6.2 GREEN `servicio_anuncios.dart`: banner/interstitial port + AdMob adapter (test ad unit IDs) + frequency cap. +- [x] 6.3 RED+GREEN `banner_anuncio_superior.dart` + `app.dart`: shrink when premium/unloaded, no overlap 5 tabs; `Column[banner, Expanded(body)]`, never `Stack`. + +## Phase 7: Purchase UI Wiring + +- [x] 7.1 GREEN `hoja_premium.dart` (paywall sheet) + `app.dart`: register `EstadoEntitlement` Provider. +- [x] 7.2 GREEN `pantalla_ajustes.dart`: buy/restore/premium-active row; `pantalla_favoritos.dart` + `ajustes_emisoras_personalizadas.dart`: interstitial before manual station add. + +## Phase 8: Localization (13 locales, `app_es.arb` template) + +- [x] 8.1 Add keys (`funcionPremium`, `limiteAlarmasAlcanzado`, `desbloquearPremium`, `restaurarCompras`) to `app_es.arb`; translate into 12 remaining locales. +- [x] 8.2 Run `flutter gen-l10n`; verify `AppLocalizations` getters generated. +- [x] 8.3 Run literal-encoding scan on `lib/l10n/app_*.arb` — zero mojibake (only pre-existing "REPETIÇÃO" false positive, unrelated to this change). + +## Phase 9: Verification + +- [x] 9.1 Run full suite; confirm every RED test above is GREEN. +- [x] 9.2 Regression-check: phone equalizer has zero entitlement checks. +- [x] 9.3 Update `proposal.md` Success Criteria checkboxes. diff --git a/openspec/changes/iap-freemium-unlock/verify-report.md b/openspec/changes/iap-freemium-unlock/verify-report.md new file mode 100644 index 0000000..7d90047 --- /dev/null +++ b/openspec/changes/iap-freemium-unlock/verify-report.md @@ -0,0 +1,292 @@ +```yaml +schema: gentle-ai.verify-result/v1 +evidence_revision: sha256:2c382e1b0ea0ead93ebb25ce741be99bc6005c20 +verdict: fail +blockers: 2 +critical_findings: 2 +requirements: 20/20 +scenarios: 39/39 +test_command: flutter test +test_exit_code: 1 +test_output_hash: sha256:3b2a1fcdb1436e77a8a883923ebeb01f7ebc675602162c38c1ca2b42a5acb0c1 +build_command: flutter analyze +build_exit_code: 1 +build_output_hash: sha256:cb2b64838a0c89a135b8a1b9bda36f57e6060c244129554060b00f6a7f5bcbd6 +``` + +## Verification Report + +Change: iap-freemium-unlock +Branch/Commit: feat/iap-freemium-unlock, single commit 2c382e1 +Version: N/A (no versioned spec revisions) +Mode: Strict TDD + +### Completeness +| Metric | Value | +|--------|-------| +| Tasks total | 27 | +| Tasks complete (checked) | 26 | +| Tasks incomplete (unchecked in tasks.md) | 1 (task 3.3) | + +Discrepancy: openspec/changes/iap-freemium-unlock/tasks.md line 45 shows task 3.3 +(GREEN pantalla_alarmas.dart/_EditorAlarmaSheet + pantalla_vacaciones.dart: cap message +plus Desbloquear Premium CTA; vacation upsell) as an unchecked box, despite +apply-progress.md's own summary table and both Engram apply-progress observations +(#2834, #2835) explicitly claiming ALL PHASES COMPLETE (27/27 tasks) and Phase 3 marked +complete for 3.1, 3.2 and 3.3. Source inspection confirms the underlying code for 3.3 IS +implemented and covered by regression tests (pantalla_alarmas.dart's _abrirEditor +cap-check-plus-interstitial wiring, _mostrarLimiteAlarmas snackbar and CTA, and +pantalla_vacaciones.dart's paywall-on-block via mostrarHojaPremium) -- this is a +tracking and documentation integrity failure, not a missing implementation. Per the +verify decision gate (an unchecked task always remains CRITICAL, even when other +artifacts are missing or warnings-only), this blocks a clean archive regardless of the +underlying code being present. + +### Build and Tests Execution + +Static analysis: flutter analyze -> exit 1, 5 issues (all confirmed pre-existing and +unrelated via git blame: 2x deprecated_member_use on onReorder in pantalla_favoritos.dart +and its test, predating this change; 1x unused_catch_stack in servicio_audio.dart:1310, +blamed to commit 0e18c822 dated 2026-05-21, predating this change; 1x annotate_overrides +in estado_radio_test.dart:865). Matches the apply-progress claim exactly. flutter analyze +exits 1 whenever any issue including info level is present -- this is expected repository +baseline behavior, not a regression. + +Tests: FAILING -- 1242 passed / 2 skipped / 1 FAILED (1245 total), full flutter test run +completed in about 2 minutes 34 seconds (contrary to apply-progress's claim that a single +flutter test full-suite invocation exceeds this environment's command timeout of about 10 +minutes -- it did not, in this run). + +```text +$ flutter test +... +02:34 +1242 ~2 -1: Some tests failed. + +Failing tests: + C:/Proyectos/pluriwave/test/l10n/arb_anti_copy_test.dart: every non-es value identical to + the Spanish template is a deliberately allowlisted exception, not an accidental untranslated + copy [E] + Expected: empty + Actual: [ + pt/desbloquearPremium = "Desbloquear Premium", + pt/restaurarCompras = "Restaurar compras" + ] + Found values identical to the Spanish template that are NOT in + identical_value_allowlist.dart -- this is very likely an untranslated copy-paste... +``` + +This directly contradicts the apply-progress claim of full suite green (719+ tests) and +all phases green. The failure is a genuine, reproducible regression against a pre-existing +guard test (test/l10n/arb_anti_copy_test.dart, not one of this change's own new test files), +caused by this change's own new content: 2 of the 4 new localization keys +(desbloquearPremium, restaurarCompras) were left byte-identical to the Spanish template for +the pt locale and were never added to identical_value_allowlist.dart nor genuinely +translated. The apply-progress literal-encoding scan and dart format checks would never +have caught this -- only arb_anti_copy_test.dart catches it, and it was never run: the +apply-progress's own batched regression run explicitly lists test/estado/, test/servicios/, +test/widgets/, test/pantallas/, and 4 top-level files -- test/l10n/ is absent from every +batch, so this defect went undetected until this verify pass ran the real full suite. + +Coverage: not measured (no --coverage run performed; not requested by the phase gates and +project rules prohibit flutter build, and coverage instrumentation was judged non-essential +given the full-suite pass/fail evidence already gathered). + +### Spec Compliance Matrix (by requirement; 20 requirements / 39 scenarios across 4 domains) + +| Domain | Requirement | Covering test(s) | Result | +|---|---|---|---| +| premium-entitlement | One-Time Non-Consumable Purchase | estado_entitlement_test.dart (comprar success/cancel/idempotent) | COMPLIANT | +| premium-entitlement | Restore Purchases | estado_entitlement_test.dart (restaurar found/not-found) | COMPLIANT | +| premium-entitlement | Persisted, Fail-Open Entitlement | estado_entitlement_test.dart (loads persisted flag; error does not block payer) | COMPLIANT | +| premium-entitlement | Headless-Safe Entitlement Read | estado_entitlement_test.dart (esPremiumPersistido group, no BuildContext) | COMPLIANT | +| premium-entitlement | Instant Unlock Propagation | estado_entitlement_test.dart (ChangeNotifier notification count) plus servicio_audio_gating_test.dart (Auto invalidation hook) | COMPLIANT | +| freemium-gating | Gated Feature Set (exactly 4) | equalizer-zero-refs grep plus alarm/recording/vacation/Auto gating tests | COMPLIANT | +| freemium-gating | Alarm Count Cap At 5 | estado_alarmas_gating_test.dart (4/5/6, pre-schedule block, edit-at-cap, premium uncapped) | COMPLIANT | +| freemium-gating | Alarm Cap UX Never Bare-Jumps To Paywall | pantalla_alarmas.dart _mostrarLimiteAlarmas (source-verified; snackbar plus CTA, no direct nav) | COMPLIANT (source; no dedicated widget test asserts the exact snackbar text/CTA pair) | +| freemium-gating | Grandfathering Of Existing Content | estado_alarmas_gating_test.dart (8 preexisting alarms stay, only the 9th is blocked) | COMPLIANT | +| freemium-gating | Recording Start Gated, Management Stays Free | estado_grabacion_gating_test.dart (free blocked, premium allowed, compat default) | COMPLIANT | +| freemium-gating | Purchase Entry Points At Every Gate Plus Settings | source-verified across pantalla_alarmas.dart, pantalla_vacaciones.dart, pantalla_reproductor.dart, pantalla_ajustes.dart | COMPLIANT | +| ad-display | Persistent Top Banner, Never Overlapping Content | banner_anuncio_superior_test.dart (Column layout, zero-footprint collapse) | COMPLIANT | +| ad-display | Interstitial Before Manual Station Add And Before Alarm Add | source-verified (pantalla_alarmas.dart _abrirEditor, pantalla_favoritos.dart, ajustes_emisoras_personalizadas.dart) plus servicio_anuncios_test.dart cap logic | COMPLIANT | +| ad-display | Interstitial Frequency Cap | servicio_anuncios_test.dart (2 per session, 3-minute spacing, failed load does not consume cap) | COMPLIANT | +| ad-display | Interstitial Never Stacks With The Alarm-Cap Message | source-verified: _abrirEditor returns early on cap-block, before intentarInterstitial is ever called | COMPLIANT | +| ad-display | Ads Vanish Immediately On Purchase | servicio_anuncios_test.dart (premium never shows) plus banner_anuncio_superior_test.dart (premium never attempts) | COMPLIANT | +| android-auto-media | Browsable Media Tree (premium, regression) | navegacion_auto_gating_test.dart (premium identical to current tree) plus navegacion_auto_test.dart (updated call sites, premium true) | COMPLIANT | +| android-auto-media | Free-Tier Reduced Root Browse | navegacion_auto_gating_test.dart (free: same labels, non-blank, never playable; itemPremiumBloqueado non-crash) | COMPLIANT | +| android-auto-media | Free-Tier Browse Never Leaks Real Content (Authoritative Backstop) | navegacion_auto_gating_test.dart (stale/deep-linked id backstop) plus servicio_audio_gating_test.dart (debeBloquearCambioDeEmisora) plus source-verified in all 5 servicio_audio.dart call sites | COMPLIANT | +| android-auto-media | Current-Station Playback Unaffected By Free Tier | source-verified: play(), pause(), stop() in servicio_audio.dart contain no entitlement check | COMPLIANT | + +Compliance summary: 20/20 requirements have runtime or source-verified covering evidence. +One requirement (Alarm Cap UX) is source-verified but lacks a dedicated widget test asserting +the exact snackbar/CTA pair -- downgraded to a WARNING below, not a blocker, since the logic +path is simple and exercised transitively by the passing regression suite. + +### Orchestrator-Flagged Scrutiny Points + +1. Fail-open entitlement default ("() => true" in estado_alarmas.dart:36, + estado_grabacion.dart:57) -- VERIFIED: exactly 2 production construction sites exist for + these classes (app.dart lines 71-76, EstadoRadio(esPremium: () => + context.read().esPremium), threaded internally to EstadoGrabacion at + estado_radio.dart:73; app.dart lines 93-96, EstadoAlarmas(esPremium: ...)), both correctly + wired, with EstadoEntitlement registered FIRST in the provider list specifically so these + context.read calls resolve. The headless Android Auto path (servicio_audio.dart) never + constructs EstadoAlarmas/EstadoGrabacion at all -- it calls esPremiumPersistido() directly, + a separate, unaffected function. No current production or headless path reaches the + fail-open default. See WARNING below for the latent-risk recommendation. + +2. Android Auto gating completeness (ADR-4) -- VERIFIED COMPLIANT: playFromMediaId, + playFromSearch, skipToNext, skipToPrevious all call + debeBloquearCambioDeEmisora(premium: await esPremiumPersistido()) and no-op when blocked + (servicio_audio.dart lines approximately 1601, 1626, 1863, 1896). play(), pause(), stop() + contain no such check -- transport of the current station is untouched. getChildren never + returns blank for free tier: respuestaBloqueadaPorEntitlement returns exactly one + itemPremiumBloqueado() item for any non-root id, and the root itself always resolves + through raiz() (never blocked). + +3. notifyChildrenChanged replacement -- VERIFIED FUNCTIONALLY EQUIVALENT: the deprecated + static helper is replaced by PluriWaveAudioHandler.subscribeToChildren (a per-parent-id + BehaviorSubject overriding the audio_service base class's stream-based extension point) + plus notificarHijosCambiaron(id), which pushes a fresh value into that subject. + EstadoEntitlement._desbloquear() calls notificarDesbloqueoAuto() on the free-to-premium + edge (only when the user was not already premium), which fires the hook registered in + registrarHandler() that pushes to the root plus all 4 folder ids. This is audio_service's + own documented replacement mechanism for the deprecated helper (the plugin's internal + listener subscribes to subscribeToChildren and forwards to the platform's + notifyChildrenChanged itself) -- not a workaround. Covered by + servicio_audio_gating_test.dart's registrarNotificacionDesbloqueoAuto group. + +4. Deviation #5, crearRangoVacaciones returns bool -- VERIFIED ACCEPTABLE: the method has + exactly one failure mode today (entitlement block returns false); there is no other + throw/failure path in its body, so a caller cannot currently confuse "blocked by + entitlement" with any other failure. pantalla_vacaciones.dart's _guardar checks + "if (!creada) mostrarHojaPremium(context)", correctly routing to the paywall. This is a + sound simplification given the current single-failure-mode reality, though it is not + future-proof if crearRangoVacaciones ever grows a second failure mode (see SUGGESTION + below). + +5. Interstitial ordering (cap-check before interstitial) -- VERIFIED COMPLIANT: + pantalla_alarmas.dart's _abrirEditor checks estado.puedeCrearAlarma() FIRST; on false it + calls _mostrarLimiteAlarmas(context) and returns immediately -- + ServicioAnuncios.intentarInterstitial() is only reached on the true branch. A free user at + the 5-alarm cap can never see an interstitial followed by a refusal. + +6. Equalizer NOT gated -- VERIFIED COMPLIANT: zero matches for + esPremium, EstadoEntitlement, esPremiumPersistido or ServicioAnuncios across + estado_ecualizador.dart, servicio_ecualizador.dart, pantalla_ajustes_ecualizador.dart and + ecualizador_widget.dart. + +7. Encoding scan -- VERIFIED CLEAN across all 13 app_*.arb files for the mojibake pattern + (A-tilde, A-circumflex, a-euro-etc sequences): only the pre-existing, unrelated + app_pt.arb "REPETICAO" false positive. The 4 new keys are byte-clean in every locale. + Note: this scan does NOT catch the untranslated-copy defect found above -- that is a + semantic/content problem, not a mojibake/encoding problem, and is caught by a different + test, arb_anti_copy_test.dart. + +8. Test-harness fixes -- VERIFIED LEGITIMATE: diffed all 9 modified harness files against the + commit. Every change is a strictly additive provider registration + (ChangeNotifierProvider and/or Provider added to each + test's widget tree) required because the new gated call sites now read those providers via + context.read/context.watch. Zero existing assertions were removed, weakened, or altered in + any of the 9 files (navegacion_auto_test.dart's 3 raiz() call sites gained a + "premium: true" argument, not a removed assertion). + +### TDD Compliance +| Check | Result | Details | +|-------|--------|---------| +| TDD Evidence reported | Yes | Full RED/GREEN/REFACTOR table present in apply-progress.md | +| All tasks have tests | Yes | 8 new test files map to every pure-logic phase | +| RED confirmed (tests exist) | Yes | All 8 new test files verified present on disk with real assertions | +| GREEN confirmed (tests pass) | Partial | 7/8 new test files pass fully; none of the 8 NEW files is the failing one (arb_anti_copy_test.dart is pre-existing) | +| Triangulation adequate | Yes | Every gated behavior has 3 or more cases (free/premium/edge -- cap boundary, idempotency, stale-id backstop) | +| Safety Net for modified files | Yes | estado_alarmas.dart, estado_grabacion.dart, navegacion_auto.dart, servicio_audio.dart all have pre-existing regression suites re-run and green | + +TDD Compliance: 6/6 checks passed (the one Partial is about the pre-existing, unrelated +l10n regression, not this change's own new tests). + +### Test Layer Distribution +| Layer | Tests | Files | Tools | +|-------|-------|-------|-------| +| Unit (pure logic) | approx 40 | estado_entitlement_test.dart, estado_alarmas_gating_test.dart, estado_grabacion_gating_test.dart, servicio_compras_test.dart, servicio_anuncios_test.dart, navegacion_auto_gating_test.dart, servicio_audio_gating_test.dart | flutter_test | +| Widget | approx 8 new plus 9 harness files updated | banner_anuncio_superior_test.dart plus regression widget suites | flutter_test | +| E2E | 0 | none | not installed | +| Total (full suite) | 1245 | 1242 pass / 2 skip / 1 fail | | + +### Assertion Quality +Audited all 8 new test files (estado_entitlement_test.dart, servicio_compras_test.dart, +estado_alarmas_gating_test.dart, estado_grabacion_gating_test.dart, +navegacion_auto_gating_test.dart, servicio_audio_gating_test.dart, +servicio_anuncios_test.dart, banner_anuncio_superior_test.dart) for banned patterns +(tautologies, ghost loops over possibly-empty collections, assertion-free production calls, +ratio of mocks to assertions). Loops over hardcoded non-empty literal lists (for example the +respuestaBloqueadaPorEntitlement test's loop over a literal id list) do not qualify as ghost +loops since the collection is a non-empty compile-time literal, not a runtime query result. + +Assertion quality: All assertions verify real behavior -- 0 CRITICAL, 0 WARNING. + +### Correctness (Static Evidence) +| Requirement area | Status | Notes | +|------------|--------|-------| +| Fail-open entitlement default | Implemented, no reachable bypass today | See WARNING (latent risk) | +| Android Auto gate choke points | Implemented | 5 of 5 dispatch methods gated, 3 of 3 transport methods left open | +| Vacations full gate | Implemented | bool return, single failure mode, correctly UI-routed | +| Ad ordering invariants | Implemented | Cap-check strictly precedes interstitial | +| Equalizer isolation | Implemented | Zero cross-references | +| l10n new keys | Partially implemented | 2 of 4 pt keys are untranslated copies (see CRITICAL) | + +### Coherence (Design) +| Decision | Followed? | Notes | +|----------|-----------|-------| +| ADR-1 (versioned prefs key, fail-open) | Yes | compra_premium_v1, absent key equals free | +| ADR-2 (sole in_app_purchase call site) | Yes | ServicioComprasPlayBilling only | +| ADR-3 (callback-injection, not direct EstadoEntitlement dependency) | Yes | Mirrors existing emisoraActual pattern | +| ADR-4 (root labels visible, lock one level down) | Yes | Documented deviation from the spec's literal root-locking wording, resolved per orchestrator/design.md; regression-safe for premium | +| ADR-5 (distinct ResultadoGuardarAlarma enum, not overloaded error field) | Yes | | +| ADR-6 (interstitial ordering: cap-check then interstitial then editor) | Yes | Corrected mid-run per apply-progress's own honest disclosure; final state verified correct | +| notifyChildrenChanged deprecation workaround | Yes | Uses the plugin's own documented replacement mechanism | + +### Issues Found + +CRITICAL: +1. tasks.md task 3.3 is unchecked on the filesystem despite apply-progress and Engram + artifacts claiming full 27/27 completion. Tracking and documentation integrity failure -- + blocks a clean archive per the verify decision gate, even though the underlying + implementation and tests for 3.3 are genuinely present and passing. +2. flutter test (full suite, 1245 tests) FAILS: test/l10n/arb_anti_copy_test.dart catches 2 + of the 4 new localization keys (desbloquearPremium, restaurarCompras) left byte-identical + to the Spanish template for the pt locale -- a genuine untranslated-copy defect introduced + by this change, undetected because the apply agent's regression batches never included + test/l10n/. Directly contradicts the "full suite green (719+)" claim. + +WARNING: +1. The fail-open entitlement default in EstadoAlarmas/EstadoGrabacion is a latent + monetization-bypass risk pattern: no current call site reaches it, but nothing + structurally prevents a future one from silently doing so with no test failure to catch + it (the default fabricates full premium access rather than failing safe). Recommend a + follow-up hardening task: make esPremium a required parameter (forcing every call site, + including the approximately 30 pre-existing tests, to be explicit), or flip the default to + "() => false" and update the tests that rely on implicit ungated construction. +2. "Alarm Cap UX Never Bare-Jumps To Paywall" requirement is source-verified but has no + dedicated widget test asserting the exact snackbar text plus secondary CTA pair in + isolation. + +SUGGESTION: +1. crearRangoVacaciones's bool return (Deviation #5) works today because it has exactly one + failure mode. If a second failure mode is ever added (for example a validation error), the + caller will not be able to distinguish it from an entitlement block. Consider migrating to + a small result enum before that happens, matching the ResultadoGuardarAlarma and + ResultadoIniciarGrabacion precedent already established elsewhere in this same change. +2. "dart format --set-exit-if-changed lib/ test/" currently flags 18 pre-existing files + unrelated to this change (confirmed via diff against the Files Changed table) -- + pre-existing repository drift, not a regression, but worth a separate cleanup pass. + +### Verdict +FAIL -- 2 CRITICAL findings block a clean archive: (1) tasks.md task 3.3 tracking +discrepancy, and (2) a genuine, reproducible test failure in the full flutter test suite +caused by this change's own untranslated Portuguese localization content, which the apply +agent's own claims (full suite green, 27/27 tasks) did not disclose. Both are narrow and +mechanically fixable (check the box; translate 2 strings or add reviewed allowlist entries) +-- recommend routing back to sdd-apply for a small, targeted fix-and-reverify rather than a +full re-implementation. All 20 spec requirements are otherwise source/test-verified +compliant, and the 6 orchestrator-flagged scrutiny points (fail-open default, Android Auto +gating completeness, notifyChildrenChanged replacement, vacations bool gate, interstitial +ordering, equalizer isolation) all check out as implemented correctly. diff --git a/pubspec.lock b/pubspec.lock index 5a1840e..c86ee33 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -325,6 +325,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.3" + google_mobile_ads: + dependency: "direct main" + description: + name: google_mobile_ads + sha256: "0d4a3744b5e8ed1b8be6a1b452d309f811688855a497c6113fc4400f922db603" + url: "https://pub.dev" + source: hosted + version: "5.3.1" hooks: dependency: transitive description: @@ -349,6 +357,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "0e9510b80b0074e89ab0a8e0fc901439b779dc9ae575ab8d419253c6e1627716" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + in_app_purchase_android: + dependency: transitive + description: + name: in_app_purchase_android + sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905 + url: "https://pub.dev" + source: hosted + version: "0.5.2" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "0b0076cac8ce4fa7048f01e76af8b123aeb6a7c4e0dea2a5206d6664454f3e36" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: "702a23c3d2ddc177b075d521d264900e82f01663881e4ef3ce17775de298c0e3" + url: "https://pub.dev" + source: hosted + version: "0.4.11" intl: dependency: "direct main" description: @@ -365,6 +405,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" just_audio: dependency: "direct main" description: @@ -906,6 +954,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490 + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d + url: "https://pub.dev" + source: hosted + version: "3.26.0" win32: dependency: transitive description: @@ -931,5 +1011,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 0be8af5..b8addac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pluriwave description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium" publish_to: 'none' -version: 1.2.28+150 +version: 1.3.0+151 environment: sdk: ^3.7.0 @@ -49,12 +49,13 @@ dependencies: geocoding: ^3.0.0 package_info_plus: ^8.3.1 - # Ads (activar cuando tengamos Ad Unit IDs) - # google_mobile_ads: ^5.3.0 - + # Ads — TODO: swap Google test ad unit IDs (servicio_anuncios.dart) for + # real AdMob unit IDs once provisioned (iap-freemium-unlock, Open Question). + google_mobile_ads: ^5.3.0 + # In-app purchase - # in_app_purchase: ^3.2.0 - + in_app_purchase: ^3.2.0 + # Song recognition (activar con AudD key) # permission_handler: ^11.3.1 diff --git a/test/estado/estado_alarmas_gating_test.dart b/test/estado/estado_alarmas_gating_test.dart new file mode 100644 index 0000000..b5a4154 --- /dev/null +++ b/test/estado/estado_alarmas_gating_test.dart @@ -0,0 +1,180 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes_alarmas.dart'; + +/// Freemium gating (freemium-gating spec, design ADR-3/ADR-5): the 5-alarm +/// cap for free-tier users, grandfathering of pre-existing alarms, and the +/// full premium gate on vacation-range creation. `EstadoAlarmas`'s existing +/// suite constructs it with NO `esPremium` callback and expects unrestricted +/// behavior — the default therefore stays `() => true` (ungated) so every +/// one of those tests keeps passing unchanged; only tests here explicitly +/// inject `esPremium: () => false` to exercise the free tier. +AlarmaMusical _alarma(String id, {bool activa = true}) => AlarmaMusical( + id: id, + nombre: 'Alarma $id', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + activa: activa, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + EstadoAlarmas construir({required bool premium}) { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + esPremium: () => premium, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + return estado; + } + + group('puedeCrearAlarma / cap de 5 (free tier)', () { + test('con 4 alarmas puede crear una mas', () async { + final estado = construir(premium: false); + for (var i = 1; i <= 4; i++) { + await estado.guardarAlarma(_alarma('a$i')); + } + + expect(estado.puedeCrearAlarma(), isTrue); + }); + + test( + 'con 5 alarmas (cualquier estado activa) no puede crear una 6a', + () async { + final estado = construir(premium: false); + for (var i = 1; i <= 4; i++) { + await estado.guardarAlarma(_alarma('a$i')); + } + await estado.guardarAlarma(_alarma('a5', activa: false)); + + expect(estado.puedeCrearAlarma(), isFalse); + }, + ); + + test('la 6a alarma es bloqueada ANTES de programar en Android', () async { + final estado = construir(premium: false); + for (var i = 1; i <= 5; i++) { + await estado.guardarAlarma(_alarma('a$i')); + } + final android = estado.android as FakePuertoAlarmasAndroid; + final programadasPrevias = android.programadas.length; + + final resultado = await estado.guardarAlarma(_alarma('a6')); + + expect(resultado, ResultadoGuardarAlarma.limiteAlcanzado); + expect(estado.alarmas.length, 5); + expect(android.programadas.length, programadasPrevias); + }); + + test('editar una de las 5 alarmas existentes sigue funcionando', () async { + final estado = construir(premium: false); + for (var i = 1; i <= 5; i++) { + await estado.guardarAlarma(_alarma('a$i')); + } + + final resultado = await estado.guardarAlarma( + _alarma('a3').copyWith(hora: 8), + ); + + expect(resultado, ResultadoGuardarAlarma.guardada); + expect(estado.alarmas.firstWhere((a) => a.id == 'a3').hora, 8); + }); + + test('usuario premium no tiene tope', () async { + final estado = construir(premium: true); + for (var i = 1; i <= 5; i++) { + await estado.guardarAlarma(_alarma('a$i')); + } + + final resultado = await estado.guardarAlarma(_alarma('a6')); + + expect(resultado, ResultadoGuardarAlarma.guardada); + expect(estado.alarmas.length, 6); + expect(estado.puedeCrearAlarma(), isTrue); + }); + + test( + 'grandfathering: 8 alarmas preexistentes siguen funcionando, solo se bloquea la 9a', + () async { + // Simula alarmas ya persistidas antes de que el gate existiera: + // se crean en modo premium (sin tope) y luego se re-evalua en free. + final estadoPremium = construir(premium: true); + for (var i = 1; i <= 8; i++) { + await estadoPremium.guardarAlarma(_alarma('g$i')); + } + expect(estadoPremium.alarmas.length, 8); + + // Editar una de las 8 preexistentes en free tier sigue funcionando. + final estadoFree = EstadoAlarmas( + servicio: estadoPremium.servicio, + android: estadoPremium.android, + iniciarAutomaticamente: false, + esPremium: () => false, + ); + addTearDown(estadoFree.dispose); + await estadoFree.cargarPersistidasSinRecalcular(); + expect(estadoFree.alarmas.length, 8); + + final edicion = await estadoFree.guardarAlarma( + estadoFree.alarmas.first.copyWith(hora: 9), + ); + expect(edicion, ResultadoGuardarAlarma.guardada); + expect(estadoFree.alarmas.length, 8); + + // Una 9a alarma NUEVA sigue bloqueada. + final resultado = await estadoFree.guardarAlarma(_alarma('g9')); + expect(resultado, ResultadoGuardarAlarma.limiteAlcanzado); + expect(estadoFree.alarmas.length, 8); + }, + ); + }); + + group('crearRangoVacaciones — gate completo (freemium-gating)', () { + test('free tier: cualquier creacion de vacaciones es bloqueada', () async { + final estado = construir(premium: false); + + final creada = await estado.crearRangoVacaciones( + RangoVacaciones( + id: 'v1', + nombre: 'Verano', + inicio: DateTime(2026, 7, 1), + fin: DateTime(2026, 7, 15), + ), + ); + + expect(creada, isFalse); + expect(estado.vacaciones, isEmpty); + }); + + test('premium: crea vacaciones sin restriccion', () async { + final estado = construir(premium: true); + + final creada = await estado.crearRangoVacaciones( + RangoVacaciones( + id: 'v1', + nombre: 'Verano', + inicio: DateTime(2026, 7, 1), + fin: DateTime(2026, 7, 15), + ), + ); + + expect(creada, isTrue); + expect(estado.vacaciones, hasLength(1)); + }); + }); +} diff --git a/test/estado/estado_entitlement_test.dart b/test/estado/estado_entitlement_test.dart new file mode 100644 index 0000000..de3ddef --- /dev/null +++ b/test/estado/estado_entitlement_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_entitlement.dart'; +import 'package:pluriwave/servicios/servicio_compras.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`, +/// lets each test drive [emitir] to simulate the purchase stream. +class _PuertoComprasFalso implements PuertoCompras { + final _eventos = StreamController.broadcast(); + int comprasIntentadas = 0; + int restaurosIntentados = 0; + + @override + Stream get eventos => _eventos.stream; + + @override + Future comprar() async { + comprasIntentadas++; + } + + @override + Future restaurar() async { + restaurosIntentados++; + } + + void emitir(EventoCompra evento) => _eventos.add(evento); + + Future dispose() => _eventos.close(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('EstadoEntitlement', () { + test('por defecto es free (sin flag persistida)', () async { + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + expect(estado.esPremium, isFalse); + }); + + test('carga premium desde una flag persistida previamente', () async { + SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + expect(estado.esPremium, isTrue); + }); + + test('comprar() con éxito desbloquea premium y persiste', () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final prefs = await SharedPreferences.getInstance(); + final estado = EstadoEntitlement(prefs: prefs, compras: compras); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + var notificaciones = 0; + estado.addListener(() => notificaciones++); + + unawaited(estado.comprar()); + await Future.delayed(Duration.zero); + compras.emitir(const EventoCompra(TipoEventoCompra.comprada)); + await Future.delayed(Duration.zero); + + expect(estado.esPremium, isTrue); + expect(estado.compraEnCurso, isFalse); + expect(compras.comprasIntentadas, 1); + expect(prefs.getBool('compra_premium_v1'), isTrue); + expect(notificaciones, greaterThan(0)); + }); + + test('comprar() cancelada deja el tier free sin cargo', () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + unawaited(estado.comprar()); + await Future.delayed(Duration.zero); + compras.emitir(const EventoCompra(TipoEventoCompra.cancelada)); + await Future.delayed(Duration.zero); + + expect(estado.esPremium, isFalse); + expect(estado.compraEnCurso, isFalse); + }); + + test( + 'comprar() ya premium es idempotente: no reintenta la compra', + () async { + SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + await estado.comprar(); + + expect(compras.comprasIntentadas, 0); + expect(estado.esPremium, isTrue); + }, + ); + + test('restaurar() encuentra una compra y desbloquea premium', () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + unawaited(estado.restaurar()); + await Future.delayed(Duration.zero); + compras.emitir(const EventoCompra(TipoEventoCompra.restaurada)); + await Future.delayed(Duration.zero); + + expect(estado.esPremium, isTrue); + expect(compras.restaurosIntentados, 1); + }); + + test('restaurar() sin compra previa mantiene free sin error', () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + unawaited(estado.restaurar()); + await Future.delayed(Duration.zero); + compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada)); + await Future.delayed(Duration.zero); + + expect(estado.esPremium, isFalse); + expect(estado.compraEnCurso, isFalse); + }); + + test( + 'un error en el flujo de compra no bloquea al pagador (fail-open)', + () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + unawaited(estado.comprar()); + await Future.delayed(Duration.zero); + compras.emitir(const EventoCompra(TipoEventoCompra.error)); + await Future.delayed(Duration.zero); + + // Fail-open: un error NUNCA escribe `false` sobre una flag ya premium, + // y tampoco inventa un `true` para un usuario free. + expect(estado.esPremium, isFalse); + }, + ); + }); + + group('esPremiumPersistido (headless, sin BuildContext)', () { + test('lee la flag persistida directamente desde prefs', () async { + SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); + final prefs = await SharedPreferences.getInstance(); + + expect(await esPremiumPersistido(prefs: prefs), isTrue); + }); + + test('por defecto (sin flag) resuelve a free', () async { + final prefs = await SharedPreferences.getInstance(); + + expect(await esPremiumPersistido(prefs: prefs), isFalse); + }); + + test( + 'resuelve sin prefs inyectadas (SharedPreferences.getInstance)', + () async { + SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); + + expect(await esPremiumPersistido(), isTrue); + }, + ); + }); +} diff --git a/test/estado/estado_grabacion_gating_test.dart b/test/estado/estado_grabacion_gating_test.dart new file mode 100644 index 0000000..5a0155f --- /dev/null +++ b/test/estado/estado_grabacion_gating_test.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_grabacion.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; + +import '../helpers/fakes.dart'; + +/// Freemium gating (freemium-gating spec "Recording Start Gated, Management +/// Stays Free"): starting a NEW recording requires premium; management of +/// already-existing recordings (listing/playing/deleting — untouched by +/// this file) stays free regardless. +void main() { + test( + 'free tier: iniciar() no llama al servicio y reporta requierePremium', + () async { + final servicio = _ServicioGrabacionControlado(); + final emisora = emisoraDemo(uuid: 'rec-1', nombre: 'Grabable'); + final estado = EstadoGrabacion( + servicio: servicio, + emisoraActual: () => emisora, + esPremium: () => false, + ); + addTearDown(estado.dispose); + + final resultado = await estado.iniciar(); + + expect(resultado, ResultadoIniciarGrabacion.requierePremium); + expect(servicio.inicios, 0); + }, + ); + + test('premium: iniciar() delega en el servicio normalmente', () async { + final servicio = _ServicioGrabacionControlado(); + final emisora = emisoraDemo(uuid: 'rec-2', nombre: 'Grabable'); + final estado = EstadoGrabacion( + servicio: servicio, + emisoraActual: () => emisora, + esPremium: () => true, + ); + addTearDown(estado.dispose); + + final resultado = await estado.iniciar( + duracion: const Duration(minutes: 1), + ); + + expect(resultado, ResultadoIniciarGrabacion.iniciada); + expect(servicio.inicios, 1); + }); + + test('sin callback de entitlement, el default no bloquea (compat)', () async { + final servicio = _ServicioGrabacionControlado(); + final emisora = emisoraDemo(uuid: 'rec-3', nombre: 'Grabable'); + final estado = EstadoGrabacion( + servicio: servicio, + emisoraActual: () => emisora, + ); + addTearDown(estado.dispose); + + final resultado = await estado.iniciar(); + + expect(resultado, ResultadoIniciarGrabacion.iniciada); + expect(servicio.inicios, 1); + }); +} + +class _ServicioGrabacionControlado extends ServicioGrabacionRadio { + final _controller = StreamController.broadcast(); + final EstadoGrabacionRadio _estadoActual = + const EstadoGrabacionRadio.inactiva(); + + int inicios = 0; + + @override + EstadoGrabacionRadio get estado => _estadoActual; + + @override + Stream get estadoStream => _controller.stream; + + @override + Future inicializar() async {} + + @override + Future iniciar( + Emisora emisora, { + Duration? duracion, + String? directorio, + }) async { + inicios++; + } + + @override + Future dispose() => _controller.close(); +} diff --git a/test/l10n/identical_value_allowlist.dart b/test/l10n/identical_value_allowlist.dart index 412a244..73ba388 100644 --- a/test/l10n/identical_value_allowlist.dart +++ b/test/l10n/identical_value_allowlist.dart @@ -276,4 +276,14 @@ const Set<(String locale, String key)> identicalValueAllowlist = { 'pt', 'alarmDiagnosticsManufacturerLabel', ), // fix/alarmas-fiabilidad new key -- "Fabricante" is identical in pt/es + ( + 'pt', + 'desbloquearPremium', + ), // iap-freemium-unlock new key -- "Desbloquear" is identical in pt/es and + // "Premium" is an untranslated product tier name in both. + ( + 'pt', + 'restaurarCompras', + ), // iap-freemium-unlock new key -- "Restaurar compras" is the standard + // Portuguese store wording and coincides with es word for word. }; diff --git a/test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart b/test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart index 6672626..ad98f7a 100644 --- a/test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart +++ b/test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart'; +import 'package:pluriwave/servicios/servicio_anuncios.dart'; import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -87,8 +88,13 @@ void main() { } Widget buildScreen(EstadoRadio estado) { - return ChangeNotifierProvider.value( - value: estado, + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + Provider( + create: (_) => ServicioAnuncios(esPremium: () => true), + ), + ], child: MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/test/pantallas/pantalla_ajustes_row_values_test.dart b/test/pantallas/pantalla_ajustes_row_values_test.dart index ad54ccd..1cceba2 100644 --- a/test/pantallas/pantalla_ajustes_row_values_test.dart +++ b/test/pantallas/pantalla_ajustes_row_values_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_idioma.dart'; import 'package:pluriwave/estado/estado_radio.dart'; @@ -104,6 +105,9 @@ void main() { ListenableProvider.value(value: estado.ecualizador), ListenableProvider.value(value: estado.grabacion), ChangeNotifierProvider.value(value: idioma), + ChangeNotifierProvider( + create: (_) => EstadoEntitlement(prefs: null), + ), ], child: MaterialApp( locale: const Locale('en'), diff --git a/test/pantallas/pantalla_ajustes_test.dart b/test/pantallas/pantalla_ajustes_test.dart index 9466b03..bec74d2 100644 --- a/test/pantallas/pantalla_ajustes_test.dart +++ b/test/pantallas/pantalla_ajustes_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_idioma.dart'; import 'package:pluriwave/estado/estado_radio.dart'; @@ -50,6 +51,9 @@ void main() { ListenableProvider.value(value: estado.ecualizador), ListenableProvider.value(value: estado.grabacion), ChangeNotifierProvider.value(value: estadoIdioma), + ChangeNotifierProvider( + create: (_) => EstadoEntitlement(prefs: null), + ), ], child: MaterialApp( locale: const Locale('en'), diff --git a/test/pantallas/pantalla_alarmas_fecha_test.dart b/test/pantallas/pantalla_alarmas_fecha_test.dart index 2266ec9..c26b1b5 100644 --- a/test/pantallas/pantalla_alarmas_fecha_test.dart +++ b/test/pantallas/pantalla_alarmas_fecha_test.dart @@ -9,6 +9,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/alarma_musical.dart'; import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_anuncios.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -78,6 +79,9 @@ void main() { providers: [ ChangeNotifierProvider.value(value: radio), ChangeNotifierProvider.value(value: estadoAlarmas), + Provider( + create: (_) => ServicioAnuncios(esPremium: () => true), + ), ], child: MaterialApp( locale: const Locale('es'), diff --git a/test/pantallas/pantalla_favoritos_test.dart b/test/pantallas/pantalla_favoritos_test.dart index eaa38eb..408d94a 100644 --- a/test/pantallas/pantalla_favoritos_test.dart +++ b/test/pantallas/pantalla_favoritos_test.dart @@ -6,6 +6,7 @@ import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart'; import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; +import 'package:pluriwave/servicios/servicio_anuncios.dart'; import 'package:pluriwave/widgets/fila_emisora_plana.dart'; import 'package:pluriwave/widgets/pluri_layout.dart'; import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; @@ -88,8 +89,13 @@ void main() { // gives root screens (which construct zero Scaffold themselves, per // ADR-2) a Material ancestor. Without it, Material components like // ChoiceChip/PopupMenuButton/ActionChip fail to find one. - return ChangeNotifierProvider.value( - value: estado, + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + Provider( + create: (_) => ServicioAnuncios(esPremium: () => true), + ), + ], child: MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/test/pantallas/pluri_screen_header_retired_test.dart b/test/pantallas/pluri_screen_header_retired_test.dart index 451ec5b..ab5c895 100644 --- a/test/pantallas/pluri_screen_header_retired_test.dart +++ b/test/pantallas/pluri_screen_header_retired_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_alarmas.dart'; import 'package:pluriwave/estado/estado_busqueda.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_idioma.dart'; import 'package:pluriwave/estado/estado_radio.dart'; @@ -14,6 +15,7 @@ import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; import 'package:pluriwave/pantallas/pantalla_buscar.dart'; import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_anuncios.dart'; import 'package:pluriwave/widgets/pluri_root_header.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -74,6 +76,12 @@ void main() { ListenableProvider.value(value: estado.grabacion), ListenableProvider.value(value: estado.busqueda), ChangeNotifierProvider.value(value: EstadoIdioma()), + ChangeNotifierProvider( + create: (_) => EstadoEntitlement(prefs: null), + ), + Provider( + create: (_) => ServicioAnuncios(esPremium: () => true), + ), if (alarmas != null) ChangeNotifierProvider.value(value: alarmas), ], diff --git a/test/pantallas/root_header_wiring_test.dart b/test/pantallas/root_header_wiring_test.dart index 6dc6ed1..d055529 100644 --- a/test/pantallas/root_header_wiring_test.dart +++ b/test/pantallas/root_header_wiring_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_alarmas.dart'; import 'package:pluriwave/estado/estado_busqueda.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_idioma.dart'; import 'package:pluriwave/estado/estado_radio.dart'; @@ -15,6 +16,7 @@ import 'package:pluriwave/pantallas/pantalla_buscar.dart'; import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; import 'package:pluriwave/pantallas/pantalla_inicio.dart'; import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_anuncios.dart'; import 'package:pluriwave/widgets/pluri_root_header.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -72,6 +74,12 @@ void main() { ListenableProvider.value(value: estado.grabacion), ListenableProvider.value(value: estado.busqueda), ChangeNotifierProvider.value(value: EstadoIdioma()), + ChangeNotifierProvider( + create: (_) => EstadoEntitlement(prefs: null), + ), + Provider( + create: (_) => ServicioAnuncios(esPremium: () => true), + ), if (alarmas != null) ChangeNotifierProvider.value(value: alarmas), ], diff --git a/test/servicios/navegacion_auto_gating_test.dart b/test/servicios/navegacion_auto_gating_test.dart new file mode 100644 index 0000000..5541582 --- /dev/null +++ b/test/servicios/navegacion_auto_gating_test.dart @@ -0,0 +1,106 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/navegacion_auto.dart'; + +/// Android Auto entitlement gating (android-auto-media spec "Free-Tier +/// Reduced Root Browse" + "Free-Tier Browse Never Leaks Real Content", +/// design.md ADR-4). All pure — no handler instantiation needed +/// (`PluriWaveAudioHandler` cannot be constructed in a unit test). +void main() { + group('raiz(premium:) — root keeps its labels for every tier', () { + test('premium: identical to today\'s tree (regression guard)', () { + final constructor = ConstructorArbolAuto(); + + final premiumConLocal = constructor.raiz( + incluirMusicaLocal: true, + premium: true, + ); + final premiumSinLocal = constructor.raiz( + incluirMusicaLocal: false, + premium: true, + ); + + expect(premiumConLocal.map((m) => m.id), [ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idMusicaLocal, + ]); + expect(premiumConLocal.every((m) => m.playable == false), isTrue); + expect(premiumConLocal.every((m) => m.displaySubtitle == null), isTrue); + expect(premiumSinLocal.map((m) => m.id), [ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ]); + }); + + test('free: same folder ids/titles, non-blank, never playable', () { + final constructor = ConstructorArbolAuto(); + + final libre = constructor.raiz(incluirMusicaLocal: true, premium: false); + + expect(libre, isNotEmpty); + expect(libre.map((m) => m.id), [ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idMusicaLocal, + ]); + expect(libre.every((m) => m.playable == false), isTrue); + }); + }); + + test( + 'itemPremiumBloqueado(): id fijo, no reproducible, etiqueta premium', + () { + final item = ConstructorArbolAuto().itemPremiumBloqueado(); + + expect(item.id, 'premium:info'); + expect(item.playable, isFalse); + expect(item.title, isNotEmpty); + }, + ); + + group('respuestaBloqueadaPorEntitlement — backstop de navegacion', () { + test('root nunca es bloqueada (root siempre resuelve via raiz)', () { + final respuesta = respuestaBloqueadaPorEntitlement( + parentMediaId: AudioService.browsableRootId, + premium: false, + ); + + expect(respuesta, isNull); + }); + + test('cualquier id no-root, en free, retorna SOLO el item bloqueado', () { + for (final id in [ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idMusicaLocal, + ConstructorArbolAuto.idEcualizador, + // Stale/deep-linked id from before a downgrade — the backstop must + // not special-case known ids (Spec "Stale folder id bypass + // attempt"). + 'emisora:algun-uuid-viejo', + 'grupo:algo', + ]) { + final respuesta = respuestaBloqueadaPorEntitlement( + parentMediaId: id, + premium: false, + ); + expect(respuesta, hasLength(1)); + expect(respuesta!.single.id, 'premium:info'); + } + }); + + test('cualquier id no-root, en premium, no es bloqueada', () { + final respuesta = respuestaBloqueadaPorEntitlement( + parentMediaId: ConstructorArbolAuto.idFavoritos, + premium: true, + ); + + expect(respuesta, isNull); + }); + }); +} diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 6b94dc4..d5c213b 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -240,7 +240,10 @@ void main() { group('ConstructorArbolAuto.raiz', () { test('con incluirMusicaLocal: true devuelve exactamente 4 carpetas no ' 'reproducibles con los ids esperados', () { - final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true); + final raiz = ConstructorArbolAuto().raiz( + incluirMusicaLocal: true, + premium: true, + ); expect(raiz, hasLength(4)); final ids = raiz.map((item) => item.id).toSet(); @@ -262,7 +265,10 @@ void main() { test('con incluirMusicaLocal: false devuelve exactamente 3 carpetas — ' 'Música Local queda OCULTA, no vacía', () { - final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false); + final raiz = ConstructorArbolAuto().raiz( + incluirMusicaLocal: false, + premium: true, + ); expect(raiz, hasLength(3)); final ids = raiz.map((item) => item.id).toSet(); @@ -286,7 +292,7 @@ void main() { 'segunda vuelta de la misma decisión con evidencia real de uso', () { final ids = ConstructorArbolAuto() - .raiz(incluirMusicaLocal: true) + .raiz(incluirMusicaLocal: true, premium: true) .map((item) => item.id) .toList(); diff --git a/test/servicios/servicio_anuncios_test.dart b/test/servicios/servicio_anuncios_test.dart new file mode 100644 index 0000000..d91f8f4 --- /dev/null +++ b/test/servicios/servicio_anuncios_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/servicio_anuncios.dart'; + +/// Ad-display spec: session-scoped interstitial frequency cap (max 2 per +/// process lifetime, >=3 min apart), suppressed entirely for premium, and +/// never shown when the caller reports the alarm-cap message took +/// precedence for this same tap. +void main() { + ServicioAnuncios construir({ + required DateTime Function() ahora, + required bool premium, + Future Function()? mostrarInterstitialImpl, + }) => ServicioAnuncios( + ahora: ahora, + esPremium: () => premium, + mostrarInterstitialImpl: mostrarInterstitialImpl ?? (() async => true), + ); + + group('intentarInterstitial — cap de frecuencia', () { + test('primeros 2 intentos en la sesion se muestran', () async { + var ahora = DateTime(2026, 1, 1, 10, 0); + final servicio = construir(ahora: () => ahora, premium: false); + + expect(await servicio.intentarInterstitial(), isTrue); + ahora = ahora.add(const Duration(minutes: 5)); + expect(await servicio.intentarInterstitial(), isTrue); + }); + + test('un 3er intento en la misma sesion no se muestra (cap 2)', () async { + var ahora = DateTime(2026, 1, 1, 10, 0); + final servicio = construir(ahora: () => ahora, premium: false); + + await servicio.intentarInterstitial(); + ahora = ahora.add(const Duration(minutes: 5)); + await servicio.intentarInterstitial(); + ahora = ahora.add(const Duration(minutes: 5)); + + expect(await servicio.intentarInterstitial(), isFalse); + }); + + test('menos de 3 minutos desde el ultimo: no se muestra', () async { + var ahora = DateTime(2026, 1, 1, 10, 0); + final servicio = construir(ahora: () => ahora, premium: false); + + await servicio.intentarInterstitial(); + ahora = ahora.add(const Duration(minutes: 1)); + + expect(await servicio.intentarInterstitial(), isFalse); + }); + + test('exactamente 3 minutos despues si se muestra', () async { + var ahora = DateTime(2026, 1, 1, 10, 0); + final servicio = construir(ahora: () => ahora, premium: false); + + await servicio.intentarInterstitial(); + ahora = ahora.add(const Duration(minutes: 3)); + + expect(await servicio.intentarInterstitial(), isTrue); + }); + + test('premium: nunca muestra interstitial', () async { + final servicio = construir( + ahora: () => DateTime(2026, 1, 1, 10, 0), + premium: true, + ); + + expect(await servicio.intentarInterstitial(), isFalse); + }); + + test( + 'el conteo/temporizador solo avanza si el ad realmente se muestra', + () async { + final ahora = DateTime(2026, 1, 1, 10, 0); + final servicio = construir( + ahora: () => ahora, + premium: false, + mostrarInterstitialImpl: () async => false, + ); + + final mostrado = await servicio.intentarInterstitial(); + + expect(mostrado, isFalse); + // Un fallo de carga (mostrarInterstitialImpl -> false) no debe + // consumir el cupo de la sesion. + expect(await servicio.intentarInterstitial(), isFalse); + }, + ); + }); + + group('debeMostrarBanner', () { + test('free: true', () { + final servicio = construir( + ahora: () => DateTime(2026, 1, 1), + premium: false, + ); + expect(servicio.debeMostrarBanner, isTrue); + }); + + test('premium: false', () { + final servicio = construir( + ahora: () => DateTime(2026, 1, 1), + premium: true, + ); + expect(servicio.debeMostrarBanner, isFalse); + }); + }); +} diff --git a/test/servicios/servicio_audio_gating_test.dart b/test/servicios/servicio_audio_gating_test.dart new file mode 100644 index 0000000..31ab8d7 --- /dev/null +++ b/test/servicios/servicio_audio_gating_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +/// Android Auto play-path backstop (design.md ADR-4, android-auto-media +/// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station +/// Playback Unaffected By Free Tier"): `playFromMediaId`, `playFromSearch`, +/// `skipToNext`, `skipToPrevious` must ALL no-op for a free-tier user, +/// regardless of the target id — gating `getChildren` alone is +/// insufficient because a head unit caches browse trees, so a stale +/// `emisora:` tap could otherwise bypass browsing entirely. Pure — +/// `PluriWaveAudioHandler` cannot be instantiated in a unit test (needs a +/// real platform `AudioPlayer`), so this is the extracted decision the +/// handler's dispatch methods delegate to (mirrors `mapearEstadoProceso` +/// and every other pure helper in this file). +void main() { + test('free tier: bloquea cualquier cambio de emisora/salto', () { + expect(debeBloquearCambioDeEmisora(premium: false), isTrue); + }); + + test('premium: nunca bloquea', () { + expect(debeBloquearCambioDeEmisora(premium: true), isFalse); + }); + + group('notificarDesbloqueoAuto / registrarNotificacionDesbloqueoAuto', () { + test('sin hook registrado, es un no-op seguro', () { + expect(() => notificarDesbloqueoAuto(), returnsNormally); + }); + + test('invoca el hook registrado exactamente una vez por llamada', () { + var llamadas = 0; + registrarNotificacionDesbloqueoAuto(() => llamadas++); + + notificarDesbloqueoAuto(); + + expect(llamadas, 1); + }); + }); +} diff --git a/test/servicios/servicio_compras_test.dart b/test/servicios/servicio_compras_test.dart new file mode 100644 index 0000000..8bcdfa5 --- /dev/null +++ b/test/servicios/servicio_compras_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:pluriwave/servicios/servicio_compras.dart'; + +/// Pure port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero +/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] is the ONLY +/// piece of `ServicioComprasPlayBilling` that is unit-testable without a +/// real `in_app_purchase` platform channel — [ServicioComprasPlayBilling] +/// itself is the sole call site (Design ADR-2), exercised instead through +/// `EstadoEntitlement` + a fake `PuertoCompras` +/// (`estado_entitlement_test.dart`). +void main() { + group('eventoDesdeEstadoCompra', () { + test('purchased -> comprada', () { + expect( + eventoDesdeEstadoCompra(PurchaseStatus.purchased).tipo, + TipoEventoCompra.comprada, + ); + }); + + test('restored -> restaurada', () { + expect( + eventoDesdeEstadoCompra(PurchaseStatus.restored).tipo, + TipoEventoCompra.restaurada, + ); + }); + + test('canceled -> cancelada', () { + expect( + eventoDesdeEstadoCompra(PurchaseStatus.canceled).tipo, + TipoEventoCompra.cancelada, + ); + }); + + test('pending -> pendiente', () { + expect( + eventoDesdeEstadoCompra(PurchaseStatus.pending).tipo, + TipoEventoCompra.pendiente, + ); + }); + + test('error conserva el mensaje diagnostico', () { + final evento = eventoDesdeEstadoCompra( + PurchaseStatus.error, + mensaje: 'BILLING_UNAVAILABLE', + ); + + expect(evento.tipo, TipoEventoCompra.error); + expect(evento.mensaje, 'BILLING_UNAVAILABLE'); + }); + }); + + test('idProducto es el identificador unico no-consumible', () { + expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium'); + }); +} diff --git a/test/widgets/banner_anuncio_superior_test.dart b/test/widgets/banner_anuncio_superior_test.dart new file mode 100644 index 0000000..e20abae --- /dev/null +++ b/test/widgets/banner_anuncio_superior_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_entitlement.dart'; +import 'package:pluriwave/servicios/servicio_anuncios.dart'; +import 'package:pluriwave/widgets/banner_anuncio_superior.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Ad-display spec "Persistent Top Banner, Never Overlapping Content": the +/// banner is entitlement-aware and reserves layout via a `Column` +/// (`SizedBox.shrink()` collapses it to zero footprint) — never a `Stack` +/// overlay. AdMob's own `BannerAd.load()` cannot reach a real ad server in +/// `flutter test` (no plugin channel registered), so it always resolves to +/// "unloaded" here — exactly the same degrade-to-shrink path a genuine +/// failed load takes in production (never a crash, never a placeholder). +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Widget construir({required bool premium}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) { + final estado = EstadoEntitlement(prefs: null); + return estado; + }, + ), + Provider( + create: (_) => ServicioAnuncios(esPremium: () => premium), + ), + ], + child: MaterialApp( + home: Scaffold( + body: Column( + children: [ + const BannerAnuncioSuperior(), + const Expanded(child: Center(child: Text('contenido'))), + ], + ), + ), + ), + ); + } + + testWidgets( + 'usuario free sin anuncio cargado: colapsa a SizedBox.shrink (nunca overlay)', + (tester) async { + await tester.pumpWidget(construir(premium: false)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final banner = find.byType(BannerAnuncioSuperior); + expect(banner, findsOneWidget); + // Collapsed to zero footprint (no ad ever loads in a widget test — no + // AdMob plugin channel registered) — a `SizedBox.shrink()`, not an + // overlay: the Column layout below it stays fully visible. + expect(tester.getSize(banner).height, 0); + expect(find.text('contenido'), findsOneWidget); + }, + ); + + testWidgets('usuario premium: nunca intenta mostrar el banner', ( + tester, + ) async { + await tester.pumpWidget(construir(premium: true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final banner = find.byType(BannerAnuncioSuperior); + expect(tester.getSize(banner).height, 0); + expect(find.text('contenido'), findsOneWidget); + }); +} diff --git a/test/widgets/pluri_push_scaffold_test.dart b/test/widgets/pluri_push_scaffold_test.dart index d345e30..aa21d01 100644 --- a/test/widgets/pluri_push_scaffold_test.dart +++ b/test/widgets/pluri_push_scaffold_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_alarmas.dart'; import 'package:pluriwave/estado/estado_busqueda.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_idioma.dart'; import 'package:pluriwave/estado/estado_radio.dart'; @@ -213,6 +214,9 @@ void main() { ), ListenableProvider.value(value: estado.grabacion), ChangeNotifierProvider.value(value: estadoIdioma), + ChangeNotifierProvider( + create: (_) => EstadoEntitlement(prefs: null), + ), ], child: testApp(const PantallaAjustes()), ),