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:
2026-08-10 20:37:07 +02:00
parent f4a1fac45a
commit aa0b242374
77 changed files with 3757 additions and 72 deletions
+51 -1
View File
@@ -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
+117
View File
@@ -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;
}
}
}
+118 -1
View File
@@ -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
+174
View File
@@ -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;
}