Files
pluriwave/openspec/changes/iap-freemium-unlock/explore.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

6.2 KiB

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 ListenableProviders 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/Appfilepackage_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. Equalizerlib/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 Autolib/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 vacationslib/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 recordinglib/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).

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.