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
@@ -0,0 +1,47 @@
# Exploration: iap-freemium-unlock
One-time non-consumable IAP that removes ads and unlocks 6 currently-free features. Free-tier users see ads (`google_mobile_ads`, commented out in pubspec.yaml, never activated). Purchasers get zero ads and full access forever from a single purchase (not a subscription).
## Current State
**State/persistence architecture.** `lib/app.dart` (`PluriWaveApp.build`) wires a `MultiProvider` at the app root: `ChangeNotifierProvider<EstadoRadio>`, three `ListenableProvider`s exposing `EstadoRadio`'s owned children (`EstadoEcualizador`, `EstadoGrabacion`, `EstadoBusqueda`), then independent siblings `ChangeNotifierProvider<EstadoAlarmas>`, `ChangeNotifierProvider<EstadoIdioma>`, `ChangeNotifierProvider<EstadoNavegacionRaiz>`. A single `SharedPreferences` instance is resolved once in `lib/main.dart` and injected as `prefs` into every top-level notifier.
Idiomatic per-domain notifier shape (cleanest example: `lib/estado/estado_idioma.dart`): `ChangeNotifier` subclass, optional injected `SharedPreferences?`, a `_resolverPrefs()` fallback to `SharedPreferences.getInstance()` (works from headless callers with no DI), a versioned key constant, `notifyListeners()` after every mutation+persist.
**No existing tier/limit/entitlement concept anywhere** — confirmed via grep across `lib/modelos/alarma_musical.dart`, `lib/estado/estado_alarmas.dart`, `lib/servicios/servicio_alarmas.dart`.
**pubspec.yaml** (version `1.3.0+151`): `google_mobile_ads` and `in_app_purchase` both commented out, lines ~52-56. Neither is an active dependency.
**Fastlane/CI**: `fastlane/Appfile``package_name` = `es.freetimelab.pluriwave`; `fastlane/Fastfile` has one lane (`upload_internal`) publishing to Play's `internal` track; `.gitea/workflows/build.yml` auto-bumps version and calls that lane. No in-app-product ID or billing config exists anywhere in CI/fastlane — that's Play Console-side config only, zero CI/fastlane code changes required for this change.
## Affected Areas (gating points per feature)
1. **Equalizer**`lib/estado/estado_ecualizador.dart`, screen `lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart`. UI entry: `lib/pantallas/pantalla_ajustes.dart` ~L108-123 (`FilaAjuste.onTap` → push `PantallaAjustesEcualizador`). Second surface: Android Auto's always-present `idEcualizador` folder + on/off custom action in `servicio_audio.dart`/`navegacion_auto.dart` — closed automatically once Android Auto itself is gated.
2. **Android Auto**`lib/servicios/navegacion_auto.dart`'s pure `ConstructorArbolAuto` feeds `lib/servicios/servicio_audio.dart:1667` `getChildren()``constructor.raiz(...)`, the single dispatch point for the whole car tree. `PluriWaveAudioHandler` is registered in `main.dart` before `runApp`, so any gate here must read entitlement via a prefs-lazy fallback, never `BuildContext`/`Provider`.
3. **Alarm vacations**`lib/pantallas/pantalla_vacaciones.dart` (2 create CTAs: header button `'vacation-add-header'`, mid-page `_CtaAnadirRango`), `lib/estado/estado_alarmas.dart` (`crearRangoVacaciones`/`editarRangoVacaciones`/`eliminarRangoVacaciones`/`guardarVacaciones` + 4 pure queries), `lib/servicios/servicio_alarmas.dart`. Entry from Alarms root: `lib/pantallas/pantalla_alarmas.dart`'s `_PanelVacaciones` (L93).
4. **Station recording**`lib/servicios/servicio_grabacion_radio.dart` (engine), `lib/estado/estado_grabacion.dart`'s `EstadoGrabacion.iniciar({Duration? duracion})` (L90) is the single choke point for ≥3 UI call sites (`pantalla_reproductor.dart`'s recording panel ~L489-560, duration-picker sheet ~L601-724, mini-player shortcut `'player-tool-record'` ~L1064). `pantalla_grabaciones.dart`/`pantalla_ajustes_grabaciones.dart` manage *existing* recordings and should probably stay accessible regardless of entitlement.
5. **Alarm count limit (new)**`EstadoAlarmas.guardarAlarma` (L104) is the one save call for create+edit; UI create/edit distinction lives in `_EditorAlarmaSheet` (`pantalla_alarmas.dart`, `widget.alarma == null` checks, save call ~L1189). Today's only failure channel is a `String? _error` used for native scheduling failures — a limit rejection needs a distinct signal, not reuse of `_error`.
6. **Ads** — zero ad code exists anywhere yet. Best candidates: (a) one global anchor in `lib/app.dart`'s `_PaginaPrincipalState.build` bottom `Column` (alongside `MiniReproductor`), covering all 5 tabs with one wiring point; (b) a `SliverToBoxAdapter` row in `PantallaInicio`'s `CustomScrollView` (mirrors `_seccionTusEmisoras`).
## Recommended entitlement architecture
New `lib/estado/estado_entitlement.dart` `ChangeNotifier`, shaped like `EstadoIdioma` (injected optional `SharedPreferences`, versioned key e.g. `compra_premium_v1`, `bool get esPremium`, prefs-lazy fallback for the Android Auto path), registered as an independent sibling `ChangeNotifierProvider` in `app.dart` (not owned by `EstadoRadio` — it's cross-cutting).
## Approaches considered
1. **UI-entry-point gating only** (6 call sites) — small, reviewable diffs, matches idiomatic pattern; risk of a missed call site on future refactors. Effort: Medium.
2. **State-method-layer gating only** — unbypassable, but silent no-op UX unless paired with UI copy anyway (not a real alternative to #1). Effort: Medium-High.
3. **Hybrid (recommended)** — UI entries show the paywall (good UX) + state-layer choke points (`guardarAlarma`, `EstadoGrabacion.iniciar`, Android Auto `getChildren`) carry the authoritative check. Effort: Medium.
## Risks
- Grandfathering: devices with 6+ alarms already before ship — candidate: grandfather existing, block only future creates once count ≥ 5 (needs design sign-off).
- Restore-purchases flow for reinstalls/new devices — no UI placement decided yet.
- Offline/failed entitlement checks — candidate: fail-open (trust last-persisted local flag) over fail-closed.
- No backend exists in this codebase — entitlement will be client/Play-Billing-trusted only, an accepted risk unless design decides otherwise.
- Android Auto's headless cold-start path requires the same "resolve prefs lazily, no DI at construction" convention already used by `FuenteMusicaLocalAutoImpl`.
- Alarm-count rule (all alarms vs. only active/enabled) is undecided and affects UX.
## Ready for Proposal
Yes.