# 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 5) ## 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 | | 3 | S3a + S3b — Test seams (statics/prefs/cache/mutex/dirty-guard/bounded set) + audio_session | COMPLETE (Dart-only batch; call-pause on-device verification deferred to user) | 2026-06-11 | | 4 | S7 — Streaming resilience (buffer config, reconnect state machine, UI wiring) | COMPLETE (Dart-only batch; stream-drop on-device verification deferred to user) | 2026-06-11 | | 5 | S4a — ServicioExportImport + EstadoEcualizador extraction + compat getters | COMPLETE (Dart-only batch) | 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 | ### Slice S3a — Test seams — 15/15 complete | Task | Status | Notes | |------|--------|-------| | T-S3a-01 | [x] | RED: `servicio_alarmas_android_instance_test.dart` — two channels, simulated `alarmFired` via `handlePlatformMessage`, isolation asserted both ways | | T-S3a-02 | [x] | RED: `servicio_alarmas_cache_test.dart` — `_PrefsEspia implements SharedPreferences` (setString/getString counters); no-write-when-clean, exactly-one-write-when-dirty, concurrent-no-lost-write (+ single cache hydration) | | T-S3a-03 | [x] | RED: `estado_alarmas_ejecuciones_test.dart` — 100 stale entries pruned (1 fresh survives) + 250-entry cap test | | T-S3a-04 | [x] | RED: `mini_reproductor_configurar_test.dart` — 10 rebuilds → 1 configurar; locale es→en → 2 | | T-S3a-05 | [x] | GREEN: `ServicioAlarmasAndroid` statics → instance fields; handler installed per instance in ctor. **DEVIATION:** no deprecated static shim (Dart name clash + only call site rewired in same change); `configurarLocalizaciones` added to `PuertoAlarmasAndroid` interface instead | | T-S3a-06 | [x] | GREEN: `MiniReproductor` → StatefulWidget, locale-guarded `didChangeDependencies`; alarm-bridge l10n hoisted to `app.dart` `_PaginaPrincipalState.didChangeDependencies` (design 3.3 alternative), before the early-return | | T-S3a-07 | [x] | GREEN: `main.dart` resolves prefs ONCE; `PluriWaveApp(prefs:)` → `EstadoRadio`/`EstadoAlarmas`(→`ServicioAlarmas`)/`EstadoIdioma` | | T-S3a-08 | [x] | GREEN: injected-with-fallback `_resolverPrefs()` in `estado_radio` (10 sites), `servicio_ecualizador` (6), `servicio_grabacion_radio` (4), `servicio_contenido_app` (3). rg check: only main.dart + one fallback per class remain | | T-S3a-09 | [x] | GREEN: `recalcularTodas` dirty-guard — serialized comparison vs `_cacheRaw`, skips write when identical | | T-S3a-10 | [x] | GREEN: `_cache`/`_cacheRaw` + `_enCola` writer queue; all 8 mutation methods queued over `_configActual()`. **DEVIATION:** public `cargar()` still re-reads prefs (queued cache reset) because `EstadoRadio.importarConfig` writes the raw alarms key directly — a fully cached cargar would hide imports until restart | | T-S3a-11 | [x] | GREEN: bounded `_ejecucionesEmitidas` — cap 200 + 24 h retention, pruned on every add and each `_vigilarAlarmasVencidas` pass; `@visibleForTesting` length getter | | T-S3a-12 | [x] | Targeted S3a tests green (RED first: 1 passed / 6 failed across the batch) | | T-S3a-13 | [x] | Full suite 89/89 (77 baseline + 12 new) | | T-S3a-14 | [x] | `flutter analyze` — No issues found | | T-S3a-15 | [x] | `dart format` on 19 touched files | ### Slice S3b — audio_session + intent flag — 7/7 complete | Task | Status | Notes | |------|--------|-------| | T-S3b-01 | [x] | RED: `servicio_audio_session_test.dart` — 5 tests (pause-begin, resume-end, no-resume-without-prior-pause, duck begin/end, becoming-noisy hard pause) over fake `ObjetivoAudioInterrumpible` | | T-S3b-02 | [x] | GREEN: `lib/servicios/servicio_audio_session.dart` — `music().copyWith(androidWillPauseWhenDucked: true)`; interruption + becoming-noisy subscriptions; `_pausadoPorInterrupcion` gate for auto-resume; defines `ObjetivoAudioInterrumpible` (test seam) | | T-S3b-03 | [x] | GREEN: `PluriWaveAudioHandler implements ObjetivoAudioInterrumpible`; `_intencionReproducir` true in `play()`/`playMediaItem()`, false in `pause()`/`stop()` (S7 seam); duck via `setAtenuado` ×0.3 (`_volumenEfectivo`); `configurar()` wired in `main.dart` | | T-S3b-04 | [x] | Targeted run 5/5 green (RED first: load failure) | | T-S3b-05 | [x] | Full suite 89/89 | | T-S3b-06 | [x] | `flutter analyze` — No issues found | | T-S3b-07 | [x] | `dart format` applied | ### Slice S7 — Streaming resilience — 13/13 complete | Task | Status | Notes | |------|--------|-------| | T-S7-01 | [x] | RED: `servicio_audio_reconnect_test.dart` — 8 tests over `ControladorReconexion` (backoff sequence + cap, retry scheduled with intent=true, NO retry with intent=false, exhaustion → agotado, restablecer resets counter + backoff base, cancelar kills pending timer) via injectable fake-timer factory | | T-S7-02 | [x] | RED: buffer-config test asserts `PluriWaveAudioHandler.configuracionCargaAndroid` values (15s/50s/2.5s/5s, prioritizeTime=true) — construction wiring not unit-testable without platform channels (see deviations) | | T-S7-03 | [x] | RED: `reconnect_ui_test.dart` — reconectando shows spinner + "Reconectando..." label, NO AlertDialog/SnackBar, NO manual-retry button; second test locks retry button to error state only | | T-S7-04 | [x] | GREEN: `_crearPlayer` passes `audioLoadConfiguration: configuracionCargaAndroid`; named `static const` durations. just_audio 0.9.46 API verified in pub-cache source — all design params exist, NO deviation | | T-S7-05 | [x] | GREEN: `EstadoReproduccion.reconectando` added; `estadoStream` maps the handler's `reconectando` flag (error wins, then reconectando, then cargando) | | T-S7-06 | [x] | GREEN: NEW `lib/servicios/controlador_reconexion.dart` (pure logic: maxReintentos=5, base=1s, cap=30s); handler enters reconnect on network-class errors (PlayerException 2xxx OR TimeoutException) with intent=play; retry re-issues source via revision-guarded `_cambiarFuente`; ready+playing resets; pause/stop/playMediaItem cancel/reset; exhaustion → single terminal error; `_cambiarFuente` returns normally when reconnect engaged so `EstadoRadio.reproducir` doesn't snackbar mid-retry | | T-S7-07 | [x] | GREEN: mini player (spinner + `playbackStatusReconnecting` in `_labelEstado`), full player (`_WaveHero`/`_Controles`: reconectando = loading, not error), visualizer stays active; l10n key in ALL 13 .arb locales + gen-l10n | | T-S7-08 | [x] | GREEN: S7-R4 boundary comment at `_estadoSub` listener — only `reproduciendo` cancels the alarm's 12s fallback timer; reconectando never counts as playing (code already correct, now documented + locked by enum distinctness) | | T-S7-09 | [x] | GREEN: `ServicioGrabacionRadio` untouched except S7-R5 invariant comment above `_fallar` | | T-S7-10 | [x] | Targeted run 10/10 green (RED first: `+0 -2` load failures) | | T-S7-11 | [x] | Full suite 99/99 (89 baseline + 10 new) | | T-S7-12 | [x] | `flutter analyze` — No issues found | | T-S7-13 | [x] | `dart format` on 9 touched files (2 reflowed); re-ran suite + analyze after format | ### Slice S4a — ServicioExportImport + EstadoEcualizador — 10/10 complete | Task | Status | Notes | |------|--------|-------| | T-S4a-01 | [x] | RED: `servicio_export_import_test.dart` — v2 round-trip deep-equal (favorites/groups/EQ/alarms/vacations, alarmas raw passthrough, "sin asignar" excluded) + malformed JSON → null (invalid/empty/non-object) | | T-S4a-02 | [x] | RED: `estado_ecualizador_test.dart` — `cambiarPreset` notifies EQ listeners; EQ change does NOT notify EstadoRadio listeners (radio = 0) | | T-S4a-03 | [x] | GREEN: `lib/servicios/servicio_export_import.dart` — `construirExportacion` (v2 envelope, exact legacy key set), `exportar` (pretty JSON), `importar` (graceful `Map?`). pantalla_ajustes lost `dart:convert`; EstadoRadio gained `exportarConfigJson`/`parsearConfigJson` | | T-S4a-04 | [x] | GREEN: `lib/estado/estado_ecualizador.dart` — full EQ state + persistence + audio application; `emisoraActualUuid` callback seam; `ListenableProvider` registration in app.dart (owned/disposed by EstadoRadio) | | T-S4a-05 | [x] | GREEN: EstadoRadio keeps 15 delegating members, all tagged `// TODO(S4b): remove getter`; EQ fields and private helpers removed | | T-S4a-06 | [x] | GREEN: `_SeccionEcualizador` → `Consumer2`; `ecualizador_widget.dart` already presentational (no change); pantalla_reproductor EQ toggle also rewired (deviation 3) | | T-S4a-07 | [x] | Targeted run 4/4 green (RED first: `+0 -2` load failures) | | T-S4a-08 | [x] | Full suite 103/103 (99 baseline + 4 new) | | T-S4a-09 | [x] | `flutter analyze` — No issues found | | T-S4a-10 | [x] | `dart format` on 8 touched files (4 reflowed); analyze + suite re-run after format | ### Remaining slices (not started) S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending. ## Snooze defect fixes (design audit D1–D5 / S1–S5) | 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: `; S2b run `+0 -7` before implementation. GREEN: targeted 23/23 then 7/7; full suite `00:24 +77: All tests passed!`. ### Batch 3 TDD Cycle Evidence (S3a + S3b) | Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR | |------|-----------------------------------|-------------------------------|----------| | T-S3a-01/T-S3a-05 | Static controller shared events: `expect(eventosB, isEmpty)` FAILED ('solo-a' leaked into B) | Statics → instance fields; isolation test passes | Comment documenting handler re-bind semantics | | T-S3a-02-A/T-S3a-09 | `recalcularTodas` always wrote: `Expected: <1> Actual: <2>` writes | Dirty-guard skips clean writes | `_serializar` extracted, shared with `_guardar` | | T-S3a-02-B | Passed pre-fix (exactly-once lock-in guard) | Still passes (regression lock) | — | | T-S3a-02-C/T-S3a-10 | Lost write: final config had 1 of 2 alarms; 2 hydration reads | Cache + `_enCola` queue: both alarms persisted, 1 hydration read | Mutation bodies kept verbatim, only wrapped | | T-S3a-03/T-S3a-11 | Load failure: `ejecucionesEmitidasLength`/`maxEjecucionesEmitidas` undefined | Bounded set: 1 survivor of 101, cap respected | Prune helper shared by add-path and watch-pass | | T-S3a-04/T-S3a-06 | `Expected: <1> Actual: <11>` (configurar on every rebuild) | StatefulWidget + locale guard: 1 then 2 | FakeServicioAudio gained l10n override (assert fix) | | T-S3b-01/02/03 | Load failure: `servicio_audio_session.dart` missing | 5/5 green against fake objetivo | — | RED run evidence (Batch 3): `00:06 +1 -6` before implementation (the single pass is the exactly-once write lock-in). GREEN: targeted 12/12; full suite `00:12 +89: All tests passed!`. ### Batch 4 TDD Cycle Evidence (S7) | Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR | |------|-----------------------------------|-------------------------------|----------| | T-S7-01/T-S7-06 | Load failure: `controlador_reconexion.dart` missing (`+0 -2` run) | `ControladorReconexion` created; 6 decision/backoff tests pass | Doc comments tying defaults to the ~60-90s design window | | T-S7-02/T-S7-04 | Same RED run: `configuracionCargaAndroid` undefined | Const config + `_crearPlayer` wiring; values test passes | Durations extracted as named `static const` | | T-S7-03/T-S7-05/T-S7-07 | Compile failure: `EstadoReproduccion.reconectando` missing | Enum + stream mapping + UI wiring; both widget tests pass | Test fixed to double-pump (stream event delivery + frame); diag run proved impl correct before the fix | RED run evidence (Batch 4): `00:00 +0 -2` (both files fail to load). GREEN: targeted `00:01 +10: All tests passed!`; full suite `00:08 +99: All tests passed!` (89 baseline + 10 new). ### Batch 5 TDD Cycle Evidence (S4a) | Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR | |------|-----------------------------------|-------------------------------|----------| | T-S4a-01/T-S4a-03 | Load failure: `servicio_export_import.dart` missing (`+0 -2` run) | Service created; round-trip + malformed tests pass | Envelope comments tied to legacy format compatibility | | T-S4a-02/T-S4a-04/05 | Same RED run: `estado_ecualizador.dart` missing, `estado.ecualizador` undefined | EQ notifier + EstadoRadio delegation; both tests pass | `dart format` reflow; delegation kept expression-bodied | RED run evidence (Batch 5): `00:00 +0 -2` (both files fail to load — captured before any lib code). GREEN: targeted `00:00 +4: All tests passed!`; full suite `00:12 +103: All tests passed!` (99 baseline + 4 new); analyze + suite re-run after format. ## 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) | ## Files changed (Batch 3) | File | Action | ~Lines | |------|--------|--------| | `lib/servicios/servicio_audio_session.dart` | Created | +115 (session config, interruption/noisy handling, `ObjetivoAudioInterrumpible`) | | `lib/servicios/servicio_alarmas.dart` | Modified | +106/-64 (cache, `_enCola` queue, dirty-guard, `_parsear`/`_serializar`) | | `lib/estado/estado_alarmas.dart` | Modified | +55/-2 (bounded set, `configurarLocalizaciones`, prefs→servicio default) | | `lib/servicios/servicio_alarmas_android.dart` | Modified | +18/-11 (statics → instance, interface method) | | `lib/servicios/servicio_audio.dart` | Modified | +52/-3 (intent flag, `ObjetivoAudioInterrumpible` impl, duck volume) | | `lib/estado/estado_radio.dart` | Modified | +16/-15 (prefs param + `_resolverPrefs`, static alarm-l10n call removed) | | `lib/widgets/mini_reproductor.dart` | Modified | +22/-2 (StatefulWidget, locale-guarded didChangeDependencies) | | `lib/main.dart` | Modified | +14/-2 (prefs once, ServicioAudioSession wiring) | | `lib/app.dart` | Modified | +22/-3 (PluriWaveApp.prefs, alarm l10n locale guard) | | `lib/servicios/servicio_ecualizador.dart` | Modified | +14/-6 (prefs injection) | | `lib/servicios/servicio_grabacion_radio.dart` | Modified | +13/-4 (prefs injection) | | `lib/servicios/servicio_contenido_app.dart` | Modified | +11/-3 (prefs injection) | | `test/helpers/fakes.dart` | Modified | +8 (`configurarLocalizaciones` override on FakeServicioAudio) | | `test/helpers/fakes_alarmas.dart` | Modified | +4 (interface no-op) | | `test/servicios/servicio_alarmas_android_instance_test.dart` | Created | +53 | | `test/servicios/servicio_alarmas_cache_test.dart` | Created | +105 | | `test/estado/estado_alarmas_ejecuciones_test.dart` | Created | +85 | | `test/widgets/mini_reproductor_configurar_test.dart` | Created | +85 | | `test/servicios/servicio_audio_session_test.dart` | Created | +130 | Total Batch 3 diff: ~362 insertions / ~122 deletions in lib + helpers, plus ~458 lines of new tests. ## Files changed (Batch 4) | File | Action | ~Lines | |------|--------|--------| | `lib/servicios/controlador_reconexion.dart` | Created | +100 (decision enum + backoff controller, injectable timer factory) | | `lib/servicios/servicio_audio.dart` | Modified | +148/-7 (enum `reconectando`, buffer config consts, reconnect integration, TimeoutException routing, pause/stop/play resets) | | `lib/widgets/mini_reproductor.dart` | Modified | +6/-2 (spinner for reconectando, label case) | | `lib/pantallas/pantalla_reproductor.dart` | Modified | +8/-2 (reconectando = loading in `_WaveHero` + `_Controles`) | | `lib/widgets/visualizador_audio.dart` | Modified | +2/-1 (reconectando keeps visualizer active) | | `lib/pantallas/pantalla_alarma_sonando.dart` | Modified | +5 (S7-R4 boundary comment) | | `lib/servicios/servicio_grabacion_radio.dart` | Modified | +4 (S7-R5 invariant comment) | | `lib/l10n/app_*.arb` (13 files) | Modified | +1 each (`playbackStatusReconnecting`) | | `lib/l10n/gen/*` (14 files) | Regenerated | by `flutter gen-l10n` | | `test/servicios/servicio_audio_reconnect_test.dart` | Created | +210 (8 tests) | | `test/widgets/reconnect_ui_test.dart` | Created | +100 (2 tests) | Total Batch 4 diff: ~235 insertions / ~13 deletions in lib (incl. ARB/gen), plus ~310 lines of new tests. Within the ~285-line slice estimate. No Kotlin/native files touched. ## Files changed (Batch 5) | File | Action | ~Lines | |------|--------|--------| | `lib/servicios/servicio_export_import.dart` | Created | +80 (v2 envelope builder, pretty export, graceful parse) | | `lib/estado/estado_ecualizador.dart` | Created | +205 (EQ ChangeNotifier: presets, per-station map, activo, persistence, audio application, import path) | | `lib/estado/estado_radio.dart` | Modified | +122/-208 (EQ state/methods extracted; 15 `// TODO(S4b)` delegating members; exportarConfig delegates envelope; importarConfig delegates EQ; exportarConfigJson/parsearConfigJson; ecualizador owned + disposed) | | `lib/pantallas/pantalla_ajustes.dart` | Modified | +33/-23 (backup section delegates JSON to service, `dart:convert` removed; `_SeccionEcualizador` → Consumer2 with EstadoEcualizador) | | `lib/pantallas/pantalla_reproductor.dart` | Modified | +8/-11 (EQ toggle watches EstadoEcualizador) | | `lib/app.dart` | Modified | +7 (ListenableProvider exposing EstadoRadio's instance) | | `test/servicios/servicio_export_import_test.dart` | Created | +85 (2 tests) | | `test/estado/estado_ecualizador_test.dart` | Created | +52 (2 tests) | Total Batch 5 diff: ~455 insertions / ~242 deletions in lib, plus ~137 lines of new tests. Slightly over the ~350-line slice estimate because the EQ method bodies moved (not duplicated) into the new notifier — net lib growth is ~+213. No Kotlin/native files touched. ## Deviations from design (Batch 5) 1. **`importar()` returns `Map?`, not a `ConfiguracionCompleta` model** (task text suggested one). EstadoRadio's `importarConfig(Map)` is the existing application API with v1/v2 branching and a localized version-guard error; introducing a typed model would force re-validating/re-mapping every section twice in a slice that must stay under budget. The service's contract (graceful null on malformed, version inside the map) covers S4-R4; a typed model can land with S4b/S6 if wanted. 2. **`ListenableProvider` instead of `ProxyProvider`** for EstadoEcualizador registration. The notifier needs `ServicioAudio` from EstadoRadio at CONSTRUCTION; EstadoRadio therefore constructs and disposes it (transition ownership), and the provider only exposes the instance (`create: ctx.read().ecualizador`, no dispose callback — avoids double-dispose). In S4b, when EstadoRadio sheds the remaining EQ surface, ownership can be inverted if desired. 3. **`pantalla_reproductor.dart` EQ toggle rewired in S4a** (task listed only ecualizador_widget + pantalla_ajustes). EstadoRadio no longer notifies on EQ changes (required by S4-R1-A test B), so any screen still reading EQ through EstadoRadio's compat getters under `watch` would go STALE on toggle. The reproductor button was the only such site; 8-line fix beats shipping a known visual bug until S4b. 4. **`ecualizador_widget.dart` needed NO change**: both widgets in it are presentational (preset/onCambio props, no provider reads), so T-S4a-06's intent (scoped consumption) is satisfied at the call sites in pantalla_ajustes. 5. **Compat getters do NOT relay EQ notifications to EstadoRadio listeners** — intentional and spec-mandated (S4-R1-A scenario). EQ-displaying UI was rewired in this same slice precisely because of this; S4b removes the getters entirely. 6. **`emisoraActualUuid` callback seam** on EstadoEcualizador (not in task text): per-station preset decisions need the currently playing station; a `String? Function()` injected by EstadoRadio keeps the notifier free of station-list coupling and trivially testable. ## Deviations from design (Batch 3) 1. **No deprecated static shim for `ServicioAlarmasAndroid.configurarLocalizaciones`** (task text asked for one). Dart forbids a static and an instance member with the same name in one class, and the ONLY external call site (`estado_radio.dart:74`) was rewired in this same slice — a renamed shim would be unreachable dead code. Instead `configurarLocalizaciones` joined the `PuertoAlarmasAndroid` interface (fakes no-op it). 2. **Alarm-bridge l10n configured from `app.dart`, not from `MiniReproductor`** — design 3.3 offered both options; the mini player now only configures its real dependency (`EstadoRadio`), and `_PaginaPrincipalState.didChangeDependencies` (locale-guarded, placed BEFORE the existing early-return) forwards l10n to `EstadoAlarmas`. 3. **Public `ServicioAlarmas.cargar()` re-reads from prefs instead of serving the cache.** `EstadoRadio.importarConfig` writes the raw `alarmas_musicales_v1` key directly to SharedPreferences; a fully cached `cargar()` would make a settings import invisible until app restart. Mutations DO use the cache (`_configActual`), which is what S3-R7's race fix and "one cargar per mutation burst" require. The re-read is queued, so it cannot interleave with a mutation. 4. **Duck handling added beyond the task text**: `setAtenuado` on the handler scales effective volume ×0.3 (restored on interruption end). With `androidWillPauseWhenDucked: true` Android delivers duck as pause, so this is mostly the iOS/edge path; kept minimal. 5. **`_PrefsEspia` implements SharedPreferences via noSuchMethod** rather than pulling `shared_preferences_platform_interface` into the tests — avoids a `depend_on_referenced_packages` lint on a transitive dep. 6. **`servicio_contenido_app.dart` also migrated** (3 getInstance sites; not named in the task). Its only construction site is `static final` in `pluri_onboarding_dialog.dart`, which keeps the fallback path at runtime — acceptable under the injected-with-fallback compat net; full injection there would require a dialog refactor out of S3 scope. 7. **Two-instances-same-channel semantics documented, not prevented**: with instance handlers, constructing a second `ServicioAlarmasAndroid` over the SAME MethodChannel re-binds the platform handler to the newest instance. Production creates exactly one per channel (provider singleton); tests use distinct channels. ## Deviations from design (Batch 4) 1. **Reconnect logic lives in a NEW file `lib/servicios/controlador_reconexion.dart`**, not inline in `servicio_audio.dart` (task text said "edit servicio_audio.dart"). Pure decision/backoff logic must be testable without platform channels (S7-R7); the handler cannot be constructed in tests (`AudioPlayer` hits MethodChannels at construction). The handler keeps only the integration glue. 2. **Buffer-config test asserts the config VALUES, not the construction call.** Asserting that `AudioPlayer(...)` received the config would require constructing the real player (platform channels). The config is a `static const` on the handler; `_crearPlayer` passes it (one-line wiring, verified by review + on-device item 9). Honest scope of S7-R1's `[flutter test]` portion. 3. **`TimeoutException` treated as network-class** in addition to the spec'd PlayerException 2xxx range. A real network drop usually surfaces as the existing 12s source-change timeout, NOT as a 2xxx PlayerException — without this, the most common stall would bypass reconnect entirely. The generic-Exception terminal path is otherwise unchanged. 4. **Stall detection is error-driven only** (per task T-S7-06 text); the design's optional "buffering > 8-10s watchdog" was NOT implemented. ExoPlayer/just_audio surfaces dead live streams as errors or our timeout; a buffering watchdog would add a timer racing the buffer config for marginal gain. Flagged for on-device validation (item 9): if a silent endless-buffering hang is observed, add the watchdog in a follow-up. 5. **`_cambiarFuente` completes normally when reconnect is engaged** (returns instead of rethrowing). Previously every failure rejected the `playMediaItem` future and `EstadoRadio.reproducir`'s catch pushed an error snackbar — that would show an error on the FIRST failure even while reconnecting, violating S7-R3. User-facing rejection still happens when reconnect does NOT engage (non-network error, no intent, exhausted). 6. **`restablecer()` on `playMediaItem`** (fresh user play/source switch restarts the backoff budget). Not explicit in the task text but required so the retry path (which goes through `_cambiarFuente` internally, not `playMediaItem`) can exhaust while user-initiated switches always get a full budget. 7. **Widget tests need a double `tester.pump()`** after a broadcast-stream emission (one pump delivers the event, the second rebuilds). Verified with a diagnostic harness that the implementation was correct and only the test needed the fix. ## 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) ## Verification summary (Batch 3) - `flutter test`: 89/89 passing (77 baseline + 12 new across 5 files) - `flutter analyze`: No issues found (identical to baseline) - `dart format`: applied to all 19 touched Dart files (5 reflowed) - `rg 'SharedPreferences.getInstance()' lib/`: only `main.dart` startup resolution + one injected-with-fallback expression per class (estado_alarmas, estado_idioma, estado_radio, servicio_alarmas, servicio_ecualizador, servicio_grabacion_radio, servicio_contenido_app) - `flutter build`: NOT run (forbidden) - No Kotlin/native files touched in this batch ### On-device verification items added by Batch 3 (user — Android device) 1. **Phone call pauses radio (S3-R1, checklist item 10):** while the radio plays, receive a call → radio pauses (or ducks); after the call ends it resumes automatically (transient loss). 2. **Headphones unplugged pauses radio (S3-R1):** unplug wired headphones / disconnect BT while playing → radio pauses and does NOT auto-resume. 3. **Another media app takes focus:** start playback in another app → PluriWave pauses; it must not resume on its own when focus is permanent loss. 4. **Locale switch sanity:** change app language in Ajustes → alarm titles/station names sent to new native schedules use the new language (l10n now configured per locale change, not per rebuild). 5. **Settings import still reflects alarms immediately** (cache bypass in `cargar()`): import a backup with alarms → the alarms list shows them without restarting the app. ## Verification summary (Batch 4) - `flutter test`: 99/99 passing (89 baseline + 10 new across 2 files); re-run after `dart format` - `flutter analyze`: No issues found (identical to baseline); re-run after format - `dart format`: applied to all 9 touched Dart files (2 reflowed); gen/ untouched by hand - `flutter gen-l10n`: run once after the 13 .arb edits - `flutter build`: NOT run (forbidden) - No Kotlin/native files touched in this batch (S7-R4: native alarm audio path untouched by construction) ### What the buffer actually buys (honest expectations, Design 7.1) - Configured: ExoPlayer keeps a 15-50s forward buffer; playback (re)starts after 2.5s buffered (5s after a rebuffer); time prioritized over byte thresholds. - Real drop ≲ buffered cushion (typically a few seconds up to ~15-30s depending on bitrate and how full the buffer was): audio keeps playing through the cushion, no UI change. - Drop longer than the cushion: playback stalls → "Reconectando..." spinner state (no error dialog/snackbar) → up to 5 backoff retries (1/2/4/8/16s delays + 12s attempt timeout each, total window ≈ up to ~90s) → on recovery the player rejoins the LIVE edge (live radio has no rewind — the missed audio is gone, not replayed) → on exhaustion, the single existing friendly error with manual retry. ### On-device verification items added by Batch 4 (user — Android device) 1. **Short drop plays through (S7-R1, checklist item 9):** while the radio plays (let it run ~1 min so the buffer fills), disable WiFi/LTE for ~10s → audio continues without interruption and no UI state change. 2. **Long drop reconnects (S7-R2/R3, checklist item 9):** disable connectivity ~45s → mini player and full player show "Reconectando..." with spinner (NO error dialog/snackbar); re-enable within ~90s → playback resumes at the live edge automatically. 3. **Exhaustion surfaces one error (S7-R2-C):** leave connectivity off >2 min → exactly ONE error state with the manual retry button appears after retries exhaust; no error spam during the retry window. 4. **User pause/stop during reconnect (S7-R6):** trigger a drop, then tap pause/stop while "Reconectando..." → playback stays stopped; it must NOT restart on its own when connectivity returns. 5. **Alarm fallback not delayed (S7-R4, checklist item 11):** alarm with a non-responding station URL → bundled WAV fires within the existing ~12-15s window, NOT extended by reconnect attempts. 6. **Recording during a drop (S7-R5):** record while forcing a drop → recording fails/stops cleanly per existing behavior; a subsequent recording start works. 7. **Sleep timer during a drop (S7-R6):** sleep timer expiring during "Reconectando..." stops audio for good. ## Verification summary (Batch 5) - `flutter test`: 103/103 passing (99 baseline + 4 new across 2 files); re-run after `dart format` - `flutter analyze`: No issues found (identical to baseline); re-run after format - `dart format`: applied to all 8 touched Dart files (4 reflowed) - `flutter build`: NOT run (forbidden) - No Kotlin/native, .arb or gen/ files touched in this batch ### Manual verification items added by Batch 5 (user) 1. **Backup round-trip on device (S4-R4):** export a backup from Ajustes, wipe/reinstall (or import on a second device), import the file → favorites, groups, custom stations, EQ presets, alarms (incl. vacations) and preferences all restored. Old (pre-S4a) backup files must import identically — the v2 envelope is byte-compatible. 2. **EQ controls still live-update (S4-R1):** toggle EQ from the player screen and from Ajustes; chip/switch/preset selector reflect changes immediately (these now rebuild from EstadoEcualizador, not EstadoRadio). 3. **Per-station preset on playback switch:** play a station with its own preset, switch to one without → main preset re-applies (path now goes through EstadoEcualizador). ## Workload / boundary - Mode: auto-chain local slices (no PRs) - Current work units: S1, S2a, S2b, S3a, S3b, S7 (committed, latest 0380bbb), S4a (complete, in working tree) - Boundary (Batch 5): starts from the clean post-0380bbb tree; ends with S4a fully checked off, suite green (103/103). Rollback = revert the 6 lib files + delete the 2 new test files (Dart-only; no native edits). - Next batch: S4b (EstadoGrabacion + EstadoBusqueda + context.select rewiring + REMOVE the 15 `// TODO(S4b)` compat members added here). S5 is also unblocked (depends only on S2b).