Files
pluriwave/openspec/changes/app-quality-and-native-alarms/apply-progress.md
T
FreeTLab 0380bbb1e7 feat(streaming): buffer resilience and automatic reconnection
- Construct the audio player with an enlarged live-stream buffer (15-50s forward cushion, 2.5s to start, 5s after rebuffer) so short network drops play through silently
- Add reconnect-on-stall state machine with bounded exponential backoff (1/2/4/8/16s, ~90s total window, 5 attempts) that re-prepares to the live edge; backoff/decision logic extracted to controlador_reconexion.dart as pure testable code
- Surface a new reconnecting playback state in the mini player and full player (localized in all 13 locales) instead of error dialogs during the retry window; a single friendly error appears only after exhaustion
- Guard interplay: user pause/stop cancels retries, audio interruptions cancel reconnect, alarm wake-up path keeps precedence, recording fails cleanly during drops
- Reset retry budget on station change; route stream timeouts through the network-error class
- 10 new tests (99 total green), flutter analyze clean
2026-06-11 19:54:30 +02:00

362 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 4)
## 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 |
## 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 |
### Remaining slices (not started)
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!`.
### 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).
## 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.
## 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.
## Workload / boundary
- Mode: auto-chain local slices (no PRs)
- Current work units: S1, S2a, S2b, S3a, S3b (committed f3e9487, 079e19f), S7 (complete, in working tree)
- Boundary (Batch 4): starts from the clean post-079e19f tree; ends with S7 fully checked off, suite green (99/99). Rollback = revert the Batch-4 files listed above (Dart-only; no native edits).
- Next batch: S4a (ServicioExportImport + EstadoEcualizador extraction). No dependency on S7; on-device items above can be verified in parallel.