# 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 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`) 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:` 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 ```dart class EstadoEntitlement extends ChangeNotifier { EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras}); static const idProducto = 'pluriwave_premium'; bool get esPremium; bool get compraEnCurso; Future comprar(); Future restaurar(); } Future 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. - [x] ~~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).