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)
This commit is contained in:
2026-06-11 15:33:30 +02:00
parent b5acf97ba4
commit f3e9487215
57 changed files with 4902 additions and 509 deletions
@@ -0,0 +1,179 @@
# Apply Progress: app-quality-and-native-alarms
**Mode**: Strict TDD (test runner: `flutter test`)
**Artifact store**: openspec (Engram unavailable this session)
**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence)
**Last updated**: 2026-06-11 (Batch 2)
## Batch log
| Batch | Slice | Status | Date |
|-------|-------|--------|------|
| 1 | S1 — Alarm native reliability | COMPLETE (Dart verified; Kotlin/manifest on-device verification deferred to user) | 2026-06-11 |
| 2 | S2a + S2b — Snooze correctness end-to-end + Alarm UX parity | COMPLETE (Dart verified; Kotlin on-device verification deferred to user) | 2026-06-11 |
## Task status (cumulative)
### Slice S1 — Alarm native reliability — 17/17 complete
| Task | Status | Notes |
|------|--------|-------|
| T-S1-01 | [x] | RED test: `programar()` payload carries `fallbackStationName/Url`, `fadeInSegundos`, `fallbackSound` |
| T-S1-02 | [x] | RED test: `solicitarExencionBateria()` invokes `requestIgnoreBatteryOptimizations` |
| T-S1-03 | [x] | Manifest FGS type + permission. **DEVIATION** (see below) |
| T-S1-04 | [x] | API ≥ 34 3-arg `startForeground` with type bitmask. **DEVIATION** (see below) |
| T-S1-05 | [x] | Receiver `showFireNotification` + `ensureFireChannel` removed; service notification (id 92841) is sole FSI owner; `fireNotificationIdForAlarm` kept for cancel-migration safety |
| T-S1-06 | [x] | `setFullScreenIntent` was ALREADY present in service `buildNotification`; `stopAlarm` already cancels legacy fire id + `stopForeground(STOP_FOREGROUND_REMOVE)`; FSI-before-audio ordering documented with comment |
| T-S1-07 | [x] | Channel `pluriwave_alarm_fire_v2` (IMPORTANCE_HIGH, `DEFAULT_ALARM_ALERT_URI` + USAGE_ALARM attrs, vibration); one-time deletion of `pluriwave_alarm_native` + `pluriwave_alarm_fire` guarded by `channels_migrated_v2` flag in device-protected prefs `pluriwave_alarm_channels`; channel count consolidated to 2 (fire_v2 + pre-notice) |
| T-S1-08 | [x] | `NativeAlarmSpec` + `fallbackStationName/Url`, `fadeInSegundos`; schemaVersion 2→3; `fromJson` backward-compatible (null/0 defaults); wired through `scheduleAlarm`, `MainActivity` handler, `EXTRA_*` consts, `fireIntent` extras |
| T-S1-09 | [x] | Three-stage chain primary(15s) → fallback station(15s) → bundled WAV via continuation lambdas (`onStageFailed`); `scheduleStationFallback` per stage with independent timeout windows |
| T-S1-10 | [x] | `startFadeIn` ramp: start 0.05×target, 250 ms steps over `fadeInSegundos`; applied to station and bundled-WAV players; `cancelFadeIn()` in `stopAlarm` (snooze path goes through `stopAlarm`) |
| T-S1-11 | [x] | `requestIgnoreBatteryOptimizations` MethodChannel handler + private method in `MainActivity.kt` mirroring `requestExactAlarmPermission` |
| T-S1-12 | [x] | GREEN: payload fields added to `programar()` args map |
| T-S1-13 | [x] | GREEN: `solicitarExencionBateria()` on `PuertoAlarmasAndroid` + impl |
| T-S1-14 | [x] | GREEN: asked-once guard in `_solicitarPermisosNecesariosParaAlarma` — calls only when `!diag.ignoraOptimizacionBateria` AND `bateria_exencion_solicitada` unset; optional `SharedPreferences? prefs` ctor param added to `EstadoAlarmas` (forward-compatible with S3 injection) |
| T-S1-15 | [x] | `flutter test` — full suite 54 tests, all passing (5 new) |
| T-S1-16 | [x] | `flutter analyze``No issues found!` (baseline before S1 was also clean) |
| T-S1-17 | [x] | `dart format` applied to the 4 touched Dart files |
### Slice S2a — Snooze correctness — 20/20 complete
| Task | Status | Notes |
|------|--------|-------|
| T-S2a-01 | [x] | RED: `test/estado/estado_alarmas_snooze_test.dart` — anchor, snoozed-event, recalc-preserve + extras (cold-start import, stop-cancels-snooze, finalizar clears) |
| T-S2a-02 | [x] | RED: `test/servicios/servicio_alarmas_snooze_test.dart` — anchor future/clamped/custom(7), payload `snoozeUntilMillis`+`snoozeOriginMillis`, `getNativeSnoozeState` parse/empty. Test C (finalizar) lives in the estado file |
| T-S2a-03 | [x] | RED: synchronous list update after `posponerAlarma` (no poll) |
| T-S2a-04 | [x] | Kotlin: `ACTION_SNOOZE` reports back via `MainActivity.notifyAlarmEvent` with `alarmAction="snoozed"` (`PluriWaveAlarmService.kt:56-80`). *On-device verify* |
| T-S2a-05 | [x] | Kotlin: `MainActivity.notifyAlarmEvent` companion (lines ~610-635), `@Volatile activeInstance` set in `configureFlutterEngine`, cleared in `onDestroy`; main-thread post; no-op when engine dead. *On-device verify* |
| T-S2a-06 | [x] | Kotlin: `AlarmScheduler.snooze()` (lines 266-292) unified to `occurrenceAt + minutes` clamped to `now + minutes`; persists `snoozeMinutes`; returns `NativeSnoozeResult(until, origin, title)`. *On-device verify* |
| T-S2a-07 | [x] | Kotlin: `AlarmScheduler.nativeSnoozeStates()` (lines 366-385) + `getNativeSnoozeState` handler (`MainActivity.kt:192`). *On-device verify* |
| T-S2a-08 | [x] | GREEN: `EventoAlarmaAndroid.snoozeUntilMillis` + `accionSnoozed`; `app.dart` ignores `snoozed` events in `_abrirAlarmaSonando` |
| T-S2a-09 | [x] | `snoozeUntilMillis` was ALREADY in the `programar()` payload — locked by new test, no code change |
| T-S2a-10 | [x] | GREEN: `_alRecibirEventoNativo` (estado_alarmas.dart:266) — `posponerEjecucionHasta` + `_aplicar` + `notifyListeners`, NO second `programar`. Subscribed in the CONSTRUCTOR (deviation, see below); cancelled in `dispose` |
| T-S2a-11 | [x] | GREEN: `_importarSnoozesNativosActivos` (estado_alarmas.dart:312) called at the end of `_sincronizarEjecucionesGestionadasPorAndroid`; imports active future snoozes for active alarms when they differ |
| T-S2a-12 | [x] | GREEN: `obtenerEstadoSnoozeNativo()` on `PuertoAlarmasAndroid` + impl + `EstadoSnoozeNativo` model |
| T-S2a-13 | [x] | GREEN: `_recalcular` `snoozeActivo` now requires `alarma.activa` (servicio_alarmas.dart:395) — disabling clears the snooze; finalizar path already cleared + re-programs without snooze (bridge cancels natively when inactive) |
| T-S2a-14 | [x] | RED: `test/pantallas/pantalla_alarma_sonando_test.dart` — buttons 3/5/10(+7), no-dup, tap-5 behavior |
| T-S2a-15 | [x] | GREEN: `_liberarAudioLocal()` + `_posponer(int)` + `_detener` refactor (pantalla_alarma_sonando.dart:138,161). `_estadoSub.cancel()` is fire-and-forget (deviation, see below) |
| T-S2a-16 | [x] | GREEN: snooze button row via `_opcionesSnooze()` (sorted {3,5,10,custom}); NEW l10n keys in ALL 13 .arb files: `alarmSnoozeOptionLabel`, `snoozeAction`, `alarmSnoozeDurationTitle`, `alarmFallbackStationLabel`, `alarmStationPickerSearchHint` (+ `flutter gen-l10n` regenerated) |
| T-S2a-17 | [x] | Targeted snooze tests green |
| T-S2a-18 | [x] | Full suite 77/77 |
| T-S2a-19 | [x] | `flutter analyze` — No issues found |
| T-S2a-20 | [x] | `dart format` on touched files |
### Slice S2b — Editor + visual redesign — 12/12 complete
| Task | Status | Notes |
|------|--------|-------|
| T-S2b-01 | [x] | RED: scaffold test — `PluriWaveScaffold` present, no `Color(0xFF061722)` Scaffold, `Animate` present / absent under `disableAnimations` |
| T-S2b-02 | [x] | RED: 5 editor tests (preview + live update, primary picker + filtering, fallback picker, snooze duration persists, volume floor 0.0) |
| T-S2b-03 | [x] | GREEN: ringing screen on `PluriWaveScaffold`; `0xFFFFB86B``tokens.warmCoral`; `blurSigma: 10` + cold-GPU comment (Design 2.4) |
| T-S2b-04 | [x] | GREEN: `lib/tema/pluri_animate.dart``pluriFadeIn`/`pluriScaleIn` honoring `MediaQuery.maybeDisableAnimationsOf` |
| T-S2b-05 | [x] | GREEN: glass surface wrapped in `.pluriFadeIn(context)` |
| T-S2b-06 | [x] | GREEN: `_vistaProximaEjecucion` (draft → `calcularProxima`, respects vacations/exceptions; recomputed on every setState) |
| T-S2b-07 | [x] | GREEN: `_CampoSelectorEmisora` + `_SelectorEmisoraSheet` (SearchBar picker) for primary AND fallback station; `copyWith` clear-flags added (see deviations) |
| T-S2b-08 | [x] | GREEN: snooze duration SegmentedButton wired to `snoozeMinutos` (editor used to hardcode 5); volume slider min 0.25 → 0.0 (divisions 20) |
| T-S2b-09 | [x] | S2b targeted tests 7/7 green |
| T-S2b-10 | [x] | Full suite 77/77 |
| T-S2b-11 | [x] | `flutter analyze` — No issues found |
| T-S2b-12 | [x] | `dart format` applied |
### Remaining slices (not started)
S3a, S3b, S7, S4a, S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending.
## Snooze defect fixes (design audit D1D5 / S1S5)
| Defect | Fix | Where |
|--------|-----|-------|
| D1 (audit S1) — native snooze never notifies Flutter | `ACTION_SNOOZE``MainActivity.notifyAlarmEvent("snoozed", origin, until)`; Flutter records via `posponerEjecucionHasta` WITHOUT re-programming; engine-dead case covered by `getNativeSnoozeState` cold-start import | `PluriWaveAlarmService.kt:56-80`, `MainActivity.kt:627`, `estado_alarmas.dart:266,312` |
| D2 (audit S2) — two snooze anchors | Unified everywhere to `occurrence + minutes` clamped to `now + minutes`: native `snooze()` adopts `postponeNext` logic; Dart `posponerEjecucion` re-anchored from `now+min` to `ejecucion+min` | `AlarmScheduler.kt:266-292`, `servicio_alarmas.dart:256-274` |
| D3 (audit S3) — no snooze on ringing screen | 3/5/10 + custom buttons → `_posponer` → shared `_liberarAudioLocal` teardown → canonical `EstadoAlarmas.posponerAlarma` (Flutter-first; hides native notification = same stop path) | `pantalla_alarma_sonando.dart:138-176,242-256` |
| D4 (audit S4) — recalc erases native-only snooze | Resolved transitively by D1: Flutter now RECORDS every native snooze, so `_recalcular` sees `snoozeActivo` and preserves it; regression-guard test added; additionally snooze cleared when alarm disabled (S2-R5) | `servicio_alarmas.dart:392-401`, test `estado_alarmas_snooze_test.dart` |
| D5 (audit S5) — preserveNativeSnooze origin mismatch | Resolved transitively by D1/D2: Flutter always sends `snoozeUntilMillis` when snoozed, so the preservation net is no longer load-bearing; kept as belt-and-suspenders per design | `AlarmScheduler.kt:preserveNativeSnooze` (unchanged) |
## TDD Cycle Evidence (Strict TDD hard gate)
| Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR |
|------|-----------------------------------|-------------------------------|----------|
| T-S1-01/T-S1-12 | `servicio_alarmas_android_test.dart` written first; load failure + payload keys absent | Payload fields added; 3 tests pass | `dart format` |
| T-S1-02/T-S1-13 | Same RED run: `solicitarExencionBateria` undefined → compile failure | Interface + impl added; test passes | None needed |
| T-S1-14 | "solicita exencion una sola vez" FAILED (Expected 1, Actual 0) | Asked-once guard; both tests pass | Fake made configurable |
| T-S1-03..11 (Kotlin) | N/A — on-device items | N/A | Surgical diffs |
| T-S2a-01..03 / T-S2a-08..13 | All 3 new test files failed to LOAD (missing `EstadoSnoozeNativo`, `accionSnoozed`, `obtenerEstadoSnoozeNativo`, `alarmSnoozeOptionLabel`) — captured before any implementation. Anchor test would fail under old `now+min` semantics (verified by design: old code returned 7:05 vs expected 7:35) | All bridge/state/service changes added; targeted run 23/23 green | Shared `FakePuertoAlarmasAndroid` extracted to `test/helpers/fakes_alarmas.dart`; existing `estado_alarmas_test.dart` deduplicated |
| T-S2a-14..16 | Widget test load failure (l10n key missing) then tap-test failure (snoozeHasta null) | Buttons + `_posponer` implemented; 3/3 green | Debug prints removed; `unawaited` cancel documented |
| T-S2b-01..02 / T-S2b-03..08 | Scaffold tests failed (no `PluriWaveScaffold`/`Animate`); all 5 editor tests failed (key not found) — captured in dedicated RED run (0 passed / 7 failed) | `pluri_animate.dart`, scaffold migration, editor preview/pickers/snooze/volume; 7/7 green | `Material(transparency)` wrappers; initState-l10n fix; `dart format` |
RED run evidence: first run `+0 -3` (loading failures, three files); ringing tap test `Expected: DateTime:<07:35> Actual: <null>`; S2b run `+0 -7` before implementation. GREEN: targeted 23/23 then 7/7; full suite `00:24 +77: All tests passed!`.
## Files changed (Batch 2)
| File | Action | ~Lines |
|------|--------|--------|
| `android/.../AlarmScheduler.kt` | Modified | +57/-12 (unified snooze + `NativeSnoozeResult` + `nativeSnoozeStates`) |
| `android/.../MainActivity.kt` | Modified | +36/-1 (`notifyAlarmEvent` companion, `activeInstance`, `getNativeSnoozeState`) |
| `android/.../PluriWaveAlarmService.kt` | Modified | +19/-1 (snooze report-back) |
| `lib/servicios/servicio_alarmas_android.dart` | Modified | +60 (snoozed event, `EstadoSnoozeNativo`, bridge method) |
| `lib/servicios/servicio_alarmas.dart` | Modified | +20/-3 (anchor, activa-aware snooze clearing) |
| `lib/estado/estado_alarmas.dart` | Modified | +95/-15 (event subscription, snooze recording, cold-start import) |
| `lib/pantallas/pantalla_alarma_sonando.dart` | Modified | +70/-15 (snooze buttons, teardown, PluriWaveScaffold, tokens, fade-in) |
| `lib/pantallas/pantalla_alarmas.dart` | Modified | ~+330/-60 net (preview, pickers, snooze field, volume floor, initState fix, Material wrappers; large diff partly dart-format reflow) |
| `lib/modelos/alarma_musical.dart` | Modified | +10/-2 (`limpiarEmisora`/`limpiarEmisoraFallback`) |
| `lib/app.dart` | Modified | +7 (ignore snoozed events) |
| `lib/tema/pluri_animate.dart` | Created | +39 |
| `lib/l10n/app_*.arb` (13 files) | Modified | +12 each (5 keys + metadata) |
| `lib/l10n/gen/*` (15 files) | Regenerated | by `flutter gen-l10n` |
| `test/helpers/fakes_alarmas.dart` | Created | +120 |
| `test/helpers/fakes.dart` | Modified | +13 (`pausar`/`setVolumen` on FakeServicioAudio) |
| `test/estado/estado_alarmas_snooze_test.dart` | Created | +250 |
| `test/servicios/servicio_alarmas_snooze_test.dart` | Created | +155 |
| `test/pantallas/pantalla_alarma_sonando_test.dart` | Created | +165 |
| `test/pantallas/pantalla_alarma_sonando_scaffold_test.dart` | Created | +150 |
| `test/pantallas/pantalla_alarmas_editor_test.dart` | Created | +210 |
| `test/estado/estado_alarmas_test.dart` | Modified | -78/+8 (fake deduplicated to helper; anchor expectations 7:36:00 → 7:36:02) |
## Deviations from design (Batch 2)
1. **Event subscription lives in the `EstadoAlarmas` CONSTRUCTOR**, not `inicializar` (task text said inicializar). Rationale: snoozed events must be recorded even before/without full init, and tests with `iniciarAutomaticamente: false` stay light. Cancelled in `dispose`.
2. **`posponerEjecucion` clamps minutes to 1..120 instead of coercing to 3/5/10.** The old `calcularSnooze` coercion would have made the custom snooze button (e.g. "7 min", S2-R1-C) silently snooze 5. The native notification path still sanitizes to 3/5/10 (`sanitizeSnoozeMinutes`), unchanged. `calcularSnooze` kept (unused by this path) for API compatibility.
3. **Existing test expectation updated** (`estado_alarmas_test.dart`): unified anchor makes snooze land at `proximaEjecucion(+inminencia normalization) + 5min` = 7:36:02, not `now+5` = 7:36:00. This is the spec'd behavior change (S2-R6), documented inline.
4. **`_recalcular` now clears snooze for INACTIVE alarms** — required by S2-R5-A ("snoozeHasta is null in persistent storage" after disable); previously a disabled alarm kept a stale snoozeHasta forever.
5. **`AlarmaMusical.copyWith` gained `limpiarEmisora`/`limpiarEmisoraFallback`** (not in task text). Without them the picker's "no station" could never clear an existing station (latent pre-existing bug: copyWith null-coalesced).
6. **Pre-existing debug crash fixed**: `_EditorAlarmaSheetState.initState` called `AppLocalizations.of(context)``dependOnInheritedWidgetOfExactType` assert in debug builds. Name controller now created lazily in `didChangeDependencies`. (The sibling `_EditorVacacionesSheet` has the same latent issue — NOT fixed here, out of S2 scope; flag for S5/S6.)
7. **`Material(type: transparency)` wrappers** added inside `PluriGlassSurface` for the editor sheet and the station picker — ListTiles inside a DecoratedBox trigger a debug assert and invisible ink splashes otherwise.
8. **`_liberarAudioLocal` does NOT await `_estadoSub.cancel()`** — a broadcast-subscription cancel future may not resolve until the stream closes (observed in tests); cancellation of delivery is synchronous, so fire-and-forget (`unawaited`) is correct and prevents the snooze tap from stalling.
9. **`AlarmScheduler.snooze` returns `NativeSnoozeResult`** (until/origin/title) so the service can build the report-back payload; `postponeNext` untouched (already had the unified anchor).
10. **Ringing screen `blurSigma` capped to 10** (PluriGlassSurface default 18) as the Design 2.4 cold-GPU mitigation, plus reduced-motion users skip the entry animation entirely.
## Issues found
- `flutter test` does NOT auto-run gen-l10n in this setup despite `generate: true`; `flutter gen-l10n` must be run manually after editing .arb files (gen files are committed).
- `tester.tap` + an awaited broadcast-subscription `cancel()` deadlocks the gesture handler chain in widget tests (see deviation 8) — worth remembering for S3/S7 work.
## On-device verification checklist for the user
From tasks.md Section 11 — S1 items still pending from Batch 1, plus new S2 items:
1. **Kotlin compiles** — native layer edited again without compilation (HIGH risk): build FIRST.
2. Alarm fires app-killed on Android 14+ (S1-R1/R2); channel v2 on alarm stream (S1-R3); 3-stage fallback (S1-R4); native fade (S1-R6); battery dialog once (S1-R5); reboot persistence.
3. **Snooze from ringing screen (S2-R1, S2-R4)**: ring → tap "5 min" → notification dismissed, list shows snoozeHasta immediately, re-fires at that time.
4. **Snooze from notification while app killed (S2-R3)**: tap "Posponer" on the fire notification with the app killed → system alarm icon persists → reopen the app → list shows the snooze WITHOUT waiting for the 60 s poll (cold-start `getNativeSnoozeState` import).
5. **Snooze from notification while app foregrounded (S2-R3)**: same, but the list updates within the same frame via the `snoozed` MethodChannel event.
6. **Stop cancels pending snooze (S2-R5)**: snooze → disable the alarm from the list → does NOT re-fire.
7. **Ringing screen visuals (S2-R7)**: PluriWaveScaffold gradient + entry fade; verify no first-frame stutter on screen-off FSI wake (blur capped); with "remove animations" accessibility setting the entry is instant.
8. **Editor (S2-R8..R11)**: next-trigger preview updates live; searchable pickers for primary AND backup station; snooze duration control; volume slider reaches 0%.
## Verification summary (Batch 2)
- `flutter test`: 77/77 passing (54 pre-batch + 23 new)
- `flutter analyze`: No issues found (identical to baseline)
- `dart format`: applied to all touched Dart files only (gen/ untouched by hand)
- `flutter gen-l10n`: run once after .arb edits
- `flutter build`: NOT run (forbidden)
## Workload / boundary
- Mode: auto-chain local slices (no PRs)
- Current work units: S2a + S2b (complete)
- Boundary: starts from S1-complete tree; ends with S2a+S2b fully checked off, suite green. Rollback = revert the Batch-2 files listed above (S1 files only touched additively in `AlarmScheduler.kt`/`MainActivity.kt`/`PluriWaveAlarmService.kt`).
- Next batch: S3a (test seams) — prerequisite: user performs on-device verification for S1+S2 Kotlin, especially compile.
@@ -0,0 +1,282 @@
# 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.12.3, ringing buttons) and **2b** = editor + visual redesign (2.42.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.
@@ -0,0 +1,94 @@
# Exploration: App quality and native alarms
Consolidated record of three parallel explorations (alarms/notifications, architecture/robustness, UI/UX) over the PluriWave Flutter app. Findings preserve `file:line` evidence so the proposal and later phases can act without re-discovery.
## Headline
The custom native alarm implementation is the RIGHT architecture and must be kept, not replaced. The work is to close reliability gaps (some CRITICAL on Android 14+), reach UX parity with the native Android Clock app, harden runtime robustness, split a god-class, and run a design-system / a11y / i18n / quality-gate pass. No plugin migration.
## 1. Alarms and notifications
### Why the native implementation stays
The custom native alarm stack already does the hard, correct things:
- Schedules with `setAlarmClock` using `AlarmClockInfo` (survives Doze, shows the system status-bar alarm icon).
- Reschedules from `BOOT_COMPLETED`, `LOCKED_BOOT_COMPLETED`, `MY_PACKAGE_REPLACED`, `TIME_SET`, `TIMEZONE_CHANGED` receivers.
- Uses device-protected storage so alarms survive before first unlock.
- Handles `SCHEDULE_EXACT_ALARM` + `USE_EXACT_ALARM` and the `POST_NOTIFICATIONS` Android 13 flow.
- Plays with `USAGE_ALARM` AudioAttributes, holds a `PARTIAL_WAKE_LOCK` (10 min cap), times out the stream at 15s and falls back to a bundled WAV.
Plugin alternatives were evaluated and rejected: the `alarm` package, `android_alarm_manager_plus` + `flutter_local_notifications`, and `awesome_notifications` all conflict with the radio-as-alarm audio control model. DECISION: keep native, fix gaps.
### Gaps (with severity)
| # | Severity | Gap | Evidence |
|---|----------|-----|----------|
| A1 | CRITICAL | `foregroundServiceType="mediaPlayback"` is missing the `alarm` type and the `FOREGROUND_SERVICE_ALARM` permission. On API 34+ this throws `ForegroundServiceTypeException` and the alarm fails silently. | `AndroidManifest.xml`, `PluriWaveAlarmService.kt` |
| A2 | CRITICAL | Snooze UI is absent on the ringing screen; only "Detener" exists. The native notification already exposes a `Posponer` action, so state diverges from `EstadoAlarmas` until the next sync. | `pantalla_alarma_sonando.dart:168-212` |
| A3 | HIGH | Duplicate FSI notifications: `PluriWaveAlarmReceiver.showFireNotification` (id `59*hash+9`) and `PluriWaveAlarmService` (`NOTIFICATION_ID 92841`) both post simultaneously; `dismissFireNotification` only cancels the receiver's. | `PluriWaveAlarmReceiver.kt`, `PluriWaveAlarmService.kt` |
| A4 | HIGH | Fire channels `pluriwave_alarm_fire` / `pluriwave_alarm_native` are created `IMPORTANCE_HIGH` without `setSound(uri, alarmAudioAttributes)`. Android 8+ locks channel sound at creation time, so the alarm AudioAttributes never apply. | channel creation in native layer |
| A5 | MEDIUM | `emisoraFallback` exists in model/editor/persistence but `ServicioAlarmasAndroid.programar()` never passes it; Kotlin `NativeAlarmSpec` lacks the field. The fallback station is silently ignored natively. | `servicio_alarmas_android.dart`, `NativeAlarmSpec` (Kotlin) |
| A6 | MEDIUM | Native path has no fade-in; `fadeInSegundos` is only honored by `PantallaAlarmaSonando._iniciarFadeIn`. When native plays (screen not foregrounded), audio starts at full volume. | `pantalla_alarma_sonando.dart` `_iniciarFadeIn` |
| A7 | MEDIUM | `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is declared but never requested in `_solicitarPermisosNecesariosParaAlarma`. | permission flow |
| A8 | LOW | 3 notification channels where 2 would suffice. | native channel setup |
### Inherited risks from `alarm-clock-module`
- `apply-progress.md` states `flutter build` was never run, so the Kotlin native layer has never been compiled or verified. Native changes in this change carry compile risk until a build is run by the user.
- `state.yaml` says `phase: tasks-ready` while `apply-progress.md` exists -> state drift to reconcile at archive time (LOW).
## 2. Architecture and robustness
| # | Severity | Issue | Evidence |
|---|----------|-------|----------|
| B1 | CRITICAL | `audio_session ^0.1.21` declared in pubspec but ZERO imports in `lib/`. No audio-focus handling: phone calls and other apps do not pause the radio. | `pubspec.yaml:19` |
| B2 | CRITICAL | Static broadcast `StreamController` + static `_handlerInstalado` make the service un-testable and globally shared. | `servicio_alarmas_android.dart:117-119` |
| B3 | CRITICAL | `configurarLocalizaciones(l10n)` is called inside `build()`, so it fires on every `notifyListeners` including buffer events (dozens of times per second). | `mini_reproductor.dart:23` |
| B4 | HIGH | `EstadoRadio` is a 1121-line god-class owning 6 services plus direct I/O. `SharedPreferences.getInstance()` appears at 25+ sites incl. `servicio_ecualizador.dart` and `servicio_grabacion_radio.dart`. | `estado_radio.dart`, multiple services |
| B5 | HIGH | `Timer.periodic` 10s vigilance + 60s refresh; `recalcularTodas()` writes SharedPreferences unconditionally every minute. | `estado_alarmas.dart:316-323` |
| B6 | HIGH | `cargar()` runs before every mutation, no cache, racy interleaving (read-modify-write N+1). | `servicio_alarmas.dart:81-108` |
| B7 | HIGH | `unawaited(radio.reproducir)` swallows errors on the path toward the alarm screen. | `app.dart:324` |
| B8 | HIGH | 1391-line settings screen with inline `jsonDecode`/`jsonEncode` backup logic. | `pantalla_ajustes.dart` |
| B9 | MEDIUM | `_ejecucionesEmitidas` is an unbounded `Set` (memory growth over time). | `estado_alarmas.dart:32` |
| B10 | MEDIUM | Empty `catch(_){}` swallowing errors. | `servicio_audio.dart:343,346,406,428,448`; `servicio_grabacion_radio.dart:156,165,177,288` |
| B11 | MEDIUM | Root `context.watch<EstadoRadio>()` forces full-screen rebuilds. | `pantalla_inicio.dart:43`; 6 sites in `pantalla_ajustes` |
| B12 | MEDIUM | Bare `flutter_lints`; no robustness lints enabled. | `analysis_options.yaml` |
| B13 | MEDIUM | Module-level `_handlerGlobal` with assert-only guard. | `servicio_audio.dart:19-32` |
| B14 | LOW | Dead code. | `servicio_timer.dart:82-91` |
| B15 | LOW | Typo shim `agregarEmitoraCustom`. | `estado_radio.dart:887` |
| B16 | LOW | Hardcoded version list. | `servicio_contenido_app.dart:32` |
### Test base
12 test files exist (good base). Top-5 missing tests: `ServicioAlarmas` concurrent read-modify-write, alarm fire dedup across `refrescarProgramacion`, `PluriWaveAudioHandler` rapid source-switch race, export/import round-trip, `ServicioGrabacionRadio` error recovery.
## 3. UI / UX
Foundation is solid: Material 3 + `ThemeExtension` tokens (`PluriWaveTokens` / `PluriWaveMotion`) + `PluriGlassSurface`.
| # | Severity | Issue | Evidence |
|---|----------|-------|----------|
| C1 | HIGH | Ringing screen is a raw `Scaffold` with `Color(0xFF061722)`, a static PNG, no animation, no snooze. | `pantalla_alarma_sonando.dart:155-212` |
| C2 | HIGH | No `Hero` between `TarjetaEmisora` logo and player `_WaveHero`; custom `PageRouteBuilder` needs `HeroFlightShuttleBuilder` care with `BackdropFilter`. | `tarjeta_emisora.dart`, player route |
| C3 | HIGH | 14+ hardcoded `Color(0x...)` literals. | `pantalla_alarmas.dart:94,144,775`; `pluri_wave_scaffold.dart:34-37,48,53`; `pantalla_alarma_sonando.dart:159,167`; `pluri_premium_widgets.dart:41,185`; `tarjeta_emisora.dart:191` |
| C4 | HIGH | Mini favorite `InkWell` 36x36 has no `Semantics` and is below the 48dp target. | `tarjeta_emisora.dart:238-289` |
| C5 | HIGH | Alarm PNG has no `semanticLabel`. | alarm image widgets |
| C6 | MEDIUM | No reduced-motion handling anywhere (`MediaQuery.disableAnimations` ignored). | app-wide |
| C7 | MEDIUM | Alarm editor: no next-trigger preview, dropdown station picker, no section structure, keyboard-overflow risk in sheet. | `pantalla_alarmas.dart:387-636` |
| C8 | LOW-MED | `_fechaCorta` hardcodes DD/MM/YYYY, breaking ja / en-US / ar locales. | `pantalla_alarmas.dart:1114` |
| C9 | LOW | Bare counters without pluralization. | `pantalla_favoritos.dart:138` |
| C10 | LOW | Shimmer sharp corners. | `tarjeta_emisora.dart:389-420` |
| C11 | LOW | Buscar loading spinner inconsistent with shimmer pattern. | `pantalla_buscar.dart:241-245` |
| C12 | LOW | Non-rounded icon variants. | `pantalla_ajustes.dart:985,1028,1031` |
| C13 | LOW | `notificationColor` is the M3 default purple, not brand. | `main.dart:23` |
Dark-only theme: light mode is explicitly OUT OF SCOPE unless requested.
## Recommendation
Proceed to proposal. Ship reliability first (Slice 1), then UX parity (Slice 2), then runtime robustness (Slice 3), then the `EstadoRadio` split (Slice 4), then design/a11y/i18n (Slice 5), then quality gates and tests (Slice 6). Each slice is a chained, PR-sized unit under 400 changed lines. Strict TDD applies via `flutter test`. The user must run `flutter build` to validate the native (Kotlin) layer, since it has never been compiled.
## Ready for Proposal
Yes.
@@ -0,0 +1,166 @@
# 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.
@@ -0,0 +1,896 @@
# Spec: app-quality-and-native-alarms
Delta requirements for the PluriWave Android alarm reliability, UX parity, runtime
robustness, architecture decomposition, design-system, quality-gate, and streaming
resilience change. Every requirement states what MUST be true after the change is
applied; implementation details are deferred to the design phase.
Verifiability legend:
- **[flutter test]** — unit/widget test exercisable via `flutter test`
- **[flutter analyze]** — static analysis via `flutter analyze`
- **[on-device]** — requires a real build and manual or instrumented verification on Android
hardware/emulator; `flutter build` is forbidden in this project
---
## S1 — Alarm native reliability
### S1-R1 — Foreground-service alarm type declared (CRITICAL)
The `AndroidManifest.xml` MUST declare `foregroundServiceType="mediaPlayback|alarm"` for
`PluriWaveAlarmService` and MUST include the `FOREGROUND_SERVICE_ALARM` permission, so
that Android 14+ (API 34+) does not throw `ForegroundServiceTypeException` when the
alarm fires.
**[on-device]**
#### Scenario S1-R1-A: alarm fires on Android 14+
```
Given a device running Android 14+ (API 34)
And an alarm has been scheduled with a future trigger time
When the trigger time is reached
Then PluriWaveAlarmService starts in the foreground without ForegroundServiceTypeException
And the alarm ringing screen is shown (or the foreground notification is posted)
```
### S1-R2 — Single fire notification per alarm event
On any alarm fire event, exactly one notification SHALL be posted.
`PluriWaveAlarmReceiver` MUST NOT post a duplicate FSI notification when
`PluriWaveAlarmService` is already managing the foreground notification (`NOTIFICATION_ID
92841`). `dismissFireNotification` in `EstadoAlarmas` MUST cancel the single canonical
notification ID.
**[on-device]**
#### Scenario S1-R2-A: no duplicate notification
```
Given an alarm fires and PluriWaveAlarmService posts NOTIFICATION_ID 92841
When the system notification tray is inspected
Then exactly one notification for that alarm is visible
```
#### Scenario S1-R2-B: dismiss cancels the notification
```
Given the fire notification is visible
When the user dismisses the alarm (stop action)
Then the notification is removed from the tray
And no orphan notification remains
```
### S1-R3 — Channel-level alarm audio attributes
The `pluriwave_alarm_fire` and `pluriwave_alarm_native` notification channels MUST be
created with `setSound(uri, audioAttributes)` where `audioAttributes` use
`AudioAttributes.USAGE_ALARM`, so that Android 8+ honors the alarm ringer stream.
**[on-device]**
#### Scenario S1-R3-A: alarm sound plays on alarm channel
```
Given the fire channels are created at app first-launch
When an alarm fires and produces a notification
Then the notification sound plays on the alarm audio stream
And respects the device's alarm volume (not media volume)
```
### S1-R4 — Fallback station passed through MethodChannel
When `AlarmaMusical.emisoraFallback` is set, `ServicioAlarmasAndroid.programar` MUST
pass `fallbackStationUrl` in the `scheduleAlarm` MethodChannel payload, and
`NativeAlarmSpec` (Kotlin) MUST carry the field. `PluriWaveAlarmService` MUST attempt
`prepareAsync` on the fallback URL when the primary station fails.
**[on-device]** (native path); **[flutter test]** (Dart MethodChannel payload assertion)
#### Scenario S1-R4-A: fallback attempted natively
```
Given an alarm has emisoraFallback set to a valid URL
And the primary station stream fails or times out during alarm playback
When the native service handles playback
Then the fallback station URL is attempted via MediaPlayer.prepareAsync
```
#### Scenario S1-R4-B: fallback URL absent → bundled sound
```
Given an alarm has no emisoraFallback
And the primary station stream fails
When the native service handles playback
Then the bundled fallback WAV is played (existing behavior preserved)
```
### S1-R5 — Battery-optimization exemption request
`_solicitarPermisosNecesariosParaAlarma` MUST request `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`
so that Doze-mode does not suppress alarm delivery on devices where the permission is not
already granted.
**[on-device]**
#### Scenario S1-R5-A: exemption requested at setup
```
Given the user grants alarm scheduling permission for the first time
When permission setup runs
Then the system dialog for REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is presented
(unless the app is already exempt)
```
### S1-R6 — Native fade-in matching fadeInSegundos
`PluriWaveAlarmService` MUST apply a volume ramp from 0 to target volume over
`fadeInSegundos` seconds when starting alarm audio on the native path, so that fade-in
works even when the ringing screen is not foregrounded.
**[on-device]**
#### Scenario S1-R6-A: native path audio fades in
```
Given an alarm fires while the app is backgrounded/killed
And the alarm's fadeInSegundos is greater than 0
When the native service starts playing audio
Then audio starts at near-zero volume and ramps to the configured volume over fadeInSegundos seconds
```
---
## S2 — Alarm UX parity (snooze end-to-end)
### S2-R1 — Snooze buttons on PantallaAlarmaSonando
`PantallaAlarmaSonando` MUST display snooze buttons for 3, 5, and 10 minutes, plus one
button showing the alarm's configured `snoozeMinutos` value when it differs from the
fixed options. Tapping any snooze button MUST:
1. Cancel the active `_fadeInTimer`.
2. Stop and dispose the `_fallbackPlayer`.
3. Stop the native alarm service (via the same stop path used by the dismiss button).
4. Call `EstadoAlarmas.posponerAlarma(alarma, minutes)`.
5. Close `PantallaAlarmaSonando`.
**[flutter test]** (widget test for button presence and tap behavior); **[on-device]** (full
native service stop verified manually)
#### Scenario S2-R1-A: snooze button appears
```
Given PantallaAlarmaSonando is displayed for an alarm with snoozeMinutos = 5
When the widget tree is inspected
Then buttons for 3 min, 5 min, and 10 min snooze are present
And a single "stop" / dismiss button is present
```
#### Scenario S2-R1-B: tapping snooze stops audio and reschedules
```
Given PantallaAlarmaSonando is displayed and audio is playing (fadeInTimer active, fallbackPlayer active)
When the user taps the 5-minute snooze button
Then the fadeInTimer is cancelled
And the fallbackPlayer is stopped and disposed
And the native alarm service receives a stop command
And EstadoAlarmas.posponerAlarma is called with minutes = 5
And the screen is dismissed/popped
```
#### Scenario S2-R1-C: custom snoozeMinutos displayed
```
Given an alarm has snoozeMinutos = 7
When PantallaAlarmaSonando is displayed
Then a snooze button labeled "7 min" is present in addition to the fixed 3/5/10 options
```
### S2-R2 — Alarm list reflects postponed next-trigger immediately
After snooze is accepted (from the Flutter screen or from the native notification), the
alarm list in `PantallaAlarmas` MUST display the updated `snoozeHasta` as the next-trigger
time without waiting for the 60-second periodic poll.
**[flutter test]** (unit test: posponerAlarma updates notifier synchronously)
#### Scenario S2-R2-A: alarm list updated after snooze
```
Given the alarm list is visible and showing an alarm's next-trigger time T
When the user snoozes the ringing alarm for 5 minutes (from PantallaAlarmaSonando)
Then within the same UI frame after posponerAlarma completes
the alarm list shows the new next-trigger time T+5 min
And no manual refresh or navigation is required
```
### S2-R3 — Snooze from native notification while app killed or foregrounded
When the user taps the "Posponer" action on the fire notification while the app is
backgrounded or killed, `AlarmScheduler.snooze(id, minutes)` MUST execute a real
`setAlarmClock` registration for `now + N minutes`. When the app becomes active
(foreground resume), `EstadoAlarmas` MUST reflect the new `snoozeHasta` via the existing
MethodChannel event flow (not waiting for the next 60-second poll).
**[on-device]** (native scheduling verified); **[flutter test]** (Dart event handler updates EstadoAlarmas state)
#### Scenario S2-R3-A: notification snooze schedules real alarm
```
Given the app is killed (not in memory)
And a fire notification with "Posponer" action is visible
When the user taps "Posponer"
Then AlarmScheduler.snooze executes setAlarmClock for now+snoozeMinutes
And the alarm appears in the system status-bar alarm icon count
```
#### Scenario S2-R3-B: Flutter state syncs on resume without polling
```
Given snooze was triggered from the native notification while app was backgrounded
When the app is brought to the foreground
Then EstadoAlarmas.alarmas contains the snoozed alarm with snoozeHasta = snooze target time
And the update happens before the next 60-second poll fires
```
### S2-R4 — Snoozed alarm fires at postponed time
A snoozed alarm MUST fire again at the exact `snoozeHasta` time.
`AlarmScheduler.snooze` MUST call `setAlarmClock` with `snoozeHasta` as the trigger;
the resulting `AlarmClockInfo` MUST be verifiable through the diagnostics channel.
**[on-device]**
#### Scenario S2-R4-A: alarm re-fires after snooze
```
Given an alarm was snoozed for 5 minutes at time T
When time T+5 minutes is reached
Then the alarm fires again (ringing screen shown or notification posted)
And the alarm's snoozeHasta is cleared after it fires
```
#### Scenario S2-R4-B: diagnostics confirm real setAlarmClock registration
```
Given an alarm has been snoozed
When the alarm diagnostics screen is opened
Then the snoozed alarm entry shows a non-null next-trigger time matching snoozeHasta
And "exact alarm scheduled" status is shown for that alarm
```
### S2-R5 — Stop during snooze-pending state cancels snooze
If the user stops an alarm (dismiss) before the snoozed occurrence fires,
`EstadoAlarmas.finalizarEjecucion` (or a dedicated cancel path) MUST cancel the native
`setAlarmClock` registration for the snooze occurrence and clear `snoozeHasta` on the
`AlarmaMusical` model.
**[flutter test]** (unit test: finalizarEjecucion clears snoozeHasta and calls android.cancelar); **[on-device]** (alarm does not re-fire)
#### Scenario S2-R5-A: stop cancels pending snooze
```
Given an alarm is in snooze-pending state (snoozeHasta is set, alarm has not re-fired)
When the user opens the alarm list and disables or deletes the alarm
Or explicitly stops the alarm from any surface
Then ServicioAlarmasAndroid.cancelar is called for that alarm
And the alarm's snoozeHasta is null in persistent storage
And the alarm does not re-fire at the snooze time
```
### S2-R6 — Snooze Dart-side unit tests (strict TDD)
The following Dart-side behaviors MUST be covered by `flutter test` unit tests:
- `EstadoAlarmas.posponerAlarma` calls `android.programar` with updated `snoozeHasta`.
- `EstadoAlarmas.posponerAlarma` calls `notifyListeners` after state update.
- `EstadoAlarmas.finalizarEjecucion` clears `snoozeHasta` and calls `android.cancelar` or `android.programar` without `snoozeHasta`.
- `ServicioAlarmas.posponerEjecucion` computes `snoozeHasta = ejecucion + minutos` correctly.
- The MethodChannel payload sent by `ServicioAlarmasAndroid.programar` for a snoozed alarm contains `snoozeUntilMillis` matching the alarm's `snoozeHasta`.
**[flutter test]**
#### Scenario S2-R6-A: posponerAlarma unit test
```
Given a mock PuertoAlarmasAndroid and a mock ServicioAlarmas
And an alarm with proximaEjecucion = T
When EstadoAlarmas.posponerAlarma(alarma, 10) is called
Then servicio.posponerEjecucion is called with minutos = 10
And android.programar is called once with the updated alarm carrying snoozeHasta = T+10min
And notifyListeners was called
```
### S2-R7 — Ringing screen migrated to PluriWaveScaffold with entry animation
`PantallaAlarmaSonando` MUST use `PluriWaveScaffold` instead of a raw `Scaffold` with
a hardcoded `Color(0xFF061722)` background, and MUST include an entry animation on mount
(fade or slide, honoring `MediaQuery.disableAnimations`).
**[flutter test]** (widget test: PluriWaveScaffold present); **[on-device]** (visual)
#### Scenario S2-R7-A: no raw Scaffold with hardcoded color
```
Given PantallaAlarmaSonando is mounted
When the widget tree is inspected
Then no raw Scaffold with backgroundColor = Color(0xFF061722) is found at the root
And PluriWaveScaffold (or equivalent themed scaffold) wraps the content
```
### S2-R8 — Next-trigger preview in alarm editor
`_EditorAlarmaSheet` MUST display a read-only next-trigger timestamp computed from the
current editor state (schedule type, time, weekdays, one-shot date) so the user can
verify when the alarm will fire before saving.
**[flutter test]** (widget test)
#### Scenario S2-R8-A: next-trigger shown in editor
```
Given the alarm editor is open with a recurring weekday alarm set for Monday/Wednesday at 07:00
When the widget is inspected
Then a text widget shows the next calculated trigger date/time
And it updates when the user changes the schedule
```
### S2-R9 — Searchable station picker
The station selection in the alarm editor MUST use a searchable bottom-sheet picker
instead of a raw `DropdownButtonFormField`, matching the interaction pattern of the main
station picker.
**[flutter test]** (widget test: bottom sheet opens on tap)
#### Scenario S2-R9-A: search bottom sheet opens
```
Given the alarm editor is open
When the user taps the station selection field
Then a bottom sheet with a search input and station list is presented
And typing in the search input filters the list
```
### S2-R10 — Configurable snooze duration
The alarm editor MUST allow the user to set a custom snooze duration (stored as
`AlarmaMusical.snoozeMinutos`). The ringing screen snooze buttons MUST use this value as
the labeled default option.
**[flutter test]** (widget test)
### S2-R11 — Volume-slider floor lowered
The alarm volume slider MUST allow values down to 0.0 (from the current floor of 0.25).
**[flutter test]** (widget test: slider min value)
---
## S3 — Audio and runtime robustness (test seams)
### S3-R1 — audio_session integrated for audio focus
The `audio_session` package (`pubspec.yaml:19`) MUST be imported and configured so that
phone calls and other audio-focus events (transient/permanent loss) pause or duck the
radio playback. The existing `audio_session` declaration MUST NOT remain unused.
**[on-device]** (incoming call pauses radio); **[flutter analyze]** (import present)
#### Scenario S3-R1-A: phone call pauses radio
```
Given the radio is playing
When an incoming phone call starts
Then the radio playback is paused or ducked
And resumes when the call ends (if it was only transient focus loss)
```
### S3-R2 — Injectable StreamController and handler flag
The static `_eventosController` and `_handlerInstalado` fields in `ServicioAlarmasAndroid`
(lines 117-119) MUST be converted to instance fields injectable via constructor or a test
factory, making the class testable without global state side-effects.
**[flutter test]** (unit tests can construct isolated instances)
#### Scenario S3-R2-A: two instances do not share state
```
Given two ServicioAlarmasAndroid instances created independently in a test
When one instance receives an alarm event
Then the other instance's eventosAlarma stream does not emit that event
```
### S3-R3 — configurarLocalizaciones removed from build()
`configurarLocalizaciones(l10n)` MUST NOT be called inside any `build()` method (current
violation: `mini_reproductor.dart:23`). It MUST be called once from the widget lifecycle
(`initState`, `didChangeDependencies`, or the provider consumer's init path).
**[flutter analyze]** (no-ops inside build); **[flutter test]** (verify call count)
#### Scenario S3-R3-A: localization call not in build
```
Given the MiniReproductor widget is mounted and rebuilt 10 times due to state changes
When the call count to configurarLocalizaciones is measured
Then it is called at most once per locale change (not once per rebuild)
```
### S3-R4 — Single cached SharedPreferences instance
A single `SharedPreferences` instance MUST be initialized at app startup (e.g., in
`main.dart`) and injected into all services that currently call
`SharedPreferences.getInstance()` inline (25+ sites). No service MUST call
`SharedPreferences.getInstance()` after app startup.
**[flutter test]** (unit tests use injected mock); **[flutter analyze]** (no getInstance calls in service classes after injection)
#### Scenario S3-R4-A: services receive injected prefs
```
Given the app starts
When ServicioAlarmas, ServicioEcualizador, and ServicioGrabacionRadio are constructed
Then each receives the single SharedPreferences instance (no internal getInstance call)
```
### S3-R5 — recalcularTodas writes guarded by change flag
`recalcularTodas()` in `EstadoAlarmas` (lines 316-323) MUST compare the new schedule to
the existing one before writing to SharedPreferences. If the schedule is unchanged, the
write MUST be skipped.
**[flutter test]**
#### Scenario S3-R5-A: no write on unchanged schedule
```
Given the alarm schedule has not changed since the last write
When recalcularTodas() is called
Then SharedPreferences.setString is NOT called
```
#### Scenario S3-R5-B: write happens on schedule change
```
Given the alarm schedule has changed
When recalcularTodas() is called
Then SharedPreferences.setString IS called exactly once
```
### S3-R6 — Bounded _ejecucionesEmitidas set
`_ejecucionesEmitidas` in `EstadoAlarmas` (line 32) MUST be bounded. Entries that are
older than a configurable retention window (e.g., 24 hours past their scheduled time)
MUST be pruned to prevent unbounded memory growth.
**[flutter test]**
#### Scenario S3-R6-A: old entries pruned
```
Given _ejecucionesEmitidas contains 100 entries all older than 24 hours
When the pruning logic runs (triggered on next alarm event or periodic cleanup)
Then _ejecucionesEmitidas.length is less than or equal to the max expected entries
```
### S3-R7 — In-memory alarm cache in ServicioAlarmas
`ServicioAlarmas` MUST maintain an in-memory cache so that `cargar()` is not called
before every mutation (eliminating the read-modify-write N+1 race at lines 81-108). The
cache MUST be invalidated on any write operation.
**[flutter test]**
#### Scenario S3-R7-A: concurrent mutations use cache
```
Given ServicioAlarmas has loaded alarms into cache
When two mutations are dispatched concurrently
Then only one cargar() call is made (not two), and both mutations are applied correctly
```
---
## S4 — EstadoRadio god-class split
### S4-R1 — EstadoEcualizador extracted
A `EstadoEcualizador extends ChangeNotifier` MUST own all EQ state (preset, bands,
enabled flag) previously in `EstadoRadio`. `EstadoRadio` MUST NOT expose EQ state
directly.
**[flutter test]** (unit test: EstadoEcualizador notifies on preset change); **[flutter analyze]**
#### Scenario S4-R1-A: EQ state owned by EstadoEcualizador
```
Given EstadoEcualizador is registered as a provider
When aplicarPreset is called on EstadoEcualizador
Then EstadoEcualizador notifies its listeners
And EstadoRadio listeners are NOT rebuilt
```
### S4-R2 — EstadoGrabacion extracted
A `EstadoGrabacion extends ChangeNotifier` MUST own all recording state previously in
`EstadoRadio`. `ServicioGrabacionRadio` MUST be managed by `EstadoGrabacion`.
**[flutter test]**
### S4-R3 — EstadoBusqueda extracted
A `EstadoBusqueda extends ChangeNotifier` MUST own search query, results, and loading
state previously in `EstadoRadio`.
**[flutter test]**
### S4-R4 — ServicioExportImport extracted
A `ServicioExportImport` class MUST own the `jsonEncode`/`jsonDecode` backup and restore
logic currently inlined in `pantalla_ajustes.dart` (1391 lines). `PantallaAjustes` MUST
delegate all JSON serialization to `ServicioExportImport`.
**[flutter test]** (round-trip test: serialize then deserialize produces identical config)
#### Scenario S4-R4-A: export/import round-trip
```
Given a non-trivial app configuration (alarms, favorites, EQ presets)
When ServicioExportImport.exportar() is called and its output is passed to importar()
Then the reconstructed configuration equals the original
```
### S4-R5 — Consuming screens use scoped rebuilds
`PantallaInicio`, `PantallaAjustes`, and `PantallaFavoritos` MUST NOT call
`context.watch<EstadoRadio>()` at the root widget. Each MUST use `context.select` or a
`Consumer` scoped to the specific fields it reads.
**[flutter test]** (widget test: changing EQ preset does not rebuild PantallaInicio)
#### Scenario S4-R5-A: EQ change does not rebuild inicio screen
```
Given PantallaInicio is mounted and displaying station info
When EstadoEcualizador notifies (preset change)
Then PantallaInicio's build method is NOT called
```
---
## S5 — Design system, accessibility, i18n pass
### S5-R1 — Hardcoded color literals replaced by tokens
All 14+ hardcoded `Color(0x...)` literals identified in the explore report (C3) MUST be
replaced by `PluriWaveTokens` or `Theme.of(context).colorScheme` references. No new
hardcoded color literals SHALL be introduced.
**[flutter analyze]** (custom lint or grep); **[flutter test]** (widget test: token resolves correctly)
#### Scenario S5-R1-A: no raw color literals in target files
```
Given the diff for Slice 5 is applied
When flutter analyze runs
Then no instances of Color(0x...) appear in the modified files beyond theme-extension token definitions
```
### S5-R2 — Accessibility semantics on favorite button and alarm image
The mini favorite `InkWell` in `TarjetaEmisora` (line 238-289) MUST be wrapped in a
`Semantics` widget with an appropriate `label` and `button: true`. It MUST have a minimum
touch target of 48 dp. The alarm PNG widget MUST carry a `semanticLabel`.
**[flutter test]**
#### Scenario S5-R2-A: favorite button is accessible
```
Given TarjetaEmisora is mounted
When the accessibility tree is inspected
Then the favorite action node has a non-empty semantic label
And its size is at least 48x48 dp
```
### S5-R3 — Reduced-motion guard
A `PluriAnimate` extension (or equivalent helper) MUST check `MediaQuery.disableAnimations`
and skip or replace animations when the user has enabled reduced motion in system settings.
All animations in Slices 2 and 5 MUST use this guard.
**[flutter test]** (widget test: animation skipped when disableAnimations = true)
#### Scenario S5-R3-A: animation skipped in reduced-motion mode
```
Given MediaQuery.disableAnimations is true (test override)
When PantallaAlarmaSonando is mounted (entry animation present per S2-R7)
Then no animated position/opacity change is applied on mount
```
### S5-R4 — Locale-aware date formatting
`_fechaCorta` in `PantallaAlarmas` (line 1114) MUST use `intl.DateFormat` with the
current locale rather than a hardcoded DD/MM/YYYY format string.
**[flutter test]**
#### Scenario S5-R4-A: date formatted per locale
```
Given locale is 'en-US'
When _fechaCorta is called with DateTime(2026, 6, 11)
Then the result matches DateFormat.yMd('en-US').format(DateTime(2026, 6, 11))
And does NOT return "11/06/2026"
```
### S5-R5 — Pluralization for bare counters
`PantallaFavoritos` (line 138) MUST use `AppLocalizations` plural forms for station count
strings (e.g., "1 station" vs "5 stations").
**[flutter test]**
### S5-R6 — Rounded shimmer placeholders
Shimmer placeholders in `TarjetaEmisora` (lines 389-420) MUST use rounded corners
matching the actual content card corners. The `PantallaBuscar` loading state (lines
241-245) MUST use shimmer instead of a spinner.
**[flutter test]** (widget test: shimmer present during loading state)
### S5-R7 — Rounded icon variants consistent
Icon usage in `PantallaAjustes` (lines 985, 1028, 1031) MUST use `_rounded` Material
icon variants to be consistent with the rest of the app.
**[flutter analyze]** (grep for non-rounded icon names at those sites)
### S5-R8 — Brand notification color
The `notificationColor` in `AudioServiceConfig` (`main.dart:23`) MUST be set to the app's
brand color token rather than the M3 default `Color(0xFF6750A4)`.
**[flutter test]** (unit test: config uses brand color)
---
## S6 — Quality gates
### S6-R1 — Hardened analysis_options.yaml
`analysis_options.yaml` MUST enable at minimum the following lint rules:
`cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`,
`avoid_dynamic_calls`.
**[flutter analyze]**
#### Scenario S6-R1-A: new lints pass
```
Given the hardened analysis_options.yaml is applied
When flutter analyze runs on the full lib/ tree
Then no errors are emitted for cancel_subscriptions, close_sinks, unawaited_futures,
prefer_final_locals, or avoid_dynamic_calls
```
### S6-R2 — Top-5 missing unit tests written
The following test cases MUST exist and pass under `flutter test`:
1. `ServicioAlarmas` concurrent read-modify-write: two simultaneous mutations produce a
consistent final state with no lost write.
2. Alarm fire dedup across `refrescarProgramacion`: calling `refrescarProgramacion` while
an alarm is already in `_ejecucionesEmitidas` does not emit a duplicate event.
3. `PluriWaveAudioHandler` rapid source-switch race: switching stations faster than the
12-second timeout cancels the previous load and does not emit a stale error state.
4. Export/import round-trip (see S4-R4 scenario).
5. `ServicioGrabacionRadio` error recovery: a recording error clears the recording state
and does not leave open sinks.
**[flutter test]**
#### Scenario S6-R2-A: concurrent mutation test
```
Given two calls to ServicioAlarmas.guardar run concurrently with different alarm payloads
When both futures complete
Then the persisted state contains both alarms without either being lost
```
#### Scenario S6-R2-B: source-switch race test
```
Given PluriWaveAudioHandler has started loading station A
When station B is requested before station A finishes loading
Then the playback state does not transition to error for station A's load failure
And the final state reflects station B
```
---
## S7 — Streaming resilience
### S7-R1 — Enlarged live-stream buffer
`PluriWaveAudioHandler` (ExoPlayer / just_audio) MUST configure an enlarged live-stream
buffer sufficient to tolerate short network interruptions of up to approximately 15-30
seconds without audible playback interruption. The buffer size MUST be configurable (not
hardcoded to a single magic constant).
**[on-device]** (network drop test); **[flutter test]** (unit test: buffer config applied to player)
#### Scenario S7-R1-A: buffer covers short network drop
```
Given the radio is playing and ExoPlayer has buffered content
When network connectivity is lost for up to 15 seconds
Then audio playback continues without interruption (buffered content plays through)
And the UI does not show an error state during the buffer window
```
### S7-R2 — Automatic reconnection with bounded exponential backoff
When the live stream stalls or emits a `PlayerException` with a network-related error
code (2xxx range) while the user's intended playback state is "playing" (not user-paused
or user-stopped), `PluriWaveAudioHandler` MUST attempt automatic reconnection using
exponential backoff with a configurable maximum retry count (default: 5) and a configurable
maximum delay (default: 30 seconds). After retries are exhausted, the error MUST be
surfaced to the UI via the `playbackState` error state.
The handler MUST distinguish user-initiated stop/pause (no reconnect) from network stall
(reconnect).
**[flutter test]** (unit test for reconnect decision logic and backoff timing)
#### Scenario S7-R2-A: reconnect on network stall
```
Given the radio is playing and userIntent = playing
When a PlayerException with code 2001 (no internet) is received
Then the handler transitions to a "buffering/reconnecting" processing state
And schedules a retry after the backoff delay (not an immediate error state)
```
#### Scenario S7-R2-B: no reconnect on user stop
```
Given the radio is playing
When the user calls ServicioAudio.detener()
Then userIntent is set to stopped
And if a PlayerException subsequently fires (from the stop race)
Then no reconnection is attempted
```
#### Scenario S7-R2-C: retries exhausted → error surfaced
```
Given the handler has attempted 5 reconnections without success
When the 5th retry also fails
Then playbackState transitions to AudioProcessingState.error with errorMessage set
And no further retries are attempted
```
#### Scenario S7-R2-D: backoff logic unit test
```
Given a reconnect strategy with maxRetries=5 and baseDelay=1s
When retries 1..5 are simulated
Then the delay sequence is approximately [1s, 2s, 4s, 8s, 16s] capped at maxDelay
And after retry 5 the strategy returns "exhausted"
```
### S7-R3 — Buffering/reconnecting state surfaced without dialog spam
While the handler is in the reconnecting/buffering phase, the UI MUST show a loading/
buffering indicator (e.g., the existing `EstadoReproduccion.cargando` state) and MUST NOT
show an error dialog or snackbar for each retry attempt. An error MUST only be shown after
retries are exhausted (S7-R2-C).
**[flutter test]** (widget test: no dialog shown during buffering state)
#### Scenario S7-R3-A: no error dialog during reconnect
```
Given the handler is in reconnecting state (attempt 2 of 5)
When the UI observes estadoStream
Then EstadoReproduccion.cargando is emitted (not error)
And no AlertDialog or SnackBar with error text is visible
```
### S7-R4 — Alarm audio path not regressed
The enlarged buffer and reconnect logic MUST apply only to the user-initiated radio
playback path (`PluriWaveAudioHandler`). The native alarm audio path (`PluriWaveAlarmService`
MediaPlayer) MUST be unchanged by Slice 7. The existing 15-second stream timeout before
falling back to bundled WAV MUST be preserved.
**[on-device]** (alarm still fires with fallback); **[flutter test]** (alarm service not using new buffer config)
#### Scenario S7-R4-A: alarm fallback timing unchanged
```
Given an alarm fires with a station URL that never responds
When 15 seconds elapse
Then the native service falls back to the bundled WAV (behavior unchanged by S7)
```
### S7-R5 — Recording path not regressed
`ServicioGrabacionRadio` uses its own HTTP stream (not `PluriWaveAudioHandler`). Its
error handling MUST NOT be modified by Slice 7 changes.
**[flutter test]** (S6-R2 test #5 still passes after S7 changes)
#### Scenario S7-R5-A: recording error handling unchanged
```
Given ServicioGrabacionRadio is recording from a stream that errors
When the error occurs
Then the recording state is cleared (same behavior as before S7)
And no backoff or reconnect logic from S7 is triggered
```
### S7-R6 — Sleep-timer fade-out not regressed
The sleep-timer fade-out logic (gradual volume reduction → stop) MUST complete normally
even if the stream enters a buffering state during the fade-out window. The reconnect
logic MUST NOT restart playback after the sleep-timer issues a stop command.
**[flutter test]**
#### Scenario S7-R6-A: sleep timer stop honored during reconnect
```
Given the sleep timer is active and has issued a stop command
When the handler would normally schedule a reconnect attempt
Then the reconnect is suppressed because userIntent = stopped (from sleep timer stop)
And audio stops as intended
```
### S7-R7 — Reconnection unit tests (strict TDD)
The following MUST be covered by `flutter test` unit tests:
- Backoff delay computation for retries 1 through N with cap at maxDelay.
- `userIntent` transitions: `reproducir()` sets intent to playing; `detener()` and `pausar()` set intent to stopped/paused.
- No reconnect scheduled when `userIntent != playing`.
- Reconnect attempted when `userIntent == playing` and error is network-class.
- Error state emitted after `maxRetries` exhausted.
**[flutter test]**
---
## Cross-cutting requirements
### CC-R1 — No flutter build required
No requirement in this spec SHALL necessitate running `flutter build`. Native correctness
requirements (marked `[on-device]`) are validated by the user via manual device testing.
### CC-R2 — Strict TDD
All Dart-side behavioral logic introduced by Slices 1-7 MUST be covered by `flutter test`
unit tests written before or alongside the implementation code. Slices that introduce
new Dart classes MUST include at least one test file per new class.
### CC-R3 — No regressions to existing flutter test suite
After each slice, `flutter test` MUST pass with no new failures. All 12 existing test
files MUST continue to pass.
**[flutter test]**
### CC-R4 — flutter analyze clean
After each slice, `flutter analyze` MUST report zero errors. Warnings introduced by new
lint rules (S6-R1) MUST be resolved before the slice PR is merged.
**[flutter analyze]**
@@ -0,0 +1,9 @@
change: app-quality-and-native-alarms
status: proposed
artifact_store: hybrid
# NOTE: Engram MCP was unavailable at proposal time. Files in this directory are
# authoritative; engram mirror was not written and must be backfilled when available.
created: 2026-06-11
updated: 2026-06-11
phase: tasks-ready
tasks_written: 2026-06-11
@@ -0,0 +1,469 @@
# Tasks: app-quality-and-native-alarms
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines (total) | ~1 8502 100 |
| 400-line budget risk (overall) | High — all slices combined |
| Chained PRs recommended | N/A (local apply — no PRs) |
| Suggested split | S1 → S2a → S2b → S3a → S3b → S7 → S4a → S4b → S5 → S6 |
| Delivery strategy | auto-chain |
| Chain strategy | N/A (local apply — user commits at own cadence) |
Decision needed before apply: No
Chained PRs recommended: N/A (local apply)
Chain strategy: N/A (local apply)
400-line budget risk: High
> **Per-slice risks** are noted inline. Each slice is an autonomous apply batch; the
> user reviews and commits before the next slice begins.
### Suggested Work Units (apply batches)
| Batch | Slices | Goal | Prerequisite | Est. lines |
|-------|--------|------|--------------|------------|
| 1 | S1 | Native alarm reliability (manifest, FSI, channels, fallback, fade) | — | ~330 |
| 2 | S2a | Snooze correctness: bridge sync + ringing-screen buttons | S1 complete | ~260 |
| 3 | S2b | Editor redesign + visual (next-trigger, station picker, snooze field, scaffold) | S2a complete | ~180 |
| 4 | S3a | Test seams: statics→instance, prefs injection, cache/mutex, dirty-guard, bounded set | S2 complete | ~270 |
| 5 | S3b | audio_session integration + becoming-noisy + intent flag seam | S3a complete | ~100 |
| 6 | S7 | Streaming resilience: buffer config, reconnect state machine, UI wiring | S3b complete | ~285 |
| 7 | S4a | ServicioExportImport + EstadoEcualizador extraction + compat getters | S3 complete | ~350 |
| 8 | S4b | EstadoGrabacion + EstadoBusqueda + context.select rewiring + remove compat getters | S4a complete | ~380 |
| 9 | S5 | Design system, a11y, i18n, polish | S2b complete | ~210 |
| 10 | S6 | Quality gates: analysis_options + top-5 tests + lint fix-ups | S4b + S5 complete | ~120 |
---
## Slice S1 — Alarm native reliability (~330 lines)
> **Verification verbs** — on-device items are deferred to the user's device checklist (Section 11).
> Dart items: `flutter test`, `flutter analyze`, `dart format`.
### S1 pre-work: write failing tests
- [x] **T-S1-01** [RED] Write failing test: `test/servicios/servicio_alarmas_android_test.dart` — assert `programar()` MethodChannel payload contains keys `fallbackStationUrl`, `fallbackStationName`, `fadeInSegundos`, `fallbackSound`. **Reqs:** S1-R4, S1-R6. **~20 lines.**
- [x] **T-S1-02** [RED] Write failing test in same file — assert `solicitarExencionBateria()` invokes `requestIgnoreBatteryOptimizations` on the MethodChannel. **Reqs:** S1-R5. **~15 lines.**
### S1 implementation: Kotlin / manifest (on-device verification)
- [x] **T-S1-03** Edit `android/app/src/main/AndroidManifest.xml`: add `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM"/>` near line 5; change `PluriWaveAlarmService` to `android:foregroundServiceType="mediaPlayback|alarm"` (lines 54-57). **Reqs:** S1-R1. *On-device verify deferred to user.* **DEVIATION:** `alarm` FGS type / `FOREGROUND_SERVICE_ALARM` permission do NOT exist in the Android SDK (verified against android-36 `android.jar`); implemented with `systemExempted` / `FOREGROUND_SERVICE_SYSTEM_EXEMPTED`, the documented type for alarm-clock apps holding `SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM`.
- [x] **T-S1-04** Edit `android/app/src/main/kotlin/.../PluriWaveAlarmService.kt` line ~75: on API ≥ 34 call `startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_ALARM)`; on API < 34 keep 2-arg overload. **Reqs:** S1-R1. *On-device verify.* **DEVIATION:** uses `FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED` (see T-S1-03; `FOREGROUND_SERVICE_TYPE_ALARM` does not exist).
- [x] **T-S1-05** Edit `PluriWaveAlarmReceiver.kt`: remove `showFireNotification` call (lines 37, 95-133). The service `startForeground` notification (ID 92841) is now the single owner of the FSI. Keep `fireNotificationIdForAlarm` helper for `cancelAlarm` migration safety — do NOT post to it. **Reqs:** S1-R2. *On-device verify.*
- [x] **T-S1-06** Edit `PluriWaveAlarmService.kt` `buildNotification`: add `setFullScreenIntent(...)` so the FSI appears instantly at `startForeground` before audio prepares. Ensure `stopAlarm` (line ~224) calls `stopForeground(STOP_FOREGROUND_REMOVE)` and also cancels any legacy `fireNotificationIdForAlarm` id as migration guard. **Reqs:** S1-R2. *On-device verify.* (`setFullScreenIntent` and both `stopAlarm` guards were already present; verified and documented ordering with a comment.)
- [x] **T-S1-07** Edit `PluriWaveAlarmService.kt` (~line 374) and `PluriWaveAlarmReceiver.kt` (~line 269): introduce versioned channel id `pluriwave_alarm_fire_v2` (IMPORTANCE_HIGH) with `setSound(DEFAULT_ALARM_ALERT_URI, USAGE_ALARM AudioAttributes)` and `enableVibration(true)`. Add one-time channel migration: delete `pluriwave_alarm_native` and `pluriwave_alarm_fire` guarded by SharedPreferences flag `channels_migrated_v2`. Service's `startForeground` notification now uses `_fire_v2`. **Reqs:** S1-R3. *On-device verify.*
- [x] **T-S1-08** Edit `AlarmScheduler.kt` `NativeAlarmSpec` (lines 571-648): add `fallbackStationName: String?`, `fallbackStationUrl: String?`, `fadeInSegundos: Int` fields; bump `schemaVersion` 2→3; update `toJson`/`fromJson` (additive, defaults null/0 for missing fields). Wire through `scheduleAlarm` signature, `MainActivity` handler (lines 68-106), and `EXTRA_*` constants / `fireIntent` extras. **Reqs:** S1-R4, S1-R6. *On-device verify.*
- [x] **T-S1-09** Edit `PluriWaveAlarmService.kt` `startAudio` (lines 86-108): implement three-stage ordered fallback state machine (primary station 15s → fallback station 15s → bundled WAV). Reuse `scheduleStationFallback`/`cancelStationFallback` per stage. **Reqs:** S1-R4. *On-device verify.*
- [x] **T-S1-10** Edit `PluriWaveAlarmService.kt` `setOnPreparedListener` (lines 128-136, 179-183): if `fadeInSegundos > 0`, start at 0.05 × target volume and step every 250 ms toward `volume` via `mainHandler` runnable. Cancel ramp runnable in `stopAlarm` and on snooze. **Reqs:** S1-R6. *On-device verify.*
- [x] **T-S1-11** Add `requestIgnoreBatteryOptimizations` MethodChannel handler in `MainActivity.kt` (mirror `requestExactAlarmPermission` ~lines 255-270): launch `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. **Reqs:** S1-R5. *On-device verify.*
### S1 implementation: Dart bridge
- [x] **T-S1-12** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` `programar()` (lines 148-174): add `fallbackStationUrl`, `fallbackStationName` (from `alarma.emisoraFallback`), and `fadeInSegundos` to the MethodChannel args map. **Reqs:** S1-R4, S1-R6. **~15 lines.**
- [x] **T-S1-13** [GREEN] Add `solicitarExencionBateria()` method to `PuertoAlarmasAndroid` interface and `ServicioAlarmasAndroid` implementation (`lib/servicios/servicio_alarmas_android.dart` ~lines 93-107, 196-218): invoke `requestIgnoreBatteryOptimizations` MethodChannel. **Reqs:** S1-R5. **~20 lines.**
- [x] **T-S1-14** [GREEN] Edit `lib/estado/estado_alarmas.dart` `_solicitarPermisosNecesariosParaAlarma` (lines 268-284): call `android.solicitarExencionBateria()` ONLY when `!diag.ignoraOptimizacionBateria` AND a `bateria_exencion_solicitada` flag is unset in SharedPreferences (asked-once guard). **Reqs:** S1-R5. **~15 lines.**
### S1 verification
- [x] **T-S1-15** Run `flutter test test/servicios/servicio_alarmas_android_test.dart` — T-S1-01, T-S1-02 must pass (GREEN). Verify T-S1-12, T-S1-13, T-S1-14 output. (Full suite: 54 tests passing.)
- [x] **T-S1-16** Run `flutter analyze` — zero errors. (`No issues found!`, identical to pre-S1 baseline.)
- [x] **T-S1-17** Run `dart format lib/servicios/servicio_alarmas_android.dart lib/estado/estado_alarmas.dart`. (Also formatted both touched test files.)
### S1 Definition of Done
- `flutter test` green (T-S1-01, T-S1-02 passing; no regressions in existing 12 test files).
- `flutter analyze` clean.
- `dart format` applied to all edited Dart files.
- Reqs checked off: S1-R1 (on-device), S1-R2 (on-device), S1-R3 (on-device), S1-R4 (Dart portion), S1-R5 (Dart portion), S1-R6 (Dart portion).
- User performs on-device verification (see Section 11) for the Kotlin/manifest tasks before starting S2.
---
## Slice S2a — Snooze correctness (~260 lines)
> Covers Design Decisions 2.12.3. Must ship before S2b.
### S2a pre-work: write failing tests
- [x] **T-S2a-01** [RED] Create `test/estado/estado_alarmas_snooze_test.dart`:
- Test A: `posponerAlarma(alarma, 5)` calls `android.programar` once with `snoozeHasta = proximaEjecucion + 5 min`; calls `notifyListeners`. (S2-R6-A, S2-R1)
- Test B: A `snoozed` native event triggers `servicio.posponerEjecucionHasta` + `notifyListeners` WITHOUT a second `android.programar`. (S2-R3, Decision 2.1)
- Test C: `recalcularTodas` called after `posponerAlarma` PRESERVES `snoozeHasta` (S4 regression guard). (S2-R6)
**DONE — plus extra tests: cold-start snooze import, stop-cancels-snooze (S2-R5), finalizarEjecucion clears snooze. Shared fake moved to `test/helpers/fakes_alarmas.dart`.**
- [x] **T-S2a-02** [RED] Create `test/servicios/servicio_alarmas_snooze_test.dart`:
- Test A: `posponerEjecucionHasta(id, origin, until)` computes `snoozeHasta = origin + minutes` and persists. (S2-R6)
- Test B: MethodChannel payload for a snoozed alarm contains `snoozeUntilMillis` matching `snoozeHasta`. (S2-R6)
- Test C: `finalizarEjecucion` clears `snoozeHasta` and calls `android.cancelar` (or `programar` without `snoozeHasta`). (S2-R5, S2-R6)
**DONE — Test C lives in `estado_alarmas_snooze_test.dart` (it is EstadoAlarmas behavior). Added anchor-clamp and custom-minutes tests + `getNativeSnoozeState` bridge tests.**
- [x] **T-S2a-03** [RED] Add test in `test/estado/estado_alarmas_snooze_test.dart`: after `posponerAlarma`, the alarm list in the state reflects updated `snoozeHasta` synchronously (no poll wait). (S2-R2) **DONE.**
### S2a implementation: Kotlin native→Flutter sync (on-device portion)
- [x] **T-S2a-04** Edit `PluriWaveAlarmService.kt` snooze handler (`ACTION_SNOOZE`, now lines 56-80): after `AlarmScheduler.snooze(...)` (which now returns `NativeSnoozeResult`), calls `MainActivity.notifyAlarmEvent` with `alarmAction="snoozed"`, `occurrenceAtMillis`, `snoozeUntilMillis`, title and minutes. **Reqs:** S2-R3, Decision 2.1. *On-device verify.*
- [x] **T-S2a-05** `MainActivity.kt` companion `notifyAlarmEvent(payload)` (lines ~610-635): posts `alarmFired` on the main handler through a `@Volatile activeInstance` (set in `configureFlutterEngine`, cleared in `onDestroy`); no-op with log when engine dead. **Reqs:** S2-R3. *On-device verify.*
- [x] **T-S2a-06** `AlarmScheduler.kt` `snooze()` (lines 266-292): anchor unified to `occurrenceAt + minutes` clamped to `now + minutes` (postponeNext logic adopted; also persists `snoozeMinutes`); returns `NativeSnoozeResult` for the bridge callback. **Reqs:** S2-R4, Decision 2.2. *On-device verify.*
- [x] **T-S2a-07** `AlarmScheduler.kt` `nativeSnoozeStates()` (lines 366-385) returns active future snoozes (alarmId + snoozeUntilMillis + snoozeOriginMillis); wired as `getNativeSnoozeState` in `MainActivity` (line 192). **Reqs:** S2-R3, Decision 2.1 engine-dead case. *On-device verify.*
### S2a implementation: Dart bridge and state
- [x] **T-S2a-08** [GREEN] `EventoAlarmaAndroid` extended with `snoozeUntilMillis` field and `accionSnoozed` const; `app.dart` `_abrirAlarmaSonando` ignores `snoozed` events (EstadoAlarmas owns them). **DONE.**
- [x] **T-S2a-09** [GREEN] `programar()` already sent `snoozeUntilMillis`/`snoozeOriginMillis` (pre-existing); now LOCKED by test (`servicio_alarmas_snooze_test.dart` payload test). **Reqs:** S2-R6. **No code change needed.**
- [x] **T-S2a-10** [GREEN] `EstadoAlarmas` subscribes to `android.eventosAlarma` in the CONSTRUCTOR (not `inicializar` — see deviations); `_alRecibirEventoNativo` (estado_alarmas.dart:266) records the snooze via `posponerEjecucionHasta` + `_aplicar` + `notifyListeners`, with NO second `android.programar`. Subscription cancelled in `dispose`. **Reqs:** S2-R3, S2-R2. **DONE.**
- [x] **T-S2a-11** [GREEN] `_sincronizarEjecucionesGestionadasPorAndroid` now always ends with `_importarSnoozesNativosActivos()` (estado_alarmas.dart:306,312): imports active future native snoozes for active alarms when they differ from the stored value. **Reqs:** S2-R3. **DONE.**
- [x] **T-S2a-12** [GREEN] `obtenerEstadoSnoozeNativo()` added to `PuertoAlarmasAndroid` + impl invoking `getNativeSnoozeState`; new `EstadoSnoozeNativo` model with `fromMap`. **DONE.**
- [x] **T-S2a-13** [GREEN] S2-R5 implemented in `servicio_alarmas.dart` `_recalcular` (line 395): `snoozeActivo` now requires `alarma.activa`, so disabling an alarm clears its snooze; `finalizarEjecucion` already cleared it via `completarEjecucion` and re-programs without snooze through `_sincronizarTodas` (the real bridge cancels natively for inactive alarms). Both paths covered by tests. **Reqs:** S2-R5. **DONE.**
### S2a implementation: ringing screen snooze buttons
- [x] **T-S2a-14** [RED] Widget tests in `test/pantallas/pantalla_alarma_sonando_test.dart`:
- Test A: snooze buttons 3/5/10 + custom 7 present; no-dup test when snoozeMinutos=5. (S2-R1-A, S2-R1-C)
- Test B: tapping 5-min snooze records snoozeHasta, pauses audio, hides the native notification and pops. (S2-R1-B)
**DONE.**
- [x] **T-S2a-15** [GREEN] `_liberarAudioLocal()` (pantalla_alarma_sonando.dart:138) cancels `_fallbackTimer`/`_fadeInTimer`, cancels `_estadoSub` (fire-and-forget — see deviations), stops `_fallbackPlayer`; `_posponer(int)` (line 161) = teardown → `radio.audio.pausar()``posponerAlarma` → pop; `_detener` refactored to reuse it. **Reqs:** S2-R1. **DONE.**
- [x] **T-S2a-16** [GREEN] Snooze button row (`_opcionesSnooze()` = sorted {3,5,10,custom}) rendered with `l10n.alarmSnoozeOptionLabel(min)` + `l10n.snoozeAction` header, each wired to `_posponer`. New ARB keys added to ALL 13 locales. **Reqs:** S2-R1-A/B/C. **DONE.**
### S2a verification
- [x] **T-S2a-17** `flutter test` on the three snooze test files — all green (RED phase captured first: compile failures + anchor mismatch).
- [x] **T-S2a-18** `flutter test` (full suite) — 77/77 passing, no regressions.
- [x] **T-S2a-19** `flutter analyze``No issues found!`.
- [x] **T-S2a-20** `dart format` applied to all touched Dart files (lib + test).
### S2a Definition of Done
- `flutter test` green (new snooze tests passing; 12 existing files unbroken).
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S2-R1, S2-R2, S2-R3 (Dart portion), S2-R4 (Kotlin deferred), S2-R5, S2-R6.
---
## Slice S2b — Editor + visual redesign (~180 lines)
> Covers Design Decisions 2.42.5.
### S2b pre-work: write failing tests
- [x] **T-S2b-01** [RED] `test/pantallas/pantalla_alarma_sonando_scaffold_test.dart`: asserts `PluriWaveScaffold` present; no `Scaffold` with `Color(0xFF061722)`; `Animate` present normally and ABSENT with `disableAnimations=true`. **Reqs:** S2-R7, S5-R3. **DONE.**
- [x] **T-S2b-02** [RED] `test/pantallas/pantalla_alarmas_editor_test.dart` (5 tests):
- Test A: next-trigger preview present (key `next-trigger-preview`) and changes when weekday recurrence changes (Mon→Tue, date-independent). (S2-R8)
- Test B: station field opens bottom sheet with `SearchBar`; typing filters the list. (S2-R9)
- Test B2: fallback-station field opens the same picker. (S2-R9)
- Test C: snooze SegmentedButton present; selecting 10 + save persists `snoozeMinutos = 10`. (S2-R10)
- Test D: volume slider min is 0.0. (S2-R11)
**DONE.**
### S2b implementation
- [x] **T-S2b-03** [GREEN] Ringing screen migrated to `PluriWaveScaffold`; `Color(0xFF061722)` removed, `Color(0xFFFFB86B)``tokens.warmCoral`; `blurSigma` capped to 10 with cold-GPU comment (Design 2.4 mitigation). **Reqs:** S2-R7, S5-R1 (partial). **DONE.**
- [x] **T-S2b-04** [GREEN] `lib/tema/pluri_animate.dart` created: `pluriFadeIn`/`pluriScaleIn` returning the child untouched when `MediaQuery.maybeDisableAnimationsOf(context)` is true. **Reqs:** S5-R3. **DONE.**
- [x] **T-S2b-05** [GREEN] Glass surface wrapped in `.pluriFadeIn(context)` entry animation. **Reqs:** S2-R7. **DONE.**
- [x] **T-S2b-06** [GREEN] `_vistaProximaEjecucion` in the editor: computes `calcularProxima` from the in-progress draft (respects vacations/exceptions), renders `alarmNextExecution`/`alarmNoNextExecution`, recomputed on every `setState` so it tracks time/recurrence edits live. **Reqs:** S2-R8. **DONE.**
- [x] **T-S2b-07** [GREEN] `DropdownButtonFormField` replaced by `_CampoSelectorEmisora` + `_SelectorEmisoraSheet` (bottom sheet with `SearchBar` over favorites + "no station" option); second identical picker added for `emisoraFallback` (NEW field in the editor). `AlarmaMusical.copyWith` gained `limpiarEmisora`/`limpiarEmisoraFallback` so "none" actually clears. **Reqs:** S2-R9. **DONE.**
- [x] **T-S2b-08** [GREEN] Snooze duration SegmentedButton (3/5/10 + current custom value) writing `_snoozeMinutos` (saved via `copyWith(snoozeMinutos: ...)` — the editor previously hardcoded 5 for new alarms); volume slider floor lowered 0.25 → 0.0 (divisions 20). **Reqs:** S2-R10, S2-R11. **DONE.**
### S2b verification
- [x] **T-S2b-09** `flutter test` on both S2b test files — 7/7 green (RED captured first).
- [x] **T-S2b-10** `flutter test` (full suite) — 77/77 passing, no regressions.
- [x] **T-S2b-11** `flutter analyze``No issues found!`.
- [x] **T-S2b-12** `dart format` applied to all touched files.
### S2b Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S2-R7, S2-R8, S2-R9, S2-R10, S2-R11.
---
## Slice S3a — Test seams: statics, prefs, cache, mutex, bounded set (~270 lines)
> Covers Design Decisions 3.2, 3.3, 3.4, 3.5. Must complete before S3b and S7 (S7 depends on the intent flag seam from 3.1, which is in S3b).
### S3a pre-work: write failing tests
- [ ] **T-S3a-01** [RED] Create `test/servicios/servicio_alarmas_android_instance_test.dart`: two `ServicioAlarmasAndroid` instances do not share `_eventosController` (S3-R2-A). Use a fake `MethodChannel`. **~20 lines.**
- [ ] **T-S3a-02** [RED] Create `test/servicios/servicio_alarmas_cache_test.dart`:
- Test A: `recalcularTodas` does NOT call `SharedPreferences.setString` when schedule unchanged (S3-R5-A).
- Test B: `recalcularTodas` calls `SharedPreferences.setString` exactly once when changed (S3-R5-B).
- Test C: Two concurrent `guardarAlarma` calls produce consistent final state — no lost write (S3-R7-A, S6-R2 test #1 preview). **~50 lines.**
- [ ] **T-S3a-03** [RED] Create `test/estado/estado_alarmas_ejecuciones_test.dart`: `_ejecucionesEmitidas` with 100 stale entries triggers pruning; length ≤ max threshold after prune (S3-R6-A). **~20 lines.**
- [ ] **T-S3a-04** [RED] Create `test/widgets/mini_reproductor_configurar_test.dart`: `configurarLocalizaciones` called at most once per locale change across 10 rebuilds (S3-R3-A). **~20 lines.**
### S3a implementation
- [ ] **T-S3a-05** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` (lines 117-120): convert `static _eventosController`, `static _handlerInstalado`, `static _l10n` to INSTANCE fields. Install handler in constructor. Add deprecated static shim for `estado_radio.dart:74` call site (one-release compat). Rewire `EstadoRadio.configurarLocalizaciones` to call the instance. **Reqs:** S3-R2. **~40 lines.**
- [ ] **T-S3a-06** [GREEN] Edit `lib/widgets/mini_reproductor.dart` (line 23): convert to `StatefulWidget` if not already; move `configurarLocalizaciones(l10n)` call to `didChangeDependencies`, guarded by a cached `Locale` comparison so it only runs on locale change. **Reqs:** S3-R3. **~25 lines.**
- [ ] **T-S3a-07** [GREEN] Edit `lib/main.dart`: resolve `SharedPreferences.getInstance()` ONCE before `runApp`; pass the instance through to providers / service constructors. **Reqs:** S3-R4. **~10 lines.**
- [ ] **T-S3a-08** [GREEN] Audit and edit `lib/servicios/servicio_ecualizador.dart`, `lib/servicios/servicio_grabacion_radio.dart`, and any remaining service calling `SharedPreferences.getInstance()` inline (~25 sites): replace with injected `prefs` parameter. Use `_resolverPrefs` fallback in `servicio_alarmas.dart:399-400` as temporary compat net during migration. **Reqs:** S3-R4. **~30 lines total across files.**
- [ ] **T-S3a-09** [GREEN] Edit `lib/servicios/servicio_alarmas.dart` (lines 316-323): add dirty-check in `recalcularTodas` — serialize new config; compare to loaded serialized; skip `_guardar` if identical. Return loaded config unchanged when clean. **Reqs:** S3-R5. **~20 lines.**
- [ ] **T-S3a-10** [GREEN] Edit `lib/servicios/servicio_alarmas.dart` (lines 81-108): introduce in-memory `ConfiguracionAlarmas?` cache and a `Future`-chain mutex (mirror `_colaCambioFuente` pattern from `servicio_audio.dart:125`). All mutations: `await _lock` → read cache → mutate → persist → update cache → release. Remove `cargar()` calls before each mutation. **Reqs:** S3-R7. **~50 lines.**
- [ ] **T-S3a-11** [GREEN] Edit `lib/estado/estado_alarmas.dart` (line 32): replace unbounded `Set<String> _ejecucionesEmitidas` with a bounded structure (cap ~200 entries); add pruning of entries with millis suffix older than 24 h on each `_vigilarAlarmasVencidas` pass (lines 326-348). **Reqs:** S3-R6. **~25 lines.**
### S3a verification
- [ ] **T-S3a-12** Run `flutter test test/servicios/servicio_alarmas_android_instance_test.dart test/servicios/servicio_alarmas_cache_test.dart test/estado/estado_alarmas_ejecuciones_test.dart test/widgets/mini_reproductor_configurar_test.dart`.
- [ ] **T-S3a-13** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S3a-14** Run `flutter analyze` — zero errors.
- [ ] **T-S3a-15** Run `dart format` on all edited Dart files.
### S3a Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S3-R2, S3-R3, S3-R4, S3-R5, S3-R6, S3-R7.
---
## Slice S3b — audio_session + becoming-noisy + intent flag (~100 lines)
> Provides the `_intencionReproducir` flag seam that S7 requires.
### S3b pre-work: write failing tests
- [ ] **T-S3b-01** [RED] Create `test/servicios/servicio_audio_session_test.dart`:
- Test A: interruption `begin/pause` event sets `_intencionReproducir` to false and pauses playback. (S3-R1)
- Test B: interruption `end/shouldResume` resumes playback. (S3-R1)
- Test C: becoming-noisy event pauses playback. (S3-R1)
**~30 lines.**
### S3b implementation
- [ ] **T-S3b-02** [GREEN] Create `lib/servicios/servicio_audio_session.dart`: `ServicioAudioSession` wrapper around `package:audio_session`. In `configurar()`: `AudioSession.instance` → configure with `AudioSessionConfiguration.music()` adjusted (playback category, `androidWillPauseWhenDucked: true`). Subscribe to `interruptionEventStream` (pause/duck/resume) and `becomingNoisyEventStream` (pause). On interrupt begin: call `handler.pause()` + set `handler._intencionReproducir = false`. On end with `shouldResume`: call `handler.play()` + set `handler._intencionReproducir = true`. **Reqs:** S3-R1. **~60 lines.**
- [ ] **T-S3b-03** [GREEN] Edit `lib/servicios/servicio_audio.dart` `PluriWaveAudioHandler`: expose `_intencionReproducir` flag (bool, default false). Set true in `play()`/`reproducir()`/`reanudar()`; set false in `pause()`/`detener()`. This is the seam S7 will read. Wire `ServicioAudioSession.configurar()` call from `main.dart` or `PluriWaveAudioHandler` init. **Reqs:** S3-R1. **~20 lines.**
### S3b verification
- [ ] **T-S3b-04** Run `flutter test test/servicios/servicio_audio_session_test.dart`.
- [ ] **T-S3b-05** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S3b-06** Run `flutter analyze` — zero errors.
- [ ] **T-S3b-07** Run `dart format lib/servicios/servicio_audio_session.dart lib/servicios/servicio_audio.dart`.
### S3b Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S3-R1 (`flutter analyze` import present; on-device call-pause deferred to user).
---
## Slice S7 — Streaming resilience (~285 lines)
> Depends on S3b (intent flag seam). Covers Design Decisions 7.17.2.
### S7 pre-work: write failing tests
- [ ] **T-S7-01** [RED] Create `test/servicios/servicio_audio_reconnect_test.dart`:
- Test A: backoff delay sequence for retries 15 is [1s, 2s, 4s, 8s, 16s] capped at maxDelay (S7-R7).
- Test B: `_intencionReproducir=true` + stall → `reconectando` state emitted, reconnect scheduled (S7-R2-A, S7-R7).
- Test C: `_intencionReproducir=false` + stall → NO reconnect (S7-R2-B, S7-R7).
- Test D: after `maxRetries` exhausted → error state emitted (S7-R2-C, S7-R7).
- Test E: successful reconnect resets retry counter (S7-R7).
- Test F: user stop during stall cancels reconnect (S7-R6, S7-R7).
**~70 lines.**
- [ ] **T-S7-02** [RED] Add test in `test/servicios/servicio_audio_reconnect_test.dart`: buffer config (`AndroidLoadControl`) applied to player construction (S7-R1). **~15 lines.**
- [ ] **T-S7-03** [RED] Add widget test `test/widgets/reconnect_ui_test.dart`: no `AlertDialog`/`SnackBar` shown while handler in `reconectando` state (S7-R3-A). **~20 lines.**
### S7 implementation
- [ ] **T-S7-04** [GREEN] Edit `lib/servicios/servicio_audio.dart` `_crearPlayer` (lines 159-163): pass `AudioLoadConfiguration(androidLoadControl: AndroidLoadControl(minBufferDuration: 15s, maxBufferDuration: 50s, bufferForPlaybackDuration: 2.5s, bufferForPlaybackAfterRebufferDuration: 5s, prioritizeTimeOverSizeThresholds: true))` at construction. Values extracted as named constants, NOT magic literals. **Reqs:** S7-R1. **~25 lines.**
- [ ] **T-S7-05** [GREEN] Edit `lib/servicios/servicio_audio.dart`: add `EstadoReproduccion.reconectando` to the state enum (line 14). **Reqs:** S7-R2, S7-R3. **~3 lines.**
- [ ] **T-S7-06** [GREEN] Edit `lib/servicios/servicio_audio.dart` `_gestionarErrorReproduccion` (lines 207-236) and `_eventosSub.onError` (lines 189-194): instead of transitioning immediately to terminal error when `_intencionReproducir == true` and error is network-class (2xxx range), enter the reconnect state machine — emit `reconectando`, schedule backoff retry using `_cambiarFuente` revision guard. Cancel/reset on user stop or source switch. After `maxRetries` exhaustion fall through to existing terminal error path. Configurable: `_maxRetries = 5`, `_baseDelay = 1s`, `_maxDelay = 30s`. **Reqs:** S7-R2. **~130 lines.**
- [ ] **T-S7-07** [GREEN] Edit `lib/widgets/mini_reproductor.dart` and any player UI: map `EstadoReproduccion.reconectando` → buffering/loading indicator (NOT error dialog). **Reqs:** S7-R3. **~20 lines.**
- [ ] **T-S7-08** [GREEN] Edit `lib/pantallas/pantalla_alarma_sonando.dart` (alarm pre-start / estadoStream listener): ensure `reconectando` is NOT treated as `reproduciendo`; the alarm's existing 12-second fallback timer remains authoritative. Add a comment documenting the boundary. **Reqs:** S7-R4. **~10 lines.**
- [ ] **T-S7-09** [GREEN] Confirm `ServicioGrabacionRadio` error-handling code is NOT modified by S7 changes. Add inline comment referencing S7-R5 invariant. **Reqs:** S7-R5. **~3 lines (comment only).**
### S7 verification
- [ ] **T-S7-10** Run `flutter test test/servicios/servicio_audio_reconnect_test.dart test/widgets/reconnect_ui_test.dart`.
- [ ] **T-S7-11** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S7-12** Run `flutter analyze` — zero errors.
- [ ] **T-S7-13** Run `dart format lib/servicios/servicio_audio.dart lib/widgets/mini_reproductor.dart lib/pantallas/pantalla_alarma_sonando.dart`.
### S7 Definition of Done
- `flutter test` green (all reconnect tests passing).
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S7-R1 (Dart buffer config), S7-R2, S7-R3, S7-R4, S7-R5, S7-R6, S7-R7; on-device stream-drop deferred to user.
---
## Slice S4a — ServicioExportImport + EstadoEcualizador (~350 lines)
> Extraction order: ServicioExportImport first (pure logic, zero UI coupling), then EstadoEcualizador.
### S4a pre-work: write failing tests
- [ ] **T-S4a-01** [RED] Create `test/servicios/servicio_export_import_test.dart`:
- Test A: full round-trip (favorites, groups, EQ, alarms, vacations) — serialize then deserialize produces deep-equal config. (S4-R4-A, S6-R2 test #4)
- Test B: malformed JSON input to `importar()` → graceful empty result, no throw. (S4-R4)
**~40 lines.**
- [ ] **T-S4a-02** [RED] Create `test/estado/estado_ecualizador_test.dart`:
- Test A: `aplicarPreset` notifies `EstadoEcualizador` listeners. (S4-R1-A)
- Test B: `EstadoRadio` listeners are NOT rebuilt on EQ preset change. (S4-R1-A, S4-R5)
**~30 lines.**
### S4a implementation
- [ ] **T-S4a-03** [GREEN] Create `lib/servicios/servicio_export_import.dart`: `ServicioExportImport` class with `exportar(config) → String` and `importar(json) → ConfiguracionCompleta?`. Move all `jsonEncode`/`jsonDecode` backup/restore logic from `lib/pantallas/pantalla_ajustes.dart` (~1391 lines). `PantallaAjustes` delegates to this service. **Reqs:** S4-R4. **~100 lines (service) + ~30 lines cleanup in pantalla_ajustes.**
- [ ] **T-S4a-04** [GREEN] Create `lib/estado/estado_ecualizador.dart`: `EstadoEcualizador extends ChangeNotifier` — owns preset, bands, enabled flag. Move EQ state fields and methods from `lib/estado/estado_radio.dart`. Add `ProxyProvider` registration in `MultiProvider` (wherever providers are registered, likely `lib/main.dart` or `lib/app.dart`). **Reqs:** S4-R1. **~90 lines.**
- [ ] **T-S4a-05** [GREEN] Edit `lib/estado/estado_radio.dart`: add backward-compatible getters delegating EQ state to `EstadoEcualizador` (transition bridge). These are removed in S4b. Add `// TODO(S4b): remove getter` comments. **~20 lines.**
- [ ] **T-S4a-06** [GREEN] Edit `lib/widgets/ecualizador_widget.dart` and `lib/pantallas/pantalla_ajustes.dart` EQ sections: consume `context.watch<EstadoEcualizador>()` (scoped). Screens still compile via compat getters if missed. **Reqs:** S4-R5. **~20 lines.**
### S4a verification
- [ ] **T-S4a-07** Run `flutter test test/servicios/servicio_export_import_test.dart test/estado/estado_ecualizador_test.dart`.
- [ ] **T-S4a-08** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S4a-09** Run `flutter analyze` — zero errors.
- [ ] **T-S4a-10** Run `dart format lib/servicios/servicio_export_import.dart lib/estado/estado_ecualizador.dart lib/estado/estado_radio.dart lib/pantallas/pantalla_ajustes.dart`.
### S4a Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S4-R1, S4-R4.
---
## Slice S4b — EstadoGrabacion + EstadoBusqueda + context.select rewiring (~380 lines)
### S4b pre-work: write failing tests
- [ ] **T-S4b-01** [RED] Create `test/estado/estado_grabacion_test.dart`: `ServicioGrabacionRadio` is managed by `EstadoGrabacion`; notifies listeners on recording state change. (S4-R2) **~20 lines.**
- [ ] **T-S4b-02** [RED] Create `test/estado/estado_busqueda_test.dart`: search query update notifies `EstadoBusqueda` listeners. (S4-R3) **~15 lines.**
- [ ] **T-S4b-03** [RED] Add widget test: changing EQ preset does NOT rebuild `PantallaInicio` (S4-R5-A). **~20 lines.**
### S4b implementation
- [ ] **T-S4b-04** [GREEN] Create `lib/estado/estado_grabacion.dart`: `EstadoGrabacion extends ChangeNotifier` — owns recording state + `_escucharGrabacion` subscription (currently `estado_radio.dart:51, :79`). Register in `MultiProvider`. **Reqs:** S4-R2. **~80 lines.**
- [ ] **T-S4b-05** [GREEN] Create `lib/estado/estado_busqueda.dart`: `EstadoBusqueda extends ChangeNotifier` — owns search query, results, loading state. Register in `MultiProvider`. **Reqs:** S4-R3. **~60 lines.**
- [ ] **T-S4b-06** [GREEN] Edit `lib/pantallas/pantalla_inicio.dart` (line 43): replace root `context.watch<EstadoRadio>()` with `context.select` / `Consumer` scoped to fields it actually reads. **Reqs:** S4-R5. **~30 lines.**
- [ ] **T-S4b-07** [GREEN] Edit `lib/pantallas/pantalla_ajustes.dart` (~6 watch sites): replace each `context.watch<EstadoRadio>()` with scoped `context.select` / `Consumer` for the specific field. **Reqs:** S4-R5. **~40 lines.**
- [ ] **T-S4b-08** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart`: scope the `EstadoRadio` watch. **Reqs:** S4-R5. **~15 lines.**
- [ ] **T-S4b-09** [GREEN] Edit `lib/estado/estado_radio.dart`: remove EQ, recording, and search state fields/methods; remove backward-compatible getters added in S4a (they carried `// TODO(S4b): remove getter` comments). **Reqs:** S4-R1, S4-R2, S4-R3. **~80 lines removed.**
### S4b verification
- [ ] **T-S4b-10** Run `flutter test test/estado/estado_grabacion_test.dart test/estado/estado_busqueda_test.dart` plus the rebuild scope test.
- [ ] **T-S4b-11** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S4b-12** Run `flutter analyze` — zero errors.
- [ ] **T-S4b-13** Run `dart format lib/estado/estado_grabacion.dart lib/estado/estado_busqueda.dart lib/estado/estado_radio.dart lib/pantallas/pantalla_inicio.dart lib/pantallas/pantalla_ajustes.dart lib/pantallas/pantalla_favoritos.dart`.
### S4b Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S4-R2, S4-R3, S4-R5.
---
## Slice S5 — Design system, a11y, i18n (~210 lines)
> Parallelizable after S2b completes (ringing screen literals migrated in S2b).
### S5 pre-work: write failing tests
- [ ] **T-S5-01** [RED] Create `test/widgets/tarjeta_emisora_a11y_test.dart`: favorite `InkWell` has semantic label + `button:true`; size ≥ 48×48 dp (S5-R2-A). **~20 lines.**
- [ ] **T-S5-02** [RED] Add test in `test/tema/pluri_animate_test.dart`: `pluriFadeIn` returns unanimated child when `disableAnimations=true` (S5-R3-A). **~15 lines.**
- [ ] **T-S5-03** [RED] Create `test/pantallas/pantalla_alarmas_fecha_test.dart`: `_fechaCorta` with locale `en-US` returns `DateFormat.yMd('en-US')` result, NOT `11/06/2026` (S5-R4-A). **~15 lines.**
- [ ] **T-S5-04** [RED] Add test `test/pantallas/pantalla_favoritos_plural_test.dart`: plural form changes between 1 and 5 station count strings (S5-R5). **~10 lines.**
- [ ] **T-S5-05** [RED] Add widget test: shimmer present during loading state in `PantallaBuscar` (S5-R6). **~10 lines.**
- [ ] **T-S5-06** [RED] Add unit test: `AudioServiceConfig.notificationColor` equals brand color token (S5-R8). **~10 lines.**
### S5 implementation
- [ ] **T-S5-07** [GREEN] Edit all 14+ remaining `Color(0x...)` literal sites identified in explore C3 (files: `lib/pantallas/`, `lib/widgets/`, excluding `pantalla_alarma_sonando.dart` done in S2b): replace with `PluriWaveTokens` or `Theme.of(context).colorScheme` references. **Reqs:** S5-R1. **~30 lines across files.**
- [ ] **T-S5-08** [GREEN] Edit `lib/widgets/tarjeta_emisora.dart` (lines 238-289): wrap mini favorite `InkWell` in `Semantics(button: true, label: l10n.toggleFavorite)`; set `constraints: BoxConstraints(minWidth: 48, minHeight: 48)`. Add `semanticLabel` to `_AssetIcon`/alarm PNG. **Reqs:** S5-R2. **~15 lines.**
- [ ] **T-S5-09** [GREEN] `lib/tema/pluri_animate.dart` already created in S2b (T-S2b-04). Verify tests pass (no new code needed here unless edge case found).
- [ ] **T-S5-10** [GREEN] Edit `lib/pantallas/pantalla_alarmas.dart` `_fechaCorta` (line 1114): replace hardcoded format string with `intl.DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(date)`. **Reqs:** S5-R4. **~5 lines.**
- [ ] **T-S5-11** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart` (line 138): replace bare counter string with ARB plural form using `AppLocalizations` `stationCount(n)` plural message. Add the ARB plural entry to `lib/l10n/*.arb` files for all supported locales. **Reqs:** S5-R5. **~20 lines (Dart) + ARB entries.**
- [ ] **T-S5-12** [GREEN] Edit `lib/widgets/tarjeta_emisora.dart` shimmer placeholders (lines 389-420): apply `BorderRadius` matching card corners. Edit `lib/pantallas/pantalla_buscar.dart` (lines 241-245): replace spinner with shimmer during loading state. **Reqs:** S5-R6. **~20 lines.**
- [ ] **T-S5-13** [GREEN] Edit `lib/pantallas/pantalla_ajustes.dart` icon sites (lines 985, 1028, 1031): replace non-`_rounded` icon variants with their `_rounded` equivalents. **Reqs:** S5-R7. **~5 lines.**
- [ ] **T-S5-14** [GREEN] Edit `lib/main.dart` (line 23) `AudioServiceConfig`: set `notificationColor` to `PluriWaveTokens.brandColor` (or equivalent token). **Reqs:** S5-R8. **~3 lines.**
### S5 verification
- [ ] **T-S5-15** Run `flutter test test/widgets/tarjeta_emisora_a11y_test.dart test/tema/pluri_animate_test.dart test/pantallas/pantalla_alarmas_fecha_test.dart test/pantallas/pantalla_favoritos_plural_test.dart`.
- [ ] **T-S5-16** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S5-17** Run `flutter analyze` — zero errors (no `Color(0x...)` in modified files beyond token definitions).
- [ ] **T-S5-18** Run `dart format` on all edited files.
### S5 Definition of Done
- `flutter test` green.
- `flutter analyze` clean (no new color literals in modified files).
- `dart format` applied.
- Reqs checked off: S5-R1 through S5-R8.
---
## Slice S6 — Quality gates (~120 lines)
> Hardening pass; depends on S4b + S5 complete (all code settled before lint enforcement).
### S6 pre-work: write failing tests (top-5 required tests not yet written)
- [ ] **T-S6-01** [RED] `test/servicios/servicio_alarmas_cache_test.dart` — Test C (concurrent mutation, S6-R2 test #1): already written as T-S3a-02 Test C. Verify it is present and passing.
- [ ] **T-S6-02** [RED] `test/estado/estado_alarmas_ejecuciones_test.dart` (fire dedup, S6-R2 test #2): already written as T-S3a-03. Verify passing.
- [ ] **T-S6-03** [RED] Create `test/servicios/servicio_audio_source_switch_test.dart`: rapid `playMediaItem(A)`, `playMediaItem(B)`, `playMediaItem(C)` — only C's source active; no stale error from A/B (S6-R2 test #3). Use fake `AudioPlayer` seam. **~35 lines.**
- [ ] **T-S6-04** Confirm `test/servicios/servicio_export_import_test.dart` (S6-R2 test #4, round-trip) exists from T-S4a-01. Verify passing.
- [ ] **T-S6-05** [RED] Create `test/servicios/servicio_grabacion_radio_test.dart`: recording error clears state and releases resources; subsequent start succeeds (S6-R2 test #5, S7-R5 invariant). **~30 lines.**
### S6 implementation
- [ ] **T-S6-06** [GREEN] Edit `analysis_options.yaml`: under `linter.rules` add `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls`. **Reqs:** S6-R1. **~6 lines.**
- [ ] **T-S6-07** [GREEN] Fix violations surfaced by the new lint rules across `lib/` (empty catches → `developer.log`, unawaited futures → `unawaited()` or `await`, open sinks/subscriptions — ensure they are tracked and cancelled). Scope: sites already noted in design B7/B10 plus any new violations. **~30 lines across files.**
- [ ] **T-S6-08** [GREEN] Run `flutter analyze` with new rules and fix remaining violations until clean.
### S6 verification
- [ ] **T-S6-09** Run `flutter test test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_grabacion_radio_test.dart` — green.
- [ ] **T-S6-10** Run `flutter test` (full suite) — all passing including 12 original files.
- [ ] **T-S6-11** Run `flutter analyze` — zero errors under hardened rules.
- [ ] **T-S6-12** Run `dart format` on all edited files.
### S6 Definition of Done
- `flutter test` green — all 5 required tests present and passing; 12 original files unbroken.
- `flutter analyze` clean under hardened `analysis_options.yaml`.
- `dart format` applied.
- Reqs checked off: S6-R1, S6-R2 (tests 1-5).
---
## Cross-cutting batch — state.yaml + on-device checklist
- [ ] **T-CC-01** Update `openspec/changes/app-quality-and-native-alarms/state.yaml`: set `phase: tasks-ready`, `updated: 2026-06-11`.
- [ ] **T-CC-02** After the full apply and all flutter test / analyze passes, run final `dart format lib/` sweep.
### On-device verification checklist (user — Android 14 device)
Perform after S1 and after all slices are applied. **No `flutter build` from this repo — build from IDE or `flutter run`.**
1. **Alarm fires app-killed (S1-R1, S1-R2):** kill the app; wait for a scheduled alarm; confirm `PluriWaveAlarmService` starts with no `ForegroundServiceTypeException` in logcat; exactly one notification in the tray.
2. **Alarm channel uses alarm stream (S1-R3):** lower the alarm volume to 0; raise media volume; confirm alarm sound is silent (alarm volume, not media volume).
3. **Snooze from ringing screen (S2-R1, S2-R4):** with app foreground, let alarm ring; tap 5-min snooze; confirm notification dismissed; alarm list shows `snoozeHasta = now+5min`; alarm re-fires at that time.
4. **Snooze from notification while app killed (S2-R3):** kill the app; let alarm fire to notification; tap "Posponer"; confirm system alarm icon still present; bring app to foreground — alarm list shows snoozed state WITHOUT waiting for 60-second poll.
5. **Stop cancels pending snooze (S2-R5):** snooze an alarm; before re-fire, disable the alarm from the list; confirm alarm does NOT re-fire at the snooze time.
6. **Reboot persistence (S1-R1, S2-R4):** schedule an alarm; reboot device; confirm alarm still fires at scheduled time.
7. **Fallback station attempted (S1-R4):** set primary station to an invalid URL, set `emisoraFallback` to a valid one; let alarm fire; confirm the fallback station plays (or bundled WAV if fallback also fails).
8. **Battery optimization exemption requested (S1-R5):** fresh install; grant alarm permission; confirm the battery-optimization dialog appears exactly once.
9. **Stream drop recovery (S7-R1, S7-R2):** while radio plays, briefly disable WiFi/LTE for ~10 s; confirm audio continues if buffered; on reconnect, playback resumes to live edge without error dialog; a longer drop (>30s) shows reconnecting state, eventually surfaces error after retries exhausted.
10. **Phone call pauses radio (S3-R1):** while radio plays, receive a call; confirm radio pauses/ducks; confirm it resumes after the call.
11. **No alarm regression after S7 (S7-R4):** with S7 changes applied, let an alarm fire with a non-responding URL; confirm WAV fallback fires within ~15 seconds (not delayed by reconnect loop).
---
## Per-slice estimated lines and budget risk
| Slice | Est. lines | 400-line budget risk | Notes |
|-------|-----------|----------------------|-------|
| S1 | ~330 | Medium | Kotlin edits not compilable here; on-device only |
| S2a | ~260 | Medium | Snooze correctness + ringing buttons |
| S2b | ~180 | Low | Editor + visual |
| S3a | ~270 | Medium | Test seams across multiple files |
| S3b | ~100 | Low | audio_session wrapper |
| S7 | ~285 | Medium | Reconnect state machine |
| S4a | ~350 | Medium-High | Two extractions + compat getters |
| S4b | ~380 | Medium-High | Two more extractions + rewiring |
| S5 | ~210 | Low | Design system / i18n |
| S6 | ~120 | Low | Lint rules + 2 new tests |
| **Total** | **~2 285** | **High (overall)** | Distributed across 10 local slices |
+1 -1
View File
@@ -1,7 +1,7 @@
schema: spec-driven
context: |
Tech stack: Flutter/Dart app for Android+iOS. Version 0.1.59+60. Dart SDK ^3.7.0.
Tech stack: Flutter/Dart app for Android+iOS. Version 0.1.60+61. Dart SDK ^3.7.0.
Architecture: Provider/ChangeNotifier with Spanish domain folders: estado, modelos, pantallas, servicios, widgets.
Core deps: just_audio, audio_service, audio_session, provider, sqflite, shared_preferences, http,
google_fonts, flutter_animate, cached_network_image, shimmer, share_plus, file_picker, uuid,