diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ba0614e..12793f3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -137,6 +137,15 @@
+
+
+
diff --git a/lib/app.dart b/lib/app.dart
index 0908726..b9fa4bd 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';
@@ -31,8 +35,32 @@ import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_alarmas_android.dart';
import 'servicios/servicio_dispositivo_audio.dart';
+/// Extracted out of `_PaginaPrincipalState.build` (FIX 1, code review) so
+/// the banner + status-bar-inset composition is unit-testable in isolation
+/// — `_PaginaPrincipal` itself is library-private and constructs real
+/// platform-backed services (see `app_test.dart`'s own comments), so it
+/// cannot be safely widget-tested directly. Mirrors this file's existing
+/// `@visibleForTesting` top-level extraction convention
+/// (`main.dart`'s `orientacionesPara`/`aplicarPoliticaOrientacion`).
+///
+/// `BannerAnuncioSuperior` owns its OWN top `SafeArea` internally now (see
+/// `banner_anuncio_superior.dart`) — this function deliberately does NOT
+/// wrap it in one, since `SafeArea` reserves `MediaQuery.padding.top` even
+/// around a zero-size collapsed child, which used to leave a permanent
+/// blank status-bar-height strip for premium users and for free users
+/// before the first ad finished loading.
+@visibleForTesting
+Widget construirCuerpoPrincipal({required Widget contenido}) {
+ return Column(
+ children: [
+ const BannerAnuncioSuperior(),
+ Expanded(child: SafeArea(top: false, child: contenido)),
+ ],
+ );
+}
+
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 +72,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 +112,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,9 +276,17 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
final indice = navegacion.indice;
return PluriWaveScaffold(
- body: SafeArea(
- top: false,
- child: AnimatedSwitcher(
+ // 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, and
+ // (FIX 1, code review) owns its OWN top `SafeArea` internally — this
+ // level no longer wraps it in an unconditional `SafeArea`, which used
+ // to reserve `MediaQuery.padding.top` even for a zero-size collapsed
+ // child, leaving a permanent blank status-bar-height strip.
+ body: construirCuerpoPrincipal(
+ contenido: AnimatedSwitcher(
duration: context.pluriMotion.normal,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart
index da2afe6..b354794 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). REQUIRED on
+ // purpose: an optional parameter with any default lets a forgotten
+ // wiring compile and silently pick a tier, and no test can catch that.
+ // Callers must state the entitlement source explicitly.
+ required bool Function() esPremium,
}) : servicio = servicio ?? ServicioAlarmas(prefs: prefs),
android = android ?? ServicioAlarmasAndroid(),
- _prefs = prefs {
+ _prefs = prefs,
+ _esPremium = esPremium {
// 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_ecualizador.dart b/lib/estado/estado_ecualizador.dart
index c3a5dc9..a1ce397 100644
--- a/lib/estado/estado_ecualizador.dart
+++ b/lib/estado/estado_ecualizador.dart
@@ -37,7 +37,9 @@ class EstadoEcualizador extends ChangeNotifier {
_presetsPersonalizadosService =
presetsPersonalizadosService ?? ServicioPresetsPersonalizados(),
_dispositivoAudio = dispositivoAudio,
- _emisoraActualUuid = emisoraActualUuid ?? (() => null);
+ _emisoraActualUuid = emisoraActualUuid ?? (() => null) {
+ _escucharCambiosEqDesdeHandler();
+ }
final ServicioAudio audio;
final ServicioEcualizador servicio;
@@ -84,6 +86,20 @@ class EstadoEcualizador extends ChangeNotifier {
StreamSubscription? _deviceSub;
Future? _refrescoEnCurso;
+ /// Catches a car/notification-initiated EQ change that bypasses this
+ /// class entirely (eq-sync-superficies): `accionEqToggle` calls
+ /// `PluriWaveAudioHandler.setEcualizadorActivo` directly, and
+ /// `seleccionarPresetEqPorMediaId` calls `aplicarPreset` directly — both
+ /// mutate ONLY the handler's own `_ecualizadorActivo`/`_presetActual`
+ /// fields, never [audio]'s owner ([EstadoEcualizador]). Mirrors the exact
+ /// shape `EstadoRadio._escucharErroresReproduccion` already uses for the
+ /// equivalent `playFromMediaId` gap: on every [ServicioAudio.estadoStream]
+ /// tick (which the handler already re-emits on any EQ change via
+ /// `_actualizarControlesEq()`, regardless of who triggered it), compare
+ /// the handler's current EQ state against our cached copy and adopt it on
+ /// divergence.
+ StreamSubscription? _suscripcionEstadoAudioEq;
+
PresetEcualizador get presetActual => _presetActual;
PresetEcualizador get presetPrincipal => _presetPrincipal;
bool get activo => _activo;
@@ -337,6 +353,58 @@ class EstadoEcualizador extends ChangeNotifier {
notifyListeners();
}
+ /// Subscribes to [ServicioAudio.estadoStream] to catch a
+ /// car/notification-initiated EQ change (see [_suscripcionEstadoAudioEq]
+ /// doc for the full rationale).
+ void _escucharCambiosEqDesdeHandler() {
+ _suscripcionEstadoAudioEq = audio.estadoStream.listen((_) {
+ unawaited(_resincronizarConHandler());
+ });
+ }
+
+ /// Compares the handler's live EQ state ([ServicioAudio.ecualizadorActivo],
+ /// [ServicioAudio.presetActual]) against our cached [_activo]/
+ /// [_presetActual] and adopts the handler's value on divergence.
+ ///
+ /// Deliberately never calls back into [audio] here (no
+ /// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the
+ /// handler's own `_actualizarControlesEq()` re-push, which would tick
+ /// [ServicioAudio.estadoStream] again and re-enter this method forever.
+ /// Only a local field write, [servicio] persistence and [notifyListeners]
+ /// happen here, so a divergence is resolved in a single pass.
+ ///
+ /// Wrapped in try/catch like every other handler-facing read in this
+ /// class (e.g. [_sembrarDispositivoActual]): a test double or an
+ /// unexpected platform state that makes [audio]'s EQ getters unavailable
+ /// must never crash the stream subscription — it just skips this tick.
+ Future _resincronizarConHandler() async {
+ try {
+ final activoHandler = audio.ecualizadorActivo;
+ final presetHandler = audio.presetActual;
+
+ final activoDiverge = activoHandler != _activo;
+ final presetDiverge = presetHandler != _presetActual;
+ if (!activoDiverge && !presetDiverge) return;
+
+ if (activoDiverge) {
+ _activo = activoHandler;
+ // Closes the persistence gap: `PluriWaveAudioHandler` never
+ // persists anything itself (it must stay headless-constructible,
+ // with zero SharedPreferences/Provider access) — [servicio] is the
+ // only owner of EQ persistence, so a car/notification toggle must
+ // be saved HERE or it is lost on the next process restart.
+ await servicio.guardarActivo(activoHandler);
+ }
+ if (presetDiverge) {
+ _presetActual = presetHandler;
+ }
+
+ notifyListeners();
+ } catch (_) {
+ // See doc above — never let a resync failure crash the app.
+ }
+ }
+
/// Applies [preset] to the audio engine and tracks it as current
/// WITHOUT persisting it (used when switching stations).
Future aplicarPresetActivo(PresetEcualizador preset) async {
@@ -673,6 +741,7 @@ class EstadoEcualizador extends ChangeNotifier {
@override
void dispose() {
_deviceSub?.cancel();
+ _suscripcionEstadoAudioEq?.cancel();
super.dispose();
}
}
diff --git a/lib/estado/estado_entitlement.dart b/lib/estado/estado_entitlement.dart
new file mode 100644
index 0000000..cc1e804
--- /dev/null
+++ b/lib/estado/estado_entitlement.dart
@@ -0,0 +1,193 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import '../servicios/servicio_audio.dart' show notificarDesbloqueoAuto;
+import '../servicios/servicio_compras.dart';
+
+/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
+/// premium unlock. Older builds that predate this key simply never read it —
+/// no migration needed (Rollout "Versioned key ... is ignored by older
+/// builds").
+const _keyPremium = 'compra_premium_v1';
+
+/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
+/// Entitlement Read"): resolves the persisted premium flag directly from
+/// prefs, with NO `BuildContext`/`Provider` dependency. Mirrors
+/// `FuenteMusicaLocalAutoImpl._resolverPrefs()`'s
+/// inject-or-`getInstance()` convention (`musica_local_auto.dart:163`) —
+/// this is what `PluriWaveAudioHandler` calls, since it registers before
+/// `runApp` and no widget tree (therefore no `Provider`) exists yet.
+///
+/// Absent key = free tier (Rollout "Additive and prefs-backed; absent key =
+/// free"). Never throws — a `SharedPreferences.getInstance()` failure would
+/// propagate here exactly like the persisted read failing, which the caller
+/// (Design ADR-2 "fail-open") must treat as "trust the last known state",
+/// not this function's job to catch.
+Future esPremiumPersistido({SharedPreferences? prefs}) async {
+ final resueltas = prefs ?? await SharedPreferences.getInstance();
+ return resueltas.getBool(_keyPremium) ?? false;
+}
+
+/// User-facing, non-error-text outcomes [EstadoEntitlement] can expose (FIX
+/// 3, code review): the UI layer (`hoja_premium.dart`) has no BuildContext
+/// here, so this file never carries localized/user-facing STRINGS itself —
+/// only this typed signal, mapped to a localized message by the widget.
+/// Cleared back to `null` once consumed ([EstadoEntitlement.consumirResultadoUsuario]).
+enum ResultadoEntitlementUsuario {
+ /// A purchase or restore attempt failed (network, billing error, product
+ /// not yet available in the store, etc). This NEVER carries the raw
+ /// exception/developer string from [EventoCompra.mensaje] — the UI maps
+ /// this enum value to ONE generic localized message, never the internal
+ /// diagnostic text.
+ error,
+
+ /// [EstadoEntitlement.restaurar] completed successfully but found nothing
+ /// to restore. Distinct from [error]: an expected, non-error outcome
+ /// (Spec "Restore Purchases" — "finds nothing -> stays free tier with a
+ /// clear non-error result").
+ restauracionSinCompras,
+}
+
+/// Cross-cutting entitlement notifier (Design ADR-1), idiomatic
+/// `EstadoIdioma`-shaped `ChangeNotifier`: UI layers `context.watch`/`read`
+/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
+/// since no `Provider` exists on that path.
+class EstadoEntitlement extends ChangeNotifier {
+ EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
+ : _prefs = prefs,
+ _compras = compras {
+ final flujo = _compras;
+ if (flujo != null) {
+ _comprasSub = flujo.eventos.listen(_alRecibirEvento);
+ }
+ _cargar();
+ }
+
+ /// The single non-consumable product id (Design "Interfaces / Contracts"),
+ /// re-exported here so UI/paywall code depends on ONE canonical constant
+ /// rather than reaching into `servicio_compras.dart` for it.
+ static const idProducto = ServicioComprasPlayBilling.idProducto;
+
+ final SharedPreferences? _prefs;
+ final PuertoCompras? _compras;
+ StreamSubscription? _comprasSub;
+
+ bool _esPremium = false;
+ bool _compraEnCurso = false;
+ ResultadoEntitlementUsuario? _resultadoUsuario;
+
+ bool get esPremium => _esPremium;
+ bool get compraEnCurso => _compraEnCurso;
+
+ /// FIX 3 (code review): the user-facing signal for a failed purchase/
+ /// restore, or a restore that found nothing. `null` when there is nothing
+ /// to show — see [consumirResultadoUsuario].
+ ResultadoEntitlementUsuario? get resultadoUsuario => _resultadoUsuario;
+
+ /// Clears [resultadoUsuario] once the UI has consumed/displayed it.
+ /// A no-op (no extra notification) if there is nothing to clear.
+ void consumirResultadoUsuario() {
+ if (_resultadoUsuario == null) return;
+ _resultadoUsuario = null;
+ notifyListeners();
+ }
+
+ Future _cargar() async {
+ final prefs = await _resolverPrefs();
+ final premium = prefs.getBool(_keyPremium) ?? false;
+ if (premium != _esPremium) {
+ _esPremium = premium;
+ }
+ notifyListeners();
+ }
+
+ Future _resolverPrefs() async =>
+ _prefs ?? SharedPreferences.getInstance();
+
+ /// Starts the purchase flow (Spec "Successful purchase"). A no-op when
+ /// already premium (Spec "Already-purchased attempt is idempotent") — no
+ /// duplicate charge is even attempted.
+ Future comprar() async {
+ if (_esPremium) return;
+ final compras = _compras;
+ if (compras == null) return;
+ _compraEnCurso = true;
+ // FIX 3 (code review): a fresh attempt clears any stale result left over
+ // from a previous failed attempt, so the UI never shows an outdated
+ // error/confirmation across two unrelated attempts.
+ _resultadoUsuario = null;
+ notifyListeners();
+ await compras.comprar();
+ }
+
+ /// Re-queries Play Billing for a prior purchase (Spec "Restore Purchases").
+ Future restaurar() async {
+ final compras = _compras;
+ if (compras == null) return;
+ _compraEnCurso = true;
+ _resultadoUsuario = null;
+ notifyListeners();
+ await compras.restaurar();
+ }
+
+ Future _alRecibirEvento(EventoCompra evento) async {
+ switch (evento.tipo) {
+ case TipoEventoCompra.comprada:
+ case TipoEventoCompra.restaurada:
+ await _desbloquear();
+ case TipoEventoCompra.cancelada:
+ // Spec "Purchase cancelled or failed": a user-INITIATED cancel
+ // stays free tier with no error surfaced — just stop the in-flight
+ // spinner. Not a failure, so no [resultadoUsuario] either.
+ _compraEnCurso = false;
+ notifyListeners();
+ case TipoEventoCompra.noEncontrada:
+ // FIX 3 (code review): "Restore finds nothing" is an expected,
+ // NON-error outcome (Spec "Restore Purchases") but `hoja_premium.dart`
+ // had zero feedback for it — the spinner just stopped with no
+ // confirmation. Distinct signal from [TipoEventoCompra.error].
+ _compraEnCurso = false;
+ _resultadoUsuario = ResultadoEntitlementUsuario.restauracionSinCompras;
+ notifyListeners();
+ case TipoEventoCompra.error:
+ // Fail-open (Design ADR-2): an error NEVER writes `false` over an
+ // already-premium flag, and never invents a `true` for a free user
+ // either — the persisted flag from `_cargar()` is left untouched.
+ //
+ // FIX 3 (code review): [EventoCompra.mensaje] (raw exception/
+ // developer text, e.g. "Producto no encontrado en Play Console") is
+ // DELIBERATELY discarded here — only the typed enum crosses into
+ // [resultadoUsuario], never the raw string. `hoja_premium.dart` maps
+ // it to ONE generic localized message.
+ _compraEnCurso = false;
+ _resultadoUsuario = ResultadoEntitlementUsuario.error;
+ notifyListeners();
+ case TipoEventoCompra.pendiente:
+ _compraEnCurso = true;
+ notifyListeners();
+ }
+ }
+
+ Future _desbloquear() async {
+ final yaEraPremium = _esPremium;
+ _esPremium = true;
+ _compraEnCurso = false;
+ final prefs = await _resolverPrefs();
+ await prefs.setBool(_keyPremium, true);
+ notifyListeners();
+ if (!yaEraPremium) {
+ // Orchestrator-resolved open question (design.md): actively
+ // invalidate the Android Auto browse cache on the free -> premium
+ // transition, rather than waiting for the head unit's own re-bind.
+ notificarDesbloqueoAuto();
+ }
+ }
+
+ @override
+ void dispose() {
+ _comprasSub?.cancel();
+ super.dispose();
+ }
+}
diff --git a/lib/estado/estado_grabacion.dart b/lib/estado/estado_grabacion.dart
index 3816079..58974bd 100644
--- a/lib/estado/estado_grabacion.dart
+++ b/lib/estado/estado_grabacion.dart
@@ -35,14 +35,27 @@ 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. REQUIRED on purpose: an
+ // optional parameter with any default lets a forgotten wiring compile
+ // and silently pick a tier, and no test can catch that. Callers must
+ // state the entitlement source explicitly.
+ required bool Function() esPremium,
}) : servicio = servicio ?? ServicioGrabacionRadio(),
_emisoraActual = emisoraActual ?? (() => null),
- _alError = alError {
+ _alError = alError,
+ _esPremium = esPremium {
_suscripcion = this.servicio.estadoStream.listen((estado) {
if (estado.tipo == EstadoGrabacionRadioTipo.error &&
estado.error != null) {
@@ -65,6 +78,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 +102,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 +119,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..9272961 100644
--- a/lib/estado/estado_radio.dart
+++ b/lib/estado/estado_radio.dart
@@ -47,6 +47,11 @@ 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, but it owns that notifier's construction, so it
+ // inherits the same "required, never defaulted" entitlement contract.
+ required bool Function() esPremium,
}) : audio = audio ?? ServicioAudio(),
favoritos = favoritos ?? ServicioFavoritos(),
radio = radio ?? ServicioRadio(),
@@ -66,6 +71,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..47e952b 100644
--- a/lib/l10n/app_ar.arb
+++ b/lib/l10n/app_ar.arb
@@ -897,5 +897,12 @@
"alarmDiagnosticsFixAction": "إصلاح",
"alarmDiagnosticsIntentUnavailable": "تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.",
"alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.",
- "autoEqDisableOption": "تعطيل"
+ "autoEqDisableOption": "تعطيل",
+ "funcionPremium": "ميزة مميزة",
+ "limiteAlarmasAlcanzado": "لقد وصلت إلى الحد المجاني وهو 5 منبهات.",
+ "desbloquearPremium": "فتح النسخة المميزة",
+ "restaurarCompras": "استعادة المشتريات",
+ "compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
+ "restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
+ "premiumActivo": "النسخة المميزة مفعّلة"
}
diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb
index 4e5ab6b..ac9312a 100644
--- a/lib/l10n/app_bn.arb
+++ b/lib/l10n/app_bn.arb
@@ -897,5 +897,12 @@
"alarmDiagnosticsFixAction": "সমাধান করুন",
"alarmDiagnosticsIntentUnavailable": "এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।",
"alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।",
- "autoEqDisableOption": "বন্ধ করুন"
+ "autoEqDisableOption": "বন্ধ করুন",
+ "funcionPremium": "প্রিমিয়াম বৈশিষ্ট্য",
+ "limiteAlarmasAlcanzado": "আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।",
+ "desbloquearPremium": "প্রিমিয়াম আনলক করুন",
+ "restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
+ "compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
+ "restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
+ "premiumActivo": "প্রিমিয়াম সক্রিয়"
}
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index 6fe80b7..55432d7 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -897,5 +897,12 @@
"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",
+ "compraError": "Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
+ "restauracionSinCompras": "Wir haben auf diesem Konto keinen früheren Kauf gefunden.",
+ "premiumActivo": "Premium aktiv"
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 355e368..19f7e7d 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -897,5 +897,12 @@
"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",
+ "compraError": "We couldn't complete the purchase. Please try again.",
+ "restauracionSinCompras": "We didn't find any previous purchase on this account.",
+ "premiumActivo": "Premium active"
}
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index df3c63a..b9805cf 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -856,5 +856,12 @@
"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",
+ "compraError": "No se ha podido completar la compra. Inténtalo de nuevo.",
+ "restauracionSinCompras": "No hemos encontrado ninguna compra anterior en esta cuenta.",
+ "premiumActivo": "Premium activo"
}
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 9ab6328..0350120 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -897,5 +897,12 @@
"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",
+ "compraError": "Impossible de finaliser l'achat. Veuillez réessayer.",
+ "restauracionSinCompras": "Nous n'avons trouvé aucun achat antérieur sur ce compte.",
+ "premiumActivo": "Premium actif"
}
diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb
index 13cac7d..022c0ca 100644
--- a/lib/l10n/app_hi.arb
+++ b/lib/l10n/app_hi.arb
@@ -897,5 +897,12 @@
"alarmDiagnosticsFixAction": "ठीक करें",
"alarmDiagnosticsIntentUnavailable": "इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।",
"alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।",
- "autoEqDisableOption": "बंद करें"
+ "autoEqDisableOption": "बंद करें",
+ "funcionPremium": "प्रीमियम सुविधा",
+ "limiteAlarmasAlcanzado": "आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।",
+ "desbloquearPremium": "प्रीमियम अनलॉक करें",
+ "restaurarCompras": "खरीदारी पुनर्स्थापित करें",
+ "compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
+ "restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
+ "premiumActivo": "प्रीमियम सक्रिय"
}
diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb
index 4864f62..0cd5ee3 100644
--- a/lib/l10n/app_id.arb
+++ b/lib/l10n/app_id.arb
@@ -897,5 +897,12 @@
"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",
+ "compraError": "Pembelian tidak dapat diselesaikan. Silakan coba lagi.",
+ "restauracionSinCompras": "Kami tidak menemukan pembelian sebelumnya di akun ini.",
+ "premiumActivo": "Premium aktif"
}
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 31e6a46..e71c341 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -897,5 +897,12 @@
"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",
+ "compraError": "Non è stato possibile completare l'acquisto. Riprova.",
+ "restauracionSinCompras": "Non abbiamo trovato acquisti precedenti su questo account.",
+ "premiumActivo": "Premium attivo"
}
diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb
index 1327254..da8fa0e 100644
--- a/lib/l10n/app_ja.arb
+++ b/lib/l10n/app_ja.arb
@@ -897,5 +897,12 @@
"alarmDiagnosticsFixAction": "修正する",
"alarmDiagnosticsIntentUnavailable": "この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。",
"alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。",
- "autoEqDisableOption": "無効化"
+ "autoEqDisableOption": "無効化",
+ "funcionPremium": "プレミアム機能",
+ "limiteAlarmasAlcanzado": "無料プランのアラーム上限(5件)に達しました。",
+ "desbloquearPremium": "プレミアムを解除",
+ "restaurarCompras": "購入を復元",
+ "compraError": "購入を完了できませんでした。もう一度お試しください。",
+ "restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
+ "premiumActivo": "プレミアム有効"
}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 1702dd3..df0a5b3 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -897,5 +897,12 @@
"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",
+ "compraError": "Não foi possível concluir a compra. Tente novamente.",
+ "restauracionSinCompras": "Não encontramos nenhuma compra anterior nesta conta.",
+ "premiumActivo": "Premium ativo"
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 6b14723..f11ede4 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -897,5 +897,12 @@
"alarmDiagnosticsFixAction": "Исправить",
"alarmDiagnosticsIntentUnavailable": "Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.",
"alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника.",
- "autoEqDisableOption": "Отключить"
+ "autoEqDisableOption": "Отключить",
+ "funcionPremium": "Премиум-функция",
+ "limiteAlarmasAlcanzado": "Вы достигли бесплатного лимита в 5 будильников.",
+ "desbloquearPremium": "Разблокировать Премиум",
+ "restaurarCompras": "Восстановить покупки",
+ "compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
+ "restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
+ "premiumActivo": "Премиум активен"
}
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 484aad8..2d48b57 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -897,5 +897,12 @@
"alarmDiagnosticsFixAction": "解决",
"alarmDiagnosticsIntentUnavailable": "无法在此手机上打开该设置界面。请尝试在设置中手动查找。",
"alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。",
- "autoEqDisableOption": "关闭"
+ "autoEqDisableOption": "关闭",
+ "funcionPremium": "高级功能",
+ "limiteAlarmasAlcanzado": "您已达到免费版 5 个闹钟的上限。",
+ "desbloquearPremium": "解锁高级版",
+ "restaurarCompras": "恢复购买",
+ "compraError": "无法完成购买,请重试。",
+ "restauracionSinCompras": "未在此账户中找到以前的购买记录。",
+ "premiumActivo": "高级版已解锁"
}
diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart
index c6cb50f..6be60e2 100644
--- a/lib/l10n/gen/app_localizations.dart
+++ b/lib/l10n/gen/app_localizations.dart
@@ -3325,6 +3325,48 @@ 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;
+
+ /// No description provided for @compraError.
+ ///
+ /// In es, this message translates to:
+ /// **'No se ha podido completar la compra. Inténtalo de nuevo.'**
+ String get compraError;
+
+ /// No description provided for @restauracionSinCompras.
+ ///
+ /// In es, this message translates to:
+ /// **'No hemos encontrado ninguna compra anterior en esta cuenta.'**
+ String get restauracionSinCompras;
+
+ /// No description provided for @premiumActivo.
+ ///
+ /// In es, this message translates to:
+ /// **'Premium activo'**
+ String get premiumActivo;
}
class _AppLocalizationsDelegate
diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart
index db5e4a4..e2b0952 100644
--- a/lib/l10n/gen/app_localizations_ar.dart
+++ b/lib/l10n/gen/app_localizations_ar.dart
@@ -1840,4 +1840,27 @@ 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 => 'استعادة المشتريات';
+
+ @override
+ String get compraError => 'تعذّر إتمام عملية الشراء. حاول مرة أخرى.';
+
+ @override
+ String get restauracionSinCompras =>
+ 'لم نجد أي عملية شراء سابقة في هذا الحساب.';
+
+ @override
+ String get premiumActivo => 'النسخة المميزة مفعّلة';
}
diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart
index 5bb11da..dffb7b5 100644
--- a/lib/l10n/gen/app_localizations_bn.dart
+++ b/lib/l10n/gen/app_localizations_bn.dart
@@ -1851,4 +1851,27 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get autoEqDisableOption => 'বন্ধ করুন';
+
+ @override
+ String get funcionPremium => 'প্রিমিয়াম বৈশিষ্ট্য';
+
+ @override
+ String get limiteAlarmasAlcanzado =>
+ 'আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।';
+
+ @override
+ String get desbloquearPremium => 'প্রিমিয়াম আনলক করুন';
+
+ @override
+ String get restaurarCompras => 'কেনাকাটা পুনরুদ্ধার করুন';
+
+ @override
+ String get compraError => 'কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।';
+
+ @override
+ String get restauracionSinCompras =>
+ 'এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।';
+
+ @override
+ String get premiumActivo => 'প্রিমিয়াম সক্রিয়';
}
diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart
index 732a38f..771a63d 100644
--- a/lib/l10n/gen/app_localizations_de.dart
+++ b/lib/l10n/gen/app_localizations_de.dart
@@ -1864,4 +1864,28 @@ 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';
+
+ @override
+ String get compraError =>
+ 'Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.';
+
+ @override
+ String get restauracionSinCompras =>
+ 'Wir haben auf diesem Konto keinen früheren Kauf gefunden.';
+
+ @override
+ String get premiumActivo => 'Premium aktiv';
}
diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart
index 0e416a1..e0e278c 100644
--- a/lib/l10n/gen/app_localizations_en.dart
+++ b/lib/l10n/gen/app_localizations_en.dart
@@ -1843,4 +1843,28 @@ 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';
+
+ @override
+ String get compraError =>
+ 'We couldn\'t complete the purchase. Please try again.';
+
+ @override
+ String get restauracionSinCompras =>
+ 'We didn\'t find any previous purchase on this account.';
+
+ @override
+ String get premiumActivo => 'Premium active';
}
diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart
index 2d6f10e..81b57e5 100644
--- a/lib/l10n/gen/app_localizations_es.dart
+++ b/lib/l10n/gen/app_localizations_es.dart
@@ -1857,4 +1857,28 @@ 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';
+
+ @override
+ String get compraError =>
+ 'No se ha podido completar la compra. Inténtalo de nuevo.';
+
+ @override
+ String get restauracionSinCompras =>
+ 'No hemos encontrado ninguna compra anterior en esta cuenta.';
+
+ @override
+ String get premiumActivo => 'Premium activo';
}
diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart
index 4c4c487..3bcfd4c 100644
--- a/lib/l10n/gen/app_localizations_fr.dart
+++ b/lib/l10n/gen/app_localizations_fr.dart
@@ -1870,4 +1870,28 @@ 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';
+
+ @override
+ String get compraError =>
+ 'Impossible de finaliser l\'achat. Veuillez réessayer.';
+
+ @override
+ String get restauracionSinCompras =>
+ 'Nous n\'avons trouvé aucun achat antérieur sur ce compte.';
+
+ @override
+ String get premiumActivo => 'Premium actif';
}
diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart
index c7baf12..c2d7c44 100644
--- a/lib/l10n/gen/app_localizations_hi.dart
+++ b/lib/l10n/gen/app_localizations_hi.dart
@@ -1844,4 +1844,27 @@ 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 => 'खरीदारी पुनर्स्थापित करें';
+
+ @override
+ String get compraError => 'खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।';
+
+ @override
+ String get restauracionSinCompras =>
+ 'इस खाते में हमें कोई पिछली खरीद नहीं मिली।';
+
+ @override
+ String get premiumActivo => 'प्रीमियम सक्रिय';
}
diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart
index eee2b0a..08dbd8b 100644
--- a/lib/l10n/gen/app_localizations_id.dart
+++ b/lib/l10n/gen/app_localizations_id.dart
@@ -1854,4 +1854,28 @@ 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';
+
+ @override
+ String get compraError =>
+ 'Pembelian tidak dapat diselesaikan. Silakan coba lagi.';
+
+ @override
+ String get restauracionSinCompras =>
+ 'Kami tidak menemukan pembelian sebelumnya di akun ini.';
+
+ @override
+ String get premiumActivo => 'Premium aktif';
}
diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart
index 8f9b018..280c271 100644
--- a/lib/l10n/gen/app_localizations_it.dart
+++ b/lib/l10n/gen/app_localizations_it.dart
@@ -1867,4 +1867,28 @@ 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';
+
+ @override
+ String get compraError =>
+ 'Non è stato possibile completare l\'acquisto. Riprova.';
+
+ @override
+ String get restauracionSinCompras =>
+ 'Non abbiamo trovato acquisti precedenti su questo account.';
+
+ @override
+ String get premiumActivo => 'Premium attivo';
}
diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart
index 8d5dcf8..9e3b48a 100644
--- a/lib/l10n/gen/app_localizations_ja.dart
+++ b/lib/l10n/gen/app_localizations_ja.dart
@@ -1791,4 +1791,25 @@ 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 => '購入を復元';
+
+ @override
+ String get compraError => '購入を完了できませんでした。もう一度お試しください。';
+
+ @override
+ String get restauracionSinCompras => 'このアカウントでは以前の購入が見つかりませんでした。';
+
+ @override
+ String get premiumActivo => 'プレミアム有効';
}
diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart
index a562d30..77af60d 100644
--- a/lib/l10n/gen/app_localizations_pt.dart
+++ b/lib/l10n/gen/app_localizations_pt.dart
@@ -1854,4 +1854,28 @@ 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';
+
+ @override
+ String get compraError =>
+ 'Não foi possível concluir a compra. Tente novamente.';
+
+ @override
+ String get restauracionSinCompras =>
+ 'Não encontramos nenhuma compra anterior nesta conta.';
+
+ @override
+ String get premiumActivo => 'Premium ativo';
}
diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart
index 25ce7f5..e30d9a6 100644
--- a/lib/l10n/gen/app_localizations_ru.dart
+++ b/lib/l10n/gen/app_localizations_ru.dart
@@ -1861,4 +1861,27 @@ 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 => 'Восстановить покупки';
+
+ @override
+ String get compraError => 'Не удалось завершить покупку. Попробуйте ещё раз.';
+
+ @override
+ String get restauracionSinCompras =>
+ 'Мы не нашли предыдущих покупок на этом аккаунте.';
+
+ @override
+ String get premiumActivo => 'Премиум активен';
}
diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart
index 3e53ca2..8c15f6d 100644
--- a/lib/l10n/gen/app_localizations_zh.dart
+++ b/lib/l10n/gen/app_localizations_zh.dart
@@ -1776,4 +1776,25 @@ 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 => '恢复购买';
+
+ @override
+ String get compraError => '无法完成购买,请重试。';
+
+ @override
+ String get restauracionSinCompras => '未在此账户中找到以前的购买记录。';
+
+ @override
+ String get premiumActivo => '高级版已解锁';
}
diff --git a/lib/main.dart b/lib/main.dart
index f7ae79a..ca1f338 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -5,13 +5,17 @@ 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 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart';
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_consentimiento.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'tema/pluriwave_tokens.dart';
@@ -104,6 +108,37 @@ 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).
+ //
+ // FIX 4 (code review): the Mobile Ads SDK is only initialized AFTER the
+ // GDPR/UMP consent flow resolves that ads may actually be requested
+ // (`ConsentInformation.canRequestAds()`) — serving personalized ads to
+ // EEA/UK users with no CMP violates Google's EU User Consent Policy.
+ // Premium users never even reach the consent form (`resolverConsentimientoAnuncios`
+ // short-circuits for them — they get zero ads regardless). This whole
+ // chain is deliberately `unawaited`: consent/ads are exactly as
+ // "cosmetic, never gates startup" as `aplicarPoliticaOrientacion` above,
+ // and any failure inside it degrades to "no ads", never a crash or a
+ // blocked UI.
+ unawaited(
+ esPremiumPersistido()
+ .then(
+ (premium) => resolverConsentimientoAnuncios(
+ esPremium: premium,
+ consentimiento: ServicioConsentimientoUmp(),
+ ),
+ )
+ .then((puedeSolicitarAnuncios) async {
+ if (puedeSolicitarAnuncios) {
+ await 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 +190,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..669400b
--- /dev/null
+++ b/lib/servicios/servicio_anuncios.dart
@@ -0,0 +1,224 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart' show debugPrint, kReleaseMode;
+import 'package:google_mobile_ads/google_mobile_ads.dart';
+
+/// Official Google TEST ad unit ids. ALWAYS used outside release builds —
+/// tapping your own real ad unit during development/testing is invalid
+/// traffic and AdMob suspends accounts for it, so this is not optional.
+const bannerAdUnitIdPrueba = 'ca-app-pub-3940256099942544/6300978111';
+const interstitialAdUnitIdPrueba = 'ca-app-pub-3940256099942544/1033173712';
+
+/// Real banner unit id, provisioned in the AdMob console (iap-freemium-unlock).
+const _bannerAdUnitIdReal = 'ca-app-pub-6038935671414339/5658618378';
+
+/// Real interstitial unit id, provisioned in the AdMob console (iap-freemium-unlock).
+const _interstitialAdUnitIdReal = 'ca-app-pub-6038935671414339/4189478248';
+
+/// TESTING-PHASE SWITCH. While `true`, release builds serve Google's official
+/// TEST ad units instead of the real ones, so none of the closed-testing
+/// human testers can generate invalid traffic against the AdMob account
+/// (they cannot be registered as AdMob test devices). Flip to `false` for
+/// the production release — that is the ONLY change needed to start serving
+/// real ads. This does NOT affect the AdMob application id in
+/// `AndroidManifest.xml`, which stays real in every build (it only
+/// initializes the SDK and carries none of the click risk).
+const usarAnunciosDePruebaEnRelease = true;
+
+/// Real id in release builds only, and only once [usarAnunciosDePruebaEnRelease]
+/// is flipped to `false`; test id everywhere else (debug/profile, including
+/// internal-testing-track builds run via `flutter run --release` on a
+/// personal device — see the "never tap your own ads" note above).
+const bannerAdUnitId =
+ kReleaseMode && !usarAnunciosDePruebaEnRelease
+ ? _bannerAdUnitIdReal
+ : bannerAdUnitIdPrueba;
+const interstitialAdUnitId =
+ kReleaseMode && !usarAnunciosDePruebaEnRelease
+ ? _interstitialAdUnitIdReal
+ : interstitialAdUnitIdPrueba;
+
+/// 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({
+ required bool Function() esPremium,
+ DateTime Function()? ahora,
+ Future Function()? mostrarInterstitialImpl,
+ Duration? timeoutIntentoInterstitial,
+ }) : _esPremium = esPremium,
+ _ahora = ahora ?? DateTime.now,
+ _mostrarInterstitialImpl =
+ mostrarInterstitialImpl ?? _mostrarInterstitialAdMob,
+ _timeoutIntentoInterstitial =
+ timeoutIntentoInterstitial ?? timeoutIntentoInterstitialPorDefecto;
+
+ /// 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);
+
+ /// FIX 2 (code review): bounds `InterstitialAd.load`'s callback wait
+ /// inside [_mostrarInterstitialAdMob] so a load callback that never fires
+ /// cannot hang a caller — every call site (`pantalla_alarmas.dart`,
+ /// `pantalla_favoritos.dart`,
+ /// `ajustes/pantalla_ajustes_emisoras_personalizadas.dart`) `await`s
+ /// [intentarInterstitial] before opening its form.
+ static const timeoutCargaInterstitialPorDefecto = Duration(seconds: 5);
+
+ /// FIX 2 (code review): bounds the wait for the ad to actually PRESENT
+ /// (`onAdShowedFullScreenContent`) or fail
+ /// (`onAdFailedToShowFullScreenContent`) after `show()`. This method
+ /// deliberately never waits for the ad to be DISMISSED — the caller is
+ /// not blocked on ad dismissal at all, only on the ad actually rendering.
+ static const timeoutPresentacionInterstitialPorDefecto = Duration(seconds: 5);
+
+ /// FIX 2 (code review): the overall bound applied around the INJECTED
+ /// [_mostrarInterstitialImpl] itself (production default: the sum of the
+ /// two timeouts above, plus headroom) — so ANY implementation, including
+ /// a future bug in an injected fake or a different ad SDK, can never hang
+ /// a caller indefinitely. Injectable so tests can use a short value.
+ static const timeoutIntentoInterstitialPorDefecto = Duration(seconds: 15);
+
+ final bool Function() _esPremium;
+ final DateTime Function() _ahora;
+ final Future Function() _mostrarInterstitialImpl;
+ final Duration _timeoutIntentoInterstitial;
+
+ 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;
+ // FIX 2 (code review): bound the injected implementation itself — no
+ // caller may ever await this indefinitely, regardless of what
+ // [_mostrarInterstitialImpl] does internally. A timeout is treated
+ // exactly like "no ad shown": `false`, cap not consumed.
+ final mostrado = await _mostrarInterstitialImpl().timeout(
+ _timeoutIntentoInterstitial,
+ onTimeout: () => false,
+ );
+ if (mostrado) {
+ _mostrados++;
+ _ultimoMostrado = _ahora();
+ }
+ return mostrado;
+ }
+
+ static Future _mostrarInterstitialAdMob() async {
+ try {
+ final cargaCompleter = Completer();
+ // FIX 2 (code review): a load callback that never fires used to hang
+ // this await forever. `expiradoCarga` guards a LATE callback that
+ // still arrives after the timeout — the ad is disposed instead of
+ // leaked, and never completes the already-abandoned completer.
+ var expiradoCarga = false;
+ await InterstitialAd.load(
+ adUnitId: interstitialAdUnitId,
+ request: const AdRequest(),
+ adLoadCallback: InterstitialAdLoadCallback(
+ onAdLoaded: (ad) {
+ if (expiradoCarga) {
+ ad.dispose();
+ return;
+ }
+ if (!cargaCompleter.isCompleted) cargaCompleter.complete(ad);
+ },
+ onAdFailedToLoad: (error) {
+ debugPrint('[PluriWave][anuncios] interstitial load ERROR $error');
+ if (!cargaCompleter.isCompleted) cargaCompleter.complete(null);
+ },
+ ),
+ );
+ final InterstitialAd? cargado;
+ try {
+ cargado = await cargaCompleter.future.timeout(
+ timeoutCargaInterstitialPorDefecto,
+ );
+ } on TimeoutException {
+ expiradoCarga = true;
+ return false;
+ }
+ if (cargado == null) return false;
+
+ // FIX 6 (code review): only a genuinely PRESENTED ad may consume the
+ // session cap. `onAdFailedToShowFullScreenContent` used to complete
+ // the same completer as a real dismissal and the method returned
+ // `true` unconditionally — a failed-to-show ad silently burned one of
+ // only 2 session slots.
+ //
+ // FIX 2 (code review): this method no longer waits for the ad to be
+ // DISMISSED at all — only for it to PRESENT or fail to present — and
+ // that wait is itself bounded, so a `fullScreenContentCallback` that
+ // never fires cannot hang the caller either. `expiradoPresentacion`
+ // guards a late callback the same way `expiradoCarga` does above.
+ var expiradoPresentacion = false;
+ final presentacionCompleter = Completer();
+ cargado.fullScreenContentCallback = FullScreenContentCallback(
+ onAdShowedFullScreenContent: (ad) {
+ if (!presentacionCompleter.isCompleted) {
+ presentacionCompleter.complete(true);
+ }
+ },
+ onAdDismissedFullScreenContent: (ad) {
+ ad.dispose();
+ },
+ onAdFailedToShowFullScreenContent: (ad, error) {
+ if (expiradoPresentacion) {
+ ad.dispose();
+ return;
+ }
+ ad.dispose();
+ if (!presentacionCompleter.isCompleted) {
+ presentacionCompleter.complete(false);
+ }
+ },
+ );
+ await cargado.show();
+ try {
+ return await presentacionCompleter.future.timeout(
+ timeoutPresentacionInterstitialPorDefecto,
+ );
+ } on TimeoutException {
+ expiradoPresentacion = true;
+ await cargado.dispose();
+ return false;
+ }
+ } 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..08d9fe6 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
@@ -534,6 +582,13 @@ class ServicioAudio {
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
PresetEcualizador get presetActual => _handler.presetActual;
+ /// Forwards the handler's own on/off flag (eq-sync-superficies): a
+ /// car/notification toggle (`accionEqToggle`) mutates
+ /// `PluriWaveAudioHandler._ecualizadorActivo` directly, bypassing
+ /// [setEcualizadorActivo] entirely. [EstadoEcualizador] polls this getter
+ /// on every [estadoStream] tick to detect and resync that divergence.
+ bool get ecualizadorActivo => _handler.ecualizadorActivo;
+
Future aplicarPreset(PresetEcualizador preset) =>
_handler.aplicarPreset(preset);
Future setEcualizadorActivo(bool activo) =>
@@ -638,6 +693,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