Files
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

81 lines
6.0 KiB
Markdown

# Tasks: Freemium unlock via one-time in-app purchase
## Review Workload Forecast
Estimated changed lines: 1200-2000+ (5 new, ~12 modified Dart, 13 `.arb` locales, pubspec.yaml, AndroidManifest.xml, plus tests).
Suggested split: single PR now (`single-pr`); Work Units below double as chained-PR slices if `size:exception` is declined.
Delivery strategy: single-pr.
Decision needed before apply: Yes
Chained PRs recommended: Yes
Chain strategy: size-exception
400-line budget risk: High
Deferred, non-blocking: price point (Play Console); AdMob ad unit IDs — use Google test IDs. Do not invent values.
### Suggested Work Units
| Unit | Goal | Focused test command | Runtime harness | Rollback boundary |
|---|---|---|---|---|
| 1 | Entitlement + purchase I/O | `flutter test test/estado/estado_entitlement_test.dart test/servicios/servicio_compras_test.dart` | Manual: Settings > Restaurar compras | `estado_entitlement.dart`, `servicio_compras.dart` |
| 2 | Alarm, recording, Auto gates + cache invalidation | `flutter test test/estado/estado_alarmas_test.dart test/estado/estado_grabacion_test.dart test/servicios/navegacion_auto_test.dart test/servicios/servicio_audio_test.dart` | Auto head-unit browse smoke | gate diffs in `estado_alarmas.dart`, `estado_grabacion.dart`, `navegacion_auto.dart`, `servicio_audio.dart` |
| 3 | Ads (banner + interstitial) | `flutter test test/servicios/servicio_anuncios_test.dart test/widgets/banner_anuncio_superior_test.dart` | Manual: banner/no-overlap 5 tabs | `servicio_anuncios.dart`, `banner_anuncio_superior.dart`, `app.dart` Column diff |
| 4 | Paywall UI + localization | `flutter test test/pantallas/pantalla_ajustes_test.dart && flutter gen-l10n` | Manual: tap each gate | `hoja_premium.dart`, screen CTA diffs, `app_*.arb` keys |
## Phase 0: Foundation
- [x] 0.1 Uncomment `in_app_purchase`/`google_mobile_ads` in `pubspec.yaml`; `flutter pub get`.
- [x] 0.2 Add AdMob test app ID to `AndroidManifest.xml`.
## Phase 1: Entitlement Core
- [x] 1.1 RED `estado_entitlement_test.dart`: default free; persisted true; fail-open on failure; `esPremiumPersistido()` headless, no `BuildContext`.
- [x] 1.2 GREEN `estado_entitlement.dart`: `EstadoEntitlement` `ChangeNotifier` (key `compra_premium_v1`) + `esPremiumPersistido()`.
- [x] 1.3 REFACTOR: shared prefs-key constant; document fail-open contract.
## Phase 2: Purchase I/O
- [x] 2.1 RED `servicio_compras_test.dart`: `comprar()` success/cancel/idempotent; `restaurar()` found/not-found, no error.
- [x] 2.2 GREEN `servicio_compras.dart`: `PuertoCompras` + `ServicioComprasPlayBilling` (sole `in_app_purchase` site); wire `comprar/restaurar`.
## Phase 3: Alarm Gating
- [x] 3.1 RED `estado_alarmas_gating_test.dart`: `puedeCrearAlarma` 4/5/6; 6th blocked pre-schedule; edit-at-cap ok; premium uncapped; 8 preexisting grandfathered, 9th blocked; vacations free-blocked/premium-ok.
- [x] 3.2 GREEN `estado_alarmas.dart`: `ResultadoGuardarAlarma` enum, `puedeCrearAlarma`, gate `guardarAlarma`(:104)+`crearRangoVacaciones`(:510).
- [x] 3.3 GREEN `pantalla_alarmas.dart`/`_EditorAlarmaSheet` + `pantalla_vacaciones.dart`: cap message + "Desbloquear Premium" CTA; vacation upsell.
## Phase 4: Recording Gating
- [x] 4.1 RED `estado_grabacion_gating_test.dart`: `iniciar()` blocked free/allowed premium; existing recordings stay free.
- [x] 4.2 GREEN `estado_grabacion.dart`: gate `iniciar()`(:90); upsell at 3 sites in `pantalla_reproductor.dart`.
## Phase 5: Android Auto Gating
- [x] 5.1 RED `navegacion_auto_gating_test.dart`: `raiz(premium:false)` non-blank tree with the real folder labels (design ADR-4: root labels stay visible for every tier, lock enforced one level down); `respuestaBloqueadaPorEntitlement(non-root,free)->[itemPremiumBloqueado()]`; premium unchanged (regression).
- [x] 5.2 RED `servicio_audio_gating_test.dart`: `debeBloquearCambioDeEmisora` free/premium; stale-id backstop wired into `playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious`.
- [x] 5.3 RED: free->premium transition invokes the registered Auto-invalidation hook (`registrarNotificacionDesbloqueoAuto`/`notificarDesbloqueoAuto`), which pushes to `PluriWaveAudioHandler.subscribeToChildren`'s per-id `BehaviorSubject`s (the current non-deprecated `audio_service` API — the plugin's OWN internal listener forwards each push to the platform's `notifyChildrenChanged`).
- [x] 5.4 GREEN: `raiz(premium:)`+`itemPremiumBloqueado()`+`respuestaBloqueadaPorEntitlement` (`navegacion_auto.dart`); gate `getChildren`/`playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious` + `subscribeToChildren`/`notificarHijosCambiaron` wiring (`servicio_audio.dart`).
## Phase 6: Ads
- [x] 6.1 RED `servicio_anuncios_test.dart`: cap 2/session >=3min (fake clock); over-cap no-op; suppressed with alarm-cap message; none when premium.
- [x] 6.2 GREEN `servicio_anuncios.dart`: banner/interstitial port + AdMob adapter (test ad unit IDs) + frequency cap.
- [x] 6.3 RED+GREEN `banner_anuncio_superior.dart` + `app.dart`: shrink when premium/unloaded, no overlap 5 tabs; `Column[banner, Expanded(body)]`, never `Stack`.
## Phase 7: Purchase UI Wiring
- [x] 7.1 GREEN `hoja_premium.dart` (paywall sheet) + `app.dart`: register `EstadoEntitlement` Provider.
- [x] 7.2 GREEN `pantalla_ajustes.dart`: buy/restore/premium-active row; `pantalla_favoritos.dart` + `ajustes_emisoras_personalizadas.dart`: interstitial before manual station add.
## Phase 8: Localization (13 locales, `app_es.arb` template)
- [x] 8.1 Add keys (`funcionPremium`, `limiteAlarmasAlcanzado`, `desbloquearPremium`, `restaurarCompras`) to `app_es.arb`; translate into 12 remaining locales.
- [x] 8.2 Run `flutter gen-l10n`; verify `AppLocalizations` getters generated.
- [x] 8.3 Run literal-encoding scan on `lib/l10n/app_*.arb` — zero mojibake (only pre-existing "REPETIÇÃO" false positive, unrelated to this change).
## Phase 9: Verification
- [x] 9.1 Run full suite; confirm every RED test above is GREEN.
- [x] 9.2 Regression-check: phone equalizer has zero entitlement checks.
- [x] 9.3 Update `proposal.md` Success Criteria checkboxes.