refactor(state): extract recording and search state, scope screen rebuilds

- New EstadoGrabacion owns the recording service, subscription, directory/size preferences and open-file actions
- New EstadoBusqueda owns search, nearby stations, pagination and the min-bitrate filter
- New orden_emisoras.dart with the OrdenEmisoras enum, shared sorter and list identity memoization so context.select comparisons work on derived lists
- Large screens (inicio, buscar, favoritos, ajustes, reproductor) consume scoped selects/dedicated notifiers instead of root context.watch<EstadoRadio>, so audio buffer events no longer rebuild whole screens
- Remove all 15 TODO(S4b) compat members from EstadoRadio; consumers use the dedicated providers. EstadoRadio drops from ~1121 to 753 lines, keeping playback/stations/favorites orchestration
- 8 new tests including a rebuild-scoping probe (110 total green), flutter analyze clean
This commit is contained in:
2026-06-11 21:43:18 +02:00
parent 0416b301b2
commit 52855e75c2
17 changed files with 1195 additions and 643 deletions
@@ -3,7 +3,7 @@
**Mode**: Strict TDD (test runner: `flutter test`)
**Artifact store**: openspec (Engram unavailable this session)
**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence)
**Last updated**: 2026-06-11 (Batch 5)
**Last updated**: 2026-06-11 (Batch 6)
## Batch log
@@ -14,6 +14,7 @@
| 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 |
## Task status (cumulative)
@@ -146,9 +147,27 @@
| 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 |
### Remaining slices (not started)
S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending.
S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending.
## Snooze defect fixes (design audit D1D5 / S1S5)
@@ -207,6 +226,16 @@ RED run evidence (Batch 4): `00:00 +0 -2` (both files fail to load). GREEN: targ
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.
## Files changed (Batch 2)
| File | Action | ~Lines |
@@ -292,6 +321,38 @@ Total Batch 4 diff: ~235 insertions / ~13 deletions in lib (incl. ARB/gen), plus
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.
@@ -416,9 +477,27 @@ From tasks.md Section 11 — S1 items still pending from Batch 1, plus new S2 it
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.
## Workload / boundary
- Mode: auto-chain local slices (no PRs)
- Current work units: S1, S2a, S2b, S3a, S3b, S7 (committed, latest 0380bbb), S4a (complete, in working tree)
- Boundary (Batch 5): starts from the clean post-0380bbb tree; ends with S4a fully checked off, suite green (103/103). Rollback = revert the 6 lib files + delete the 2 new test files (Dart-only; no native edits).
- Next batch: S4b (EstadoGrabacion + EstadoBusqueda + context.select rewiring + REMOVE the 15 `// TODO(S4b)` compat members added here). S5 is also unblocked (depends only on S2b).
- Current work units: S1, S2a, S2b, S3a, S3b, S7, S4a (committed, latest 0416b30), S4b (complete, in working tree)
- Boundary (Batch 6): starts from the clean post-0416b30 tree; ends with S4b fully checked off, suite green (110/110). Rollback = revert the 9 modified lib/test files + delete the 6 new files (Dart-only; no native edits).
- Next batch: S5 (design system / a11y / i18n — unblocked since S2b) then S6 (quality gates — now unblocked: depends on S4b + S5).