Files
pluriwave/openspec/changes/iap-freemium-unlock/design.md
T
FreeTLab aa0b242374 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.
2026-08-10 20:37:07 +02:00

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.dartvacation-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 (notifyChildrenChanged) at purchase time, or is the next browse refresh enough? RESOLVED (orchestrator): actively invalidate. On the entitlement transition to premium, call notifyChildrenChanged for the affected parent ids. Rationale: the same head-unit caching that forces the playFromMediaId guard in ADR-4 also means a purchaser would otherwise keep seeing the locked tree until the unit re-binds — plausibly the rest of the drive. A user who just paid and still sees "Premium feature" in the car reads that as a broken purchase, which is a refund and a one-star review. Relying on the next browse refresh trades a cheap, bounded call for a highly visible failure. The invalidation is one-directional and only fires on the free → premium transition; there is no premium → free transition to handle (the purchase is permanent and entitlement never writes false, per ADR-2).