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

7.6 KiB
Raw Blame History

Proposal: Freemium unlock via one-time in-app purchase

Intent

PluriWave (1.3.0+151, Internal Testing) has no monetization. Add one non-consumable purchase that permanently removes ads and unlocks the premium feature set, keeping the free tier usable. Purchasers get everything forever, restorable after reinstall, with no renewal or expiry concept.

Scope

In Scope

  • EstadoEntitlement ChangeNotifier (SharedPreferences, versioned key, prefs-lazy resolve for headless Android Auto), top-level provider in app.dart.
  • Activate in_app_purchase: buy flow, purchase stream, restorePurchases() from Settings.
  • Activate google_mobile_ads: persistent top banner anchored in app.dart (must not overlap or displace existing content), plus a full-screen interstitial before two specific actions — adding a station manually and adding an alarm. All ads absent when premium.
  • Gate 4 features: Android Auto reduced mode, alarm vacations, starting recordings, creating alarms past 5.
  • Paywall reachable from every gated entry point (Settings row + contextual upsell at each gate); distinct "limit reached" signal from EstadoAlarmas.guardarAlarma (not the existing _error).

Out of Scope

  • Price point and Play Console product setup (console-side, undecided).
  • Server-side receipt validation — no backend exists; client + Play Billing trust accepted for v1.
  • Subscriptions, trials, promo codes, iOS store setup, CI/fastlane changes (none needed).
  • Deleting, hiding, or trimming content free users already created.
  • The equalizer on the phone: explicitly stays free for all users (user decision). Only its Android Auto surface is affected, as a consequence of Auto reduced mode.

Business Rules

Rule Decision
Purchase Non-consumable, permanent, per Play account
Alarm cap Free tier = 5 alarms total, enabled or not
Alarm cap UX 6th attempt shows an explanatory message with a secondary "unlock" action — never a bare paywall jump
Grandfathering Existing alarms/vacations/recordings survive; only new creation past the cap is blocked
Entitlement failure Fail-open: trust last persisted flag; never lock out a payer offline
Equalizer (phone) Free for everyone — not a gated feature
Android Auto (free) Reduced mode: current-station player only. No station browsing/switching, no local music. Every other car entry shows a "Premium feature" item
Ads — banner Persistent top banner, laid out so it never overlaps or covers existing UI
Ads — interstitial Full-screen ad before adding a station manually and before adding an alarm
Ads lifecycle Vanish immediately on purchase, no restart
Purchase entry points Settings row + contextual upsell at each gated feature
Existing content Viewing/managing stays free; only new gated actions are blocked

Capabilities

New Capabilities

  • premium-entitlement: purchase, restore, persistence, offline policy.
  • freemium-gating: gated features, limits, and how a free user is informed.
  • ad-display: ad placement and lifecycle for free users only.

Modified Capabilities

  • android-auto-media: browse tree becomes entitlement-aware — free tier collapses to a current-station-player-only tree.

Approach

Hybrid gating (exploration approach 3): UI entry points show the paywall; state-layer choke points (guardarAlarma, EstadoGrabacion.iniciar, Auto getChildren) hold the authoritative check.

Affected Areas

Area Impact Description
lib/estado/estado_entitlement.dart New Entitlement, purchase, restore
lib/app.dart Modified Provider registration, top banner anchor
lib/estado/estado_alarmas.dart, estado_grabacion.dart Modified Cap, vacation gate, recording gate
lib/servicios/servicio_audio.dart, navegacion_auto.dart Modified Gate car tree
lib/pantallas/ (ajustes, vacaciones, alarmas, reproductor) Modified Paywall on gated CTAs
pubspec.yaml Modified Uncomment both plugins

Risks

Risk Likelihood Mitigation
Client-only entitlement is tamperable Med Accepted for v1; no backend exists
Cap feels like data loss Med Grandfather all data; explain at creation time
Headless Auto has no Provider Med Prefs-lazy resolve, mirror FuenteMusicaLocalAutoImpl
Missed gate on a call site Low State-layer choke points as backstop
Interstitial before add-alarm/add-station reads as punitive, or trips AdMob's disruptive-ad policy Med Interstitial fires on the action's natural transition, never mid-task; enforce a frequency cap so repeated adds in one session don't chain ads; never stack it with the alarm-cap message in the same tap
Auto reduced mode leaves a free driver with an empty-looking car UI Med Current-station player always present; every locked branch renders an explicit "Premium feature" item, never a blank list

Rollback Plan

Additive and prefs-backed. Revert by re-commenting both plugins in pubspec.yaml and reverting the gate commits; no migration, no schema change. The persisted key is versioned (compra_premium_v1) so older builds ignore it.

Dependencies

  • Play Console in-app product created and priced; AdMob ad unit IDs.

Success Criteria

  • Purchase unlocks every gated item with no restart and survives restart. Verified at the unit level: EstadoEntitlement.comprar()/restaurar() flip esPremium and notifyListeners() immediately on a comprada/restaurada event (no restart needed by construction — every gate reads esPremium/esPremiumPersistido() live), and the flag persists under compra_premium_v1. Full on-device Play Billing QA is still outstanding (deferred — no sandbox purchase available in this environment).
  • restorePurchases() restores entitlement on a fresh install. Verified: estado_entitlement_test.dart covers found/not-found restore outcomes.
  • Free tier blocks the 4 gated features and caps alarms at 5 without destroying data. Verified: estado_alarmas_gating_test.dart (cap + grandfathering), estado_grabacion_gating_test.dart (recording), navegacion_auto_gating_test.dart/servicio_audio_gating_test.dart (Android Auto).
  • Equalizer remains fully usable on the phone for free users. Verified: zero esPremium/EstadoEntitlement/esPremiumPersistido references anywhere in estado_ecualizador.dart, servicio_ecualizador.dart, pantalla_ajustes_ecualizador.dart, ecualizador_widget.dart.
  • Free-tier Android Auto still plays the current station and never shows a blank list. Verified: respuestaBloqueadaPorEntitlement never returns an empty list, raiz(premium:) keeps the root non-blank for every tier, and debeBloquearCambioDeEmisora only gates playFromMediaId/playFromSearch/skipToNext/skipToPreviousplay/pause/stop are untouched.
  • Zero ads (banner and interstitial) for purchasers; offline cold start keeps a purchaser unlocked. Verified: ServicioAnuncios.debeMostrarBanner/intentarInterstitial gate on esPremium first; offline cold start is esPremiumPersistido's fail-open persisted-flag read.
  • Top banner never overlaps, covers, or displaces existing UI on any tab. Verified: banner_anuncio_superior_test.dart + app.dart's Column[banner, Expanded(body)] (never a Stack).

Real-device/Play Console/AdMob QA (purchase flow, restore on a fresh install, car head-unit browse, live ad rendering) remains outstanding per the Work Unit runtime-harness notes in tasks.md — none of it is exercisable from this environment.

Open Questions

  1. Price point (Play Console decision; 2.994.99 EUR was a benchmark, never confirmed).