Extracts ServicioRadio's transport loop (server discovery, host rotation, bounded retries, User-Agent, timeout, status check, json.decode, sticky-host bookkeeping) out of `_get` into a new `_getJson(path, params) -> Future<List<dynamic>>` helper, moved as one block with no logic edits. `_get` is reimplemented on top, still owning every station-specific concern: `lastcheckok: '1'`, `Emisora.fromApi` + the empty-uuid/url filter, and the `_compararCalidad` quality sort. `_getJson` is deliberately sort-agnostic and filter-agnostic so a non-station endpoint can reuse the resilience behaviour without inheriting station-only semantics. Non-negotiable ordering followed per design ADR-4: new test/servicios/servicio_radio_transporte_test.dart characterises all 8 existing station calls (7 via `_get` plus `registrarClick`, which builds its own URI) against the UNMODIFIED `_get` first - green by construction - pinning path, lastcheckok=1, hidebroken=true, a non-empty User-Agent, exact order/reverse/limit/offset, and the exact returned UUID sequence from a fixture with deliberately shuffled bitrate/clickcount/votes. That last assertion is what makes the extraction safe: a sort that silently sank into transport would pass every other check. Re-running the same file after the extraction is byte-identical green. test/servicios/servicio_radio_test.dart is untouched by this work unit - its passing unmodified is itself a signal that transport wasn't disturbed. The 6 pre-existing `order: bitrate` occurrences (obtenerPopulares, buscarPorNombre, buscarPorPais, buscarPorIdioma, buscarPorTag, buscar) are untouched - a deliberate server-side quality bias deciding which stations return within `limit`, unrelated to and never to be confused with the user-facing "Ordenar" control, which stays entirely client-side via the existing OrdenEmisoras (Engram reference/radio-browser-sort-order). Behaviour delta, accepted per ADR-4, not a regression: moving `_servidorActual` bookkeeping into `_getJson` means a successful `/json/countries` call now warms the sticky host for subsequent station calls too - one shared warm mirror per instance, desirable, not per-call-type state. Adds the Paises browser over the verified `/json/countries` contract (Engram reference/radio-browser-countries-endpoint): new lib/modelos/pais_radio.dart (`PaisRadio.fromApi` parses `stationcount` via `int.tryParse` since the API returns it as a JSON string, not an int - an `as int` cast would throw), `obtenerPaises()` sends neither `lastcheckok` nor `order` (the screen sorts client-side by name; the API's raw byte order isn't proper collation for any locale this app ships), and inherits `hidebroken=true` from the unchanged `_uri` (desirable here too, since the endpoint's own default is false). `EstadoBusqueda` gains `paises`/`cargandoPaises`/`cargarPaises()` with an in-memory cache guard so re-entering the screen never refetches. New PantallaPaises (lib/pantallas/pantalla_paises.dart): a "Tus idiomas" shortlist (one representative country per the app's 13 supported locales, matched against the fetched list - the proposal/spec name this section but don't specify its derivation) above the full alphabetical list, each entry showing its parsed station count. Reachable from Buscar's discovery landing state via a new entry row, added now rather than left dangling per this file's own forward-reference comment (and the WU15/WU15b lesson: a fully-tested but unreachable screen is a real defect, not a follow-up). New ARB keys (en/es only, matching this change's established precedent): countriesScreenTitle, countriesYourLanguagesTitle, countriesAllTitle, radioCountriesError. Tests: 631 -> 649 (2 skipped, unchanged). flutter analyze unchanged at 1 pre-existing info. grep confirms `countrycodes` appears nowhere in lib/.
73 KiB
Tasks: Functional Redesign (rediseno-funcional)
Reads (authoritative):
proposal.md,design.md,specs/*/spec.mdin this folder. Binding corrections from the design phase are applied throughout (see table below) — they override the proposal where they conflict with it. Delivery model: 18 work-unit commits on one branch,feat/rediseno-funcional— NOT chained PRs (Engramsdd/rediseno-funcional/delivery-strategy, id 2504, a user decision that supersedes the proposal's feature-branch-chain recommendation). Conventional commits only. NeverCo-Authored-Byor AI attribution. Never push tomain. The user opens one PR in the Gitea web UI at the end; this agent never opens PRs. This artifact intentionally exceeds the default ~530-word task-budget, for the same reasonproposal.mdanddesign.mddo: 18 work units each need a commit message, a RED/GREEN/REFACTOR breakdown, named test files, spec traceability, and a verification command. Collapsing that to fit a budget would hide exactly the detail a reviewer needs. Tables and checklists are used throughout to keep it scannable despite the length. WU12 does not exist — the native EQ band-count spike was resolved before planning closed (Engram id 2498, "keep 5 bands"). Numbering skips 11→13 on purpose; this is not a gap. WU15b was added mid-apply, not planned upfront — WU15 shippedPantallaGrabaciones(the recordings library) fully tested but reachable from nowhere in the app. WU15b (below, after WU15's section) is the coordinator-ruled fix that wires it into Settings navigation. It is small and does not change the 18-commit delivery model's shape. Strict TDD is ON. Runner:flutter test.flutter analyzeand a scopeddart formatgate every commit.flutter buildis never run.⚠️ NEVER run bare
dart format .Verified on this machine:
dart format .reformats 27 pre-existing files unrelated to any work unit (Formatted 141 files (27 changed)). Cause is formatter version skew — this machine runs Dart SDK 3.12.0 while the repo's committed formatting came from another version. Running it bare would silently break four hard success criteria at once: the empty-git diffguards onnavegacion_auto.dart,servicio_ecualizador.dartandservicio_audio.dart, plus "pantalla_alarma_sonando_dismiss_guard_test.dartpasses unmodified".Always scope the formatter to the files this work unit actually touched. The verify commands below use:
dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')Full detail: Engram
reference/dart-format-scope-hazard(id 2511).
Review Workload Forecast (per commit, not per PR)
| WU | Commit | Depends on | Est. changed lines | Budget risk | Exception |
|---|---|---|---|---|---|
| 1 | feat(tokens): add design tokens, type scale, push scaffold, and root nav state |
— | 220-330 | Low | No |
| 2 | test(auto): confirm Android Auto tree matches the redesign, no code changes |
— | 0 | None | No |
| 3a | refactor(ajustes): split Settings AUDIO/EMISORAS into pushed detail screens |
1 | High | Yes — move-only diff | |
| 3b | refactor(ajustes): split remaining Settings sections into pushed screens |
3a | Medium-High | Yes — move-only diff | |
| 4 | feat(favoritos): replace stacked group panels with chip-filtered reorderable list |
1 | 300-400 | Medium | Monitor |
| 5 | feat(escuchar): replace discovery browser with embedded player and favorites grid |
1, 4 | 350-450 | Medium | Monitor |
| 6 | feat(buscar): add discovery landing state, filter pills, counter, and sort |
5 | 350-500 | Medium-High | Monitor |
| 7 | feat(paises): add country browser and extract shared radio transport |
6 | 300-400 | Medium (test-heavy) | Monitor |
| 8 | feat(alarmas): simplify alarm cards and add vacation summary row |
1 | 400-550 | Medium-High | Monitor† |
| 9 | feat(vacaciones): add vacation range manager screen |
8 | 350-450 | Medium | Monitor |
| 10 | feat(alarmas): rewrite alarm editor with inline time widget |
8 | 500-650 | High | Yes — indivisible new widget‡ |
| 11 | feat(alarma-sonando): restyle ringing screen, drop live countdown label |
1 | 200-300 | Low (safety-critical review attention: High) | No |
| 13 | feat(eq): restyle equalizer screen and add custom presets |
3a | 400-550 | Medium-High | Monitor |
| 14 | feat(reproductor): restructure full player with tool-tray and EQ sheet |
13 | 450-600 | Medium-High | Monitor |
| 15 | feat(grabaciones): add recordings library screen |
3b | Medium | Monitor§ | |
| 15b | fix(grabaciones): wire the recordings library into Settings navigation |
15 | 60-100 | Low | No |
| 16 | feat(connectivity): restyle offline and reconnect banners |
1 | 150-250 | Low | No |
| 17 | feat(bienvenida): add monetization-free welcome screen |
1 | 150-200 | Low | No |
| 18 | feat(i18n): add redesign strings and translate Escuchar rename to 11 locales |
all | ~0 eng. / 400-600 data | Medium (data volume, low logic risk) | No |
* Re-derived after WU3a landed. The original 500-700 figure was scaled pro-rata from WU3a's estimate, which
turned out to be wrong by roughly 4x — so the old WU3b number inherited the same error and has been replaced.
WU3a's realized diff was 4,374 changed lines across 35 files against an 800-1000 estimate. The gap was not
scope creep; the estimate simply never counted (a) the 7 matching new test files strict TDD requires, or (b) the
13 regenerated lib/l10n/gen/app_localizations*.dart files that any ARB edit forces. lib/ alone, excluding
generated l10n, was 1,474+ / 1,214-, and the root shed 1,320 lines — so the "move-only" characterisation held; only
the arithmetic was wrong.
WU3b moves 5 sections rather than 7 and needs no new shared widget (fila_ajuste.dart already exists), hence
~2,500-3,200. Estimating lesson for every remaining work unit: a strict-TDD commit carries its test files, and any
ARB touch drags 13 generated files with it. Estimates that count only lib/ production code will read ~3-4x low.
Root line count, for reference: pantalla_ajustes.dart went 1,897 → 788 in WU3a → 198 in WU3b. The "under
400 lines" success criterion was the WU3a+WU3b combined end state — WU3a owned only 7 of the 12 detail screens.
WU3b converted the remaining 5 (Grabaciones, Música local, Idioma, Backup, Info) and the root is now exactly 4
GrupoAjustes cards, well under the 400-line target.
† Record size:exception at apply time only if the realized diff exceeds ~500 lines; justification: "largest single
alarm-card + hero + vacation-summary restyle, not divisible without breaking the one-commit-per-work-unit rule."
‡ The proposal already isolates WU10 as "its own PR, never bundled" — splitting further would leave an unintegrated
commit (a widget with no consumer, or a sheet rewrite with no new editor).
§ Re-derived after WU15 landed. Same estimating lesson as WU3a/WU3b, at brand-new-screen scale: the 300-400
figure covered only the production screen, not (a) its matching ~470-line strict-TDD test file, (b) the 13
regenerated lib/l10n/gen/*.dart files (12 new ARB keys this time, since a genuinely new screen needs new copy,
unlike WU3a/WU3b's move-only reuse), or (c) the new servicio_grabacion_radio.dart additions and their own test
group. Realized 1,767 changed lines / 22 files, all additions (no deletions — nothing pre-existing was touched
beyond the 3 new ServicioGrabacionRadio/EstadoGrabacion methods). Not recorded as size:exception since the
commit is still a single, cleanly-scoped deliverable (one new screen, its one dependency, no split candidate).
Decision needed before apply: No
Chained PRs recommended: No
Chain strategy: size-exception
400-line budget risk: High
Why these values, not a fresh ask. The delivery model was already decided by the user (Engram 2504), which
supersedes the proposal's own feature-branch-chain recommendation once the host was confirmed as Gitea (no gh,
agent cannot open PRs). That decision is functionally equivalent to an already-accepted size:exception at the
aggregate level — the one eventual PR will be reviewed commit-by-commit, which is why this forecast is expressed per
commit. Three commits (WU3a, WU3b, WU10) get an explicit size:exception because they cannot be reduced under 400
lines without violating the one-commit-per-work-unit rule; the rest are within or near budget individually.
Parallelization note. Delivery is sequential commits on one branch, so WUs land in the order below regardless of true independence. For planning only: WU2 has no dependencies and may be pulled to any point (already noted in the proposal). WU11, WU16, and WU17 each depend only on WU1 and are mutually independent of the WU3a-WU10 and WU13-15 chains — they could be reordered among themselves without breaking anything, but the sequence below is kept to match the proposal's narrative and dependency table.
Corrections From the Design Phase (applied throughout)
| # | Correction | Where applied |
|---|---|---|
| 1 | EstadoNavegacionRaiz moves from WU5 into WU1 |
WU1 tasks 1.6-1.7; WU5 task 5.3 only consumes irA(...) |
| 2 | WU3a/WU3b line estimates were structurally low (verbatim move = add+delete in git diff) |
Forecast table above; size:exception recorded for both, justification "move-only diff" |
| 3 | Settings detail-screen count is 12, not "~8" | WU3a (7 screens) + WU3b (5 screens) = 12, listed by name in tasks 3a.4-3a.5, 3b.2-3b.3 |
| 4a | Custom EQ preset persistence must live in a new file, never in servicio_ecualizador.dart |
WU13 task 13.6, hazard box restated inline |
| 4b | Mini player must hide visually (visible: false), never leave the tree, to preserve the S3-R3 didChangeDependencies side effect |
WU5 task 5.2, 5.6 |
| 5 | MiniReproductor.altura must be derived from laid-out height at apply time, not guessed |
WU5 task 5.6 |
Non-Negotiable Guards (master checklist — verify before the branch is considered done)
test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dartpasses unmodified (touched by WU9, WU11 — if a restyle appears to require editing it, STOP and escalate; do not edit the test).git diffis empty forlib/servicios/navegacion_auto.dart(WU2),lib/servicios/servicio_ecualizador.dart(WU13), and the band-application block atlib/servicios/servicio_audio.dart:749-762(WU13).- The equalizer renders exactly 5 sliders (WU13, task 13.1).
- No shipped string contains "PRO", a trial duration, or a price (WU17 task 17.2/17.6; WU18 task 18.6 final whole-app grep).
- All 13 ARB files keep identical key sets (WU18 tasks 18.1, 18.5 — final parity check; WU1 only touches values in 2 of the 13 files, so it cannot break parity on its own).
WU1 — Tokens, push scaffold, Escuchar rename, root nav provider
Commit: feat(tokens): add design tokens, type scale, push scaffold, and root nav state
Depends on: — (first commit)
Spec refs: app-navigation-shell — Five-Tab Root Navigation, Push-Chrome on Second-Level Screens, Root-to-Root
Switching Without Push (provider only; consumption is WU5), Escuchar Tab Rename Preserves the ARB Key
Verify: flutter test test/tema/ test/widgets/pluri_push_scaffold_test.dart test/estado/estado_navegacion_test.dart test/widget_test.dart && flutter analyze && dart format --set-exit-if-changed .
New tests: test/tema/pluriwave_typography_test.dart, test/widgets/pluri_push_scaffold_test.dart, test/estado/estado_navegacion_test.dart
Modified tests: test/tema/pluriwave_tokens_test.dart (or equivalent), test/widget_test.dart
- 1.1 RED — write
pluriwave_typography_test.dartasserting the 6 named styles (heroTime,sectionTitle,screenTitle,cardTitle,bodyStrong,eyebrowLabel) exist with their documented size/weight/letter-spacing and thatPluriWaveTheme.dark()registers the extension. - 1.2 RED — extend the tokens test asserting
PluriWaveTokens.listSurface,.liveGreen,.offlineAccentexist and participate inlerp. - 1.3 GREEN — add the 3 colour fields to
lib/tema/pluriwave_tokens.dart(constructor,copyWith,lerp); de-literalisepluriwave_theme.dart:12,15; createlib/tema/pluriwave_typography.dartbuilt insidePluriWaveTheme.dark(). - 1.4 RED — write
pluri_push_scaffold_test.dart: each of the 5 root screens mounted bare builds zeroScaffold; aPluriPushScaffoldrenders exactly one 56pxAppBar+ back affordance and exposes nobottomNavigationBarparameter on its constructor. - 1.5 GREEN — implement
lib/widgets/pluri_push_scaffold.dartper the ADR-2 API (title,body,titleOverride,leadingIcon,onBack,actions,bottom,floatingActionButton, staticpush). - 1.6 RED — write
estado_navegacion_test.dart:irA()transitionsRaizPluriWave, no-ops and does not notify on same-root,indicematches enum declaration order. - 1.7 GREEN — implement
lib/estado/estado_navegacion.dart(RaizPluriWaveenum,EstadoNavegacionRaiz); register it inapp.dart'sMultiProvider; convert the 3setState(() => _indice = ...)alarm-deep-link sites (app.dart:310,333,350) toirA(...). - 1.8 RED — update
test/widget_test.dartto expect "Escuchar" (not "Inicio") as the first tab's label, withPluriIconGlyph.homeunchanged as its icon. - 1.9 GREEN — change
app_en.arbline 4navHomevalue to"Listen"andapp_es.arbline 4 to"Escuchar"; retitle the first tab inlib/app.dart. Leave the other 11 ARB files untouched (WU18's job). - 1.10 REFACTOR — sweep
lib/tema/,app.dartfor now-dead literals; confirm zero consumer screens were touched (WU1 converts zero screens toPluriPushScaffold— that is what keeps this commit at its line budget). - 1.11 Verify — run the command above; confirm
git diff --stattouches only tokens/theme/typography files, the new push-scaffold and nav-state files,app.dart,app_en.arb,app_es.arb, and their tests.
WU2 — Android Auto verification (zero code)
Commit: test(auto): confirm Android Auto tree matches the redesign, no code changes
Depends on: —
Spec refs: android-auto-media — no requirement delta; this WU records the confirmation
Verify: flutter test test/servicios/navegacion_auto_test.dart && git diff --stat lib/servicios/navegacion_auto.dart
Tests: none created or modified — existing suite re-run only
- 2.1 Run
flutter test test/servicios/navegacion_auto_test.dart; confirm every case passes unmodified. - 2.2 Confirm
git diff lib/servicios/navegacion_auto.dartis empty — no code change in this commit. - 2.3 Record in the commit body: A1-A5 closed, zero code changes,
MediaBrowserServicemodel confirmed non-custom-renderable (the waveform in A4 is not implementable on this platform). - 2.4 No REFACTOR step — this WU is verification-only by design.
WU3a — Settings shell + AUDIO/EMISORAS groups
Commit: refactor(ajustes): split Settings AUDIO/EMISORAS into pushed detail screens
Depends on: WU1
Spec refs: app-navigation-shell — Push-Chrome on Second-Level Screens (Settings detail screen scenario)
Verify: flutter test test/pantallas/pantalla_ajustes_test.dart test/pantallas/ajustes/ && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
New tests: one per new screen under test/pantallas/ajustes/ (7 files, AUDIO + EMISORAS)
Modified tests: test/pantallas/pantalla_ajustes_test.dart (near-total rewrite)
- 3a.1 RED — rewrite
pantalla_ajustes_test.dart: root renders exactly 4 grouped nav lists, zero inline controls, file stays under 400 lines. Corrected at apply time: WU3a converts only AUDIO + EMISORAS (2 groups, 7 rows) per its own tasks 3a.4-3a.6 and the explicit "WU3a delivers 7 of 12 screens" scope — the remaining 5 sections (GRABACIONES Y MÚSICA, APLICACIÓN) stay inline pending WU3b. "4 groups / <400 lines" is the combined WU3a+WU3b end state (matches design ADR-3's own aggregate blast-radius note), not a WU3a-only claim; the rewritten test asserts the true WU3a-scoped state instead (2 groups zero-inline, 5 sections still inline and reachable). - 3a.2 RED — write one test per AUDIO/EMISORAS detail screen (Ecualizador, Salida de audio, Temporizador de
sueño, Grupos de favoritos, Emisora preferida, Emisoras personalizadas, Orden de listas) asserting it renders
inside a
PluriPushScaffoldand its moved controls still respond. - 3a.3 GREEN — create
lib/pantallas/ajustes/widgets/fila_ajuste.dart(FilaAjuste/GrupoAjustesprimitives). - 3a.4 GREEN — cut
_SeccionEcualizador(694-782),_SeccionEcualizadorAvanzado+_FilaDispositivo+_DialogoEdicionDispositivo(782-1013),_SeccionTimerSueno+_FormularioDuracionTimer(399-604) intopantalla_ajustes_ecualizador.dart,pantalla_ajustes_salida_audio.dart,pantalla_ajustes_timer_sueno.dart. Verbatim-move rule: delete only the panel header row; body is untouched. The two sections whose header row carried a real action (not just a status readout) — Temporizador de sueño's "Add" and, in 3a.5, Grupos de favoritos' "Add list" / Emisoras personalizadas' "Add" — keep that action in the body, right-aligned, rather than dropping it;_SeccionEcualizador's header also carried a statusChip, dropped since the very next row (the enable switch) already shows the same state. - 3a.5 GREEN — cut
_SeccionGruposFavoritos(1187),_SeccionEmisoraPreferida(1342),_SeccionEmisoras+_FormularioEmisora(1459-1669),_SeccionOrdenListas(1133) intopantalla_ajustes_grupos_favoritos.dart,pantalla_ajustes_emisora_preferida.dart,pantalla_ajustes_emisoras_personalizadas.dart,pantalla_ajustes_orden_listas.dart. - 3a.6 GREEN — in the root, replace the 7 moved sections with
FilaAjusterows under twoGrupoAjustescards (AUDIO, EMISORAS); each row pushes its screen viaPluriPushScaffold.push. Two new ARB keys added (en/es only, matching the WU1 precedent) for the group eyebrow labels:settingsGroupAudioTitle("AUDIO"/"AUDIO"),settingsGroupStationsTitle("STATIONS"/"EMISORAS") — all 7 detail-screen titles reuse existing ARB keys (the same string the old in-body header already showed), so no other new UI copy was introduced. - 3a.7 REFACTOR — confirm zero business-logic edits inside any
_CuerpoX(diff reads "header removed, body identical"); confirmapp.dart's import ofpantalla_ajustes.dartis unchanged. - 3a.8 Verify —
git difftouches only screen files (no service/state file) — confirmed. Root file line count: 788 lines, not yet <400 — accurate for WU3a alone (5 sections remain inline; see 3a.1's note). WU3b's own 3b.5 REFACTOR is where the root actually crosses under 400.
size:exception — "move-only diff". ~800-1000 changed lines (design-verified figure), ~85% relocated not
modified. Do not attempt to slice under 450.
WU3b — Settings GRABACIONES Y MÚSICA / APLICACIÓN groups
Commit: refactor(ajustes): split remaining Settings sections into pushed screens
Depends on: WU3a
Spec refs: app-navigation-shell — Push-Chrome on Second-Level Screens
Verify: flutter test test/pantallas/pantalla_ajustes_test.dart test/pantallas/ajustes/ && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
New tests: one per new screen under test/pantallas/ajustes/ (5 files: Grabaciones, Música local, Idioma,
Backup, Info)
Modified tests: test/pantallas/pantalla_ajustes_test.dart (extended)
- 3b.1 RED — extend detail-screen tests for the remaining 5 sections, asserting
PluriPushScaffoldusage and preserved controls. Applied against the CURRENT 788-line file (re-read fresh at apply time, not the stale 92-1896 line references below, which were computed against the pre-WU3a 1,897-line file). Root test file: removed the now-false "Sections pending WU3b remain inline" and "Phase 7" groups, added aWU3b — GRABACIONES Y MÚSICA and APLICACIÓN groupsgroup (5 scenarios: 2-row/3-row zero-inline-control checks, 2 push-navigation checks, one "root is exactly 4 GrupoAjustes cards" check). The 2 relocated Phase-7 friendly-folder-name scenarios moved verbatim into the newajustes/pantalla_ajustes_musica_local_test.dart, targeting the isolated pushed screen directly instead of scrolling to find it inside the whole root. - 3b.2 GREEN — cut
_SeccionGrabaciones,_SeccionMusicaLocal(Stateful) intopantalla_ajustes_grabaciones.dart,pantalla_ajustes_musica_local.dart— verbatim, header row removed per the established rule. - 3b.3 GREEN — cut
_SeccionIdioma+_IdiomaDisponible,_SeccionBackup,_SeccionInfointopantalla_ajustes_idioma.dart,pantalla_ajustes_backup.dart,pantalla_ajustes_info.dart. Correction found at apply time: unlike the other 4 sections,_SeccionInfonever had its own header icon+title row — its first tile (app name + version) already served that role — so there was no header to strip for that one screen; its body moved in full, unchanged. This is also whyinfoSectionTitleis a genuinely new ARB key (no existing in-body header string covered a bare "Info" label), unlike the other 4 screens which all reuse pre-existing strings. - 3b.4 GREEN — in the root, replaced the remaining 5 sections with
FilaAjusterows under two moreGrupoAjustescards: RECORDINGS & MUSIC (Recordings, Local music) and APPLICATION (Language, Backup, Info). Confirmed sleep timer (WU3a) and backup/restore (this WU) rows are both present — corollary ruling, nothing dropped. 3 new ARB keys (en/es only, matching WU1/WU3a's precedent of leaving the other 11 locales for WU18):settingsGroupRecordingsTitle("RECORDINGS & MUSIC"/"GRABACIONES Y MÚSICA"),settingsGroupApplicationTitle("APPLICATION"/"APLICACIÓN") for the two new group eyebrow labels, andinfoSectionTitle("Info"/"Información") for the one row with no pre-existing header string (see 3b.3). All other row titles reuse existing ARB keys. - 3b.5 REFACTOR — confirmed
pantalla_ajustes.dartnow contains exactly 4GrupoAjustescards and is 198 lines (well under the 400-line target); deleted all 5 now-unused private section widgets plus every import that was only needed by their bodies (dart:async,dart:io,file_picker,package_info_plus,path_provider,provider,share_plus,estado_grabacion.dart,estado_idioma.dart,estado_radio.dart,musica_local_auto.dart,pluri_glass_surface.dart,pluri_onboarding_dialog.dart— none of these are read by the root anymore since it is pure navigation chrome). - 3b.6 Verify — scoped suite (
pantalla_ajustes_test.dart+ajustes/) green: 45/45. All 12 detail screens present and reachable.git difftouches only screen/test/ARB(+generated l10n) files — no service/state file touched. Full suite: 592/592 (up from 579).flutter analyze: 1 issue, identical to baseline.
size:exception — "move-only diff". Realized: 2,190 changed lines (1,485+ / 705-) across 28 files — under
the ~2,500-3,200 estimate this time (Música local's compact Stateful body and this batch's conservative choice to
verify reachability rather than tap through unmocked native-channel controls for Backup/Grabaciones' riskiest rows
both kept it below forecast). Same "move-only diff" justification as WU3a.
WU4 — Favoritos restyle
Commit: feat(favoritos): replace stacked group panels with chip-filtered reorderable list
Depends on: WU1
Spec refs: favorites-organization — Chip-Filtered Flat List, Drag-to-Reorder Within the Active Filter, Sort
Action Using Existing Criteria, Group Management Reachable from Favoritos, Custom-Station CTA Preserved
Verify: flutter test test/pantallas/pantalla_favoritos_test.dart test/pantallas/pantalla_favoritos_plural_test.dart test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')
New tests: test/pantallas/pantalla_favoritos_test.dart (see 4.1's note — this WU's actual new test file, not
pantalla_favoritos_plural_test.dart).
Modified tests: test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart (comment only, no
scenario change — see 4.4's note).
- 4.1 RED — corrected at apply time:
pantalla_favoritos_plural_test.dart(the file this task originally named) never importsPantallaFavoritos— it only exercisesstationCount's ARB plural formatting viaAppLocalizationsdirectly, with no widget-level coverage of this screen at all. Left it untouched (still a valid, unrelated regression guard) and createdtest/pantallas/pantalla_favoritos_test.dartinstead, with: 3 state-layer tests for the newEstadoRadiosurface (listaFavoritosManual,reordenarFavorito,ordenarFavoritos— see 4.3's note) plus 6 widget-level scenarios (empty-state CTA, chip filter narrows the list, drag-reorder persists across a simulated restart,swap_vertsort, create-group action + new-group-chip reactivity, custom-station CTA). - 4.2 GREEN — replaced
_GrupoFavoritosPanelwith_FilaChipsGrupos(horizontally-scrollableChoiceChiprow, "{name} · {count}" label, new ARB keysfavoriteGroupsChipLabel/favoritesFilterAllLabel) and aReorderableListView(header/footerhold the screen header, chip row, sort action and the CTA;buildDefaultDragHandles: false+ReorderableDragStartListenerper row, matching the spec's "via a drag handle" wording rather than whole-row long-press). Used the modernonReorderItemcallback, not the now-@DeprecatedonReorder(Flutter 3.44 marks it obsolete — using it would have added a newdeprecated_member_usewarning above the 1-issue baseline). - 4.3 GREEN — design decision, not specified by any ADR (WU4 has none):
EstadoRadio.listaFavoritosalready unconditionally re-sorts by the GLOBALordenListassetting on every read, which would silently discard any drag-to-reorder the instant anything reloads favorites. AddedlistaFavoritosManual(a new memoized getter returning the storedorden-column sequence, untouched byordenListas) instead of changinglistaFavoritositself —navegacion_auto.dart's Android Auto tree and the future Escuchar grid (WU5) both readlistaFavoritosand are correctly unaffected by Favoritos' own manual order. AddedreordenarFavorito(uuid, nuevoIndice)(thin wrapper over the ALREADY-EXISTINGServicioFavoritos.reordenar+FakeServicioFavoritostest double — both pre-dated this WU, unused until now) andordenarFavoritos(criterio)(appliesOrdenEmisorasvia the existingordenarEmisoras()function, then PERSISTS the result as the new manual order via the samereordenarprimitive in a loop — chosen so the sort action's result also survives a restart, consistent with the drag-reorder persistence contract, even though the spec's own sort scenario only asserts the immediate re-render). - 4.4 GREEN — create-group action: an
ActionChip("Manage lists", new ARB keyfavoriteGroupsManage) at the end of the chip row pushes the EXISTINGPantallaAjustesGruposFavoritos(Settings' screen, reused rather than duplicated) — satisfies "Group Management Reachable... in addition to its existing entry point in Settings" as a second entry point to the SAME screen. Custom-station CTA: a new dashed-bordered card (_DashedBorderPainter, a small self-containedCustomPainter— no dashed-border package was available or added) opens the add-station form directly. That form (_FormularioEmisora) was renamed to publicFormularioEmisoraPersonalizadainpantalla_ajustes_emisoras_personalizadas.dartso both screens share the one form instead of duplicating it; its one existing test file only referenced the old name in a comment, updated, no scenario changed. New ARB keycustomStationsAddCta("Add custom station" / "Añadir emisora personalizada", matching the spec's quoted Spanish text exactly). - 4.5 REFACTOR — extracted
_FilaChipsGrupos,_FilaFavorito(the reorderable row, now carrying a leading drag handle plus the pre-existing assign/remove actions),_CtaEmisoraPersonalizada,_DashedBorderPainteras private widgets. ConfirmedPantallaFavoritosstill constructs zeroScaffold(pluri_push_scaffold_test.dart"The 5 root screens build zero Scaffold when mounted bare" still passes for it) — push-chrome is not applied here, Favoritos keeps its tab bar. - 4.6 Verify — scoped suite green: 9/9 new + 2/2
pantalla_favoritos_plural_test.dart+ 2/2pantalla_ajustes_emisoras_personalizadas_test.dart. Reorder-persists scenario green (verified via a simulated restart —cargarFavoritos()re-fetch from the fake service reflects the new order). Sort scenario green (both state persistence and the visual re-render order). Full suite: 614/614 green (2 skipped, unchanged), up from 605.flutter analyze: 1 issue, identical to baseline (3 new warnings + 1 new info surfaced during REFACTOR — unused import, 3 unused optional painter parameters, missingsuper.keyon the newly-public form widget — all fixed before this count).
WU5 — Escuchar restructure
Commit: feat(escuchar): replace discovery browser with embedded player and favorites grid
Depends on: WU1, WU4
Spec refs: app-navigation-shell — Root-to-Root Switching Without Push
Verify: flutter test test/pantallas/pantalla_inicio_test.dart test/pantallas/pantalla_inicio_rebuild_test.dart test/widgets/mini_reproductor_configurar_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')
Modified tests: pantalla_inicio_test.dart, mini_reproductor_configurar_test.dart, test/helpers/fakes.dart
(see 5.4's note). pantalla_inicio_rebuild_test.dart needed no change — it never taps "Ver todas", so
EstadoNavegacionRaiz's absence there is never exercised (lazily read only inside the button's onPressed).
- 5.1 RED — the ADR-7 anti-cache test:
await audio.reproducir(estacionB)mutates the underlyingFakeServicioAudiodirectly, bypassingEstadoRadio.reproducir()entirely (the same shape asnavegacion_auto.dart's out-of-band mutation); asserts the hero's rendered station name follows. The hero being aStatelessWidget(noStateclass at all) makes "zero cached fields" true by construction — this behavioral test is also what would catch a hypothetical cached-field regression, since a cached value set once ininitStatewould not follow an external mutation the way this test requires. - 5.2 RED — hazard test added to
mini_reproductor_configurar_test.dart: with a station actively "reproduciendo" (so a naiveemisora == nullcheck couldn't accidentally satisfy it),MiniReproductor(visible: false)renders nothing (find.textfor the station name finds nothing) whileconfigurarLocalizacionesstill ran exactly once — confirmingdidChangeDependenciesfired independent ofbuild()'s early return. - 5.3 RED — asserts
navegacion.actual == RaizPluriWave.favoritosafter tapping "Ver todas", using a_RecordingNavigatorObserver(countsdidPushcalls) to assert the push count is unchanged before/after the tap — proves "switches tabs, does not push" mechanically rather than by inspection. - 5.4 RED — test-file correction, noted explicitly:
pantalla_inicio_rebuild_test.dartneeded no scenario change (see the header note above). Discovered and fixed a genuine, pre-existing gap while writing 5.1: no test in this codebase had ever exercisedServicioAudio.androidAudioSessionIdStreamagainst a bareFakeServicioAudio— the real getter requiresregistrarHandler()(main.dart, production-only) and throws"registrarHandler() no fue llamado en main.dart"otherwise.pantalla_reproductor.darthas always read this exact getter but has no test file at all, so the gap was latent untilPantallaIniciostarted wiringVisualizadorAudioto it here. Added aStream<int?>.empty()override toFakeServicioAudio(test/helpers/fakes.dart) — purely additive, matchesVisualizadorAudio's own documented no-native-session fallback, does not change any existing test's behavior (grep-confirmed nothing else reads this getter).context.selectscoping: the hero selectsemisoraActualitself (one scalar per ADR-7 rule 2 —Emisora's own==/hashCodeare uuid-based, so this only rebuilds on a real station change, not on audio-buffer notifications); the fast-changingEstadoReproduccionis read viaStreamBuilderinstead (the same pattern_Controles/MiniReproductoralready use), so playback-status ticks never even reach the hero's own rebuild path.pantalla_inicio_rebuild_test.dart's EQ-preset-doesn't-rebuild guard still passes unmodified. - 5.5 GREEN — built
_EscucharHero(StatelessWidget) inpantalla_inicio.dart: square art (_ArteEscuchar,ClipRRectnotClipOval— the full player's own_WaveHerostays circular and untouched), live/offlinePluriStatusPill,VisualizadorAudio(barras: 30, altura: 26, color: liveGreen). Transport row (_FilaTransporteEscuchar) is favorite / EQ toggle / stop / play-pause (primary) / sleep, in that order — design decision, not spec-tested (no GIVEN/WHEN/THEN scenario enumerates the exact 5 actions; only ADR-7's structural rules are): the first 4 mirror the full player's own existing app-bar + transport actions (favorite, EQ, stop, play/pause) exactly, with sleep as the documented 5th, satisfying "no new playback methods" (rule 3) by construction — every action calls an existingEstadoRadio/EstadoEcualizadormethod. A separate "tool-tray entry chip" (OutlinedButton.icon) opens the full player via the existingPantallaReproductor.abrir. 5 new ARB keys (en/es only, established precedent):yourStationsTitle,seeAllAction,openFullPlayerTooltip,nothingPlayingTitle,nothingPlayingSubtitle(the last two back a lightweight placeholder state whenemisoraActualis null, so the hero degrades gracefully before any playback has started). - 5.6 GREEN — added
MiniReproductor.visible(defaulttrue) andstatic const double altura. Measured, not guessed: added a self-verifying test assertingalturaiscloseTo(±4px tolerance) the REALtester.getSize(find.byType(MiniReproductor)).heightwith a representative station and default text scale; ran it with a placeholder first, read the actual measured value (72.0) from the assertion failure, then set the constant to match exactly.build()returnsSizedBox.shrink()when!visible, independent ofdidChangeDependencies(task 5.2's hazard test proves the side effect still runs).app.dartpassesvisible: indice != RaizPluriWave.escuchar.index. - 5.7 GREEN — added
PluriLayout.escucharBottomChromeInset = bottomChromeInset - MiniReproductor.alturaand wired it intopantalla_inicio.dart's own bottomSliverPadding(replacing the plainbottomChromeInsetevery other root/scrollable still uses) — the mini player is hidden for this whole screen, not just the new sections, so the reduced inset applies to the still-present discovery grid too. - 5.8 GREEN — added
_seccionTusEmisoras(replaces the removed_heroHeader's call site, right after the hero):listaFavoritos(notlistaFavoritosManual— Escuchar previews the same globally-ordered list every other screen shows, it doesn't need Favoritos' own manual-order view) capped at 6, horizontally-scrollable compactTarjetaEmisoracards, "Ver todas" (TextButton) callingcontext.read<EstadoNavegacionRaiz>().irA(RaizPluriWave.favoritos). Empty-favorites case reuses the existingfavoritesEmptySubtitlestring rather than a new key. - 5.9 REFACTOR — confirmed
_seccionCercanas/_seccionTendencias/_chipGeneros/_errorBanner/_gridEmisoras(the discovery grid itself) are untouched, left in place below the new hero + "Tus emisoras" section, exactly as instructed — WU6 relocates and deletes them. Confirmedflutter test/widgets/pluri_push_scaffold_test.dart"The 5 root screens build zero Scaffold when mounted bare PantallaInicio" still passes (the hero adds slivers, noScaffold). - 5.10 Verify — anti-cache test green; mini-player-hidden-but-side-effect-ran test green; tab-switch-not-push
test green (push count unchanged);
git diff --statconfirmed empty forlib/widgets/visualizador_audio.dartANDlib/estado/estado_radio.dart(WU5 touches neither). Full suite: 618/618 green (2 skipped, unchanged), up from 614.flutter analyze: 1 issue, identical to baseline.
Discovery worth flagging for WU6+ (or any future WU rendering an actively-playing station in a widget test):
VisualizadorAudio starts an indeterminately-repeating AnimationController (visualizador_audio.dart:77,
_controller.repeat()) whenever the stream reports "reproduciendo"/"cargando"/"reconectando" — this is the same
class of hazard as an indeterminate spinner (pumpAndSettle() never returns while it keeps scheduling frames), just
via an animation instead of a progress indicator. Any test that renders a playing station through a widget that
embeds VisualizadorAudio (this hero, the full player) must use a bounded pump(), never pumpAndSettle(),
once the station starts "reproduciendo".
WU6 — Buscar landing state + filters
Commit: feat(buscar): add discovery landing state, filter pills, counter, and sort
Depends on: WU5
Spec refs: station-discovery-browse — Buscar Landing State Shows Discovery Content, Active-Filter Pills and
Results Counter, One-Tap Clear-All-Filters on Empty Results, Client-Side Search Sort Only
Verify: flutter test test/pantallas/pantalla_buscar_shimmer_test.dart test/estado/estado_busqueda_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
Modified tests: pantalla_buscar_shimmer_test.dart, estado_busqueda_test.dart (non-sort paths unmodified)
- 6.1 No spike needed here — the sort ruling is already verified (Engram 2505): server-side
order/reverseexist but are rejected;OrdenEmisorasis the only extension point. Do not re-derive. - 6.2 RED — empty query renders discovery content (near-you / genres / trending / Países entry); non-empty query replaces it with the results view.
- 6.3 RED — active-filter pills (country/language/quality) with a close affordance; results counter text; the one-tap "Quitar los 2 filtros" action on a 2-filter/0-result fixture.
- 6.4 RED — regression guard: every rendered "Ordenar" option maps to a real
OrdenEmisorascase with its own test. - 6.5 GREEN — move the discovery content (near-you/genres/trending, Países entry point) from
pantalla_inicio.dart's old sections intopantalla_buscar.dart's empty-query landing state; delete the moved sections frompantalla_inicio.dartnow (completes WU5's deferred cleanup, task 5.9). - 6.6 GREEN — collapse the 3 always-visible
FilterChiprows into an active-pills bar with bottom-sheet pickers; add the results counter and clear-all-filters action. - 6.7 GREEN — extend
enum OrdenEmisoras(orden_emisoras.dart:4) only with criteria backed by existingEmisorafields (bitrate,votes,clickcount); wire "Ordenar" client-side only — noorder/reverseparam added to any request. - 6.8 REFACTOR — confirm
estado_busqueda_test.dart's non-sort paths still pass unmodified. - 6.9 Verify — no test in this WU asserts an
orderparam on any outgoing request; sort-option regression guard green.
WU7 — Países browser + _get transport extraction
Commit: feat(paises): add country browser and extract shared radio transport
Depends on: WU6
Spec refs: station-discovery-browse — Países Browser Over the Verified Countries Contract, Existing Station
Calls Unchanged by Transport Extraction
Verify: flutter test test/servicios/servicio_radio_test.dart test/servicios/servicio_radio_transporte_test.dart test/estado/estado_busqueda_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
New tests: test/servicios/servicio_radio_transporte_test.dart, country-model test, pantalla_paises_test.dart
Modified tests: estado_busqueda_test.dart (additions only); servicio_radio_test.dart is not modified
Special ordering rule (non-negotiable): task 7.1 must be green before task 7.4 begins. Its shuffled-fixture UUID-sequence assertion is what makes the extraction safe — skipping it lets a sort silently sink into transport.
- 7.1 RED→GREEN (characterisation, green by construction against the unmodified
_get) — createservicio_radio_transporte_test.dartpinning, for each of the 7_get-based methods (obtenerPopulares,obtenerTendencias,buscarPorNombre,buscarPorPais,buscarPorIdioma,buscarPorTag,buscar): request path,lastcheckok=1present,hidebroken=truepresent, non-emptyUser-Agent, exactorder/reverse/limit/offset, and the exact returned UUID sequence from a fixture with deliberately shuffledbitrate/clickcount/votes. PinregistrarClick's path and that it sends someUser-Agent(do not pin its known-stale literal at line 325 — that would convert a bug into a contract). - 7.2 RED —
PaisRadio.fromApiparsesstationcountviaint.tryParsefrom a fixture where it arrives as a JSON string; defaults safely on missing fields. - 7.3 RED —
estado_busqueda_test.dartadditions forpaises,cargandoPaises,cargarPaises()(in-memory cache guard);pantalla_paises_test.dartfor "Tus idiomas" + full alphabetical list with counts. - 7.4 GREEN — extract
_getJson(transport only:_descubrirServidores, host rotation, bounded retries, User-Agent, timeout, status check,json.decode, sticky-host bookkeeping) out of_get, moved as one block, no logic edits;_getre-implemented on top, still applyinglastcheckok,Emisora.fromApi,_compararCalidad. - 7.5 GREEN — implement
lib/modelos/pais_radio.dart,obtenerPaises()(calls_getJson('/json/countries', const {}), nolastcheckok, noorder), andlib/pantallas/pantalla_paises.dart. - 7.6 RE-RUN (proof step) — re-run task 7.1's tests against the extracted code; must be byte-identical green, including the shuffled-fixture UUID-sequence assertion.
- 7.7 REFACTOR — document in the commit body that a successful
/json/countriescall now warms_servidorActualfor subsequent station calls — an accepted, intentional behaviour delta, not a regression. - 7.8 Verify — grep confirms
countrycodesappears nowhere inlib/; the string-stationcountfixture renders482without a cast error; the countries request has nolastcheckok; all 8 original methods' tests pass identically before and after.
WU8 — Alarmas root restyle
Commit: feat(alarmas): simplify alarm cards and add vacation summary row
Depends on: WU1
Spec refs: native-alarms (delta) — Alarm Card Tap-to-Edit Swipe-to-Delete, Hero Banner Inline Skip;
alarm-vacation-ranges — Vacation Summary Row Replaces the Inline Panel
Verify: flutter test test/pantallas/pantalla_alarmas_editor_test.dart test/estado/estado_alarmas_test.dart test/estado/estado_alarmas_snooze_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
Modified tests: pantalla_alarmas_editor_test.dart (+ new card-gesture scenarios); estado_alarmas_test.dart,
estado_alarmas_snooze_test.dart must pass unmodified
- 8.1 RED — tapping a simplified alarm card (giant time + station + switch) opens the editor pre-filled; swiping triggers delete with confirmation; no capability from the old always-visible button row is lost.
- 8.2 RED — the hero banner's inline "Saltar" action skips the featured (soonest-firing) alarm, consistent with existing skip-next behaviour.
- 8.3 RED — the vacation panel is now a summary row (range count + next-range countdown) that pushes a screen
on tap (destination screen not yet built — assert
PluriPushScaffold.pushis invoked; WU9 builds it). - 8.4 GREEN — simplify
_TarjetaAlarmato giant time/name/switch; move Edit/Skip/Delete behind tap/swipe. - 8.5 GREEN — add the hero banner's inline "Saltar" pill calling the existing skip-next path.
- 8.6 GREEN — replace
_PanelVacaciones's inline list with a summary row wired to push the Vacaciones manager screen. - 8.7 REFACTOR — confirm no scheduling/dismiss-guard code path was touched;
estado_alarmas_test.dartandestado_alarmas_snooze_test.dartpass unmodified. - 8.8 Verify — tap/swipe/hero-skip scenarios all green; confirm
pantalla_alarma_sonando_dismiss_guard_test.dartwas not touched by this WU (it only edits the root list screen).
WU9 — Vacaciones manager (new screen)
Commit: feat(vacaciones): add vacation range manager screen
Depends on: WU8
Spec refs: alarm-vacation-ranges — Active Range Detection, Per-Alarm Pause-Impact Computation, Upcoming Ranges
Query, Past-Ranges History Query, Vacation Summary Row Replaces the Inline Panel (destination screen)
Verify: flutter test test/estado/estado_alarmas_test.dart test/pantallas/pantalla_vacaciones_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
New tests: pantalla_vacaciones_test.dart
Modified tests: estado_alarmas_test.dart (additions only)
- 9.1 RED — with a fixed
ahora:rangoVacacionesActivoreturns the active range + days-remaining, or null when none matches;vacacionesProximasreturns future ranges ascending by start;vacacionesPasadasreturns ended ranges descending by end, disjoint from active/upcoming. - 9.2 RED —
impactoDeRango: given 3 alarms (2 withsonarEnVacaciones == false, 1 withtrue) during an active range, assert exactly those 2 inpausadasand that 1 innoAfectadas, mirroringservicio_programacion_alarmas.dart:150's predicate exactly. - 9.3 RED —
pantalla_vacaciones_test.dart: active-range hero with progress + per-alarm impact line; "PROGRAMADOS" upcoming list; dashed "Añadir rango" CTA; "Rangos pasados" history row/screen. - 9.4 GREEN — add the 4 pure query members to
EstadoAlarmas({DateTime? ahora}defaulting toDateTime.now()), delegatingrangoVacacionesActivoto the existingRangoVacaciones.contiene(fecha). - 9.5 GREEN — add
ImpactoVacacionesbesideRangoVacacionesinlib/modelos/alarma_musical.dart. - 9.6 GREEN — build
lib/pantallas/pantalla_vacaciones.dartas aPluriPushScaffold; wire WU8's summary-row tap to it. - 9.7 REFACTOR — confirm none of the 4 new methods call
guardarVacacionesor any reprogramming path (read-only over_alarmas/_vacaciones); confirm no ticker/periodic timer was added. - 9.8 Verify —
pantalla_alarma_sonando_dismiss_guard_test.dartpasses unmodified (hard rule — if a query addition appears to require touching it, STOP and escalate; do not edit the test).
WU10 — Alarm editor sheet rewrite
Commit: feat(alarmas): rewrite alarm editor with inline time widget
Depends on: WU8
Spec refs: native-alarms (delta) — Alarm Editor Preserves Date, Fallback Station, and Sound Fields
Verify: flutter test test/widgets/editor_hora_inline_test.dart test/pantallas/pantalla_alarmas_fecha_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
New tests: test/widgets/editor_hora_inline_test.dart (standalone)
Modified tests: pantalla_alarmas_fecha_test.dart
- 10.1 RED —
editor_hora_inline_test.dart, standalone, independent of the sheet: drag/tap adjusts hour and minute, wraps at 23:59→00:00, exposes correct a11y labels. - 10.2 GREEN — implement
lib/widgets/editor_hora_inline.dart(giant inline HH:MM display, drag/tap-to-adjust). - 10.3 RED — update
pantalla_alarmas_fecha_test.dart: one-time date alarm still creatable via the (now collapsed) Advanced section; fallback-station picker and sound dropdown still settable and persisted. - 10.4 GREEN — replace the native
showTimePickerdialog withEditorHoraInline; keep weekday circles always visible; move the date field, fallback-station picker, and sound dropdown into a collapsed "Advanced" section rather than dropping them. - 10.5 GREEN — restyle the volume/fade-in sliders (cosmetic only, no behaviour change).
- 10.6 REFACTOR — confirm the dismiss-guard test is untouched by this WU (editor sheet only, not the ringing screen).
- 10.7 Verify — standalone widget test green independent of the sheet; date/fallback/sound round-trip test green.
size:exception recommended. ~500-650 lines — the largest genuinely-new widget in the plan; already isolated
as its own unit per the proposal ("never bundled"). See forecast table footnote ‡.
WU11 — Alarm ringing restyle
Commit: feat(alarma-sonando): restyle ringing screen, drop live countdown label
Depends on: WU1
Spec refs: native-alarms (delta) — Ringing Screen Shows a Static Status Label, Dismiss Guard and Force-Stop
Banner Survive the Restyle
Verify: flutter test test/pantallas/pantalla_alarma_sonando_test.dart test/pantallas/pantalla_alarma_sonando_scaffold_test.dart test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
Modified tests: pantalla_alarma_sonando_test.dart, pantalla_alarma_sonando_scaffold_test.dart.
pantalla_alarma_sonando_dismiss_guard_test.dart is run but MUST NOT be edited.
Hard rule, restated: if the restyle appears to require changing the dismiss-guard test, the task is to STOP and escalate — not to edit the test.
- 11.1 RED — update the two non-guard test files for the full-bleed blurred-art layout, giant 88px time
(
heroTime, wrapped inFittedBox(fit: BoxFit.scaleDown)per the text-scaling rule), 3 fixed snooze tiles (3/5/10 min, 10 highlighted), full-width stop pill. - 11.2 RED — the status label renders "Subiendo volumen" (or localized equivalent) with no accompanying numeric value that changes over time.
- 11.3 GREEN — restyle to a full-bleed art background; replace the
Wrapof snooze chips with 3 fixed tiles; replace the glass-card container. - 11.4 GREEN — keep the status label static, sourced only from
widget.alarma(unchanged from the existing documented constraint); preserve the force-stop retry banner in the new layout. - 11.5 REFACTOR — run
pantalla_alarma_sonando_dismiss_guard_test.dartunchanged; if it fails, STOP — do not edit it, escalate instead. - 11.6 Verify — force-stop banner still appears on a simulated stop failure; dismiss-guard test file diff is empty.
WU13 — Ecualizador settings screen (5-band restyle)
Commit: feat(eq): restyle equalizer screen and add custom presets
Depends on: WU3a
Spec refs: eq-custom-presets — Five-Band Equalizer (Regression Guard), Custom Preset Save, Custom Preset
Naming Validates Non-Empty Input, Per-Station EQ Entry Point (component only; consumption is WU14),
Stations-With-Own-EQ Drill-Down, Base-vs-Per-Station Explainer Preserved, Active Output Surfaced on the Main Screen
Verify: flutter test test/widgets/ecualizador_widget_test.dart test/servicios/servicio_presets_personalizados_test.dart test/servicios/servicio_ecualizador_test.dart test/estado/estado_ecualizador_test.dart test/servicios/servicio_audio_eq_reapply_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
New tests: servicio_presets_personalizados_test.dart, pantalla_ajustes_ecualizador_test.dart
Modified tests: ecualizador_widget_test.dart. servicio_ecualizador_test.dart, estado_ecualizador_test.dart,
servicio_audio_eq_reapply_test.dart must pass unmodified.
Hazard, restated: persistence for custom presets must NOT go in
servicio_ecualizador.dart. That file has an empty-git diffsuccess criterion.
- 13.1 RED — the Ecualizador screen renders exactly 5 vertical sliders (regression guard — reject any future 7-slider change on sight).
- 13.2 RED —
servicio_presets_personalizados_test.dart: save/list/delete round-trip for a named custom preset via its own SharedPreferences keyeq_custom_presets_v1. - 13.3 RED — submitting an empty/whitespace-only preset name shows a validation message and persists nothing.
- 13.4 RED — the "Emisoras con ajuste propio" drill-down lists exactly the stations present in
presetsPorEmisora. - 13.5 RED — the "Salida activa" row is visible on the main screen (not only Advanced) and updates on a simulated device change; the base-vs-per-station explainer banner is visible.
- 13.6 GREEN — create
lib/servicios/servicio_presets_personalizados.dart(new file, own SharedPreferences key). Do NOT add any member toservicio_ecualizador.dart.lib/modelos/preset_ecualizador.dartstays unchanged — a user preset is aPresetEcualizadorwith a user-suppliednombre;toJson/desdeJsonalready exist. - 13.7 GREEN — extend
EstadoEcualizadorwithpresetsPersonalizados,guardarPresetPersonalizado(nombre),eliminarPresetPersonalizado(nombre), reading/writing only through the new service. - 13.8 GREEN — restyle
lib/widgets/ecualizador_widget.dartin place: strip the internal title + preset chip row (lines 59-77 — the pushed header now carries the title), restyle the 5Card/RotatedBox/Slidertiles to the vertical-track look, add thehabilitadoparameter. Keep thefor (int i = 0; i < 5; i++)loop bound literally5. - 13.9 GREEN — build
lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dartwiringEstadoEcualizador.presetPrincipal/cambiarPresetPrincipal, surfacing "Salida activa" and the drill-down row. - 13.10 REFACTOR — reviewer check: confirm
git diffis empty forlib/servicios/servicio_ecualizador.dart,lib/modelos/preset_ecualizador.dart, and the band-application block atlib/servicios/servicio_audio.dart:749-762. - 13.11 Verify — the three EQ test files pass unmodified; exactly 5 sliders render.
WU14 — Reproductor completo + per-station EQ sheet
Commit: feat(reproductor): restructure full player with tool-tray and EQ sheet
Depends on: WU13
Spec refs: eq-custom-presets — Per-Station EQ Entry Point From the Player; multi-device-eq (delta) —
Per-Station EQ Entry Relocates, Resolution Logic Does Not
Verify: flutter test test/pantallas/pantalla_reproductor_test.dart test/servicios/servicio_ecualizador_test.dart test/estado/estado_ecualizador_test.dart test/servicios/servicio_audio_eq_reapply_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
Modified tests: pantalla_reproductor_test.dart (or equivalent widget test). Three EQ test files must pass
unmodified.
- 14.1 RED — the per-station EQ bottom sheet renders the same
EcualizadorWidgettype WU13 restyled (assert byruntimeType, so a duplicate implementation fails the test, not just a visual review). - 14.2 RED — the 4 tool-tray tiles ("EQ propio", "Grabar", sleep-timer value, "Compartir") each open their own bottom sheet.
- 14.3 RED — opening "EQ propio" for a playing station shows 5 sliders bound to that station's resolved preset,
and a change round-trips through the existing per-station persistence path (
presetsPorEmisora/presetsMatriz). - 14.4 GREEN — restructure
pantalla_reproductor.dart: square art, favorite moved into the transport row, single subtitle line (collapse the current_InfoChipsWrap), 4-tile tool-tray grid replacing the always-expanded recording panel + separate sleep-timer button + EQ toggle. - 14.5 GREEN — wire "EQ propio" to a bottom sheet hosting
EcualizadorWidgetbound viaEstadoEcualizador.presetParaEmisora(uuid)/guardarPresetPorEmisora(uuid, ...). - 14.6 GREEN — add the quality row + "Cambiar" action and the "Compartir" tool-tray tile.
- 14.7 REFACTOR — confirm no second EQ editor file was created; confirm the
multi-device-eqregression scenarios (device-event resolve-and-apply, first-seen bootstrap, cold start, connect/disconnect/reconnect, toggle-off) still pass unmodified. - 14.8 Verify — the 3 EQ test files remain green and unmodified;
EcualizadorWidgettype-identity assertion passes.
WU15 — Grabaciones library (new list)
Commit: feat(grabaciones): add recordings library screen
Depends on: WU3b
Spec refs: recordings-library — Storage Usage Display, Browsable Recordings List, Per-Recording Menu
Constrained to Rename/Share/Delete
Verify: flutter test test/pantallas/pantalla_grabaciones_test.dart test/servicios/servicio_grabacion_radio_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
New tests: pantalla_grabaciones_test.dart
Modified tests: servicio_grabacion_radio_test.dart (only if a listing method is added)
- 15.1 RED — storage progress-bar fill reflects used/total (e.g. 84/200 MB fixture) with a caption stating both values.
- 15.2 RED — 3 recording-file fixtures render as 3 rows (name/date/duration/size); an empty folder renders an empty state, not an error; tapping play starts/stops playback.
- 15.3 RED — the "⋮" menu exposes exactly Rename/Share/Delete — constrained to what
servicio_grabacion_radio.dartalready exposes, no extra action. - 15.4 RED — Delete (file + row removed), Rename (persists across reload), Share (invokes platform share sheet)
scenarios. Delete and Rename widget-level scenarios are written but
skip: true(Share is not — see below): both hang indefinitely the instantEstadoGrabacionwires to a bare, realServicioGrabacionRadiopointed at a real directory inside atestWidgets()body — a combination no other test in this codebase uses. Four independent causes were ruled out at apply time (a directory-path off-by-one;Directory.list()'s async stream vslistSync(); the confirm-dialog interaction specifically — a render-only reproduction with zero taps hangs identically;WidgetTester.runAsync(), Flutter's own documented escape hatch for real I/O during widget lifecycle). The underlying logic is NOT unproven:eliminarGrabacionandrenombrarGrabacionboth have passing unit tests inservicio_grabacion_radio_test.dart(plaintest(), exercised against real files, no hang) — the one variable no diagnostic could change wastestWidgets()itself. Documented inline in both skipped tests; flagged for a fresh Windows-process debugging pass in a future batch, not treated as a code defect. - 15.5 GREEN — added
listarGrabaciones()toservicio_grabacion_radio.dart(pure filesystem read vialistSync/statSync, sorted most-recent-first, empty list for a missing directory — never throws). Also addedeliminarGrabacion(ruta)andrenombrarGrabacion(ruta, nuevoNombre)— file-lifecycle management for files this service already creates, not new capabilities; both needed for the constrained "⋮" menu (task 15.3/15.4), both via plaindart:io, no new conceptual surface.EstadoGrabaciongained thin delegates for all three plusnotifyListeners(). Newlib/modelos/archivo_grabacion.dart(ruta, nombre, fecha, tamanoBytes — pure filesystem metadata, no audio decoding). - 15.6 GREEN — built
lib/pantallas/pantalla_grabaciones.dart: storage bar (LinearProgressIndicatoroverEstadoGrabacion.maxBytesvs. the summed listing), per-recording rows (name/date/duration/size) with inline play/pause, "⋮" menu (PopupMenuButton, exactly 3 items). NewReproductorGrabacionesabstraction (duration-lookup + toggle-play) keepsjust_audio.AudioPlayerout ofServicioAudio(never touched — hard constraint) and out ofservicio_grabacion_radio.dart; the realjust_audio-backed implementation is static-review-only (same documented constraint asPluriWaveAudioHandlerincola_local_test.dart— a realAudioPlayerneeds platformMethodChannels this suite does not mock), every test injects a fake.Shareis similarly injected (compartirconstructor parameter), defaulting to the realshare_pluscall. 12 new ARB keys (en/es only, matching WU1/3a/3b precedent):recordingsLibraryTitle,recordingsLibraryStorageCaption,recordingsLibraryEmptyTitle,recordingsLibraryEmptySubtitle,recordingActionRename/Share/Delete,recordingRenameDialogTitle/Label/EmptyError,recordingDeleteConfirmTitle/Message— a brand-new screen needs new copy, unlike WU3a/WU3b's move-only screens. - 15.7 REFACTOR — confirmed the menu cannot expose a 4th action (task 15.3's test asserts
PopupMenuItemcount is exactly 3); confirmed the empty-folder scenario rendersPluriEmptyState, not a crash or exception (tester.takeException()asserted null). - 15.8 Verify — scoped verify green: 17 passed, 2 skipped (documented above), 0 failed — the menu-action
scenarios remain exercised end-to-end for Share (15.4-C) plus both file-mutation methods proven at the
service level. Empty-folder scenario green. Full suite: 604/604 green (2 skipped), up from 592.
flutter analyze: 1 issue, identical to baseline.
Known plan gap, not fixed here (out of WU15's task list, flagged for the orchestrator): no task in WU15 — or
anywhere else in this file — wires a navigation entry point to PantallaGrabaciones. It is a genuinely new,
currently-unreachable route. PantallaAjustesGrabaciones (WU3b) is a different screen (folder/size-limit settings)
and no task asks it to gain a "view library" link. WU15's own Verify command lists only
pantalla_grabaciones_test.dart and servicio_grabacion_radio_test.dart — not
pantalla_ajustes_grabaciones_test.dart — confirming this screen is not meant to touch WU3b's file in this work
unit. Not invented here; needs a design decision, not a guess.
WU15b — Wire the recordings library into Settings navigation
Not in the original plan — added to close the WU15 gap noted above. WU15 built PantallaGrabaciones (the
recordings library) fully tested and committed, but left it unreachable from the app: rg "PantallaGrabaciones" lib/
found only its own declaration. This work unit exists solely to fix that.
Commit: fix(grabaciones): wire the recordings library into Settings navigation
Depends on: WU15
Spec refs: recordings-library — Browsable Recordings List (reachability); app-navigation-shell — Push-Chrome
on Second-Level Screens (the settings form stays a pushed screen, now one level deeper)
Verify: flutter test test/pantallas/pantalla_ajustes_test.dart test/pantallas/pantalla_grabaciones_test.dart test/pantallas/ajustes/pantalla_ajustes_grabaciones_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')
Modified tests: pantalla_ajustes_test.dart (one scenario re-targeted), pantalla_grabaciones_test.dart (one new
scenario)
Coordinator ruling applied. The approved mockup's screen 12 ("Ajustes › Grabaciones") depicts the recordings
library, not the settings form — so the GRABACIONES Y MÚSICA group's "Grabaciones" row now opens
PantallaGrabaciones (the library) instead of PantallaAjustesGrabaciones (the folder/size-limit settings form).
The settings form is not dropped: it stays reachable, but now from within the library, via a settings icon in its
PluriPushScaffold.actions (matching the existing pantalla_ajustes_timer_sueno.dart "Add" action precedent for a
real capability living in the header, not decorative chrome).
- 15b.1 RED — re-targeted
pantalla_ajustes_test.dart's "tapping the Grabaciones row" scenario: it now asserts landing onPantallaGrabaciones(find.text('My recordings')), not the settings form (find.text('Change path')asserted absent). - 15b.2 RED — added a new scenario to
pantalla_grabaciones_test.dart: tapping the library's settings icon pushes a secondPluriPushScaffoldshowing the settings form (find.text('Change path')). - 15b.3 GREEN —
pantalla_ajustes.dart: the "Grabaciones"FilaAjuste.onTapnow pushesPantallaGrabaciones; removed the now-unusedPantallaAjustesGrabacionesimport from this file. - 15b.4 GREEN —
pantalla_grabaciones.dart: addedactions: [IconButton(Icons.settings_outlined, ...)]to itsPluriPushScaffold, pushingPantallaAjustesGrabaciones. One new ARB key (en/es only, matching precedent):recordingsLibrarySettingsTooltip. - 15b.5 REFACTOR — confirmed the new test needed the same
_suppressListTileInkAssertion()helper WU3a/WU3b established (WU3b Discovery 9: any pushed screen whosePluriGlassSurfacebody contains a tappableListTileneeds it) — added topantalla_grabaciones_test.dart, scoped to only the one new scenario that mountsPantallaAjustesGrabaciones. - 15b.6 Verify — scoped suite green: 605/605 (up from 604), 2 skipped (unchanged, both pre-existing WU15
documented skips).
flutter analyze: 1 issue, identical to baseline. Literal-encoding scan: zero hits.
Commit: feat(connectivity): restyle offline and reconnect banners
Depends on: WU1
Spec refs: crosscutting risk register (reconnect attempt counter is verify-first, not assumed)
Verify: flutter test test/widgets/reconnect_ui_test.dart test/servicios/servicio_audio_reconnect_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
Modified tests: reconnect_ui_test.dart. servicio_audio_reconnect_test.dart must pass unmodified.
- 16.1 Verify-first — run
servicio_audio_reconnect_test.dartand inspect the reconnect controller: confirm whether it tracks an attempt count. - 16.2 RED — restyle test for the offline banner (visual only) and, only if 16.1 confirms an attempt count exists, a test for the "Reconectando · intento N de M" label.
- 16.3 GREEN — restyle the offline banner to the new visual language.
- 16.4 GREEN — conditionally: wire the attempt-count label only if the controller exposes one; otherwise ship the restyle without the counter — do not add new plumbing to invent one.
- 16.5 REFACTOR — confirm shimmer skeletons (
TarjetaEmisoraShimmer) are unchanged. - 16.6 Verify —
reconnect_ui_test.dartupdated and green;servicio_audio_reconnect_test.dartunmodified and green.
WU17 — Welcome / onboarding screen
Commit: feat(bienvenida): add monetization-free welcome screen
Depends on: WU1
Spec refs: onboarding-welcome — Full-Screen Welcome Route, No Monetization Content, CTA Dismisses to Escuchar
Verify: flutter test test/pantallas/pantalla_bienvenida_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
New tests: pantalla_bienvenida_test.dart
- 17.1 RED — the welcome screen renders as a full-screen route (not a modal
Dialog) with a logo, headline, body copy, exactly 3 feature bullets, and exactly 1 primary CTA. - 17.2 RED — binding no-monetization guard: scan the rendered widget tree's text content and assert it contains none of: the substring "PRO", a currency amount, or a day-count trial phrase; no secondary "free version" link.
- 17.3 RED — tapping "Empezar a escuchar" lands on the Escuchar tab (index 0) with the welcome route removed from the back stack.
- 17.4 GREEN — build
lib/pantallas/pantalla_bienvenida.dartas a full-screen route; content only (logo / headline / body / 3 bullets / 1 CTA) — no PRO pill, no trial duration, no price, no secondary link. - 17.5 GREEN — wire the CTA to dismiss-and-navigate to Escuchar with the route removed.
- 17.6 REFACTOR — grep
lib/l10n/app_*.arbfor "PRO"/price/day-count trial strings introduced by this WU; confirm none exist. - 17.7 Verify — all 3 scenario tests green; grep scan clean.
WU18 — i18n batch (all 13 locales)
Commit: feat(i18n): add redesign strings and translate Escuchar rename to 11 locales
Depends on: WU1-WU17 (runs last, after en/es land across all slices)
Spec refs: app-navigation-shell — Escuchar Tab Rename Preserves the ARB Key (11-locale completion); backs the
UI copy every other capability introduced
Verify: flutter test && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')
Modified tests: ARB key-parity guard test (extend existing or add one if none exists)
- 18.1 RED — an ARB-parity test asserting all 13
lib/l10n/app_*.arbfiles contain an identical key set. - 18.2 RED —
navHomeexists as a key in all 13 locales, andgit difffor this commit touches exactly 11navHomevalue lines (en/es were WU1's job, task 1.9). - 18.3 GREEN — add the ~35-45 new keys (accumulated from WU4-WU17's English/Spanish-only strings) to all 13 ARB files, sourcing translations for the 11 non-en/es locales.
- 18.4 GREEN — set the
navHomevalue in the remaining 11 locales to a real translation of "Listen" (not a mechanical copy of the English/Spanish pair). - 18.5 REFACTOR — run the ARB-parity check across all 13 files; confirm zero missing/extra keys.
- 18.6 Verify — full
flutter testsuite green;flutter analyzeclean; scopeddart formatover the files this change touched produces no diff (NEVER baredart format .— see the warning at the top); grep confirms no "PRO"/price/trial-duration string exists in any ARB file (final whole-app Success Criteria check).
Final Verification Sequence (run once, after WU18's commit)
flutter test— full suite green.flutter analyze— clean.dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM main -- '*.dart')— no diff over the files this change touched. NEVER baredart format .— it reformats 27 unrelated pre-existing files and would break the empty-diff guards in steps 4 and 5 below (Engramreference/dart-format-scope-hazard, id 2511).git diff --stat lib/servicios/navegacion_auto.dart lib/servicios/servicio_ecualizador.dart— both empty.git diff lib/servicios/servicio_audio.dart— band-application block (749-762) unchanged.- Grep
lib/forcountrycodes— zero hits. - Grep
lib/l10n/app_*.arbfor "PRO", a currency amount, or a day-count trial phrase — zero hits. - Confirm all 13 ARB files have identical key sets and
navHomepresent in each. - Confirm the equalizer screen renders exactly 5 sliders.
- Run
test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dartin isolation — passes, file diff empty.
Next Step
sdd-apply — implement WU1 first (unblocks everything else); honor the size:exception commits (WU3a, WU3b, WU10)
as recorded above rather than attempting to split them further.