feat(iap): add freemium unlock via one-time in-app purchase
Adds a permanent, non-consumable premium unlock (EstadoEntitlement + PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks alarm vacations, alarms past a 5-alarm free cap, recording start, and full Android Auto browsing. The phone equalizer stays free for everyone. - Entitlement is prefs-backed (compra_premium_v1), fail-open, and resolvable headlessly via esPremiumPersistido() for the Android Auto audio handler, which registers before runApp. - Android Auto reduced mode keeps the real root folder labels for free users; browsing into any of them (and playFromMediaId/playFromSearch/ skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio choke points, with a locked "Función Premium" item as the backstop. Current-station play/pause/stop stays untouched. A free -> premium transition actively invalidates the head unit's cached browse tree. - Ads (top banner + capped interstitial before adding a station or an alarm) are gated behind entitlement via ServicioAnuncios, using official Google test ad unit IDs pending AdMob provisioning. - Alarm cap UX shows an explanatory message with a secondary unlock action rather than a bare paywall jump; existing data is grandfathered. - 4 new localization keys translated across all 13 supported locales. Co-located tests use strict TDD (RED test before implementation) for every new pure-logic unit; full existing suite passes unchanged.
This commit is contained in:
+68
-22
@@ -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<EstadoEntitlement>().esPremium,
|
||||
),
|
||||
),
|
||||
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
|
||||
@@ -69,13 +88,28 @@ class PluriWaveApp extends StatelessWidget {
|
||||
ListenableProvider<EstadoBusqueda>(
|
||||
create: (context) => context.read<EstadoRadio>().busqueda,
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)),
|
||||
ChangeNotifierProvider(
|
||||
create:
|
||||
(context) => EstadoAlarmas(
|
||||
prefs: prefs,
|
||||
esPremium: () => context.read<EstadoEntitlement>().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<ServicioAnuncios>(
|
||||
create:
|
||||
(context) => ServicioAnuncios(
|
||||
esPremium: () => context.read<EstadoEntitlement>().esPremium,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Consumer<EstadoIdioma>(
|
||||
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<Offset>(
|
||||
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<Offset>(
|
||||
begin: const Offset(0.035, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<int>(indice),
|
||||
child: _paginas[indice],
|
||||
),
|
||||
),
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<int>(indice),
|
||||
child: _paginas[indice],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
|
||||
@@ -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<AlarmaMusical> _alarmas = [];
|
||||
List<RangoVacaciones> _vacaciones = [];
|
||||
List<ExcepcionAlarma> _excepciones = [];
|
||||
@@ -101,7 +121,26 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<ResultadoGuardarAlarma> 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<void> refrescarProgramacion() async {
|
||||
@@ -507,9 +547,20 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> 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<bool> 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<void> eliminarRangoVacaciones(String id) async {
|
||||
|
||||
@@ -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<bool> 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<EventoCompra>? _comprasSub;
|
||||
|
||||
bool _esPremium = false;
|
||||
bool _compraEnCurso = false;
|
||||
|
||||
bool get esPremium => _esPremium;
|
||||
bool get compraEnCurso => _compraEnCurso;
|
||||
|
||||
Future<void> _cargar() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final premium = prefs.getBool(_keyPremium) ?? false;
|
||||
if (premium != _esPremium) {
|
||||
_esPremium = premium;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<SharedPreferences> _resolverPrefs() async =>
|
||||
_prefs ?? SharedPreferences.getInstance();
|
||||
|
||||
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
|
||||
/// already premium (Spec "Already-purchased attempt is idempotent") — no
|
||||
/// duplicate charge is even attempted.
|
||||
Future<void> comprar() async {
|
||||
if (_esPremium) return;
|
||||
final compras = _compras;
|
||||
if (compras == null) return;
|
||||
_compraEnCurso = true;
|
||||
notifyListeners();
|
||||
await compras.comprar();
|
||||
}
|
||||
|
||||
/// Re-queries Play Billing for a prior purchase (Spec "Restore Purchases").
|
||||
Future<void> restaurar() async {
|
||||
final compras = _compras;
|
||||
if (compras == null) return;
|
||||
_compraEnCurso = true;
|
||||
notifyListeners();
|
||||
await compras.restaurar();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _desbloquear() async {
|
||||
final yaEraPremium = _esPremium;
|
||||
_esPremium = true;
|
||||
_compraEnCurso = false;
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setBool(_keyPremium, true);
|
||||
notifyListeners();
|
||||
if (!yaEraPremium) {
|
||||
// Orchestrator-resolved open question (design.md): actively
|
||||
// invalidate the Android Auto browse cache on the free -> premium
|
||||
// transition, rather than waiting for the head unit's own re-bind.
|
||||
notificarDesbloqueoAuto();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_comprasSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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<EstadoGrabacionRadio>? _suscripcion;
|
||||
AppLocalizations? _l10n;
|
||||
|
||||
@@ -87,7 +101,14 @@ class EstadoGrabacion extends ChangeNotifier {
|
||||
int get maxBytes => servicio.maxBytes;
|
||||
File? get ultimoArchivo => servicio.ultimoArchivo;
|
||||
|
||||
Future<void> iniciar({Duration? duracion}) async {
|
||||
Future<ResultadoIniciarGrabacion> 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
Future<File> 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,
|
||||
|
||||
+5
-1
@@ -897,5 +897,9 @@
|
||||
"alarmDiagnosticsFixAction": "إصلاح",
|
||||
"alarmDiagnosticsIntentUnavailable": "تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.",
|
||||
"alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.",
|
||||
"autoEqDisableOption": "تعطيل"
|
||||
"autoEqDisableOption": "تعطيل",
|
||||
"funcionPremium": "ميزة مميزة",
|
||||
"limiteAlarmasAlcanzado": "لقد وصلت إلى الحد المجاني وهو 5 منبهات.",
|
||||
"desbloquearPremium": "فتح النسخة المميزة",
|
||||
"restaurarCompras": "استعادة المشتريات"
|
||||
}
|
||||
|
||||
+5
-1
@@ -897,5 +897,9 @@
|
||||
"alarmDiagnosticsFixAction": "সমাধান করুন",
|
||||
"alarmDiagnosticsIntentUnavailable": "এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।",
|
||||
"alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।",
|
||||
"autoEqDisableOption": "বন্ধ করুন"
|
||||
"autoEqDisableOption": "বন্ধ করুন",
|
||||
"funcionPremium": "প্রিমিয়াম বৈশিষ্ট্য",
|
||||
"limiteAlarmasAlcanzado": "আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।",
|
||||
"desbloquearPremium": "প্রিমিয়াম আনলক করুন",
|
||||
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন"
|
||||
}
|
||||
|
||||
+5
-1
@@ -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"
|
||||
}
|
||||
|
||||
+5
-1
@@ -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"
|
||||
}
|
||||
|
||||
+5
-1
@@ -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"
|
||||
}
|
||||
|
||||
+5
-1
@@ -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"
|
||||
}
|
||||
|
||||
+5
-1
@@ -897,5 +897,9 @@
|
||||
"alarmDiagnosticsFixAction": "ठीक करें",
|
||||
"alarmDiagnosticsIntentUnavailable": "इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।",
|
||||
"alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।",
|
||||
"autoEqDisableOption": "बंद करें"
|
||||
"autoEqDisableOption": "बंद करें",
|
||||
"funcionPremium": "प्रीमियम सुविधा",
|
||||
"limiteAlarmasAlcanzado": "आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।",
|
||||
"desbloquearPremium": "प्रीमियम अनलॉक करें",
|
||||
"restaurarCompras": "खरीदारी पुनर्स्थापित करें"
|
||||
}
|
||||
|
||||
+5
-1
@@ -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"
|
||||
}
|
||||
|
||||
+5
-1
@@ -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"
|
||||
}
|
||||
|
||||
+5
-1
@@ -897,5 +897,9 @@
|
||||
"alarmDiagnosticsFixAction": "修正する",
|
||||
"alarmDiagnosticsIntentUnavailable": "この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。",
|
||||
"alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。",
|
||||
"autoEqDisableOption": "無効化"
|
||||
"autoEqDisableOption": "無効化",
|
||||
"funcionPremium": "プレミアム機能",
|
||||
"limiteAlarmasAlcanzado": "無料プランのアラーム上限(5件)に達しました。",
|
||||
"desbloquearPremium": "プレミアムを解除",
|
||||
"restaurarCompras": "購入を復元"
|
||||
}
|
||||
|
||||
+5
-1
@@ -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"
|
||||
}
|
||||
|
||||
+5
-1
@@ -897,5 +897,9 @@
|
||||
"alarmDiagnosticsFixAction": "Исправить",
|
||||
"alarmDiagnosticsIntentUnavailable": "Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.",
|
||||
"alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника.",
|
||||
"autoEqDisableOption": "Отключить"
|
||||
"autoEqDisableOption": "Отключить",
|
||||
"funcionPremium": "Премиум-функция",
|
||||
"limiteAlarmasAlcanzado": "Вы достигли бесплатного лимита в 5 будильников.",
|
||||
"desbloquearPremium": "Разблокировать Премиум",
|
||||
"restaurarCompras": "Восстановить покупки"
|
||||
}
|
||||
|
||||
+5
-1
@@ -897,5 +897,9 @@
|
||||
"alarmDiagnosticsFixAction": "解决",
|
||||
"alarmDiagnosticsIntentUnavailable": "无法在此手机上打开该设置界面。请尝试在设置中手动查找。",
|
||||
"alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。",
|
||||
"autoEqDisableOption": "关闭"
|
||||
"autoEqDisableOption": "关闭",
|
||||
"funcionPremium": "高级功能",
|
||||
"limiteAlarmasAlcanzado": "您已达到免费版 5 个闹钟的上限。",
|
||||
"desbloquearPremium": "解锁高级版",
|
||||
"restaurarCompras": "恢复购买"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => 'استعادة المشتريات';
|
||||
}
|
||||
|
||||
@@ -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 => 'কেনাকাটা পুনরুদ্ধার করুন';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'खरीदारी पुनर्स्थापित करें';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '購入を復元';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'Восстановить покупки';
|
||||
}
|
||||
|
||||
@@ -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 => '恢复购买';
|
||||
}
|
||||
|
||||
+10
-1
@@ -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<void> 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<void> main() async {
|
||||
}
|
||||
|
||||
Widget construirApp() => _OrientacionResponsiveApp(
|
||||
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto),
|
||||
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
|
||||
);
|
||||
|
||||
final resultado = await esperarArranqueAudio(handlerFuturo);
|
||||
|
||||
@@ -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<void> _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<ServicioAnuncios>().intentarInterstitial();
|
||||
if (!context.mounted) return;
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
|
||||
@@ -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<EstadoIdioma, Locale?>(
|
||||
(e) => e.localeSeleccionado,
|
||||
);
|
||||
final esPremium = context.select<EstadoEntitlement, bool>(
|
||||
(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,
|
||||
|
||||
@@ -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<EstadoAlarmas>();
|
||||
if (!estado.puedeCrearAlarma()) {
|
||||
_mostrarLimiteAlarmas(context);
|
||||
return;
|
||||
}
|
||||
await context.read<ServicioAnuncios>().intentarInterstitial();
|
||||
if (!context.mounted) return;
|
||||
}
|
||||
await showModalBottomSheet<void>(
|
||||
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<Emisora> _favoritasConSeleccion(List<Emisora> favoritas) {
|
||||
|
||||
@@ -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<PantallaFavoritos> {
|
||||
String? _grupoSeleccionadoId;
|
||||
|
||||
Future<void> _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<ServicioAnuncios>().intentarInterstitial();
|
||||
if (!mounted) return;
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
|
||||
@@ -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<void> _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<EstadoGrabacion>();
|
||||
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),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<MediaItem> 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<MediaItem> 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:<uuid>` 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<MediaItem>? 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:<uuid>` media id and the
|
||||
/// existing internal playback path (Design "playback coherence" — reuse
|
||||
/// over duplication). Resolves the uuid via [fuente], builds the same
|
||||
|
||||
@@ -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<bool> 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<bool> 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<bool> intentarInterstitial() async {
|
||||
if (!_dentroDelCap()) return false;
|
||||
final mostrado = await _mostrarInterstitialImpl();
|
||||
if (mostrado) {
|
||||
_mostrados++;
|
||||
_ultimoMostrado = _ahora();
|
||||
}
|
||||
return mostrado;
|
||||
}
|
||||
|
||||
static Future<bool> _mostrarInterstitialAdMob() async {
|
||||
try {
|
||||
final cargaCompleter = Completer<InterstitialAd?>();
|
||||
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<void>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> 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:<uuid>` 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 = <String, BehaviorSubject<Map<String, dynamic>>>{};
|
||||
|
||||
@override
|
||||
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
|
||||
_childrenSubjects.putIfAbsent(
|
||||
parentMediaId,
|
||||
() => BehaviorSubject<Map<String, dynamic>>.seeded(<String, dynamic>{}),
|
||||
);
|
||||
|
||||
/// 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(<String, dynamic>{});
|
||||
}
|
||||
|
||||
/// 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<void> 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<void> 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<String, dynamic>? 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 = <Emisora>[
|
||||
@@ -1779,6 +1886,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
Map<String, dynamic>? 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:<uuid>`/`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
|
||||
|
||||
@@ -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<EventoCompra> get eventos;
|
||||
|
||||
/// Starts the one-time non-consumable purchase flow.
|
||||
Future<void> comprar();
|
||||
|
||||
/// Re-queries Play Billing for a prior purchase on this account.
|
||||
Future<void> 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<EventoCompra>.broadcast();
|
||||
StreamSubscription<List<PurchaseDetails>>? _sub;
|
||||
|
||||
@override
|
||||
Stream<EventoCompra> get eventos => _eventos.stream;
|
||||
|
||||
@override
|
||||
Future<void> 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<void> 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<PurchaseDetails> 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<void> 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<T> on List<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
@@ -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<BannerAnuncioSuperior> createState() => _BannerAnuncioSuperiorState();
|
||||
}
|
||||
|
||||
class _BannerAnuncioSuperiorState extends State<BannerAnuncioSuperior> {
|
||||
BannerAd? _bannerAd;
|
||||
bool _cargado = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final servicio = context.read<ServicioAnuncios>();
|
||||
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<EstadoEntitlement>();
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> mostrarHojaPremium(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
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<EstadoEntitlement>();
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user