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

14 KiB

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 ChangeNotifiers + 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 ChangeNotifiers + 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 DateFormatpantalla_alarmas.dart:1114.
  • Pluralization for bare counters — pantalla_favoritos.dart:138.
  • Rounded shimmer placeholders + shimmer in PantallaBuscartarjeta_emisora.dart:389-420, pantalla_buscar.dart:241-245.
  • Icon variant consistency (_rounded) — pantalla_ajustes.dart:985,1028,1031.
  • Brand notificationColormain.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.