Files
pluriwave/openspec/changes/app-quality-and-native-alarms/apply-progress.md
T
FreeTLab 202bef3539 feat(ui): design token discipline, accessibility and i18n pass
- Replace all hardcoded Color literals outside lib/tema with theme tokens (new static brand palette in PluriWaveTokens); media notification uses the brand color instead of the Material default purple
- Favorite button on station cards grows to a 48dp target and becomes an independent semantics node for screen readers (Semantics container fix)
- All flutter_animate call sites route through the PluriAnimate reduced-motion gate (zero direct .animate() left)
- Locale-aware short dates via intl DateFormat (new lib/l10n/formato_fechas.dart) replacing the hardcoded DD/MM/YYYY; proper plural messages for the favorites counter; example stream URL as a localized key - all 13 locales
- Rounded shimmer placeholders matching card radii; shimmer loading state in search instead of a bare spinner; rounded icon variants unified in settings; bottom-sheet conventions on the custom station form
- Fix latent debug crash: vacation editor read AppLocalizations in initState
- 11 new tests (121 total green), flutter analyze clean
2026-06-11 23:42:16 +02:00

602 lines
70 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 7)
## 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 |
| 6 | S4b — EstadoGrabacion + EstadoBusqueda + scoped rebuilds + compat-getter removal | COMPLETE (Dart-only batch) | 2026-06-11 |
| 7 | S5 — Design system, a11y, i18n, polish | 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<EstadoRadio, EstadoEcualizador>`; `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 |
### Slice S4b — EstadoGrabacion + EstadoBusqueda + scoped rebuilds — 13/13 complete
| Task | Status | Notes |
|------|--------|-------|
| T-S4b-01 | [x] | RED: `estado_grabacion_test.dart` — 4 tests (notify on state change, iniciar delegates with current station, no-station → alError without service call, error state → alError) over a controlled ServicioGrabacionRadio fake |
| T-S4b-02 | [x] | RED: `estado_busqueda_test.dart` — 3 tests (notify on buscar, pagination/memory cap MOVED from estado_radio_test, identity-stable `resultados` getter) |
| T-S4b-03 | [x] | RED: `pantalla_inicio_rebuild_test.dart` — EQ preset change does NOT rebuild PantallaInicio (S4-R5-A), `debugPrintRebuildDirtyWidgets` probe + positive control (cargarPopulares DOES rebuild) |
| T-S4b-04 | [x] | GREEN: `lib/estado/estado_grabacion.dart` — owns service, subscription, dir/maxBytes/open actions, `pluriwave/file_actions` channel; `emisoraActual`+`alError` seams; ListenableProvider in app.dart |
| T-S4b-05 | [x] | GREEN: `lib/estado/estado_busqueda.dart` — search + nearby (cercanas) + min-bitrate filter; `ordenListas`/`textos`/`alError` seams; ListenableProvider in app.dart |
| T-S4b-06 | [x] | GREEN: pantalla_inicio — no root watch; selects over identity-memoized getters (NEW `lib/estado/orden_emisoras.dart`: enum + sorter + MemoLista); cercanas/genre sections on EstadoBusqueda |
| T-S4b-07 | [x] | GREEN: pantalla_ajustes 6 watch sites — Grabaciones → watch<EstadoGrabacion>; Timer/Orden/Grupos/Preferida/Emisoras → context.select; _SeccionInfo keeps scoped Consumer |
| T-S4b-08 | [x] | GREEN: pantalla_favoritos → selects; ALSO pantalla_buscar root watch → EstadoBusqueda and pantalla_reproductor `_GrabacionWidget` → EstadoGrabacion (mandatory: EstadoRadio no longer notifies on recording/search) |
| T-S4b-09 | [x] | GREEN: estado_radio.dart — 15 compat members removed (zero `TODO(S4b)` in lib/), recording + search state extracted; 1121 (pre-split) → **753 lines** |
| T-S4b-10 | [x] | Targeted run 8/8 green (RED first: `+0 -3` load failures) |
| T-S4b-11 | [x] | Full suite 110/110 (103 baseline 1 moved test + 8 new) |
| T-S4b-12 | [x] | `flutter analyze` — No issues found |
| T-S4b-13 | [x] | `dart format` on 15 touched files (10 reflowed); analyze + suite re-run after |
### Slice S5 — Design system, a11y, i18n — 18/18 complete
| Task | Status | Notes |
|------|--------|-------|
| T-S5-01 | [x] | RED: `tarjeta_emisora_a11y_test.dart` — semantics label/button/toggled + ≥48dp. RED exposed a REAL bug: the card InkWell merged the favorite into ONE semantics node (screen readers could not reach the action independently) |
| T-S5-02 | [x] | RED: `pluri_animate_test.dart` — fadeIn/scaleIn lock-in + NEW `pluriFadeSlideIn` (honest RED: method missing) |
| T-S5-03 | [x] | RED: `pantalla_alarmas_fecha_test.dart` — en-US `6/11/2026`, NOT `11/06/2026`; es day/month |
| T-S5-04 | [x] | RED: `pantalla_favoritos_plural_test.dart``stationCount` singular ≠ plural (en, es) |
| T-S5-05 | [x] | RED: `pantalla_buscar_shimmer_test.dart` — shimmer during loading, NO spinner (cargando-true EstadoBusqueda subclass seam) |
| T-S5-06 | [x] | RED: `notification_color_test.dart``configuracionAudioService.notificationColor == PluriWaveTokens.brand` |
| T-S5-07 | [x] | GREEN: zero `Color(0x...)` outside lib/tema (rg audit). New static tokens `brand`/`brightCyan`/`auroraTeal`/`skyBlue`; scaffold gradient/orbs, alarmas glows, visualizer stop, premium orbs, tarjeta sweep mapped; Colors.grey/green/white semantic uses → colorScheme/tokens |
| T-S5-08 | [x] | GREEN: favorite `Semantics(container: true, ...)` + 48×48 in both variants; `_AssetIcon.semanticLabel` (+`excludeFromSemantics` when decorative); alarm/vacation/ringing images labelled via NEW `alarmIconLabel`/`vacationIconLabel` |
| T-S5-09 | [x] | GREEN: `pluriFadeSlideIn` added; ALL `flutter_animate` call sites routed through PluriAnimate (inicio chips/grid, buscar results, reproductor ×8); zero direct `.animate()` in lib/ |
| T-S5-10 | [x] | GREEN: NEW `lib/l10n/formato_fechas.dart` (`fechaCortaLocalizada`); `_fechaCorta(l10n, fecha)` delegates via `l10n.localeName` (6 call sites) |
| T-S5-11 | [x] | GREEN: `stationCount` ICU plural in ALL 13 locales (ru/ar full category sets); NEW `streamUrlHint` replaces hardcoded `stream.ejemplo.com`; gen-l10n run |
| T-S5-12 | [x] | GREEN: shimmer rounded (radiusLg/6) + NEW `esCompacta` row variant; PantallaBuscar loading → 4 compact shimmer rows |
| T-S5-13 | [x] | GREEN: 9 ajustes icons → `_rounded`; `*_outlined` family left (no rounded-outline variant exists); `_FormularioEmisora` sheet → `useSafeArea` + `showDragHandle` |
| T-S5-14 | [x] | GREEN: `configuracionAudioService` top-level const; `notificationColor: PluriWaveTokens.brand` |
| T-S5-15 | [x] | Targeted run 11/11 green (RED first: `+0 -6`) |
| T-S5-16 | [x] | Full suite 121/121 (110 baseline + 11 new) |
| T-S5-17 | [x] | `flutter analyze` — No issues found; color-literal audit ZERO |
| T-S5-18 | [x] | `dart format` on 20 touched Dart files (7 reflowed); analyze + suite re-run after |
### Remaining slices (not started)
S6, cross-cutting (T-CC-01, T-CC-02) — pending. S6 is now UNBLOCKED (depends on S4b + S5, both complete).
## 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).
### 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.
### Batch 6 TDD Cycle Evidence (S4b)
| Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR |
|------|-----------------------------------|-------------------------------|----------|
| T-S4b-01/T-S4b-04 | Load failure: `estado_grabacion.dart` missing (`+0 -3` run) | EstadoGrabacion created; 4 tests pass | Comment ties callbacks to the S4a seam pattern |
| T-S4b-02/T-S4b-05 | Same RED run: `estado_busqueda.dart` missing | EstadoBusqueda created; 3 tests pass | Pagination test deduplicated out of estado_radio_test |
| T-S4b-03/T-S4b-06..09 | Same RED run: `estado.busqueda` undefined; then first GREEN attempt FAILED honestly (`Expected: true Actual: <false>`) because the `element.dirty` probe cannot observe provider's deferred dependent notification | Probe rewritten over `debugPrintRebuildDirtyWidgets`; EQ change → screen NOT in rebuild log; cargarPopulares control → screen IS in log | Memo identity test added to estado_busqueda_test locking the select-enabler invariant |
RED run evidence (Batch 6): `00:00 +0 -3` (all three files fail to load — captured before any lib code). GREEN: targeted 8/8; full suite `00:11 +110: All tests passed!` (103 baseline 1 moved + 8 new); analyze + suite re-run after format.
### Batch 7 TDD Cycle Evidence (S5)
| Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR |
|------|-----------------------------------|-------------------------------|----------|
| T-S5-01/T-S5-08 | `find.bySemanticsLabel` found 0 nodes — the card InkWell MERGED the favorite into one node (real a11y defect, probed with a semantics dump) | `Semantics(container: true)` + 48dp target; node found, button/toggled flags pass | `hasFlag` (deprecated) → `flagsCollection` + `Tristate`; explicit `semantics.dispose()` (addTearDown fires too late for the handle check) |
| T-S5-02/T-S5-09 | Compile failure: `pluriFadeSlideIn` undefined | Helper added; 4/4 animate tests green | — |
| T-S5-03/T-S5-10 | Load failure: `formato_fechas.dart` missing | `fechaCortaLocalizada` + delegation; en-US/es tests pass | — |
| T-S5-04/T-S5-11 | Compile failure: `stationCount` missing from AppLocalizations | ARB plural ×13 + gen-l10n; both locale tests pass | — |
| T-S5-05/T-S5-12 | `TarjetaEmisoraShimmer` found 0, spinner found 1 | Compact shimmer variant + buscar loading swap | Shimmer block builder extracted (`bloque`) shared by both variants |
| T-S5-06/T-S5-14 | Compile failure: `configuracionAudioService`/`PluriWaveTokens.brand` undefined | Const extraction + brand token; test passes | `electricMagenta` re-pointed at `brand` (no duplicate literal) |
RED run evidence (Batch 7): `00:02 +0 -6` (4 compile/load failures + 2 honest assertion failures) captured before any lib code. GREEN: targeted 11/11; full suite `00:14 +121: All tests passed!`; analyze + suite re-run after `dart format`.
## Files changed (Batch 7)
| File | Action | ~Lines |
|------|--------|--------|
| `lib/tema/pluriwave_tokens.dart` | Modified | +12 (static `brand`/`brightCyan`/`auroraTeal`/`skyBlue` token definitions; format reflow) |
| `lib/tema/pluri_animate.dart` | Modified | +14 (`pluriFadeSlideIn`) |
| `lib/l10n/formato_fechas.dart` | Created | +14 (locale-aware short date) |
| `lib/main.dart` | Modified | +13/-8 (`configuracionAudioService` const, brand notification color) |
| `lib/widgets/pluri_wave_scaffold.dart` | Modified | +8/-8 (gradient + orbs → tokens/colorScheme) |
| `lib/widgets/tarjeta_emisora.dart` | Modified | +60/-30 (brightCyan, favorite container semantics + 48dp, rounded shimmer + compact variant) |
| `lib/widgets/visualizador_audio.dart` | Modified | +2/-1 (warmCoral token) |
| `lib/widgets/pluri_premium_widgets.dart` | Modified | +3/-2 (brightCyan) |
| `lib/pantallas/pantalla_alarmas.dart` | Modified | +55/-30 (glow tokens, `_AssetIcon` semanticLabel, locale-aware `_fechaCorta`, vacaciones initState l10n fix) |
| `lib/pantallas/pantalla_favoritos.dart` | Modified | +2/-1 (plural counter) |
| `lib/pantallas/pantalla_buscar.dart` | Modified | +18/-7 (shimmer loading, pluriFadeSlideIn) |
| `lib/pantallas/pantalla_ajustes.dart` | Modified | +20/-12 (rounded icons, semantic colors, l10n hint, sheet conventions) |
| `lib/pantallas/pantalla_reproductor.dart` | Modified | +25/-20 (PluriAnimate routing ×8, white overlays → colorScheme/tokens) |
| `lib/pantallas/pantalla_inicio.dart` | Modified | +6/-3 (PluriAnimate routing) |
| `lib/l10n/app_*.arb` (13 files) | Modified | +4-5 each (`stationCount` plural, `alarmIconLabel`, `vacationIconLabel`, `streamUrlHint`) |
| `lib/l10n/gen/*` (14 files) | Regenerated | by `flutter gen-l10n` |
| `test/widgets/tarjeta_emisora_a11y_test.dart` | Created | +72 (1 test) |
| `test/tema/pluri_animate_test.dart` | Created | +80 (4 tests) |
| `test/tema/notification_color_test.dart` | Created | +17 (1 test) |
| `test/pantallas/pantalla_alarmas_fecha_test.dart` | Created | +29 (2 tests) |
| `test/pantallas/pantalla_favoritos_plural_test.dart` | Created | +21 (2 tests) |
| `test/pantallas/pantalla_buscar_shimmer_test.dart` | Created | +42 (1 test) |
Total Batch 7 lib diff: ~240 insertions / ~120 deletions (incl. ARB, excl. gen/), plus ~260 lines of new tests. Within the ~210-line slice estimate for hand-written lib changes. No Kotlin/native files touched.
## Deviations from design (Batch 7)
1. **`Semantics(container: true)` on the favorite button (not in task text).** The task only asked for `Semantics(button, label)` — which ALREADY existed. The RED test proved the real defect: the card-level InkWell merged the favorite into a single semantics node, so screen readers could not reach the action. `container: true` forces an own node; this is the actual S5-R2 fix.
2. **`fechaCortaLocalizada` lives in NEW `lib/l10n/formato_fechas.dart`** (task said edit `_fechaCorta` in place). The private top-level function is untestable from `flutter test`; the public helper takes a locale tag (testable without widgets) and `_fechaCorta(l10n, fecha)` delegates via `l10n.localeName`. Spec scenario S5-R4-A is met verbatim.
3. **Brand token is `PluriWaveTokens.brand`, not `brandColor`** (task text guessed the name). Defined as a `static const` so it works in the `const AudioServiceConfig` context; `electricMagenta` now references it (single source for 0xFF21D4D9). `brightCyan`/`auroraTeal`/`skyBlue` added the same way for palette colors that had no token.
4. **`Colors.white` overlays mapped to `colorScheme.onSurface`/`onPrimary` and `tokens.glassBorder`** — the scheme's `onSurface` is 0xFFF2F7FA (near-white), so visuals are intentionally near-identical while becoming theme-driven.
5. **`pluriScaleIn` on the hero adds a fade** the old bare `.scale()` did not have (the shared helper pairs fade+scale). Visually negligible at 420 ms; keeping one canonical scale-entry beats a third helper.
6. **Icon variants: only base-name icons got `_rounded`** (9 sites). The `*_outlined` Material family (folder_outlined, backup_outlined, upload/download_outlined, verified_outlined, music_note_outlined) has NO rounded-outline variant; switching them to filled `_rounded` would change their visual weight, so they stay outlined.
7. **`streamUrlHint` added as an l10n key** (the prompt's "handled per design"): the hardcoded hint leaked a Spanish-looking host (`stream.ejemplo.com`) into all 13 locales; the key ships the same neutral `stream.example.com` URL everywhere but is now localizable.
8. **`_EditorVacacionesSheet` initState l10n crash fixed** (flagged in Batch 2 as a latent debug-mode crash, explicitly brought into this batch's scope): controller now created lazily in `didChangeDependencies`, mirroring the alarm-editor fix.
9. **Pagination spinner in PantallaBuscar kept** — S5-R6 covers the initial loading state (explore C11, lines 241-245); the small inline load-more spinner is a different affordance and was left untouched.
10. **`flagsCollection`/`Tristate` instead of `SemanticsFlag.hasFlag`** in the a11y test — `hasFlag` is deprecated in the current SDK and `flutter analyze` flags it; also `tester.ensureSemantics()` must be disposed in the test body (an `addTearDown` runs after the framework's handle-leak check).
## 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<EstadoEcualizador> 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.
## Files changed (Batch 6)
| File | Action | ~Lines |
|------|--------|--------|
| `lib/estado/orden_emisoras.dart` | Created | +55 (OrdenEmisoras enum moved here + `ordenarEmisoras` + `MemoLista` identity memo; estado_radio re-exports the enum so existing imports keep compiling) |
| `lib/estado/estado_grabacion.dart` | Created | +144 (recording notifier: service ownership, subscription, dir/maxBytes, open-file/dir actions, file_actions channel) |
| `lib/estado/estado_busqueda.dart` | Created | +222 (search notifier: query/filters/pagination, cercanas + geolocation, min-bitrate filter, memoized sorted views) |
| `lib/estado/estado_radio.dart` | Modified | +154/-375 net (recording/search/EQ-compat removed; memoized list getters; creates+disposes the 3 notifiers; custom-station mutations now reassign for memo identity) — **753 lines final (was ~1121 pre-split)** |
| `lib/app.dart` | Modified | +10/-4 (ListenableProviders for EstadoGrabacion + EstadoBusqueda) |
| `lib/pantallas/pantalla_inicio.dart` | Modified | ~+55/-41 (root watch removed; selects + EstadoBusqueda sections) |
| `lib/pantallas/pantalla_buscar.dart` | Modified | ~+30/-27 (root watch → watch<EstadoBusqueda>; renamed members) |
| `lib/pantallas/pantalla_favoritos.dart` | Modified | ~+12/-3 (root watch → selects) |
| `lib/pantallas/pantalla_ajustes.dart` | Modified | ~+50/-32 (Grabaciones → EstadoGrabacion; 5 sections → selects) |
| `lib/pantallas/pantalla_reproductor.dart` | Modified | ~+18/-10 (_GrabacionWidget → watch<EstadoGrabacion>) |
| `test/estado/estado_grabacion_test.dart` | Created | +122 (4 tests) |
| `test/estado/estado_busqueda_test.dart` | Created | +67 (3 tests) |
| `test/pantallas/pantalla_inicio_rebuild_test.dart` | Created | +97 (1 test, S4-R5-A) |
| `test/estado/estado_radio_test.dart` | Modified | EQ call sites → `estado.ecualizador.*`; pagination test moved out |
| `test/pantallas/pantalla_inicio_test.dart` | Modified | `_conProviders` helper mirrors app.dart wiring (3 pump sites) |
Total Batch 6 lib diff: ~386 insertions / ~625 deletions across 9 pre-existing files plus 3 new lib files (+421) and 3 new test files (+286). Net lib growth ≈ +180; EstadoRadio shrank by ~260 lines this batch. No Kotlin/native, .arb or gen/ files touched.
## Deviations from design (Batch 6)
1. **Provider ownership NOT inverted — documented as accepted.** Design 217 allows "pass the shared service instances at construction"; EstadoEcualizador/EstadoGrabacion/EstadoBusqueda need EstadoRadio's services AND callbacks (`emisoraActual`, `alError`, `ordenListas`, `textos`) at construction, so EstadoRadio creates and disposes all three and the ListenableProviders only expose the instances (S4a deviation 2 pattern, now final). Inverting would require lifting ServicioAudio/ServicioRadio creation into app.dart — out of slice budget and blast radius.
2. **NEW `lib/estado/orden_emisoras.dart` (not in task text).** Two reasons: (a) the `OrdenEmisoras` enum is needed by both EstadoRadio and EstadoBusqueda without a circular import (estado_radio re-exports it, so consumers compile unchanged); (b) `MemoLista` — derived-list getters used to return a fresh copy per read, which would make every `context.select` degrade to watch behavior (lists compare by identity). Identity-memoized getters are the enabler that makes S4-R5's "stop rebuilding on buffer events" REAL, not just formal.
3. **EstadoBusqueda also owns the nearby-stations (cercanas) flow** (task text only said query/results/loading). cercanas shares the min-bitrate filter and `radio.buscar` plumbing with search; leaving it in EstadoRadio would have kept a search-state remnant there against S4-R3's intent.
4. **`pantalla_buscar` and `pantalla_reproductor` rewired beyond the task list** (tasks named inicio/ajustes/favoritos). Mandatory, not optional: EstadoRadio no longer notifies on search or recording changes, so any screen still reading them through EstadoRadio would go permanently stale. Buscar now watches EstadoBusqueda; the player's `_GrabacionWidget` watches EstadoGrabacion.
5. **Custom-station mutations reassign the backing list** instead of mutating in place — required so the identity memo (and therefore `select`) sees the change. Behavior identical.
6. **`element.dirty` is NOT a valid rebuild probe with provider** — provider defers dependent notification to the next build phase (`markNeedsNotifyDependents` → inherited element rebuild → dependents marked during build). The widget test uses `debugPrintRebuildDirtyWidgets` log capture with a positive control instead. Worth remembering for future rebuild-scope tests.
7. **`emisorasDisponiblesPreferencia` staleness window (minor, accepted):** the preferred-station dropdown's option list now refreshes when favoritos/custom/populares/tendencias change identity, but a pure search/cercanas update no longer rebuilds the section (EstadoRadio does not notify on those anymore). The options re-derive on the section's next rebuild; preferred-station resolution itself prefers favorites, so impact is cosmetic.
## Deviations from design (Batch 5)
1. **`importar()` returns `Map<String, dynamic>?`, 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<EstadoRadio>().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<EstadoRadio>` 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).
## Verification summary (Batch 6)
- `flutter test`: 110/110 passing (103 baseline 1 pagination test moved to estado_busqueda_test + 8 new across 3 files); re-run after `dart format`
- `flutter analyze`: No issues found (identical to baseline) — used as the safety net for missed call sites after removing the 15 compat members; re-run after format
- `dart format`: applied to all 15 touched Dart files (10 reflowed)
- `rg 'TODO\(S4b\)' lib/`: ZERO occurrences (only historical mentions in tasks.md/apply-progress.md remain)
- EstadoRadio final size: **753 lines** (was ~1121 pre-split, ~1010 after S4a)
- `flutter build`: NOT run (forbidden)
- No Kotlin/native, .arb or gen/ files touched in this batch
### Manual verification items added by Batch 6 (user)
1. **Search screen (S4-R3):** search by name/country/language/quality, infinite scroll, genre chips on home — results and spinners behave as before (now driven by EstadoBusqueda).
2. **Nearby stations (S4-R3):** "Detect" on home requests location and fills the nearby strip; error text when undetectable.
3. **Recording (S4-R2):** start/stop from the player (indefinite, fixed and custom durations), live duration/bytes counter updates, open-folder/open-last-file buttons, recordings settings section (change/restore dir, max size) — all now via EstadoGrabacion.
4. **Scoped rebuilds (S4-R5):** while audio plays/buffers, home/favorites/settings should feel identical (no visual change expected — the win is fewer rebuilds); list reordering in Ajustes still re-sorts home, search results and favorites.
5. **Stop recording on pause/stop/station switch:** unchanged orchestration in EstadoRadio — verify recording stops when playback pauses/stops or station changes.
## Verification summary (Batch 7)
- `flutter test`: 121/121 passing (110 baseline + 11 new across 6 files); re-run after `dart format`
- `flutter analyze`: No issues found (identical to baseline); re-run after format
- `dart format`: applied to all 20 touched Dart files (7 reflowed); gen/ untouched by hand
- `flutter gen-l10n`: run once after the 13 .arb edits
- Color-literal audit: `rg 'Color(0x' lib` excluding `lib/tema/` and `lib/l10n/gen/`**0 matches** (was 14)
- `rg '.animate()' lib` → 0 direct flutter_animate call sites (all via PluriAnimate)
- `flutter build`: NOT run (forbidden)
- No Kotlin/native files touched in this batch
### Manual verification items added by Batch 7 (user)
1. **Visual parity sweep (S5-R1):** home/buscar/favoritos/ajustes/alarmas/reproductor look unchanged — token mapping was value-preserving (onSurface ≈ white; electricMagenta = brand). The settings "check" icons are now mint (`colorScheme.secondary`) instead of Material green — intentional.
2. **TalkBack (S5-R2):** on a station card, the favorite toggle is now reachable as its OWN button ("Añadir a favoritos / Quitar de favoritos") separate from the card; alarm/vacation images announce their labels.
3. **Reduced motion (S5-R3):** with "remove animations" enabled, home grid/search results/player screens render instantly with no entry animations.
4. **Locale dates (S5-R4):** switch app language to English → alarm editor and vacation ranges show M/D/Y order; Japanese shows Y/M/D.
5. **Media notification (S5-R8):** the playback notification accent is teal (brand), not purple, on devices that honor `notificationColor`.
## Workload / boundary
- Mode: auto-chain local slices (no PRs)
- Current work units: S1, S2a, S2b, S3a, S3b, S7, S4a, S4b (committed, latest 52855e7), S5 (complete, in working tree)
- Boundary (Batch 7): starts from the clean post-52855e7 tree; ends with S5 fully checked off, suite green (121/121). Rollback = revert the 27 modified lib files (incl. 13 ARB + gen/) + delete the 7 new files (Dart-only; no native edits).
- Next batch: S6 (quality gates — lint hardening + remaining top-5 tests) then cross-cutting T-CC-01/T-CC-02. S6 is the LAST slice.