- 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)
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_sessionfor audio focus, remove untestable statics, move localization out ofbuild(), inject a single cached SharedPreferences, guard per-minute writes, prune the unbounded set, add an in-memory alarm cache. EstadoRadiodecomposition into focusedChangeNotifiers + an export/import service, withcontext.select/Consumerscoping 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.yamland 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.dartbeyond extracting the backup/import logic. - Running
flutter build(project constraint); the user runs builds to validate the Kotlin layer.
Approach and rationale
- 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.
- 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.
- 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.
- Decomposition fourth. Slice 4 is the largest and riskiest refactor; it runs only after seams exist and reliability/UX are stable, minimizing blast radius.
- Polish fifth. Slice 5 is low-risk, parallelizable design/a11y/i18n work that benefits from the settled structure.
- 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
alarmtoforegroundServiceType(mediaPlayback|alarm) and theFOREGROUND_SERVICE_ALARMpermission (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
AudioAttributeson the fire channels at creation — native channel setup. - Pass
emisoraFallbackthrough the MethodChannel into the KotlinNativeAlarmSpec; attempt a secondprepareAsyncon 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
PantallaAlarmaSonandowired toEstadoAlarmas.posponerAlarma, coordinating_fadeInTimercancel and_fallbackPlayerstop, native service stop, and screen dismiss —pantalla_alarma_sonando.dart:168-212. - Migrate the ringing screen to
PluriWaveScaffoldwith an entry animation. - Next-trigger preview inside
_EditorAlarmaSheet. - Replace the station
DropdownButtonFormFieldwith 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_sessionfor audio-focus handling so calls pause the radio (pubspec.yaml:19, currently never imported). - Replace static
StreamController+_handlerInstaladowith injectable instance fields —servicio_alarmas_android.dart:117-119. - Move
configurarLocalizacionesout ofMiniReproductor.build()—mini_reproductor.dart:23. - Inject a single cached
SharedPreferencesat startup (replaces 25+getInstance()calls). - Guard
recalcularTodas()writes behind a change flag —estado_alarmas.dart:316-323. - Prune the unbounded
_ejecucionesEmitidasset —estado_alarmas.dart:32. - Add an in-memory cache to
ServicioAlarmasto 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,EstadoBusquedaChangeNotifiers + aServicioExportImportfrom the 1121-lineestado_radio.dart. - Replace root
context.watch<EstadoRadio>()inPantallaInicio/Ajustes/Favoritoswithcontext.select/Consumerscopes —pantalla_inicio.dart:43and 6 sites inpantalla_ajustes. - Move backup/import
jsonDecode/jsonEncodelogic out ofpantalla_ajustes.dart(1391 lines) intoServicioExportImport.
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. Semanticson the grid favorite button +semanticLabelon_AssetIcon/ alarm images —tarjeta_emisora.dart:238-289.- Central reduced-motion guard (
PluriAnimateextension honoringMediaQuery.disableAnimations). - Locale-aware
_fechaCortaviaintlDateFormat—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):ServicioAlarmasconcurrent read-modify-write, alarm fire dedup acrossrefrescarProgramacion,PluriWaveAudioHandlerrapid source-switch race, export/import round-trip,ServicioGrabacionRadioerror 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
userIntenttracking inPluriWaveAudioHandlerto distinguish user-initiated pause/stop from network stalls. - Add bounded exponential-backoff reconnection for network-class errors (
PlayerExceptioncodes 2xxx) whenuserIntent == playing. Default: 5 retries, base 1 s, max 30 s. - Surface
EstadoReproduccion.cargandoduring 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 (ServicioGrabacionRadiomanages its own stream), sleep-timer fade-out. - Unit tests required (strict TDD): backoff delay computation,
userIntenttransitions, 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
EstadoRadiogetters 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 viasetAlarmClockand 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(). EstadoRadiono 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 passflutter 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.