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.
14 KiB
14 KiB
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 getpremium: 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)
- 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'srespuestaBloqueadaPorEntitlementchoke point, which returns exactly oneitemPremiumBloqueado()for ANY non-root id when free (including stale/deep-linked ids — the mandatory backstop). notifyChildrenChangeddeprecated:audio_service0.18.18 deprecated the staticAudioService.notifyChildrenChangedhelper in favor of asubscribeToChildren-stream-based mechanism. ImplementedPluriWaveAudioHandler.subscribeToChildren(aBehaviorSubjectper 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.- 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.
- Default
esPremiumcallbacks inEstadoAlarmas/EstadoGrabacion/EstadoRadiodefault 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; productionapp.dartalways wires the realEstadoEntitlement-backed callback. This is a deliberate, documented DI default, not a security gap — no production code path can reach the default. crearRangoVacacionesreturnsbool, notResultadoGuardarAlarma— 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 toguardarAlarmaspecifically.PluriWaveAppgained an optionalcomprasconstructor param mirroring the existingfuenteAutoinjection convention, so no pre-existing widget test ever touches the realin_app_purchaseplugin channel;main.dartwires the realServicioComprasPlayBilling.- Paywall sheet copy stays minimal:
HojaPremiumreuses the existingl10n.equalizerActivestring 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 viagit diff --statand reverted withgit 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.dart→ 48/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 singleflutter testfull-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 inproposal.md. - Rollback boundary: every file in the "Files Changed" table above is independently revertable;
pubspec.yaml/AndroidManifest.xmlrevert re-comments both plugins perproposal.md's Rollback Plan (no migration, no schema change, versioned prefs keycompra_premium_v1is ignored by older builds).
Final Verification
flutter analyze: clean (5 issues, all pre-existing/unrelated: 2deprecated_member_useononReorderpredating this change, 1 pre-existingunused_catch_stack, 1 pre-existingannotate_overridesinfo inestado_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.arbfiles: clean except one PRE-EXISTING false positive (app_pt.arb's legitimate "REPETIÇÃO", unrelated to this change). - Equalizer regression check: zero
esPremium/EstadoEntitlement/esPremiumPersistidoreferences inestado_ecualizador.dart,servicio_ecualizador.dart,pantalla_ajustes_ecualizador.dart,ecualizador_widget.dart— confirmed viagrep.