Files
pluriwave/openspec/changes/app-quality-and-native-alarms/proposal.md
T
FreeTLab f3e9487215 feat(alarms): native reliability fixes and end-to-end snooze
- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK)
- Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed
- Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels
- Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV
- Native fade-in volume ramp honoring fadeInSegundos when the app is killed
- Request battery-optimization exemption once, tracked with a persisted asked-once flag
- Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze
- Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown
- Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper)
- Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0
- New alarm strings localized across all 13 locales
- New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green)
- SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
2026-06-11 15:33:30 +02:00

167 lines
14 KiB
Markdown

# Proposal: app-quality-and-native-alarms
Raise PluriWave to native-Android-Clock alarm reliability and UX, then pay down the architecture, accessibility, and quality debt that surfaced around it. The custom native alarm stack is the right design and is KEPT; this change closes its gaps and hardens the app around it. Work ships as six chained, PR-sized slices, each under 400 changed lines, ordered by user-facing risk.
## Intent
**Problem.** The alarm feature can fail silently on Android 14+ (missing foreground-service type), posts duplicate fire notifications, ignores the configured alarm sound at the channel level, drops the fallback station on the native path, and offers no snooze on its own ringing screen. Around it, the app carries runtime debt: a declared-but-unused `audio_session` (so calls do not pause the radio), a `build()`-time localization call firing dozens of times per second, untestable statics, a 1121-line god-class, and unguarded per-minute SharedPreferences writes. UI/UX has 14+ hardcoded colors, missing accessibility semantics, no reduced-motion handling, and locale-breaking date formatting.
**Why now.** Android 14+ already makes the foreground-service gap a silent production failure (CRITICAL). The alarm is the highest-trust feature in the app — when a user sets an alarm they expect to wake up — so reliability cannot wait. The architecture debt directly amplifies alarm risk (state divergence, races, swallowed errors), so fixing it is part of the same arc, not a separate cleanup.
**Success looks like.** Alarms fire reliably on Android 14+, present a single notification, sound with the configured alarm audio, honor the fallback station and fade-in natively, and offer snooze that stays consistent with `EstadoAlarmas`. The radio pauses for phone calls. No localization call runs inside `build()`. `EstadoRadio` is decomposed into focused notifiers. The design system, accessibility, i18n, and lint gates close the highlighted gaps, backed by the top-5 missing tests under strict TDD.
## Scope (in scope)
- Android native alarm reliability: foreground-service type + permission, notification dedup, channel-level alarm sound, fallback station over the MethodChannel, battery-optimization exemption request, native fade-in.
- Alarm UX parity: snooze on the ringing screen, scaffold/animation migration, next-trigger preview, searchable station picker, configurable snooze duration, volume floor adjustment.
- Runtime robustness: integrate `audio_session` for audio focus, remove untestable statics, move localization out of `build()`, inject a single cached SharedPreferences, guard per-minute writes, prune the unbounded set, add an in-memory alarm cache.
- `EstadoRadio` decomposition into focused `ChangeNotifier`s + an export/import service, with `context.select`/`Consumer` scoping at the consuming screens.
- Design-system / accessibility / i18n pass: color tokens, semantics, reduced-motion guard, locale-aware dates, pluralization, shimmer/icon consistency, brand notification color.
- Quality gates: hardened `analysis_options.yaml` and the top-5 missing tests.
## Out of scope
- Light theme / theming beyond the existing dark design (explicitly out unless requested).
- iOS reliable-alarm parity (Android-first, unchanged from `alarm-clock-module`).
- Replacing the native alarm stack with any plugin (`alarm`, `android_alarm_manager_plus` + `flutter_local_notifications`, `awesome_notifications`) — evaluated and rejected.
- Cloud sync of alarms or preferences.
- New alarm capabilities (dismiss challenges, multi-fallback chains, smart/adaptive alarms).
- Full rewrite of `pantalla_ajustes.dart` beyond extracting the backup/import logic.
- Running `flutter build` (project constraint); the user runs builds to validate the Kotlin layer.
## Approach and rationale
1. **Reliability before everything.** Slice 1 ships the native fixes that prevent silent failure and state divergence. Highest user trust, smallest safe footprint, no dependency on later refactors.
2. **UX parity next, on the now-reliable base.** Slice 2 adds snooze and editor improvements once the underlying behavior is correct, so UI never papers over a broken native path.
3. **Robustness third.** Slice 3 introduces test seams (injected SharedPreferences, instance fields, audio session) that later slices and tests depend on. It deliberately precedes the god-class split so the split lands on testable foundations.
4. **Decomposition fourth.** Slice 4 is the largest and riskiest refactor; it runs only after seams exist and reliability/UX are stable, minimizing blast radius.
5. **Polish fifth.** Slice 5 is low-risk, parallelizable design/a11y/i18n work that benefits from the settled structure.
6. **Gates last.** Slice 6 hardens lints and writes the top-5 tests, locking in the prior slices and catching regressions. Strict TDD means tests in Slice 6 (and seams from Slice 3) drive behavior, not follow it.
Chaining: slices are sequential PRs. Each PR targets the previous slice's branch (or main per the cached chain strategy) and stays under 400 changed lines so reviewers verify one unit of work at a time.
## Work breakdown — chained PR-sized slices
### Slice 1 — Alarm native reliability (CRITICAL, ship first)
Risk: CRITICAL. Effort: M. Est. < 350 lines (Kotlin + manifest + Dart bridge).
- Add `alarm` to `foregroundServiceType` (`mediaPlayback|alarm`) and the `FOREGROUND_SERVICE_ALARM` permission (fixes Android 14+ silent failure) — `AndroidManifest.xml`, `PluriWaveAlarmService.kt`.
- Deduplicate fire notifications: keep the service FSI (`NOTIFICATION_ID 92841`) as the single source; stop the receiver from posting its own — `PluriWaveAlarmReceiver.kt`, `PluriWaveAlarmService.kt`.
- Set channel-level sound with alarm `AudioAttributes` on the fire channels at creation — native channel setup.
- Pass `emisoraFallback` through the MethodChannel into the Kotlin `NativeAlarmSpec`; attempt a second `prepareAsync` on the fallback — `servicio_alarmas_android.dart`, `NativeAlarmSpec`.
- Request battery-optimization exemption inside `_solicitarPermisosNecesariosParaAlarma`.
- Native-side fade-in matching Dart `fadeInSegundos`.
### Slice 2 — Alarm UX parity with native Android Clock
Risk: HIGH. Effort: M. Est. < 380 lines.
> **Snooze correctness is in full scope.** This slice audits the entire native snooze
> path (AlarmScheduler.snooze → setAlarmClock registration, notification "Posponer"
> action while app killed, Flutter state sync on resume via MethodChannel event — not
> waiting for the 60-second poll). The goal is end-to-end correctness, not just adding
> UI buttons.
- Snooze buttons (3/5/10 + configured default) on `PantallaAlarmaSonando` wired to `EstadoAlarmas.posponerAlarma`, coordinating `_fadeInTimer` cancel and `_fallbackPlayer` stop, native service stop, and screen dismiss — `pantalla_alarma_sonando.dart:168-212`.
- Migrate the ringing screen to `PluriWaveScaffold` with an entry animation.
- Next-trigger preview inside `_EditorAlarmaSheet`.
- Replace the station `DropdownButtonFormField` with a searchable bottom-sheet picker.
- Configurable snooze duration.
- Lower the volume-slider floor from 0.25 toward 0.
### Slice 3 — Audio / runtime robustness (test seams)
Risk: HIGH. Effort: M-L. Est. < 400 lines.
- Integrate `audio_session` for audio-focus handling so calls pause the radio (`pubspec.yaml:19`, currently never imported).
- Replace static `StreamController` + `_handlerInstalado` with injectable instance fields — `servicio_alarmas_android.dart:117-119`.
- Move `configurarLocalizaciones` out of `MiniReproductor.build()``mini_reproductor.dart:23`.
- Inject a single cached `SharedPreferences` at startup (replaces 25+ `getInstance()` calls).
- Guard `recalcularTodas()` writes behind a change flag — `estado_alarmas.dart:316-323`.
- Prune the unbounded `_ejecucionesEmitidas` set — `estado_alarmas.dart:32`.
- Add an in-memory cache to `ServicioAlarmas` to kill the read-modify-write N+1 race — `servicio_alarmas.dart:81-108`.
### Slice 4 — EstadoRadio god-class split (LARGE)
Risk: HIGH (broad surface). Effort: L. May split into 4a/4b if forecast exceeds 400 lines.
- Extract `EstadoEcualizador`, `EstadoGrabacion`, `EstadoBusqueda` `ChangeNotifier`s + a `ServicioExportImport` from the 1121-line `estado_radio.dart`.
- Replace root `context.watch<EstadoRadio>()` in `PantallaInicio` / `Ajustes` / `Favoritos` with `context.select` / `Consumer` scopes — `pantalla_inicio.dart:43` and 6 sites in `pantalla_ajustes`.
- Move backup/import `jsonDecode`/`jsonEncode` logic out of `pantalla_ajustes.dart` (1391 lines) into `ServicioExportImport`.
### Slice 5 — Design system, a11y, i18n pass
Risk: LOW. Effort: M. Parallelizable internally; est. < 350 lines.
- Replace 14+ hardcoded `Color(0x...)` literals with tokens — see explore C3 sites.
- `Semantics` on the grid favorite button + `semanticLabel` on `_AssetIcon` / alarm images — `tarjeta_emisora.dart:238-289`.
- Central reduced-motion guard (`PluriAnimate` extension honoring `MediaQuery.disableAnimations`).
- Locale-aware `_fechaCorta` via `intl` `DateFormat``pantalla_alarmas.dart:1114`.
- Pluralization for bare counters — `pantalla_favoritos.dart:138`.
- Rounded shimmer placeholders + shimmer in `PantallaBuscar``tarjeta_emisora.dart:389-420`, `pantalla_buscar.dart:241-245`.
- Icon variant consistency (`_rounded`) — `pantalla_ajustes.dart:985,1028,1031`.
- Brand `notificationColor``main.dart:23`.
### Slice 6 — Quality gates
Risk: LOW. Effort: M. Est. < 350 lines (mostly tests).
- Harden `analysis_options.yaml`: `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls`.
- New tests (strict TDD, `flutter test`): `ServicioAlarmas` concurrent read-modify-write, alarm fire dedup across `refrescarProgramacion`, `PluriWaveAudioHandler` rapid source-switch race, export/import round-trip, `ServicioGrabacionRadio` error recovery.
### Slice 7 — Streaming resilience
Risk: MEDIUM. Effort: M. Est. < 380 lines (Dart only — no Kotlin changes).
The app uses `just_audio` (ExoPlayer on Android) via `PluriWaveAudioHandler`
(`lib/servicios/servicio_audio.dart`). The current implementation creates a fresh
`AudioPlayer` on every source switch (`_recrearPlayer`) and surfaces errors immediately
to the UI without any retry logic. A short network hiccup (a few seconds) therefore
causes an immediate error state and requires the user to manually re-tap play.
- Configure an enlarged ExoPlayer live-stream buffer (targeting ~15-30 s of buffered
content) so brief network drops do not interrupt audible playback.
- Introduce `userIntent` tracking in `PluriWaveAudioHandler` to distinguish user-initiated
pause/stop from network stalls.
- Add bounded exponential-backoff reconnection for network-class errors (`PlayerException`
codes 2xxx) when `userIntent == playing`. Default: 5 retries, base 1 s, max 30 s.
- Surface `EstadoReproduccion.cargando` during reconnect attempts; surface the error only
after retries are exhausted — no dialog spam for transient drops.
- Must not regress: alarm audio path (native `PluriWaveAlarmService`), recording
(`ServicioGrabacionRadio` manages its own stream), sleep-timer fade-out.
- Unit tests required (strict TDD): backoff delay computation, `userIntent` transitions,
reconnect suppressed on user stop, error emitted after max retries.
## Risks
| Risk | Severity | Mitigation |
|------|----------|------------|
| Kotlin layer has never been compiled (`flutter build` never run per `alarm-clock-module` apply-progress). Slice 1/3 native edits may not compile. | HIGH | Keep native edits surgical; ask the user to run `flutter build` after Slice 1 before chaining further; do not run build ourselves (project constraint). |
| Android 14+ foreground-service behavior is version- and OEM-sensitive. | HIGH | Pair the `alarm` type with the matching permission exactly; verify against API 34 docs in design phase. |
| Channel sound is locked at channel creation; changing it may require recreating channels and could reset user notification settings. | MEDIUM | Design the channel-id/versioning strategy in the design phase; document the migration. |
| `EstadoRadio` split (Slice 4) has broad blast radius across screens. | HIGH | Land only after Slice 3 seams exist; keep backward-compatible getters; split into 4a/4b if the forecast exceeds 400 lines. |
| Injecting SharedPreferences touches many constructors. | MEDIUM | Provide backward-compatible defaults; introduce the injection in Slice 3 with tests. |
| Hero transition with `BackdropFilter` can flicker without a `HeroFlightShuttleBuilder`. | MEDIUM | Treat Hero work carefully in Slice 2/5; provide an explicit shuttle builder. |
| `alarm-clock-module` state drift (`tasks-ready` vs. existing apply-progress). | LOW | Reconcile at this change's archive time; out of scope to fix mid-flight. |
## Rollback plan
- Each slice is an independent PR; revert the slice's commit/branch to roll back without touching others.
- Slice 1 native changes are additive (manifest attributes/permission, channel config, an extra MethodChannel field); reverting restores the prior — but Android-14-broken — behavior, so prefer fixing forward.
- Slice 3 SharedPreferences injection uses backward-compatible defaults, so a partial revert leaves the app functional.
- Slice 4 keeps backward-compatible `EstadoRadio` getters during extraction; if a screen regresses, revert that screen's scoping commit independently.
## Success criteria
- [ ] Alarm fires on Android 14+ without `ForegroundServiceTypeException` (manual user build verification).
- [ ] Exactly one fire notification is posted per alarm event.
- [ ] Fire channels sound with alarm `AudioAttributes`; fallback station is used when the primary fails on the native path.
- [ ] Ringing screen offers 3/5/10 snooze that stays consistent with `EstadoAlarmas` (no divergence after sync). Snooze from the native notification while app killed also reschedules via `setAlarmClock` and syncs Flutter state on resume without waiting for the 60-second poll.
- [ ] Phone calls / other audio-focus events pause the radio.
- [ ] No localization call runs inside any `build()`.
- [ ] `EstadoRadio` no longer owns EQ / recording / search state; consuming screens use scoped rebuilds.
- [ ] All highlighted hardcoded colors replaced by tokens; favorite button and alarm images expose semantics; reduced-motion respected.
- [ ] Hardened lint set passes `flutter analyze`; the top-5 tests pass `flutter test`.
- [ ] Brief network drops (up to ~15-30 s) do not interrupt radio playback; automatic reconnection with bounded backoff recovers silently; alarm audio, recording, and sleep-timer paths are unaffected.