f3e9487215
- 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)
283 lines
43 KiB
Markdown
283 lines
43 KiB
Markdown
# Design: app-quality-and-native-alarms
|
||
|
||
Technical design for the seven chained slices that raise PluriWave to native-Android-Clock alarm reliability, fix the snooze divergence, harden the audio/runtime layer, add streaming resilience, split the `EstadoRadio` god-class, and close the design-system / a11y / i18n / quality-gate gaps. The custom native alarm stack is KEPT and hardened. Every decision below is grounded in the actual code (`file:line`) so `sdd-tasks` can act without re-discovery.
|
||
|
||
This document is the HOW at the architectural level. It does not enumerate task steps (that is `sdd-tasks`). `flutter build` is FORBIDDEN; Kotlin is verified on-device by the user. Strict TDD applies via `flutter test`.
|
||
|
||
## Architecture at a glance
|
||
|
||
| Layer | Owns | Key files |
|
||
|-------|------|-----------|
|
||
| Flutter domain | Alarm data, recurrence math, persistence, single source of truth for snooze state | `servicio_alarmas.dart`, `servicio_programacion_alarmas.dart`, `estado_alarmas.dart` |
|
||
| Flutter audio | Radio playback, EQ, recording, reconnect-on-stall, audio focus | `servicio_audio.dart` (`PluriWaveAudioHandler`), new `ServicioAudioSession` |
|
||
| Flutter UI | Ringing screen, editor, mini player, screens | `pantalla_alarma_sonando.dart`, `pantalla_alarmas.dart`, `mini_reproductor.dart` |
|
||
| Bridge | MethodChannel `pluriwave/alarm_scheduler` (Flutter↔native), `alarmFired` callback (native→Flutter) | `servicio_alarmas_android.dart`, `MainActivity.kt` |
|
||
| Native delivery | Exact wakeup (`setAlarmClock`), FSI notification, native audio + fade, snooze reschedule | `AlarmScheduler.kt`, `PluriWaveAlarmReceiver.kt`, `PluriWaveAlarmService.kt`, `AndroidManifest.xml` |
|
||
|
||
The guiding principle for this change: **the Flutter `ServicioAlarmas` config is the single source of truth for "postponed until"**. Native reschedules autonomously for wakeup reliability, but every native state mutation that changes the next occurrence MUST be reflected back to Flutter through the bridge so the two never diverge. Slice 2 establishes that protocol; the current divergence is the root cause of the user-reported broken snooze.
|
||
|
||
---
|
||
|
||
## Slice 1 — Alarm native reliability (CRITICAL)
|
||
|
||
### Decision 1.1 — Foreground-service type and permission (A1)
|
||
|
||
- Manifest: change `PluriWaveAlarmService` to `android:foregroundServiceType="mediaPlayback|alarm"` (`AndroidManifest.xml:54-57`). Add `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM"/>` next to the existing `FOREGROUND_SERVICE_MEDIA_PLAYBACK` (`AndroidManifest.xml:5`).
|
||
- Service: when calling `startForeground` (`PluriWaveAlarmService.kt:75`), on API ≥ 34 pass the explicit type bitmask `ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_ALARM` via the 3-arg `startForeground(id, notification, type)` overload. On API < 34 keep the 2-arg overload.
|
||
- Rationale: include BOTH types because the service plays a radio stream (mediaPlayback) AND is an alarm (alarm). The `alarm` type is the one that satisfies the API 34+ FGS-launch-from-background restriction when started from `PluriWaveAlarmReceiver.onReceive` (`PluriWaveAlarmReceiver.kt:27`). Alarm-triggered broadcasts receive a temporary FGS-while-in-use exemption, so starting an `alarm`-typed FGS from the fire receiver is the documented, allowed path on API 34+.
|
||
- ADR note (rejected): using only `mediaPlayback` keeps the silent-failure bug; using only `alarm` would block the legitimate media-playback path the radio stream needs. KEEP both.
|
||
|
||
### Decision 1.2 — Single FSI notification, ownership and ordering (A3)
|
||
|
||
Today two notifications fire for one event: the receiver posts `fireNotificationIdForAlarm` (`PluriWaveAlarmReceiver.kt:95-133`) AND the service posts `NOTIFICATION_ID 92841` via `startForeground` (`PluriWaveAlarmService.kt:75`). `dismissFireNotification` only cancels the receiver's (`AlarmScheduler.kt:304-308`), leaving the service's orphaned.
|
||
|
||
- Decision: the **service's `startForeground` notification (`NOTIFICATION_ID 92841`) is the single owner** of the ringing FSI. Remove `showFireNotification` from the receiver entirely (`PluriWaveAlarmReceiver.kt:37, 95-133`).
|
||
- Ordering problem: an FSI must appear immediately, even before the radio stream prepares (`prepareAsync` is async, ~seconds). The service already calls `startForeground` synchronously at the top of `startAlarm` BEFORE `startAudio` (`PluriWaveAlarmService.kt:75` then `:83`). That notification carries `setFullScreenIntent(...)` (add it to `buildNotification`, currently the service builder has it at `:267`). So the FSI shows the instant the service enters foreground, audio attaches afterward — correct ordering, no gap.
|
||
- The receiver still does `context.startActivity(launch)` (`PluriWaveAlarmReceiver.kt:38-43`) to bring the Flutter ringing screen forward; that is the activity launch, not a notification, so no duplication.
|
||
- Cleanup: `stopAlarm` must cancel `NOTIFICATION_ID 92841` through `stopForeground(STOP_FOREGROUND_REMOVE)` (already at `:242`) and ALSO cancel any legacy `fireNotificationIdForAlarm` id for installs upgrading mid-ring (`:236-240` already does this — keep it as a migration safety net for one release).
|
||
- `fireNotificationIdForAlarm` helper stays (referenced by `cancelAlarm` `AlarmScheduler.kt:300`) but is no longer posted to.
|
||
|
||
### Decision 1.3 — Channel sound versioning to apply USAGE_ALARM (A4)
|
||
|
||
Android locks `setSound(uri, attributes)` at channel creation; the existing channels `pluriwave_alarm_native` (`PluriWaveAlarmService.kt:374`) and `pluriwave_alarm_fire` (`PluriWaveAlarmReceiver.kt:269`) were created without sound, so editing them in place is a no-op on existing installs.
|
||
|
||
- Decision: **channel versioning**. Introduce versioned channel ids and delete the old ones once:
|
||
- New ringing channel: `pluriwave_alarm_fire_v2` (IMPORTANCE_HIGH) with `setSound(<bundled-alarm-uri>, alarmAudioAttributes)` and `enableVibration(true)`. This is the channel the service's `startForeground` notification uses.
|
||
- Keep the pre-notice channel id but bump only if its config changes; it stays silent (`setSound(null, null)`, `PluriWaveAlarmReceiver.kt:232`) so no version bump needed.
|
||
- Consolidate to TWO channels total (A8): the ringing channel (`_fire_v2`) and the pre-notice channel. The service stops using its own `pluriwave_alarm_native` channel and posts on `_fire_v2`.
|
||
- Sound URI: a `Settings.System.DEFAULT_ALARM_ALERT_URI` (system default alarm tone) is the channel-level sound for the heads-up/locked alert. Note the bundled WAV played by `MediaPlayer` (`PluriWaveAlarmService.kt:355-362`) is the AUDIO that loops; the channel sound is the notification-attached alert. They are distinct; channel sound exists so even if the service audio fails the channel still produces an alarm-attributed sound. Use `USAGE_ALARM` AudioAttributes on both.
|
||
- Migration: add a one-time deletion of the obsolete channels (`pluriwave_alarm_native`, `pluriwave_alarm_fire`) via `manager.deleteNotificationChannel(...)` guarded by a SharedPreferences flag `channels_migrated_v2` so it runs once and does not reset the user's settings on the new channel repeatedly. Deleting a channel and recreating under a NEW id is the only way Android lets you change locked sound settings without the user manually clearing data.
|
||
- ADR note (rejected): mutating the existing channel in place — Android ignores `setSound` after creation, so it would silently keep the bug.
|
||
|
||
### Decision 1.4 — emisoraFallback passthrough and second prepare attempt (A5)
|
||
|
||
Today `programar()` sends `stationName/stationUrl/fallbackSound` but NEVER `emisoraFallback` (`servicio_alarmas_android.dart:167-173`); the Kotlin `NativeAlarmSpec` has no fallback-station field. The native fallback is only the bundled WAV.
|
||
|
||
- Bridge payload: add `fallbackStationName` and `fallbackStationUrl` to the `scheduleAlarm` MethodChannel args in `programar()` (`servicio_alarmas_android.dart:148-174`), sourced from `alarma.emisoraFallback`.
|
||
- Kotlin model: add `fallbackStationName: String?` and `fallbackStationUrl: String?` to `NativeAlarmSpec` (`AlarmScheduler.kt:571-648`), to `toJson` (bump `schemaVersion` 2→3 at `:594`) and `fromJson` (`:618-646`, read with `optString(...).takeIf { isNotBlank() }`). Wire through `scheduleAlarm(...)` signature (`AlarmScheduler.kt:21-40`) and the `MainActivity` handler (`MainActivity.kt:68-106`). Add the two extras to `EXTRA_*` constants and the `fireIntent` extras (`PluriWaveAlarmReceiver.kt:277-279`, `AlarmScheduler.kt:487-507`).
|
||
- Service audio chain: extend `startAudio` (`PluriWaveAlarmService.kt:86-108`) to a three-stage ordered fallback:
|
||
1. Primary station: `startStationAudio(primary)` with a 15s timeout (`STATION_START_TIMEOUT_MILLIS` already 15s at `:379`).
|
||
2. On primary timeout/error/completion → fallback station (if present): a SECOND `prepareAsync` against `fallbackStationUrl`, again with a 15s timeout.
|
||
3. On fallback-station timeout/error (or absent) → bundled WAV (`startFallbackAudio`, `:162`).
|
||
- Implement as a small state machine: pass the next-stage lambda into the prepared/error/completion/timeout handlers instead of jumping straight to the WAV. Reuse `scheduleStationFallback` per stage with its own runnable so the 15s windows are independent and `cancelStationFallback` clears the current stage.
|
||
- Persisted-spec migration: already-scheduled alarms have `schemaVersion: 2` specs in device-protected SharedPreferences (`AlarmScheduler.kt:436-444`). `fromJson` must default the two new fields to `null` when absent (no fallback station) — additive and backward compatible. On the next Flutter `programar()` (boot resync via `reschedulePersistedAlarms` `:315` or app open via `_sincronizarTodas` `estado_alarmas.dart:286-296`) the v3 fields are written. No destructive migration needed.
|
||
|
||
### Decision 1.5 — Native fade-in honoring fadeInSegundos (A6)
|
||
|
||
The native service plays at constant `volume` (`PluriWaveAlarmService.kt:120-121, 176-177`). The Dart screen fade (`pantalla_alarma_sonando.dart:92-115`) only runs when the screen is foregrounded.
|
||
|
||
- Bridge: add `fadeInSegundos` to the `scheduleAlarm` payload (`servicio_alarmas_android.dart`) and to `NativeAlarmSpec` (default 0). `AlarmaMusical.fadeInSegundos` already exists (used at `pantalla_alarma_sonando.dart:96`).
|
||
- Service: implement a `MediaPlayer.setVolume` ramp in the service driven by the existing `mainHandler` (`PluriWaveAlarmService.kt:27`). On the `setOnPreparedListener` start (`:128-136` and the fallback `:179-183`), if `fadeInSegundos > 0`, start at a low floor (e.g. 0.05 * target) and step every 250 ms toward `volume` over `fadeInSegundos`, mirroring the Dart algorithm (`pantalla_alarma_sonando.dart:101-114`). Cancel the ramp runnable in `stopAlarm` (`:224`) and on snooze.
|
||
- Coordination with the Dart fade: when the ringing screen is foregrounded with native audio already ramping, the screen must NOT double-ramp. The screen owns the fade ONLY for its own `_fallbackPlayer` and the `radio.audio` it pre-started; the native service owns the fade for native MediaPlayer audio. They never play the same source simultaneously (service stops when Flutter confirms audio via `confirmFlutterAudio`, `MainActivity.kt:139-148`). Document this hand-off boundary in the service header comment.
|
||
|
||
### Decision 1.6 — Battery-optimization request placement (A7)
|
||
|
||
`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is declared (`AndroidManifest.xml:13`) and `diagnostico.ignoraOptimizacionBateria` is read (`servicio_alarmas_android.dart:65`) but never requested.
|
||
|
||
- Decision: add a native `requestIgnoreBatteryOptimizations` MethodChannel method in `MainActivity` (mirror `requestExactAlarmPermission` `:255-270`) that launches `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` with `package:` data. Add `solicitarExencionBateria()` to `PuertoAlarmasAndroid` / `ServicioAlarmasAndroid` (`servicio_alarmas_android.dart:93-107, 196-218`).
|
||
- Placement and prompt-fatigue guard: call it inside `_solicitarPermisosNecesariosParaAlarma` (`estado_alarmas.dart:268-284`) ONLY when `!diag.ignoraOptimizacionBateria` AND an **asked-once flag** is unset. Persist the flag (`bateria_exencion_solicitada`) in the injected SharedPreferences (Slice 3). Request once; never re-prompt automatically. The user can re-request from the diagnostics UI manually.
|
||
- ADR note: requesting on every `guardarAlarma` (which calls this method, `estado_alarmas.dart:87`) would spam the user. The asked-once flag is mandatory.
|
||
|
||
### Slice 1 size note
|
||
Kotlin (manifest + service FGS type + channel v2 + 3-stage fallback + fade ramp) + Dart bridge (payload fields + battery method) ≈ 300-340 lines. Within budget. Native edits are surgical and mirror existing patterns to minimize compile risk.
|
||
|
||
---
|
||
|
||
## Slice 2 — Alarm UX parity and SNOOZE CORRECTNESS (HIGH)
|
||
|
||
### Snooze audit — found defects (file:line)
|
||
|
||
The user reports snooze did not work correctly. Root cause is **state divergence between the native scheduler and the Flutter source of truth**, plus inconsistent snooze anchoring. Precise findings:
|
||
|
||
| # | Defect | Evidence | Effect |
|
||
|---|--------|----------|--------|
|
||
| S1 | The native ringing-notification snooze (`ACTION_SNOOZE`) reschedules natively then `stopAlarm()`, and NEVER notifies Flutter. The `alarmFired` callback only fires from `MainActivity.onNewIntent` on an activity launch (`MainActivity.kt:227-234`); the snooze service path starts no activity. | `PluriWaveAlarmService.kt:42-49` → `AlarmScheduler.snooze()` → `stopAlarm()`; no `startActivity`; `MainActivity.kt:233` never reached. | Flutter `EstadoAlarmas` keeps the OLD next-occurrence; native fires at the snoozed time. UI shows wrong "next alarm"; reconciliation only happens on the next 60s `refrescarProgramacion` (`estado_alarmas.dart:316`), which RECALCULATES from scratch and can lose the native snooze. Divergence. |
|
||
| S2 | Two different snooze anchors in native code. `snooze()` uses `now + minutes` (`AlarmScheduler.kt:257`); `postponeNext()` uses `occurrenceAt + minutes` clamped to now (`AlarmScheduler.kt:270-273`). | `AlarmScheduler.kt:254-287` | Snooze-from-ringing and snooze-from-pre-notice land at different times for the same intent. Inconsistent with Flutter `posponerEjecucion` which uses `calcularSnooze(now, minutos)` (`servicio_alarmas.dart:262`). |
|
||
| S3 | The ringing screen has NO snooze button at all — only "Detener" (`_detener`). The only ringing-time snooze is the notification action, which is the S1-broken path. | `pantalla_alarma_sonando.dart:200-204` | User cannot snooze from the screen; if they snooze from the notification, S1 divergence triggers. |
|
||
| S4 | `refrescarProgramacion` calls `servicio.recalcularTodas()` which runs `_recalcular` → clears snooze when `!snoozeActivo` and recomputes `proximaEjecucion` ignoring any native-only snooze. | `estado_alarmas.dart:98-107`, `servicio_alarmas.dart:159-172, 378-397` | A native snooze that Flutter never recorded (S1) is erased by the next periodic recalculation; native and Flutter then disagree on the next trigger. |
|
||
| S5 | `preserveNativeSnooze` (`AlarmScheduler.kt:342-357`) only preserves a native snooze when Flutter reschedules WITHOUT a snooze and the origin matches `requestedTriggerAtMillis`. After S4 erases Flutter's snooze and recalculates a new trigger, the origin no longer matches, so preservation fails and the native snooze is dropped on the next `programar()`. | `AlarmScheduler.kt:347-356` | The one safety net for divergence is defeated by the recalculation in S4. |
|
||
|
||
### Decision 2.1 — Single source of truth + native→Flutter snooze sync protocol
|
||
|
||
- **Flutter `ServicioAlarmas` config is the canonical "postponed until"** (`snoozeHasta` / `snoozeOrigen` on `AlarmaMusical`, written by `posponerEjecucionHasta`, `servicio_alarmas.dart:266-294`). Native is a mirror that must report back.
|
||
- New native→Flutter event: when the service handles `ACTION_SNOOZE` (`PluriWaveAlarmService.kt:42-49`), after `AlarmScheduler.snooze(...)`, fire the `alarmFired` MethodChannel callback with a new action `snoozed` carrying `alarmId`, `occurrenceAtMillis` (the snooze origin) and `snoozeUntilMillis`. Because the service has no activity, route it through the EXISTING `alarmMethodChannel` held by `MainActivity`: expose a static `MainActivity.notifyAlarmEvent(map)` that invokes `alarmMethodChannel?.invokeMethod("alarmFired", map)` on the main thread when the engine is alive; if the engine is dead, the native snooze is already persisted and Flutter will reconcile on next launch via the handled-occurrences sync (extended below). This avoids inventing a second channel.
|
||
- Flutter side: `ServicioAlarmasAndroid._instalarHandler` (`servicio_alarmas_android.dart:269-285`) already forwards `alarmFired` events. Extend `EventoAlarmaAndroid` to carry the action `snoozed`. `EstadoAlarmas` listens (wire a subscription in `inicializar`) and on a `snoozed` event calls `servicio.posponerEjecucionHasta(alarmId, occurrence, snoozeUntil)` + `_aplicar` + `notifyListeners` — WITHOUT calling `android.programar()` again (native already scheduled it). This makes Flutter record the snooze native chose, killing S1.
|
||
- Extend the launch-on-snooze reconciliation: the native already persists snooze in its spec. Add `snoozeUntilMillis`/`snoozeOriginMillis` to `getHandledAlarmOccurrences` OR add a new `getNativeSnoozeState` method so `_sincronizarEjecucionesGestionadasPorAndroid` (`estado_alarmas.dart:251-266`) also imports active native snoozes on cold start. This covers the engine-dead case in the previous bullet.
|
||
|
||
### Decision 2.2 — Unify the snooze anchor (fixes S2, S4, S5)
|
||
|
||
- Make native `snooze()` use the SAME anchor as `postponeNext()`: `occurrenceAt + minutes` where `occurrenceAt = snoozeOriginMillis ?: triggerAtMillis`, clamped to `now + minutes` if already past (`AlarmScheduler.kt:254-265` adopts `:270-273` logic). One snooze semantic everywhere, matching the Flutter `posponerEjecucionHasta(ejecucion = snoozeOrigen ?? proximaEjecucion)` anchor (`estado_alarmas.dart:165-167`).
|
||
- Guard `recalcularTodas` against erasing an active snooze: `_recalcular` already preserves snooze when `snoozeActivo` (`servicio_alarmas.dart:384-385, 395`). The real fix is S1/S4 — once Flutter RECORDS the native snooze (Decision 2.1), `snoozeActivo` is true and `recalcularTodas` preserves it. So S4/S5 are resolved transitively by 2.1. Keep `preserveNativeSnooze` as a belt-and-suspenders net.
|
||
|
||
### Decision 2.3 — End-to-end snooze from the ringing screen (fixes S3)
|
||
|
||
- Add snooze buttons to `PantallaAlarmaSonando` (`pantalla_alarma_sonando.dart:200-204` area): 3 / 5 / 10 plus the alarm's configured default (`alarma.snoozeMinutos`). A `_posponer(int minutos)` handler that mirrors `_detener` (`:132-143`) teardown: cancel `_fallbackTimer`, `_fadeInTimer`, `_estadoSub`; stop `_fallbackPlayer`; pause `radio.audio`; then `await context.read<EstadoAlarmas>().posponerAlarma(widget.alarma, minutos)`; then `navigator.pop()`.
|
||
- `posponerAlarma` already exists and does the right Flutter-first sequence: hides the native notification, records `posponerEjecucion`, re-programs native (`estado_alarmas.dart:165-183`). This is the CANONICAL path — the screen uses it, so Flutter is always the writer and native is the mirror. No divergence by construction.
|
||
- Teardown coordination: `_fadeInTimer` and `_fallbackPlayer` MUST be torn down before `posponerAlarma` re-programs native, otherwise the Dart fallback keeps looping after snooze. Add the same teardown to a shared private `_liberarAudioLocal()` used by both `_detener` and `_posponer`.
|
||
|
||
### Decision 2.4 — Ringing screen redesign (C1, C3)
|
||
|
||
- Migrate from raw `Scaffold` (`pantalla_alarma_sonando.dart:158`) to `PluriWaveScaffold`. Replace hardcoded `Color(0xFF061722)` (`:159`) and `Color(0xFFFFB86B)` (`:167`) with `PluriWaveTokens` from the theme extension.
|
||
- Entry animation: wrap the glass surface content in a `flutter_animate` fade+scale entry, gated by the reduced-motion guard from Slice 5 (`PluriAnimate` extension). Do NOT introduce a Hero here (Hero work is C2, deferred/optional in Slice 5).
|
||
- BackdropFilter perf note: `PluriGlassSurface` uses `BackdropFilter`. On a cold GPU wake (screen-off → FSI), the first frame can stutter. Mitigation: render a cheap solid/gradient placeholder for the first ~1 frame, then enable the blur after the first `addPostFrameCallback`, OR cap the blur sigma on this screen. Document the constraint; keep the blur optional behind the reduced-motion guard so accessibility users also skip the expensive filter.
|
||
|
||
### Decision 2.5 — Editor improvements (C7)
|
||
|
||
- Next-trigger preview inside `_EditorAlarmaSheet` (`pantalla_alarmas.dart:387-636`): compute via the existing `ServicioProgramacionAlarmas.calcularProxima` against the in-progress alarm draft and render a localized "Next: <date/time>" line. Reuse the Slice 5 locale-aware `DateFormat`.
|
||
- Searchable station picker: replace the `DropdownButtonFormField` with a bottom-sheet using `SearchBar` over the user's favorites (and optionally recent stations). Returns the selected `Emisora` for both primary and fallback fields. This also surfaces `emisoraFallback` selection in the UI, which Slice 1 now honors natively.
|
||
- Configurable snooze duration field: a segmented/slider control writing `alarma.snoozeMinutos` (sanitized to 3/5/10 to match native `sanitizeSnoozeMinutes`). Lower the volume-slider floor from 0.25 toward 0 (proposal scope).
|
||
|
||
### Slice 2 size note
|
||
Snooze sync (bridge event + EstadoAlarmas listener + native callback + anchor unify) ≈ 120 lines; ringing screen redesign + buttons ≈ 130; editor (preview + picker + snooze field) ≈ 130. Total ≈ 380, AT the budget ceiling. **Risk: may exceed 400.** Proposed sub-split if the forecast trips the guard: **2a** = snooze correctness (audit fixes 2.1–2.3, ringing buttons) and **2b** = editor + visual redesign (2.4–2.5). 2a is the user-trust fix and ships first.
|
||
|
||
---
|
||
|
||
## Slice 3 — Audio / runtime robustness (test seams) (HIGH)
|
||
|
||
### Decision 3.1 — audio_session integration (B1)
|
||
|
||
- Add a `ServicioAudioSession` wrapper around `package:audio_session` (already in pubspec `:19`). Configure on app init with `AudioSession.instance` → `configure(AudioSessionConfiguration.music())` adjusted: `AVAudioSessionCategory.playback`, `AndroidAudioAttributes(usage: media, contentType: music)`, `androidAudioFocusGainType: gain`, `androidWillPauseWhenDucked: true`.
|
||
- Hook point: inside `PluriWaveAudioHandler` (`servicio_audio.dart:113-147`) or a thin collaborator it owns. Subscribe to:
|
||
- `interruptionEventStream`: on `begin` with `AudioInterruptionType.pause` (phone call) → pause and remember "was playing"; on `begin` with `duck` → lower volume; on `end` with `shouldResume` → resume. This is what makes calls pause the radio (the missing B1 behavior).
|
||
- `becomingNoisyEventStream` (headphones unplugged) → pause.
|
||
- Interaction with reconnect (Slice 7): an audio-session pause is a USER-INTENT pause-equivalent and MUST set the same "intentional pause" flag the reconnect logic checks (Decision 7.2), so the stall detector does not fight the interruption handler and try to reconnect during a phone call.
|
||
- Alarm path: the native alarm uses `USAGE_ALARM` and its own MediaPlayer, independent of this media session, so audio focus for the radio does not interfere with native alarm audio.
|
||
|
||
### Decision 3.2 — Kill static state in ServicioAlarmasAndroid (B2)
|
||
|
||
- Convert `static _eventosController`, `static _handlerInstalado`, `static _l10n` (`servicio_alarmas_android.dart:117-120`) to INSTANCE fields. Install the handler in the constructor per instance.
|
||
- Make the channel injectable (the constructor already accepts `MethodChannel`, `:110-112`) and the localizations injectable per instance instead of a static setter. `EstadoRadio.configurarLocalizaciones` (`estado_radio.dart:70-75`) currently calls the static `ServicioAlarmasAndroid.configurarLocalizaciones`; rewire it to call the instance held by `EstadoAlarmas.android`. This makes the service unit-testable with a `TestDefaultBinaryMessenger` and removes global shared state.
|
||
- Backward-compat: keep a deprecated static shim for one release if other call sites reference it (grep shows only `estado_radio.dart:74`).
|
||
|
||
### Decision 3.3 — Move configurarLocalizaciones out of build() (B3)
|
||
|
||
- `MiniReproductor.build()` calls `configurarLocalizaciones(l10n)` every rebuild (`mini_reproductor.dart:23`), firing on every buffer notification. Move it to `didChangeDependencies` (fires when locale/inherited widgets change, not on every `notifyListeners`), guarded so it only re-runs when the `Locale` actually changes. Convert `MiniReproductor` to `StatefulWidget` if needed, or hoist the call to a top-level locale listener in `app.dart` that runs once per locale change.
|
||
|
||
### Decision 3.4 — Inject a single cached SharedPreferences (B4)
|
||
|
||
- Resolve `SharedPreferences.getInstance()` ONCE at startup (in `main.dart`) and inject the instance into `ServicioAlarmas` (constructor already accepts `prefs`, `servicio_alarmas.dart:23-29`), `ServicioEcualizador`, `ServicioGrabacionRadio`, and any service doing `getInstance()` (25+ sites per B4). Provide a backward-compatible default (`_resolverPrefs` already falls back to `getInstance()`, `:399-400`) so partial adoption stays functional and a revert is safe.
|
||
- This is also a test seam: tests pass `SharedPreferences.setMockInitialValues({})` once.
|
||
|
||
### Decision 3.5 — Guard recalcularTodas writes + single-writer cache (B5, B6, B9)
|
||
|
||
- `recalcularTodas` writes SharedPreferences unconditionally every minute (`estado_alarmas.dart:316-318` → `servicio_alarmas.dart:159-172`). Add a dirty-check: compute the new config, compare serialized JSON (or a change flag from `_recalcular`) against the loaded config, and only `_guardar` when something changed. Returns the loaded config unchanged when clean (mirror the `sincronizarEjecucionesNativas` `huboCambios` pattern, `:181, 219`).
|
||
- In-memory cache + single-writer mutex in `ServicioAlarmas` to kill the read-modify-write N+1 race (B6): `cargar()` runs before every mutation (`:86, 111, 124, 160, 179, 230, 271, 300`) with no cache and no serialization, so concurrent `guardarAlarma`/`posponer`/`refrescar` interleave and lose writes. Decision: hold an in-memory `ConfiguracionAlarmas?` cache, hydrate on first `cargar`, and serialize ALL mutations through a single `Future`-chain mutex (the same pattern `PluriWaveAudioHandler._colaCambioFuente` uses, `servicio_audio.dart:125, 282-285`). Every mutation = `await _lock` → read cache → mutate → persist → update cache → release. This is THE seam the Slice 6 concurrency test exercises.
|
||
- Bound `_ejecucionesEmitidas` (`estado_alarmas.dart:32`): replace the unbounded `Set<String>` with a bounded LRU (cap ~200) or prune entries older than a day on each `_vigilarAlarmasVencidas` pass (`:326-348`). Keys are `alarmId:millis`; prune by parsing the millis suffix.
|
||
|
||
### Decision 3.6 — Tame empty catch + unawaited (B7, B10) — scoped
|
||
- Not the focus of Slice 3 structurally, but where the seams touch `servicio_audio.dart` empty catches (`:343, 346, 406, 428, 448`) and `app.dart:324` `unawaited(radio.reproducir)`, replace silent swallow with at least a `developer.log`. Full lint enforcement is Slice 6 (`unawaited_futures`). Keep edits minimal here to stay under budget.
|
||
|
||
### Slice 3 size note
|
||
Audio session ≈ 90, statics→instance + l10n rewire ≈ 70, prefs injection ≈ 60, dirty-guard + cache/mutex + bounded set ≈ 120, logging touch-ups ≈ 30. Total ≈ 370. Within budget but tight; if it trips, split **3a** = test seams (statics, prefs, cache/mutex, dirty-guard) and **3b** = audio_session + becoming-noisy. 3a unblocks Slice 6 tests; 3b is the call-pause feature.
|
||
|
||
---
|
||
|
||
## Slice 7 — Streaming resilience (NEW, user request) (MEDIUM-HIGH)
|
||
|
||
Radio streams should survive short connection drops (seconds) via buffering and auto-recover.
|
||
|
||
### Decision 7.1 — just_audio buffer configuration for LIVE streams
|
||
|
||
- `just_audio ^0.9.42` supports `AudioLoadConfiguration(androidLoadControl: AndroidLoadControl(...))`, applied at `AudioPlayer` construction (`servicio_audio.dart:159-163` `_crearPlayer`). Set:
|
||
- `minBufferDuration: 15s`, `maxBufferDuration: 50s` — pre-roll the player keeps; larger min buffer absorbs jitter.
|
||
- `bufferForPlaybackDuration: 2.5s`, `bufferForPlaybackAfterRebufferDuration: 5s` — how much must buffer before (re)starting playback; higher after-rebuffer value reduces re-stutter.
|
||
- `targetBufferBytes`, `prioritizeTimeOverSizeThresholds: true`.
|
||
- **Achievable window — be honest:** for LIVE icy/HTTP radio there is NO seekable rewind window; the stream is unbounded and not stored to disk by default. The buffer is a forward jitter cushion, not a rewind history. So:
|
||
- What we CAN achieve: tolerate network jitter / micro-drops up to roughly the `maxBufferDuration` worth of already-buffered audio (realistically a few to ~15-30 seconds of cushion depending on bitrate and how full the buffer was when the drop hit), and FAST automatic re-prepare when the connection returns.
|
||
- What we CANNOT achieve: pause-and-resume across a long outage without a gap (live audio advances in real time; on reconnect you rejoin the live edge, not where you dropped). We do not promise gap-free recovery for outages longer than the buffered cushion.
|
||
- Decision: the realistic goal is **jitter tolerance + fast recovery to the live edge**, with a clear "reconnecting" UI state, not seamless time-shift. State this in the spec acceptance criteria so expectations are honest.
|
||
|
||
### Decision 7.2 — Reconnect-on-stall with bounded exponential backoff
|
||
|
||
- Stall vs user-pause discrimination: track an explicit `_intencionReproducir` (intent-to-play) flag, set true on `play`/`reproducir`/`reanudar` and false on `pause`/`stop` and on an audio-session interruption pause (Decision 3.1). A STALL is: `_intencionReproducir == true` AND `playerStateStream` reports `processingState == buffering` for longer than a threshold (e.g. 8-10s) OR an error event arrives (`servicio_audio.dart:189-194` `_eventosSub.onError`, currently routes to `_gestionarErrorReproduccion`).
|
||
- Reconnect loop in `PluriWaveAudioHandler`: on detected stall, instead of going straight to terminal error (`_gestionarErrorReproduccion`, `:207-236`), enter a reconnect state machine:
|
||
- Surface a new `EstadoReproduccion.reconectando` (extend the enum `servicio_audio.dart:14`) so mini player / player UI shows "reconnecting".
|
||
- Re-issue the source (`_player.setUrl(mediaItem.id)` then `play`) using the existing revision-guarded `_cambiarFuente` machinery (`:288-332`) so a user source-switch during reconnect cancels it (revision mismatch).
|
||
- Bounded exponential backoff: delays 1s, 2s, 4s, 8s, 16s, 32s, then cap; total window ~60-90s (configurable max attempts ~7-8). After exhaustion, fall through to the EXISTING terminal `_gestionarErrorReproduccion` with the friendly message. This preserves current error UX as the final state.
|
||
- Cancel/reset the backoff and counter on successful `ready`+`playing`, on user `stop`/`pause`, and on source switch.
|
||
- Interaction with EQ/volume/recording: reconnect re-creates the player via `_recrearPlayer` (`:334-354`) which already re-applies `_volumen` (`:352`) and re-activates the EQ (`_activarEcualizador`, `:306, 372-383`). Recording (`ServicioGrabacionRadio`) reads the live PCM/visualizer; on reconnect the `androidAudioSessionId` changes (re-emitted at `:196-203`) and recording/visualizer resubscribe via the existing session-id stream — no extra wiring needed, but the design MUST verify recording survives a session-id change (Slice 6 recording-recovery test covers it).
|
||
- Interaction with the alarm pre-start path (`app.dart:316-325` `_prearrancarAudioAlarma`): when the alarm pre-starts the radio and the stream stalls, reconnect should engage normally, BUT the alarm screen already has its own 12s station timeout → bundled-WAV fallback (`pantalla_alarma_sonando.dart:74-79`). Decision: during an active alarm ring, the alarm's fallback timer takes precedence — if the radio has not reached `reproduciendo` within the alarm's timeout, the alarm switches to the bundled fallback regardless of reconnect attempts, because waking the user reliably beats reconnect persistence. The reconnect loop must not extend the alarm wake-up window. Expose enough state for the alarm screen to make that call (it already listens to `estadoStream`; `reconectando` must NOT be misread as `reproduciendo`).
|
||
|
||
### Slice 7 size note
|
||
Buffer config ≈ 25, reconnect state machine + enum + backoff ≈ 180, UI "reconnecting" wiring in mini/player ≈ 60, alarm-path guard ≈ 20. Total ≈ 285. Within budget. Depends on Slice 3 (audio-session intent flag) landing first.
|
||
|
||
---
|
||
|
||
## Slices 4-6
|
||
|
||
### Slice 4 — EstadoRadio god-class split (HIGH, broad)
|
||
|
||
`EstadoRadio` (1121 lines, `estado_radio.dart`) owns 6 services + direct I/O (`:30-62`). Split seams and EXTRACTION ORDER (lowest-coupling first so each PR stays small and backward-compatible):
|
||
|
||
1. **`ServicioExportImport`** — extract the backup/import `jsonDecode`/`jsonEncode` from `pantalla_ajustes.dart` (1391 lines) AND any export logic in `EstadoRadio`. Pure logic, highest test value (round-trip), zero UI coupling. Extract FIRST.
|
||
2. **`EstadoEcualizador`** (`ChangeNotifier`) — EQ preset/active/band state currently proxied through `EstadoRadio` to `audio`/`servicioEcualizador`. Self-contained.
|
||
3. **`EstadoGrabacion`** (`ChangeNotifier`) — recording state + `_escucharGrabacion` subscription (`estado_radio.dart:51, :79`).
|
||
4. **`EstadoBusqueda`** (`ChangeNotifier`) — search results/query state.
|
||
|
||
- Provider wiring: register the new notifiers in the existing `MultiProvider` (alongside `EstadoRadio`). Use `ProxyProvider` where a notifier needs `ServicioAudio` from `EstadoRadio`, OR pass the shared service instances at construction.
|
||
- Migration strategy keeping each PR <400 lines: KEEP backward-compatible getters on `EstadoRadio` that delegate to the new notifiers during the transition, so screens compile unchanged. Migrate consuming screens to `context.select`/`Consumer` scopes (B11: `pantalla_inicio.dart:43` root watch + 6 sites in `pantalla_ajustes`) in the SAME or a follow-up PR.
|
||
- **Confirmed 4a/4b split** (the proposal flags it; forecast WILL exceed 400 lines for all four extractions + rewiring):
|
||
- **4a** = `ServicioExportImport` + `EstadoEcualizador` extraction with backward-compatible getters (no screen rewiring yet). ≈ 350 lines.
|
||
- **4b** = `EstadoGrabacion` + `EstadoBusqueda` extraction + `context.select` rewiring of `PantallaInicio`/`Ajustes`/`Favoritos` + removal of the temporary getters. ≈ 380 lines.
|
||
- ADR note: a big-bang single PR would blow the budget and the blast radius; the getters-bridge keeps each PR independently revertible (proposal rollback plan).
|
||
|
||
### Slice 5 — Design system, a11y, i18n (LOW, parallelizable)
|
||
|
||
- Color tokens: replace the 14+ `Color(0x...)` literals (explore C3 sites) with `PluriWaveTokens` from the theme extension. The ringing screen literals are migrated in Slice 2 (Decision 2.4); the rest here.
|
||
- A11y: `Semantics(button: true, label: ...)` on the 36x36 favorite `InkWell` (`tarjeta_emisora.dart:238-289`, also enlarge tap target toward 48dp), `semanticLabel` on `_AssetIcon` and alarm images (C4, C5).
|
||
- **Reduced-motion guard — `PluriAnimate` extension design**: a Dart extension on `Widget` (e.g. `extension PluriAnimate on Widget`) exposing `pluriFadeIn(...)`, `pluriScaleIn(...)` etc. Each method reads `MediaQuery.maybeDisableAnimationsOf(context)` (or `MediaQuery.of(context).disableAnimations`); when true it returns the child UNANIMATED (or with duration zero), otherwise it applies the `flutter_animate` effect. Centralizes C6 so every entry animation (including the Slice 2 ringing screen) respects the OS reduced-motion setting through ONE call site. Requires a `BuildContext`, so it is a method taking `context` rather than a pure getter.
|
||
- i18n: locale-aware `_fechaCorta` via `intl` `DateFormat.yMd(localeName)` (`pantalla_alarmas.dart:1114`, fixes C8 for ja/en-US/ar). Pluralization for bare counters via ARB plural messages (`pantalla_favoritos.dart:138`, C9).
|
||
- Polish: rounded shimmer corners + shimmer in `PantallaBuscar` (C10, C11), `_rounded` icon variants (C12), brand `notificationColor` in `main.dart:23` (C13).
|
||
|
||
### Slice 6 — Quality gates (LOW, mostly tests)
|
||
|
||
- Harden `analysis_options.yaml` (currently bare `flutter_lints`, `:10`). Add under `linter.rules`: `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls`. Fix the violations surfaced (the empty-catch/unawaited sites from B7/B10 land here if not in Slice 3).
|
||
- Tests (strict TDD, `flutter test`, mirror `test/helpers/fakes.dart` patterns). See Test Designs below.
|
||
|
||
---
|
||
|
||
## Test designs (Slice 6 + seams introduced earlier)
|
||
|
||
| Test | Target | Design |
|
||
|------|--------|--------|
|
||
| ServicioAlarmas concurrency | `servicio_alarmas.dart` mutex (Decision 3.5) | Inject mock prefs (`setMockInitialValues`). Fire N concurrent `guardarAlarma`/`posponerEjecucion`/`recalcularTodas` without awaiting between them; await all; assert the final persisted config reflects ALL writes (no lost update) and the mutation count matches. Without the mutex this fails (read-modify-write race). |
|
||
| Fire dedup across refrescarProgramacion | `estado_alarmas.dart` `_ejecucionesEmitidas` + `_vigilarAlarmasVencidas` (`:326-348`) | Drive a due alarm; call `refrescarProgramacion` repeatedly; assert `alarmasVencidasStream` emits the occurrence exactly once per `alarmId:millis` key, and the bounded set prunes old keys. |
|
||
| Audio handler rapid source-switch | `PluriWaveAudioHandler._colaCambioFuente` / `_revisionFuente` (`:280-332`) | Issue rapid `playMediaItem(A)`, `playMediaItem(B)`, `playMediaItem(C)`; assert only C's source ends active, earlier revisions are cancelled (revision guard), and no stale error is surfaced from A/B. Use a fake/seam over `AudioPlayer` URL-set. |
|
||
| Export/import round-trip | new `ServicioExportImport` (Slice 4a) | Build a full config (favorites, groups, EQ, alarms, vacations); export to JSON; import into a fresh service; assert deep equality. Edge: malformed JSON → graceful empty, not throw. |
|
||
| Recording error recovery | `ServicioGrabacionRadio` (B10 empty catches `:156,165,177,288`) | Simulate a recorder failure mid-recording (session-id change / IO error); assert the service transitions to a recoverable error state, releases resources, and a subsequent start succeeds. Covers the Slice 7 reconnect session-id-change interaction. |
|
||
| Snooze logic | `AlarmScheduler.snooze`/`postponeNext` anchor + Flutter `posponerEjecucionHasta` (Decisions 2.1-2.2) | Dart-side: assert `posponerAlarma` writes `snoozeHasta = origin + minutes` and that a subsequent `recalcularTodas` PRESERVES the active snooze (not erased — S4 regression guard). Assert a `snoozed` native event recorded via the bridge updates state WITHOUT a second `programar()` call. |
|
||
| Reconnect/backoff | `PluriWaveAudioHandler` reconnect state machine (Decision 7.2) | Seam over the player so a stall/error can be injected; assert backoff delays follow 1/2/4/8…cap, `reconectando` state is emitted, success resets the counter, and exhaustion lands on terminal `error` with the friendly message. Assert an intentional `pause` during stall cancels reconnect (intent flag). |
|
||
|
||
Kotlin (`AlarmScheduler.snooze` anchor, FGS type, channels) is NOT covered by `flutter test` and NOT by `flutter analyze`; it is verified ON-DEVICE by the user. Design keeps Kotlin diffs small and mirrors existing patterns to minimize compile risk.
|
||
|
||
---
|
||
|
||
## Cross-cutting decisions and ADRs
|
||
|
||
| Decision | Choice | Rejected alternative | Rationale |
|
||
|----------|--------|----------------------|-----------|
|
||
| Snooze source of truth | Flutter `ServicioAlarmas` config; native mirrors and reports back via `alarmFired/snoozed` | Native as source of truth | Flutter already owns recurrence math and persistence; UI reads from it; a second authority is what caused the divergence. |
|
||
| Native→Flutter snooze channel | Reuse existing `alarmFired` MethodChannel via a static `MainActivity.notifyAlarmEvent` | New dedicated EventChannel | Fewer moving parts, no new channel lifecycle to manage, engine-dead case covered by cold-start sync. |
|
||
| Channel sound change | New versioned channel id `_fire_v2` + one-time delete of old ids | Mutate existing channel | Android locks channel sound at creation; in-place edit is silently ignored. |
|
||
| FGS notification owner | Service `startForeground` notification (id 92841) | Receiver notification | Service is the long-lived owner; the FSI must persist for the whole ring and be cancelled by `stopForeground`. |
|
||
| Streaming recovery scope | Jitter tolerance + fast reconnect to live edge | Gap-free time-shift across long outages | Live radio has no rewind history; honesty in acceptance criteria. |
|
||
| Concurrency fix | Single-writer `Future`-chain mutex + in-memory cache in `ServicioAlarmas` | Per-mutation lock library | Mirrors the existing `_colaCambioFuente` pattern; zero new deps; testable. |
|
||
| Slice 4 migration | Backward-compatible getters bridge, 4a/4b split | Big-bang split | Keeps each PR <400 lines and independently revertible. |
|
||
|
||
## Risks
|
||
|
||
- **Kotlin compile risk (HIGH):** Slices 1, 2, 7-adjacent native edits cannot be compiled here (`flutter build` forbidden, never run per `alarm-clock-module`). Mitigation: surgical diffs, mirror existing patterns, schemaVersion bump 2→3 is additive; ask the user to build after Slice 1 before chaining further native work.
|
||
- **FGS type behavior is OEM/version-sensitive (HIGH):** the `alarm` FGS type + permission pairing must be exact; some OEMs are stricter. Mitigation: pair type and permission precisely; on-device verification by user.
|
||
- **Channel migration could reset user notification prefs (MEDIUM):** deleting old channels and creating `_fire_v2` resets per-channel user settings for the ringing channel only. Mitigation: one-time guarded migration; pre-notice channel untouched; document in release notes.
|
||
- **Snooze sync engine-dead case (MEDIUM):** if the Flutter engine is dead when native snoozes, the `alarmFired` callback is lost; reconciliation relies on the extended cold-start sync (`getNativeSnoozeState`). Mitigation: import active native snoozes on `inicializar`.
|
||
- **Slice 2 and Slice 3 at the 400-line ceiling (MEDIUM):** both may need the documented 2a/2b and 3a/3b sub-splits. Flagged for the Review Workload Forecast.
|
||
- **BackdropFilter cold-GPU stutter on FSI (MEDIUM):** the ringing screen blur may drop the first frame on screen-off wake. Mitigation: deferred blur / capped sigma / reduced-motion bypass.
|
||
- **Reconnect vs alarm wake-up window (MEDIUM):** reconnect persistence must never delay the alarm's bundled-WAV fallback. Mitigation: alarm fallback timer takes precedence; `reconectando` must not be read as playing.
|
||
- **audio_session interruption vs reconnect fighting (LOW-MEDIUM):** a call-pause must set the same intent flag the stall detector reads. Mitigation: shared intent flag (Decision 3.1 ↔ 7.2).
|
||
|
||
## Open assumptions requiring validation
|
||
|
||
- The `alarm`-typed FGS started from `PluriWaveAlarmReceiver` is exempt under the alarm-broadcast FGS-while-in-use allowance on API 34+ — validate on a real API 34/35 device (user build).
|
||
- `AndroidLoadControl` buffer values are tunable but the effective jitter cushion depends on stream bitrate; final values may need on-device tuning.
|
||
- `MainActivity.notifyAlarmEvent` invoked from the service requires the Flutter engine to be alive and the channel bound; the cold-start sync is the fallback — confirm the engine lifecycle assumption holds when the ringing screen is foregrounded.
|