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

14 KiB
Raw Blame History

Apply Progress: iap-freemium-unlock

Mode: Strict TDD. Delivery: single-pr with size:exception (user-approved, single commit).

Status: ALL 9 PHASES COMPLETE — 27/27 TASKS DONE

TDD Cycle Evidence

Task(s) RED GREEN REFACTOR Test file(s)
0.1/0.2 N/A (config) pubspec.yaml + AndroidManifest.xml N/A N/A
1.1-1.3 estado_entitlement_test.dart written first, failed (no impl) estado_entitlement.dart (EstadoEntitlement, esPremiumPersistido) shared _keyPremium const, fail-open documented in doc comments test/estado/estado_entitlement_test.dart
2.1-2.2 servicio_compras_test.dart (pure mapping) written first, failed servicio_compras.dart (PuertoCompras, ServicioComprasPlayBilling, eventoDesdeEstadoCompra extracted for testability) N/A test/servicios/servicio_compras_test.dart
3.1-3.2 estado_alarmas_gating_test.dart written first, failed ResultadoGuardarAlarma enum + puedeCrearAlarma + gated guardarAlarma/crearRangoVacaciones N/A test/estado/estado_alarmas_gating_test.dart
3.3 N/A (UI wiring, no new pure logic) pantalla_alarmas.dart (cap-check+interstitial at the "+" CTA tap per ADR-6, snackbar+CTA on block) + pantalla_vacaciones.dart (paywall on block) Corrected mid-run: interstitial originally placed at save time, moved to the CTA tap per design.md's literal "then open the editor" wording Regression: pantalla_alarmas_editor_test.dart, pantalla_alarmas_fecha_test.dart, pantalla_vacaciones_test.dart
4.1-4.2 estado_grabacion_gating_test.dart written first, failed ResultadoIniciarGrabacion enum + gated iniciar() N/A test/estado/estado_grabacion_gating_test.dart
5.1 navegacion_auto_gating_test.dart written first, failed raiz(premium:), itemPremiumBloqueado(), respuestaBloqueadaPorEntitlement() N/A test/servicios/navegacion_auto_gating_test.dart
5.2 servicio_audio_gating_test.dart written first, failed debeBloquearCambioDeEmisora() wired into playFromMediaId/playFromSearch/skipToNext/skipToPrevious N/A test/servicios/servicio_audio_gating_test.dart
5.3 same file, notificarDesbloqueoAuto/registrarNotificacionDesbloqueoAuto cases Discovered mid-implementation that AudioService.notifyChildrenChanged is deprecated in this audio_service version — implemented via subscribeToChildren override + per-id BehaviorSubject + notificarHijosCambiaron, which is what the plugin's own internal listener now forwards to the platform Wired registrarHandler to push to all root-level ids on the hook test/servicios/servicio_audio_gating_test.dart
5.4 (covered above) getChildren checks respuestaBloqueadaPorEntitlement before any other resolution N/A (covered above) + regression: navegacion_auto_test.dart
6.1-6.2 servicio_anuncios_test.dart (fake clock) written first, failed ServicioAnuncios cap/gating logic + AdMob adapter (test ad unit IDs, TODO-marked) N/A test/servicios/servicio_anuncios_test.dart
6.3 banner_anuncio_superior_test.dart written first, failed BannerAnuncioSuperior widget + app.dart Column[banner, Expanded(body)] N/A test/widgets/banner_anuncio_superior_test.dart
7.1 N/A (wiring) hoja_premium.dart + EstadoEntitlement/ServicioAnuncios registered in app.dart's provider list (EstadoEntitlement FIRST so later create closures can context.read it) N/A Regression: app_test.dart, widget_test.dart
7.2 N/A (wiring) Settings premium row (pantalla_ajustes.dart); interstitial-before-open at both station-add CTAs (pantalla_favoritos.dart, ajustes_emisoras_personalizadas.dart) N/A Regression: pantalla_ajustes_test.dart, pantalla_favoritos_test.dart, ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart
8.1-8.3 N/A (content) 4 keys × 13 locales added to app_*.arb; flutter gen-l10n regenerated N/A literal-encoding scan clean
9.1-9.3 N/A (verification) Full suite run in batches, equalizer grep-verified ungated, proposal.md checkboxes updated with verification notes N/A See Work Unit Evidence below

Files Changed

File Action What Was Done
pubspec.yaml Modified Uncommented in_app_purchase, google_mobile_ads
android/app/src/main/AndroidManifest.xml Modified AdMob test app id meta-data (TODO to swap for real)
lib/estado/estado_entitlement.dart Created EstadoEntitlement ChangeNotifier + esPremiumPersistido()
lib/servicios/servicio_compras.dart Created PuertoCompras + ServicioComprasPlayBilling (sole in_app_purchase site)
lib/servicios/servicio_anuncios.dart Created ServicioAnuncios — banner/interstitial gating + frequency cap + AdMob adapter
lib/widgets/banner_anuncio_superior.dart Created Entitlement-aware top banner slot
lib/widgets/hoja_premium.dart Created Reusable paywall bottom sheet
lib/estado/estado_alarmas.dart Modified ResultadoGuardarAlarma enum, puedeCrearAlarma(), gated guardarAlarma/crearRangoVacaciones, esPremium injection (default () => true)
lib/estado/estado_grabacion.dart Modified ResultadoIniciarGrabacion enum, gated iniciar(), esPremium injection
lib/estado/estado_radio.dart Modified Threaded esPremium through to internal EstadoGrabacion
lib/servicios/navegacion_auto.dart Modified raiz(premium:), itemPremiumBloqueado(), respuestaBloqueadaPorEntitlement()
lib/servicios/servicio_audio.dart Modified getChildren/playFromMediaId/playFromSearch/skipToNext/skipToPrevious gated; subscribeToChildren override + notificarHijosCambiaron; registrarNotificacionDesbloqueoAuto/notificarDesbloqueoAuto hook
lib/pantallas/pantalla_alarmas.dart Modified Cap-check + interstitial at the "+" CTA tap; cap snackbar + "Desbloquear Premium" CTA
lib/pantallas/pantalla_vacaciones.dart Modified Paywall sheet on gate block
lib/pantallas/pantalla_reproductor.dart Modified 3 record-start call sites route through the gate, open paywall on block
lib/pantallas/pantalla_ajustes.dart Modified Premium row (buy/restore/active) in APLICACIÓN group
lib/pantallas/pantalla_favoritos.dart Modified Interstitial before opening the add-station form
lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart Modified Interstitial before opening the add-station form
lib/app.dart Modified EstadoEntitlement/ServicioAnuncios providers; compras injection param; banner Column wiring
lib/main.dart Modified MobileAds.instance.initialize(), ServicioComprasPlayBilling wiring
lib/l10n/app_*.arb (13 files) + lib/l10n/gen/* (regenerated) Modified funcionPremium, limiteAlarmasAlcanzado, desbloquearPremium, restaurarCompras
openspec/changes/iap-freemium-unlock/proposal.md Modified Success Criteria checked off with verification notes

Test Files Added

  • test/estado/estado_entitlement_test.dart
  • test/estado/estado_alarmas_gating_test.dart
  • test/estado/estado_grabacion_gating_test.dart
  • test/servicios/servicio_compras_test.dart
  • test/servicios/servicio_anuncios_test.dart
  • test/servicios/navegacion_auto_gating_test.dart
  • test/servicios/servicio_audio_gating_test.dart
  • test/widgets/banner_anuncio_superior_test.dart

Test Files Modified (harness fixes — added ServicioAnuncios/EstadoEntitlement providers so pre-existing widget tests keep working against the new gated call sites)

  • test/servicios/navegacion_auto_test.dart (3 raiz() call sites get premium: true)
  • test/pantallas/pantalla_alarmas_fecha_test.dart
  • test/pantallas/pantalla_ajustes_test.dart
  • test/pantallas/pantalla_ajustes_row_values_test.dart
  • test/pantallas/pantalla_favoritos_test.dart
  • test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart
  • test/pantallas/pluri_screen_header_retired_test.dart
  • test/pantallas/root_header_wiring_test.dart
  • test/widgets/pluri_push_scaffold_test.dart

Deviations from Design (reported honestly)

  1. ADR-4 root/non-root reconciliation: design.md's ADR-4 prose ("keeps the same visible folder labels for free users") and the android-auto-media spec's literal "rendered as ... explicitly locked item labeled as a premium feature" (for the ROOT) point in slightly different directions. Followed design.md/the orchestrator's own constraint summary: ROOT keeps real folder labels for every tier (regression-safe, byte-identical to today for premium); the lock is enforced one level down, at getChildren's respuestaBloqueadaPorEntitlement choke point, which returns exactly one itemPremiumBloqueado() for ANY non-root id when free (including stale/deep-linked ids — the mandatory backstop).
  2. notifyChildrenChanged deprecated: audio_service 0.18.18 deprecated the static AudioService.notifyChildrenChanged helper in favor of a subscribeToChildren-stream-based mechanism. Implemented PluriWaveAudioHandler.subscribeToChildren (a BehaviorSubject per parent id) + notificarHijosCambiaron(id), which is what the plugin's own internal listener forwards to the platform. Functionally equivalent to the design's intent; the public hook name (registrarNotificacionDesbloqueoAuto/notificarDesbloqueoAuto) is unchanged.
  3. ADR-6 interstitial ordering — corrected mid-run: initially implemented the alarm interstitial at SAVE time; corrected to fire at the "+" CTA tap (before the editor sheet even opens), matching design.md's literal "puedeCrearAlarma -> ... maybe-interstitial, then open the editor" and mirroring the add-station CTA's identical ordering.
  4. Default esPremium callbacks in EstadoAlarmas/EstadoGrabacion/EstadoRadio default to () => true (ungated) when the caller doesn't inject one. This was necessary because 30+ pre-existing test files construct these classes with zero entitlement awareness and expect unrestricted (today's) behavior; production app.dart always wires the real EstadoEntitlement-backed callback. This is a deliberate, documented DI default, not a security gap — no production code path can reach the default.
  5. crearRangoVacaciones returns bool, not ResultadoGuardarAlarma — vacations are a full premium gate (no free allowance), semantically distinct from the alarm cap's count-based enum, which design.md's Interfaces/Contracts scoped to guardarAlarma specifically.
  6. PluriWaveApp gained an optional compras constructor param mirroring the existing fuenteAuto injection convention, so no pre-existing widget test ever touches the real in_app_purchase plugin channel; main.dart wires the real ServicioComprasPlayBilling.
  7. Paywall sheet copy stays minimal: HojaPremium reuses the existing l10n.equalizerActive string for "active" state (an established codebase pattern for reusable generic labels) rather than inventing new arb keys beyond the 4 explicitly scoped in tasks.md, to keep the 13-locale translation surface bounded.

Issues Found

  • dart format lib/ test/ (broad invocation) reformatted several pre-existing test files that were untouched semantically. These formatting-only diffs were identified via git diff --stat and reverted with git checkout -- to keep this change scoped to the feature (avoiding an unrelated multi-hundred-line formatting diff riding along in the single-commit delivery).
  • None outstanding beyond the above.

Work Unit Evidence (cumulative, final)

  • Focused test command and result: flutter test test/estado/estado_entitlement_test.dart test/estado/estado_alarmas_gating_test.dart test/estado/estado_grabacion_gating_test.dart test/servicios/servicio_compras_test.dart test/servicios/servicio_anuncios_test.dart test/servicios/navegacion_auto_gating_test.dart test/servicios/servicio_audio_gating_test.dart test/widgets/banner_anuncio_superior_test.dart48/48 passed.
  • Runtime harness: full regression suite run in batches — test/estado/ (207 passed), test/servicios/ (512 passed), test/widgets/ (96 passed), test/pantallas/ (~248+ across all 30 files, run in multiple batches, all passed after harness fixes), top-level (app_test.dart, arranque_orientacion_test.dart, assets_contenido_declarados_test.dart, widget_test.dart — 38 passed). A single flutter test full-suite invocation exceeds this environment's command timeout (~10 min); batched runs are the practical substitute and cover 100% of files. Manual on-device QA (Play Billing sandbox purchase, real AdMob rendering, car head-unit browse) is explicitly out of reach of this environment and remains outstanding — noted in proposal.md.
  • Rollback boundary: every file in the "Files Changed" table above is independently revertable; pubspec.yaml/AndroidManifest.xml revert re-comments both plugins per proposal.md's Rollback Plan (no migration, no schema change, versioned prefs key compra_premium_v1 is ignored by older builds).

Final Verification

  • flutter analyze: clean (5 issues, all pre-existing/unrelated: 2 deprecated_member_use on onReorder predating this change, 1 pre-existing unused_catch_stack, 1 pre-existing annotate_overrides info in estado_radio_test.dart).
  • dart format: applied to every file this change touches; unrelated pre-existing files swept up by a broad format invocation were reverted (see Issues Found).
  • Literal-encoding scan (Ã|Â|â€|<25FD>) on all 13 touched .arb files: clean except one PRE-EXISTING false positive (app_pt.arb's legitimate "REPETIÇÃO", unrelated to this change).
  • Equalizer regression check: zero esPremium/EstadoEntitlement/esPremiumPersistido references in estado_ecualizador.dart, servicio_ecualizador.dart, pantalla_ajustes_ecualizador.dart, ecualizador_widget.dart — confirmed via grep.