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:
@@ -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.
|
||||
Reference in New Issue
Block a user