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.
9.4 KiB
Design: Freemium unlock via one-time in-app purchase
Technical Approach
One cross-cutting EstadoEntitlement notifier (idiomatic EstadoIdioma shape) plus a top-level prefs-lazy reader for headless callers. Gating is hybrid: UI CTAs open the paywall, state-layer choke points hold the authoritative check. Ads are a port + AdMob adapter; the banner is a layout sibling (never an overlay), the interstitial fires on a CTA's natural transition behind a frequency cap.
Architecture Decisions
ADR-1: Entitlement is a notifier plus a free function, not a singleton
Choice: lib/estado/estado_entitlement.dart exports EstadoEntitlement extends ChangeNotifier (optional injected SharedPreferences, key compra_premium_v1, bool get esPremium) and a top-level Future<bool> esPremiumPersistido({SharedPreferences? prefs}) that reads the same key directly.
Alternatives: global singleton; passing the notifier into PluriWaveAudioHandler.
Rationale: PluriWaveAudioHandler registers before runApp, so no BuildContext/Provider exists. The free function mirrors FuenteMusicaLocalAutoImpl._resolverPrefs() (musica_local_auto.dart:163) — same convention, testable via setMockInitialValues, no lifecycle to leak.
ADR-2: Purchase I/O behind a port
Choice: PuertoCompras abstraction (comprar, restaurar, Stream<EventoCompra>) with ServicioComprasPlayBilling as the only in_app_purchase call site; EstadoEntitlement takes PuertoCompras?.
Alternatives: calling InAppPurchase.instance from the notifier.
Rationale: matches EstadoAlarmas(android: PuertoAlarmasAndroid); keeps Strict TDD viable with zero plugin channels in unit tests.
Fail-open: only purchased/restored writes true. Errors, timeouts and offline never write false; the persisted flag is the source of truth at cold start.
ADR-3: Gate placement (4 gates)
| Gate | Authoritative check | UI paywall entry |
|---|---|---|
| Alarm cap > 5 | EstadoAlarmas.guardarAlarma (estado_alarmas.dart:104) |
_EditorAlarmaSheet save + the add CTA in pantalla_alarmas.dart |
| Alarm vacations | EstadoAlarmas.crearRangoVacaciones (:510) |
pantalla_vacaciones.dart — vacation-add-header + _CtaAnadirRango |
| Recording | EstadoGrabacion.iniciar (estado_grabacion.dart:90) |
3 call sites in pantalla_reproductor.dart |
| Android Auto | getChildren / playFromMediaId / playFromSearch / skipToNext-Previous in servicio_audio.dart |
none (car never shows a purchase flow) |
The phone equalizer is not gated.
ADR-4: Auto reduced mode = real root labels, locked children, locked switching
Choice: ConstructorArbolAuto.raiz({required bool incluirMusicaLocal, required bool premium}) keeps the same visible folder labels for free users; getChildren resolves entitlement once via esPremiumPersistido() and, when free, returns exactly [itemPremiumBloqueado()] (non-playable, id premium:info, hardcoded Spanish label like every other car label) for any non-root parentMediaId. Station switching is additionally blocked at playFromMediaId, playFromSearch, skipToNext/skipToPrevious (no-op returns).
Alternatives: empty root; omitting the folders entirely.
Rationale: head units cache browse trees, so a stale emisora:<uuid> tap would bypass getChildren — the play-path gates are mandatory, not belt-and-braces. Keeping labels + one explicit locked item guarantees no blank list. Play/pause/stop of the already-playing station are untouched.
ADR-5: Distinct alarm-limit signal
Choice: guardarAlarma returns ResultadoGuardarAlarma { guardada, limiteAlcanzado }; _error stays reserved for native scheduling failures. Pure query bool puedeCrearAlarma (count = _alarmas.length, enabled or not; edits of an existing id always pass).
Rationale: overloading _error would surface a limit as a scheduling failure in app.dart's snackbar path. Grandfathering falls out for free — nothing is deleted, only new creation past 5 is refused.
ADR-6: Banner reserves layout; interstitial is cap-checked first
Choice: In _PaginaPrincipalState.build, body: becomes Column[ SafeArea(bottom:false, child: BannerAnuncioSuperior), Expanded(existing SafeArea+AnimatedSwitcher) ]. Premium or unloaded ⇒ SizedBox.shrink() (zero layout impact). Never a Stack/overlay.
Interstitial ordering (add-alarm): puedeCrearAlarma → if false, show the limit message and no ad; if true, maybe-interstitial, then open the editor. Add-station: interstitial on the CTA tap, before FormularioEmisoraPersonalizada opens.
Frequency cap: in-memory in ServicioAnuncios — max 2 interstitials per process lifetime and ≥3 min apart; over cap ⇒ silent no-op.
Rationale: an ad followed by "you can't create this" is both hostile and an AdMob disruptive-ad policy risk.
Data Flow
Play Billing ──→ PuertoCompras ──→ EstadoEntitlement ──→ prefs(compra_premium_v1)
│ │
UI (Provider.watch)┘ │
▼
PluriWaveAudioHandler.getChildren ──→ esPremiumPersistido() ──────┘ (no Provider)
File Changes
| File | Action | Description |
|---|---|---|
lib/estado/estado_entitlement.dart |
Create | Notifier + esPremiumPersistido() |
lib/servicios/servicio_compras.dart |
Create | PuertoCompras + Play Billing adapter |
lib/servicios/servicio_anuncios.dart |
Create | Banner/interstitial port + AdMob adapter + frequency cap |
lib/widgets/banner_anuncio_superior.dart |
Create | Entitlement-aware banner slot |
lib/widgets/hoja_premium.dart |
Create | Paywall sheet, reused by every gate |
lib/app.dart |
Modify | Provider registration + banner Column |
lib/estado/estado_alarmas.dart |
Modify | puedeCrearAlarma, ResultadoGuardarAlarma, vacation gate |
lib/estado/estado_grabacion.dart |
Modify | Recording gate in iniciar |
lib/servicios/navegacion_auto.dart |
Modify | raiz(premium:), itemPremiumBloqueado() |
lib/servicios/servicio_audio.dart |
Modify | Entitlement gate in browse + play paths |
lib/pantallas/pantalla_ajustes.dart |
Modify | Purchase + restore rows |
lib/pantallas/pantalla_alarmas.dart, pantalla_vacaciones.dart, pantalla_reproductor.dart, pantalla_favoritos.dart, ajustes/pantalla_ajustes_emisoras_personalizadas.dart |
Modify | Contextual upsell / interstitial trigger |
pubspec.yaml |
Modify | Activate in_app_purchase, google_mobile_ads |
lib/l10n/app_*.arb |
Modify | Paywall, limit message, restore strings |
Interfaces / Contracts
class EstadoEntitlement extends ChangeNotifier {
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras});
static const idProducto = 'pluriwave_premium';
bool get esPremium;
bool get compraEnCurso;
Future<void> comprar();
Future<void> restaurar();
}
Future<bool> esPremiumPersistido({SharedPreferences? prefs});
enum ResultadoGuardarAlarma { guardada, limiteAlcanzado }
Testing Strategy
| Layer | What to Test | Approach |
|---|---|---|
| Unit | Entitlement persistence, fail-open on error, restore | Fake PuertoCompras + setMockInitialValues |
| Unit | puedeCrearAlarma at 4/5/6, edit-at-cap, vacations, recording |
EstadoAlarmas(prefs:)/EstadoGrabacion directly |
| Unit | raiz(premium:false), locked-child for every parent id, play-path no-ops |
Pure ConstructorArbolAuto + handler fakes |
| Unit | Interstitial cap (2/session, 3 min) and cap-before-ad ordering | Fake clock in ServicioAnuncios |
| Widget | Banner absent when premium; no overlap on all 5 tabs | pumpWidget(PluriWaveApp(prefs:)) + golden-free layout asserts |
| Widget | Limit message with secondary unlock action, paywall from each gate | Existing pantalla_*_test.dart conventions |
Threat Matrix
N/A — no routing, shell, subprocess, VCS/PR automation, executable-file classification, or process-integration boundary. Android Auto media-id dispatch is pre-existing in-process routing, not shell/process execution.
Migration / Rollout
No migration. Additive and prefs-backed; absent key = free. Revert by re-commenting both plugins and reverting the gate commits. Versioned key (compra_premium_v1) is ignored by older builds.
Open Questions
- Price point (Play Console decision).
- AdMob ad unit IDs (banner + interstitial) not yet provisioned; test IDs until then.
Should a cached head-unit tree be actively invalidated (RESOLVED (orchestrator): actively invalidate. On the entitlement transition to premium, callnotifyChildrenChanged) at purchase time, or is the next browse refresh enough?notifyChildrenChangedfor the affected parent ids. Rationale: the same head-unit caching that forces theplayFromMediaIdguard in ADR-4 also means a purchaser would otherwise keep seeing the locked tree until the unit re-binds — plausibly the rest of the drive. A user who just paid and still sees "Premium feature" in the car reads that as a broken purchase, which is a refund and a one-star review. Relying on the next browse refresh trades a cheap, bounded call for a highly visible failure. The invalidation is one-directional and only fires on the free → premium transition; there is no premium → free transition to handle (the purchase is permanent and entitlement never writesfalse, per ADR-2).