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.
117 lines
9.4 KiB
Markdown
117 lines
9.4 KiB
Markdown
# Design: Freemium unlock via one-time in-app purchase
|
|
|
|
## Technical Approach
|
|
|
|
One cross-cutting `EstadoEntitlement` notifier (idiomatic `EstadoIdioma` shape) plus a top-level prefs-lazy reader for headless callers. Gating is hybrid: UI CTAs open the paywall, state-layer choke points hold the authoritative check. Ads are a port + AdMob adapter; the banner is a layout sibling (never an overlay), the interstitial fires on a CTA's natural transition behind a frequency cap.
|
|
|
|
## Architecture Decisions
|
|
|
|
### ADR-1: Entitlement is a notifier plus a free function, not a singleton
|
|
|
|
**Choice**: `lib/estado/estado_entitlement.dart` exports `EstadoEntitlement extends ChangeNotifier` (optional injected `SharedPreferences`, key `compra_premium_v1`, `bool get esPremium`) **and** a top-level `Future<bool> esPremiumPersistido({SharedPreferences? prefs})` that reads the same key directly.
|
|
**Alternatives**: global singleton; passing the notifier into `PluriWaveAudioHandler`.
|
|
**Rationale**: `PluriWaveAudioHandler` registers before `runApp`, so no `BuildContext`/`Provider` exists. The free function mirrors `FuenteMusicaLocalAutoImpl._resolverPrefs()` (`musica_local_auto.dart:163`) — same convention, testable via `setMockInitialValues`, no lifecycle to leak.
|
|
|
|
### ADR-2: Purchase I/O behind a port
|
|
|
|
**Choice**: `PuertoCompras` abstraction (`comprar`, `restaurar`, `Stream<EventoCompra>`) with `ServicioComprasPlayBilling` as the only `in_app_purchase` call site; `EstadoEntitlement` takes `PuertoCompras?`.
|
|
**Alternatives**: calling `InAppPurchase.instance` from the notifier.
|
|
**Rationale**: matches `EstadoAlarmas(android: PuertoAlarmasAndroid)`; keeps Strict TDD viable with zero plugin channels in unit tests.
|
|
**Fail-open**: only `purchased`/`restored` writes `true`. Errors, timeouts and offline never write `false`; the persisted flag is the source of truth at cold start.
|
|
|
|
### ADR-3: Gate placement (4 gates)
|
|
|
|
| Gate | Authoritative check | UI paywall entry |
|
|
|---|---|---|
|
|
| Alarm cap > 5 | `EstadoAlarmas.guardarAlarma` (`estado_alarmas.dart:104`) | `_EditorAlarmaSheet` save + the add CTA in `pantalla_alarmas.dart` |
|
|
| Alarm vacations | `EstadoAlarmas.crearRangoVacaciones` (`:510`) | `pantalla_vacaciones.dart` — `vacation-add-header` + `_CtaAnadirRango` |
|
|
| Recording | `EstadoGrabacion.iniciar` (`estado_grabacion.dart:90`) | 3 call sites in `pantalla_reproductor.dart` |
|
|
| Android Auto | `getChildren` / `playFromMediaId` / `playFromSearch` / `skipToNext-Previous` in `servicio_audio.dart` | none (car never shows a purchase flow) |
|
|
|
|
The phone equalizer is **not** gated.
|
|
|
|
### ADR-4: Auto reduced mode = real root labels, locked children, locked switching
|
|
|
|
**Choice**: `ConstructorArbolAuto.raiz({required bool incluirMusicaLocal, required bool premium})` keeps the same visible folder labels for free users; `getChildren` resolves entitlement once via `esPremiumPersistido()` and, when free, returns exactly `[itemPremiumBloqueado()]` (non-playable, id `premium:info`, hardcoded Spanish label like every other car label) for **any** non-root `parentMediaId`. Station switching is additionally blocked at `playFromMediaId`, `playFromSearch`, `skipToNext`/`skipToPrevious` (no-op returns).
|
|
**Alternatives**: empty root; omitting the folders entirely.
|
|
**Rationale**: head units cache browse trees, so a stale `emisora:<uuid>` tap would bypass `getChildren` — the play-path gates are mandatory, not belt-and-braces. Keeping labels + one explicit locked item guarantees no blank list. Play/pause/stop of the already-playing station are untouched.
|
|
|
|
### ADR-5: Distinct alarm-limit signal
|
|
|
|
**Choice**: `guardarAlarma` returns `ResultadoGuardarAlarma { guardada, limiteAlcanzado }`; `_error` stays reserved for native scheduling failures. Pure query `bool puedeCrearAlarma` (count = `_alarmas.length`, enabled or not; edits of an existing id always pass).
|
|
**Rationale**: overloading `_error` would surface a limit as a scheduling failure in `app.dart`'s snackbar path. Grandfathering falls out for free — nothing is deleted, only new creation past 5 is refused.
|
|
|
|
### ADR-6: Banner reserves layout; interstitial is cap-checked first
|
|
|
|
**Choice**: In `_PaginaPrincipalState.build`, `body:` becomes `Column[ SafeArea(bottom:false, child: BannerAnuncioSuperior), Expanded(existing SafeArea+AnimatedSwitcher) ]`. Premium or unloaded ⇒ `SizedBox.shrink()` (zero layout impact). Never a `Stack`/overlay.
|
|
**Interstitial ordering (add-alarm)**: `puedeCrearAlarma` → if false, show the limit message and **no ad**; if true, maybe-interstitial, then open the editor. Add-station: interstitial on the CTA tap, before `FormularioEmisoraPersonalizada` opens.
|
|
**Frequency cap**: in-memory in `ServicioAnuncios` — max 2 interstitials per process lifetime and ≥3 min apart; over cap ⇒ silent no-op.
|
|
**Rationale**: an ad followed by "you can't create this" is both hostile and an AdMob disruptive-ad policy risk.
|
|
|
|
## Data Flow
|
|
|
|
Play Billing ──→ PuertoCompras ──→ EstadoEntitlement ──→ prefs(compra_premium_v1)
|
|
│ │
|
|
UI (Provider.watch)┘ │
|
|
▼
|
|
PluriWaveAudioHandler.getChildren ──→ esPremiumPersistido() ──────┘ (no Provider)
|
|
|
|
## File Changes
|
|
|
|
| File | Action | Description |
|
|
|---|---|---|
|
|
| `lib/estado/estado_entitlement.dart` | Create | Notifier + `esPremiumPersistido()` |
|
|
| `lib/servicios/servicio_compras.dart` | Create | `PuertoCompras` + Play Billing adapter |
|
|
| `lib/servicios/servicio_anuncios.dart` | Create | Banner/interstitial port + AdMob adapter + frequency cap |
|
|
| `lib/widgets/banner_anuncio_superior.dart` | Create | Entitlement-aware banner slot |
|
|
| `lib/widgets/hoja_premium.dart` | Create | Paywall sheet, reused by every gate |
|
|
| `lib/app.dart` | Modify | Provider registration + banner Column |
|
|
| `lib/estado/estado_alarmas.dart` | Modify | `puedeCrearAlarma`, `ResultadoGuardarAlarma`, vacation gate |
|
|
| `lib/estado/estado_grabacion.dart` | Modify | Recording gate in `iniciar` |
|
|
| `lib/servicios/navegacion_auto.dart` | Modify | `raiz(premium:)`, `itemPremiumBloqueado()` |
|
|
| `lib/servicios/servicio_audio.dart` | Modify | Entitlement gate in browse + play paths |
|
|
| `lib/pantallas/pantalla_ajustes.dart` | Modify | Purchase + restore rows |
|
|
| `lib/pantallas/pantalla_alarmas.dart`, `pantalla_vacaciones.dart`, `pantalla_reproductor.dart`, `pantalla_favoritos.dart`, `ajustes/pantalla_ajustes_emisoras_personalizadas.dart` | Modify | Contextual upsell / interstitial trigger |
|
|
| `pubspec.yaml` | Modify | Activate `in_app_purchase`, `google_mobile_ads` |
|
|
| `lib/l10n/app_*.arb` | Modify | Paywall, limit message, restore strings |
|
|
|
|
## Interfaces / Contracts
|
|
|
|
```dart
|
|
class EstadoEntitlement extends ChangeNotifier {
|
|
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras});
|
|
static const idProducto = 'pluriwave_premium';
|
|
bool get esPremium;
|
|
bool get compraEnCurso;
|
|
Future<void> comprar();
|
|
Future<void> restaurar();
|
|
}
|
|
Future<bool> esPremiumPersistido({SharedPreferences? prefs});
|
|
enum ResultadoGuardarAlarma { guardada, limiteAlcanzado }
|
|
```
|
|
|
|
## Testing Strategy
|
|
|
|
| Layer | What to Test | Approach |
|
|
|---|---|---|
|
|
| Unit | Entitlement persistence, fail-open on error, restore | Fake `PuertoCompras` + `setMockInitialValues` |
|
|
| Unit | `puedeCrearAlarma` at 4/5/6, edit-at-cap, vacations, recording | `EstadoAlarmas(prefs:)`/`EstadoGrabacion` directly |
|
|
| Unit | `raiz(premium:false)`, locked-child for every parent id, play-path no-ops | Pure `ConstructorArbolAuto` + handler fakes |
|
|
| Unit | Interstitial cap (2/session, 3 min) and cap-before-ad ordering | Fake clock in `ServicioAnuncios` |
|
|
| Widget | Banner absent when premium; no overlap on all 5 tabs | `pumpWidget(PluriWaveApp(prefs:))` + golden-free layout asserts |
|
|
| Widget | Limit message with secondary unlock action, paywall from each gate | Existing `pantalla_*_test.dart` conventions |
|
|
|
|
## Threat Matrix
|
|
|
|
N/A — no routing, shell, subprocess, VCS/PR automation, executable-file classification, or process-integration boundary. Android Auto media-id dispatch is pre-existing in-process routing, not shell/process execution.
|
|
|
|
## Migration / Rollout
|
|
|
|
No migration. Additive and prefs-backed; absent key = free. Revert by re-commenting both plugins and reverting the gate commits. Versioned key (`compra_premium_v1`) is ignored by older builds.
|
|
|
|
## Open Questions
|
|
|
|
- [ ] Price point (Play Console decision).
|
|
- [ ] AdMob ad unit IDs (banner + interstitial) not yet provisioned; test IDs until then.
|
|
- [x] ~~Should a cached head-unit tree be actively invalidated (`notifyChildrenChanged`) at purchase time, or is the next browse refresh enough?~~ **RESOLVED (orchestrator): actively invalidate.** On the entitlement transition to premium, call `notifyChildrenChanged` for the affected parent ids. Rationale: the same head-unit caching that forces the `playFromMediaId` guard in ADR-4 also means a purchaser would otherwise keep seeing the locked tree until the unit re-binds — plausibly the rest of the drive. A user who just paid and still sees "Premium feature" in the car reads that as a broken purchase, which is a refund and a one-star review. Relying on the next browse refresh trades a cheap, bounded call for a highly visible failure. The invalidation is one-directional and only fires on the free → premium transition; there is no premium → free transition to handle (the purchase is permanent and entitlement never writes `false`, per ADR-2).
|