# 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.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 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`.