# Tasks: Functional Redesign (`rediseno-funcional`) > Reads (authoritative): `proposal.md`, `design.md`, `specs/*/spec.md` in 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 (Engram > `sdd/rediseno-funcional/delivery-strategy`, id 2504, a user decision that supersedes the proposal's > feature-branch-chain recommendation). Conventional commits only. Never `Co-Authored-By` or AI attribution. Never > push to `main`. 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 reason `proposal.md` and > `design.md` do: 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 shipped `PantallaGrabaciones` (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. > **WU17b was added mid-apply, not planned upfront — same shape as WU15b.** WU17 shipped `PantallaBienvenida` (the > welcome screen) fully tested but reachable from nowhere in the app (`rg "PantallaBienvenida" lib/app.dart lib/main.dart` > found nothing). WU17b (below, after WU17's section) wires it into the genuine first-launch flow and establishes how > it coexists with the pre-existing, unrelated `PluriOnboardingDialog` ("what's new" modal). It is small and does not > change the 18-commit delivery model's shape. > Strict TDD is ON. Runner: `flutter test`. `flutter analyze` and a **scoped** `dart format` gate every commit. > **`flutter build` is 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 diff` guards on `navegacion_auto.dart`, `servicio_ecualizador.dart` and > `servicio_audio.dart`, plus "`pantalla_alarma_sonando_dismiss_guard_test.dart` passes 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 | ~~800-1000~~ → **REALIZED: 4,374** (2,760+ / 1,614-, 35 files) | High | **Yes — move-only diff** | | 3b | `refactor(ajustes): split remaining Settings sections into pushed screens` | 3a | ~~2,500-3,200~~\* → **REALIZED: 2,190** (1,485+ / 705-, 28 files) | 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~~ → **REALIZED: 1,871** (1,793+ / 78-, 25 files) | Medium-High | **Yes — retroactive, see WU13 section** | | 14 | `feat(reproductor): restructure full player with tool-tray and EQ sheet` | 13 | ~~450-600~~ → **REALIZED: 1,335** (1,114+ / 221-, 19 files) | Medium-High | **Yes — retroactive, see WU14 section** | | 15 | `feat(grabaciones): add recordings library screen` | 3b | ~~300-400~~ → **REALIZED: 1,767** (1,767+ / 0-, 22 files) | 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~~ → **REALIZED: 361** (screen + test, 313 / ARB 22 / docs 48) — see the WU15-precedent note below | Low | No — same reasoning as WU15§ | | 18 | `feat(i18n): add redesign strings and translate Escuchar rename to 11 locales` | all | ~~~0 eng. / 400-600 data~~ → **REALIZED: ~3,622** (2,714+ / 908-, 26 files: 11 ARB + 11 regenerated `gen/*.dart` + 4 new `test/l10n/*.dart` + this docs update) | Medium (data volume, low logic risk) | No — same estimating-lesson pattern as WU3a/WU7/WU15§, not a design risk | \* **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). **WU17 hit the identical pattern**: 150-200 covered only the screen itself; realized 361 lines across the screen (169) + its test (144) + 9 new ARB keys × 2 locales (22) + this docs update (48), PLUS 13 regenerated `lib/l10n/gen/*.dart` files (~430 more lines, untouched by hand) for a raw `git diff --stat` total of 814. Same ruling: not `size:exception` — one new, cleanly-scoped screen with no split candidate, all-additions except the ARB files (which gain lines, not churn). ```text 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) - [x] `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` passes **unmodified** (touched by WU9, WU11 — if a restyle appears to require editing it, STOP and escalate; do not edit the test). Re-confirmed in isolation after WU18: 13/13 green, file diff empty vs `main`. - [x] `git diff` is **empty** for `lib/servicios/navegacion_auto.dart` (WU2), `lib/servicios/servicio_ecualizador.dart` (WU13), and the band-application block at `lib/servicios/servicio_audio.dart:749-762` (WU13). Re-confirmed after WU18 (plus `lib/modelos/preset_ecualizador.dart` and the 3 other guarded test files — all 8 empty). - [x] The equalizer renders exactly **5** sliders (WU13, task 13.1). Re-confirmed after WU18. - [x] 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). Final ARB-only grep (18.6): zero hits for `\bPRO\b`, €, $, gratis/gratuit/"free version", or a day-count trial phrase in any of the 13 `app_*.arb` files. - [x] 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). Confirmed: 466 real keys in every one of the 13 files, zero missing, zero extra. --- ## 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` - [x] 1.1 RED — write `pluriwave_typography_test.dart` asserting the 6 named styles (`heroTime`, `sectionTitle`, `screenTitle`, `cardTitle`, `bodyStrong`, `eyebrowLabel`) exist with their documented size/weight/letter-spacing and that `PluriWaveTheme.dark()` registers the extension. - [x] 1.2 RED — extend the tokens test asserting `PluriWaveTokens.listSurface`, `.liveGreen`, `.offlineAccent` exist and participate in `lerp`. - [x] 1.3 GREEN — add the 3 colour fields to `lib/tema/pluriwave_tokens.dart` (constructor, `copyWith`, `lerp`); de-literalise `pluriwave_theme.dart:12,15`; create `lib/tema/pluriwave_typography.dart` built inside `PluriWaveTheme.dark()`. - [x] 1.4 RED — write `pluri_push_scaffold_test.dart`: each of the 5 root screens mounted bare builds zero `Scaffold`; a `PluriPushScaffold` renders exactly one 56px `AppBar` + back affordance and exposes no `bottomNavigationBar` parameter on its constructor. - [x] 1.5 GREEN — implement `lib/widgets/pluri_push_scaffold.dart` per the ADR-2 API (`title`, `body`, `titleOverride`, `leadingIcon`, `onBack`, `actions`, `bottom`, `floatingActionButton`, static `push`). - [x] 1.6 RED — write `estado_navegacion_test.dart`: `irA()` transitions `RaizPluriWave`, no-ops and does not notify on same-root, `indice` matches enum declaration order. - [x] 1.7 GREEN — implement `lib/estado/estado_navegacion.dart` (`RaizPluriWave` enum, `EstadoNavegacionRaiz`); register it in `app.dart`'s `MultiProvider`; convert the 3 `setState(() => _indice = ...)` alarm-deep-link sites (`app.dart:310,333,350`) to `irA(...)`. - [x] 1.8 RED — update `test/widget_test.dart` to expect "Escuchar" (not "Inicio") as the first tab's label, with `PluriIconGlyph.home` unchanged as its icon. - [x] 1.9 GREEN — change `app_en.arb` line 4 `navHome` value to `"Listen"` and `app_es.arb` line 4 to `"Escuchar"`; retitle the first tab in `lib/app.dart`. Leave the other 11 ARB files untouched (WU18's job). - [x] 1.10 REFACTOR — sweep `lib/tema/`, `app.dart` for now-dead literals; confirm zero consumer screens were touched (WU1 converts zero screens to `PluriPushScaffold` — that is what keeps this commit at its line budget). - [x] 1.11 Verify — run the command above; confirm `git diff --stat` touches 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 - [x] 2.1 Run `flutter test test/servicios/navegacion_auto_test.dart`; confirm every case passes unmodified. - [x] 2.2 Confirm `git diff lib/servicios/navegacion_auto.dart` is empty — no code change in this commit. - [x] 2.3 Record in the commit body: A1-A5 closed, zero code changes, `MediaBrowserService` model confirmed non-custom-renderable (the waveform in A4 is not implementable on this platform). - [x] 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) - [x] 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). - [x] 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 `PluriPushScaffold` and its moved controls still respond. - [x] 3a.3 GREEN — create `lib/pantallas/ajustes/widgets/fila_ajuste.dart` (`FilaAjuste` / `GrupoAjustes` primitives). - [x] 3a.4 GREEN — cut `_SeccionEcualizador` (694-782), `_SeccionEcualizadorAvanzado` + `_FilaDispositivo` + `_DialogoEdicionDispositivo` (782-1013), `_SeccionTimerSueno` + `_FormularioDuracionTimer` (399-604) into `pantalla_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 status `Chip`, dropped since the very next row (the enable switch) already shows the same state. - [x] 3a.5 GREEN — cut `_SeccionGruposFavoritos` (1187), `_SeccionEmisoraPreferida` (1342), `_SeccionEmisoras` + `_FormularioEmisora` (1459-1669), `_SeccionOrdenListas` (1133) into `pantalla_ajustes_grupos_favoritos.dart`, `pantalla_ajustes_emisora_preferida.dart`, `pantalla_ajustes_emisoras_personalizadas.dart`, `pantalla_ajustes_orden_listas.dart`. - [x] 3a.6 GREEN — in the root, replace the 7 moved sections with `FilaAjuste` rows under two `GrupoAjustes` cards (AUDIO, EMISORAS); each row pushes its screen via `PluriPushScaffold.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. - [x] 3a.7 REFACTOR — confirm zero business-logic edits inside any `_CuerpoX` (diff reads "header removed, body identical"); confirm `app.dart`'s import of `pantalla_ajustes.dart` is unchanged. - [x] 3a.8 Verify — `git diff` touches 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) - [x] 3b.1 RED — extend detail-screen tests for the remaining 5 sections, asserting `PluriPushScaffold` usage 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 a `WU3b — GRABACIONES Y MÚSICA and APLICACIÓN groups` group (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 new `ajustes/pantalla_ajustes_musica_local_test.dart`, targeting the isolated pushed screen directly instead of scrolling to find it inside the whole root. - [x] 3b.2 GREEN — cut `_SeccionGrabaciones`, `_SeccionMusicaLocal` (Stateful) into `pantalla_ajustes_grabaciones.dart`, `pantalla_ajustes_musica_local.dart` — verbatim, header row removed per the established rule. - [x] 3b.3 GREEN — cut `_SeccionIdioma` + `_IdiomaDisponible`, `_SeccionBackup`, `_SeccionInfo` into `pantalla_ajustes_idioma.dart`, `pantalla_ajustes_backup.dart`, `pantalla_ajustes_info.dart`. **Correction found at apply time**: unlike the other 4 sections, `_SeccionInfo` never 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 why `infoSectionTitle` is 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. - [x] 3b.4 GREEN — in the root, replaced the remaining 5 sections with `FilaAjuste` rows under two more `GrupoAjustes` cards: 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, and `infoSectionTitle` ("Info"/"Información") for the one row with no pre-existing header string (see 3b.3). All other row titles reuse existing ARB keys. - [x] 3b.5 REFACTOR — confirmed `pantalla_ajustes.dart` now contains exactly 4 `GrupoAjustes` cards 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). - [x] 3b.6 Verify — scoped suite (`pantalla_ajustes_test.dart` + `ajustes/`) green: 45/45. All 12 detail screens present and reachable. `git diff` touches 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). - [x] 4.1 RED — **corrected at apply time**: `pantalla_favoritos_plural_test.dart` (the file this task originally named) never imports `PantallaFavoritos` — it only exercises `stationCount`'s ARB plural formatting via `AppLocalizations` directly, with no widget-level coverage of this screen at all. Left it untouched (still a valid, unrelated regression guard) and created **`test/pantallas/pantalla_favoritos_test.dart`** instead, with: 3 state-layer tests for the new `EstadoRadio` surface (`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_vert` sort, create-group action + new-group-chip reactivity, custom-station CTA). - [x] 4.2 GREEN — replaced `_GrupoFavoritosPanel` with `_FilaChipsGrupos` (horizontally-scrollable `ChoiceChip` row, "{name} · {count}" label, new ARB keys `favoriteGroupsChipLabel`/`favoritesFilterAllLabel`) and a `ReorderableListView` (`header`/`footer` hold the screen header, chip row, sort action and the CTA; `buildDefaultDragHandles: false` + `ReorderableDragStartListener` per row, matching the spec's "via a drag handle" wording rather than whole-row long-press). Used the modern `onReorderItem` callback, not the now-`@Deprecated` `onReorder` (Flutter 3.44 marks it obsolete — using it would have added a new `deprecated_member_use` warning above the 1-issue baseline). - [x] 4.3 GREEN — **design decision, not specified by any ADR (WU4 has none)**: `EstadoRadio.listaFavoritos` already unconditionally re-sorts by the GLOBAL `ordenListas` setting on every read, which would silently discard any drag-to-reorder the instant anything reloads favorites. Added `listaFavoritosManual` (a new memoized getter returning the stored `orden`-column sequence, untouched by `ordenListas`) instead of changing `listaFavoritos` itself — `navegacion_auto.dart`'s Android Auto tree and the future Escuchar grid (WU5) both read `listaFavoritos` and are correctly unaffected by Favoritos' own manual order. Added `reordenarFavorito(uuid, nuevoIndice)` (thin wrapper over the ALREADY-EXISTING `ServicioFavoritos.reordenar` + `FakeServicioFavoritos` test double — both pre-dated this WU, unused until now) and `ordenarFavoritos(criterio)` (applies `OrdenEmisoras` via the existing `ordenarEmisoras()` function, then PERSISTS the result as the new manual order via the same `reordenar` primitive 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). - [x] 4.4 GREEN — create-group action: an `ActionChip` ("Manage lists", new ARB key `favoriteGroupsManage`) at the end of the chip row pushes the EXISTING `PantallaAjustesGruposFavoritos` (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-contained `CustomPainter` — no dashed-border package was available or added) opens the add-station form directly. That form (`_FormularioEmisora`) was **renamed to public `FormularioEmisoraPersonalizada`** in `pantalla_ajustes_emisoras_personalizadas.dart` so 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 key `customStationsAddCta` ("Add custom station" / "Añadir emisora personalizada", matching the spec's quoted Spanish text exactly). - [x] 4.5 REFACTOR — extracted `_FilaChipsGrupos`, `_FilaFavorito` (the reorderable row, now carrying a leading drag handle plus the pre-existing assign/remove actions), `_CtaEmisoraPersonalizada`, `_DashedBorderPainter` as private widgets. Confirmed `PantallaFavoritos` still constructs zero `Scaffold` (`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. - [x] 4.6 Verify — scoped suite green: 9/9 new + 2/2 `pantalla_favoritos_plural_test.dart` + 2/2 `pantalla_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, missing `super.key` on 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`). - [x] 5.1 RED — the ADR-7 anti-cache test: `await audio.reproducir(estacionB)` mutates the underlying `FakeServicioAudio` **directly**, bypassing `EstadoRadio.reproducir()` entirely (the same shape as `navegacion_auto.dart`'s out-of-band mutation); asserts the hero's rendered station name follows. The hero being a `StatelessWidget` (no `State` class 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 in `initState` would not follow an external mutation the way this test requires. - [x] 5.2 RED — hazard test added to `mini_reproductor_configurar_test.dart`: with a station actively "reproduciendo" (so a naive `emisora == null` check couldn't accidentally satisfy it), `MiniReproductor(visible: false)` renders nothing (`find.text` for the station name finds nothing) while `configurarLocalizaciones` still ran exactly once — confirming `didChangeDependencies` fired independent of `build()`'s early return. - [x] 5.3 RED — asserts `navegacion.actual == RaizPluriWave.favoritos` after tapping "Ver todas", using a `_RecordingNavigatorObserver` (counts `didPush` calls) to assert the push count is **unchanged** before/after the tap — proves "switches tabs, does not push" mechanically rather than by inspection. - [x] 5.4 RED — **test-file correction, noted explicitly**: `pantalla_inicio_rebuild_test.dart` needed 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 exercised `ServicioAudio.androidAudioSessionIdStream` against a bare `FakeServicioAudio` — the real getter requires `registrarHandler()` (`main.dart`, production-only) and throws `"registrarHandler() no fue llamado en main.dart"` otherwise. `pantalla_reproductor.dart` has always read this exact getter but has **no test file at all**, so the gap was latent until `PantallaInicio` started wiring `VisualizadorAudio` to it here. Added a `Stream.empty()` override to `FakeServicioAudio` (`test/helpers/fakes.dart`) — purely additive, matches `VisualizadorAudio`'s own documented no-native-session fallback, does not change any existing test's behavior (grep-confirmed nothing else reads this getter). `context.select` scoping: the hero selects `emisoraActual` itself (one scalar per ADR-7 rule 2 — `Emisora`'s own `==`/`hashCode` are uuid-based, so this only rebuilds on a real station change, not on audio-buffer notifications); the fast-changing `EstadoReproduccion` is read via `StreamBuilder` instead (the same pattern `_Controles`/`MiniReproductor` already 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. - [x] 5.5 GREEN — built `_EscucharHero` (`StatelessWidget`) in `pantalla_inicio.dart`: square art (`_ArteEscuchar`, `ClipRRect` not `ClipOval` — the full player's own `_WaveHero` stays circular and untouched), live/offline `PluriStatusPill`, `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 existing `EstadoRadio`/`EstadoEcualizador` method. A separate "tool-tray entry chip" (`OutlinedButton.icon`) opens the full player via the existing `PantallaReproductor.abrir`. 5 new ARB keys (en/es only, established precedent): `yourStationsTitle`, `seeAllAction`, `openFullPlayerTooltip`, `nothingPlayingTitle`, `nothingPlayingSubtitle` (the last two back a lightweight placeholder state when `emisoraActual` is null, so the hero degrades gracefully before any playback has started). - [x] 5.6 GREEN — added `MiniReproductor.visible` (default `true`) and `static const double altura`. **Measured, not guessed**: added a self-verifying test asserting `altura` is `closeTo` (±4px tolerance) the REAL `tester.getSize(find.byType(MiniReproductor)).height` with 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()` returns `SizedBox.shrink()` when `!visible`, independent of `didChangeDependencies` (task 5.2's hazard test proves the side effect still runs). `app.dart` passes `visible: indice != RaizPluriWave.escuchar.index`. - [x] 5.7 GREEN — added `PluriLayout.escucharBottomChromeInset = bottomChromeInset - MiniReproductor.altura` and wired it into `pantalla_inicio.dart`'s own bottom `SliverPadding` (replacing the plain `bottomChromeInset` every 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. - [x] 5.8 GREEN — added `_seccionTusEmisoras` (replaces the removed `_heroHeader`'s call site, right after the hero): `listaFavoritos` (not `listaFavoritosManual` — 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 compact `TarjetaEmisora` cards, "Ver todas" (`TextButton`) calling `context.read().irA(RaizPluriWave.favoritos)`. Empty-favorites case reuses the existing `favoritesEmptySubtitle` string rather than a new key. - [x] 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. Confirmed `flutter test/widgets/pluri_push_scaffold_test.dart` "The 5 root screens build zero Scaffold when mounted bare PantallaInicio" still passes (the hero adds slivers, no `Scaffold`). - [x] 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 --stat` confirmed empty for `lib/widgets/visualizador_audio.dart` AND `lib/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) - [x] 6.1 No spike needed here — the sort ruling is already verified (Engram 2505): server-side `order`/`reverse` exist but are rejected; `OrdenEmisoras` is the only extension point. Do not re-derive. - [x] 6.2 RED — empty query renders discovery content (near-you / genres / trending / Países entry); non-empty query replaces it with the results view. - [x] 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. - [x] 6.4 RED — regression guard: every rendered "Ordenar" option maps to a real `OrdenEmisoras` case with its own test. - [x] 6.5 GREEN — move the discovery content (near-you/genres/trending, Países entry point) from `pantalla_inicio.dart`'s old sections into `pantalla_buscar.dart`'s empty-query landing state; **delete** the moved sections from `pantalla_inicio.dart` now (completes WU5's deferred cleanup, task 5.9). - [x] 6.6 GREEN — collapse the 3 always-visible `FilterChip` rows into an active-pills bar with bottom-sheet pickers; add the results counter and clear-all-filters action. - [x] 6.7 GREEN — extend `enum OrdenEmisoras` (`orden_emisoras.dart:4`) only with criteria backed by existing `Emisora` fields (`bitrate`, `votes`, `clickcount`); wire "Ordenar" client-side only — no `order`/`reverse` param added to any request. - [x] 6.8 REFACTOR — confirm `estado_busqueda_test.dart`'s non-sort paths still pass unmodified. - [x] 6.9 Verify — no test in this WU asserts an `order` param 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. - [x] 7.1 RED→GREEN (characterisation, green by construction against the **unmodified** `_get`) — create `servicio_radio_transporte_test.dart` pinning, for each of the 7 `_get`-based methods (`obtenerPopulares`, `obtenerTendencias`, `buscarPorNombre`, `buscarPorPais`, `buscarPorIdioma`, `buscarPorTag`, `buscar`): request path, `lastcheckok=1` present, `hidebroken=true` present, non-empty `User-Agent`, exact `order`/`reverse`/`limit`/`offset`, and the exact returned UUID sequence from a fixture with deliberately shuffled `bitrate`/`clickcount`/`votes`. Pin `registrarClick`'s path and that it sends *some* `User-Agent` (do **not** pin its known-stale literal at line 325 — that would convert a bug into a contract). - [x] 7.2 RED — `PaisRadio.fromApi` parses `stationcount` via `int.tryParse` from a fixture where it arrives as a JSON **string**; defaults safely on missing fields. - [x] 7.3 RED — `estado_busqueda_test.dart` additions for `paises`, `cargandoPaises`, `cargarPaises()` (in-memory cache guard); `pantalla_paises_test.dart` for "Tus idiomas" + full alphabetical list with counts. - [x] 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**; `_get` re-implemented on top, still applying `lastcheckok`, `Emisora.fromApi`, `_compararCalidad`. - [x] 7.5 GREEN — implement `lib/modelos/pais_radio.dart`, `obtenerPaises()` (calls `_getJson('/json/countries', const {})`, no `lastcheckok`, no `order`), and `lib/pantallas/pantalla_paises.dart`. - [x] 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. - [x] 7.7 REFACTOR — document in the commit body that a successful `/json/countries` call now warms `_servidorActual` for subsequent station calls — an accepted, intentional behaviour delta, not a regression. - [x] 7.8 Verify — grep confirms `countrycodes` appears nowhere in `lib/`; the string-`stationcount` fixture renders `482` without a cast error; the countries request has no `lastcheckok`; 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 **`size:exception` — realized diff exceeded the ~500-line threshold.** Commit `9a2eb57`: **1,113 changed lines** (876+/237-) across 19 files. Breakdown: `pantalla_alarmas.dart` 485, ARB sources 54 (27 en + 27 es), 13 regenerated `lib/l10n/gen/*.dart` files ~300, new/modified test file 189, this docs file 37 — the same "a strict-TDD commit carries its test files, and any ARB touch drags 13 generated files with it" pattern as WU3a/WU7/WU15 (Engram `reference/estimating-strict-tdd-diffs`, id 2514). Justification per the forecast table's own footnote: "largest single alarm-card + hero + vacation-summary restyle, not divisible without breaking the one-commit-per-work-unit rule" — the card simplification, hero skip pill, and vacation summary row are one cohesive visual/behavioral change to one screen file; splitting them into separate commits would leave an inconsistent intermediate UI (e.g. a hero with a skip pill but cards still showing the old button row). - [x] 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. - [x] 8.2 RED — the hero banner's inline "Saltar" action skips the featured (soonest-firing) alarm, consistent with existing skip-next behaviour. - [x] 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.push` is invoked; WU9 builds it). - [x] 8.4 GREEN — simplify `_TarjetaAlarma` to giant time/name/switch; move Edit/Skip/Delete behind tap/swipe. **Corrected at apply time**: proposal.md's own WU8 row says "giant time + station + switch" (not "name") — the card shows the STATION (or `noStationUseInternalSound`), not the alarm's custom name; the name stays editable inside the editor sheet, unchanged. Tap-to-edit and swipe-to-delete-with-confirmation implemented via `Dismissible` (`confirmDismiss` shows an `AlertDialog`, `onDismissed` calls the existing `eliminarAlarma`) plus an inner `Material(type: MaterialType.transparency) > InkWell` (the known `PluriGlassSurface`-ink-splash hazard from WU7, pre-empted here rather than rediscovered). All 4 old per-card chips (repeat/vacation/volume/fade) and notice lines (next-execution/skipped/vacation-paused) are dropped from the collapsed card per "minimal cards" — still reachable via the editor once opened; only the hard-guarded trio (edit/skip/delete) is a capability that must never be lost, and it isn't. - [x] 8.5 GREEN — add the hero banner's inline "Saltar" pill calling the existing skip-next path. - [x] 8.6 GREEN — replace `_PanelVacaciones`'s inline list with a summary row wired to push the Vacaciones manager screen. **Design decision, not specified by any ADR (WU8 has none)**: the summary row's "range count + next-range countdown" is computed directly over the existing `estado.vacaciones` (no new `EstadoAlarmas` query method — ADR-6's pure query additions are WU9's job). The push target is `_PantallaVacacionesTemporal`, a private placeholder holding the OLD inline panel's body verbatim (hint text, "Add" action, per-range delete) — same "keep the real action, drop only the header" rule WU3a/WU3b established — so add/delete-range capability is never dropped, not even for one commit, and WU9 replaces this whole widget with the real `PantallaVacaciones`. - [x] 8.7 REFACTOR — confirm no scheduling/dismiss-guard code path was touched; `estado_alarmas_test.dart` and `estado_alarmas_snooze_test.dart` pass unmodified. Also removed now-dead code the simplification orphaned: the `_InfoChip` widget and the `_programacion` helper (both were only reachable from the old chips row). - [x] 8.8 Verify — tap/swipe/hero-skip scenarios all green; confirmed `pantalla_alarma_sonando_dismiss_guard_test.dart` was not touched by this WU (it only edits the root list screen) — re-ran it alongside the scoped suite, byte-identical, all 8 cases green. ## 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')` **`size:exception` — realized diff exceeded the ~350-450 forecast.** Commit `9dfcf0b`: **1,822 changed lines** (1,432+/390-) across 23 files. Breakdown: `estado_alarmas_test.dart` 558 (**mostly `dart format` reformatting pre-existing, unrelated test blocks it happened to touch** — the same Dart-SDK-3.12.0 skew as Engram `reference/dart-format-scope-hazard` id 2511, here as an unavoidable side effect of the mandatory scoped-format gate on a file this WU legitimately edits, not new test logic; the actual new WU9 tests are ~215 of those lines), new `pantalla_vacaciones.dart` 353, 14 regenerated `lib/l10n/gen/*.dart` files 334, new `pantalla_vacaciones_test.dart` 224, `pantalla_alarmas.dart` 197 (net code MOVED OUT to the new screen file, not new logic), `estado_alarmas.dart` 63, `alarma_musical.dart` 14, ARB sources 32, this docs file 47. Same "test files + generated l10n + reformatting of touched files aren't counted by a lib/-only estimate" pattern as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514). Justification: the 4 query methods, the model addition, and the new screen are one cohesive vertical slice (state + model + UI) that WU8 explicitly deferred to this commit; splitting further would leave either dead query methods with no UI consumer or a UI screen with no queries to call. **New tests**: `pantalla_vacaciones_test.dart` **Modified tests**: `estado_alarmas_test.dart` (additions only) - [x] 9.1 RED — with a **fixed `ahora`**: `rangoVacacionesActivo` returns the active range + days-remaining, or null when none matches; `vacacionesProximas` returns future ranges ascending by start; `vacacionesPasadas` returns ended ranges descending by end, disjoint from active/upcoming. **Correction found at apply time**: `DateTime(...)` is NOT a const constructor in Dart (only literal-free `const` values are) — the first draft wrote `const RangoVacaciones(..., inicio: DateTime(...))` and failed to compile; fixed by dropping `const` on every `RangoVacaciones` fixture built from a `DateTime(...)` call, same non-const pattern the pre-existing `AlarmaMusical` fixtures in this file already use whenever they set a `DateTime` field. - [x] 9.2 RED — `impactoDeRango`: given 3 alarms (2 with `sonarEnVacaciones == false`, 1 with `true`) during an active range, assert exactly those 2 in `pausadas` and that 1 in `noAfectadas`, mirroring `servicio_programacion_alarmas.dart:150`'s predicate exactly. Added a 4th case (inactive alarm counts toward neither list) and a 5th purity guard (none of the 4 query methods call `android.programar`). - [x] 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. **Correction found at apply time**: `PantallaVacaciones` calls the new `EstadoAlarmas` query methods with NO `ahora` argument (production code always uses real `DateTime.now()` — the `{DateTime? ahora}` clock injection exists for STATE- layer tests only, per ADR-6's own explicit rejection of a testable Clock abstraction). The first draft used hardcoded calendar-literal vacation ranges assuming a fixed "today", which silently exercised the WRONG code path once real time didn't match; fixed by building every fixture's `RangoVacaciones` dates relative to the real `DateTime.now()` (e.g. `hoyDia.add(Duration(days: 5))`) instead. - [x] 9.4 GREEN — add the 4 pure query members to `EstadoAlarmas` (`{DateTime? ahora}` defaulting to `DateTime.now()`), delegating `rangoVacacionesActivo` to the existing `RangoVacaciones.contiene(fecha)`. - [x] 9.5 GREEN — add `ImpactoVacaciones` beside `RangoVacaciones` in `lib/modelos/alarma_musical.dart`. - [x] 9.6 GREEN — build `lib/pantallas/pantalla_vacaciones.dart` as a `PluriPushScaffold`; wire WU8's summary-row tap to it. **Apply-time simplification, not spec-tested**: the "Añadir rango" CTA is a solid `OutlinedButton`, not a literally dashed border — no dashed-border utility exists in this codebase beyond WU4's own private one-off `_DashedBorderPainter` (`pantalla_favoritos.dart`), and duplicating a cosmetic `CustomPainter` for a detail no GIVEN/WHEN/THEN scenario actually tests isn't justified. The add-range form (`_EditorVacacionesSheet`, `_PickerButton`) was MOVED verbatim from `pantalla_alarmas.dart` (its only remaining consumer after WU8's placeholder is deleted below) rather than duplicated. - [x] 9.7 REFACTOR — confirm none of the 4 new methods call `guardarVacaciones` or any reprogramming path (read-only over `_alarmas`/`_vacaciones`); confirm no ticker/periodic timer was added. Also deleted WU8's own placeholder (`_PantallaVacacionesTemporal`) and the now-dead `_nombreVisibleVacaciones` wrapper from `pantalla_alarmas.dart`, and rewired the summary row's `onTap` to `PantallaVacaciones`. - [x] 9.8 Verify — `pantalla_alarma_sonando_dismiss_guard_test.dart` passes **unmodified** (hard rule — if a query addition appears to require touching it, STOP and escalate; do not edit the test). Confirmed via `git diff` (empty) and a full re-run (all 8 cases green) alongside the WU9 scoped suite. ## 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` - [x] 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. **Correction found at apply time**: Flutter requires a `Semantics` node exposing `onIncrease`/`onDecrease` to ALSO carry `increasedValue`/`decreasedValue` whenever `value` is set (`SemanticsNode.updateWith`'s `(value == '') == (increasedValue == '')` assertion) — added both, computed from the same pure delta-preview helpers the mutation uses. Also had to set `excludeSemantics: true` on each segment's `Semantics` node: without it, the inner `GestureDetector`'s own semantics contribution merged upward and (a) duplicated the digits into `label` via the child `Text`'s implicit semantics and (b) auto-exposed `scrollUp`/`scrollDown` (Flutter's default accessibility mapping for a registered vertical-drag recognizer) — neither wanted. Every action (`tap`/`increase`/`decrease`) is now declared explicitly on the one Semantics node instead. - [x] 10.2 GREEN — implement `lib/widgets/editor_hora_inline.dart` (giant inline HH:MM display, drag/tap-to-adjust). Tap increments by one step (wrapping); vertical drag adjusts continuously (24px/step, chosen for a comfortable scrub distance — not a reused measured constant, this widget has no other consumer). - [x] 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. **Correction found at apply time**: this file previously held ONLY the 2 pure `fechaCortaLocalizada` format tests, no widget coverage at all — extended it in place (kept both untouched) rather than creating a second file, since the task's own instruction was to "update" this file. Also required a one-line surgical fix to the PRE-EXISTING fallback-station test in `pantalla_alarmas_editor_test.dart` (not itself listed as a "Modified test" for this WU): a collapsed `ExpansionTile` does not build its children, so `find.byKey('alarm-fallback-station-field')` found nothing until the "Advanced" header is tapped first — added that one tap, no scenario/assertion changed. - [x] 10.4 GREEN — replace the native `showTimePicker` dialog with `EditorHoraInline`; keep weekday circles always visible; move the date field, fallback-station picker, and sound dropdown into a collapsed "Advanced" section rather than dropping them. **Design decision, not specified by any ADR (WU10 has none)**: the one-time/daily/weekly `SegmentedButton` stays in the main flow (not Advanced) since it gates which weekday circles are enabled; weekday circles now render unconditionally with `onSelected: null` (Material's standard disabled-chip state) when `_tipo != diasSemana`, instead of being removed from the tree — no change to `_tipo`/`diasSemana`/`fechaUnica` persistence logic, confirmed the scheduler treats `diaria` and `diasSemana` as genuinely different code paths (`_buscarDiaria` vs `_buscarPorDiasSemana` in `servicio_programacion_alarmas.dart`), so the two were never merged. - [x] 10.5 GREEN — restyle the volume/fade-in sliders (cosmetic only, no behaviour change): swapped the raw `Theme.of(context).textTheme` lookups on `_SectionLabel` ("Sonido y volumen") and the fade-in `ListTile` title for `context.pluriType.cardTitle` — same weight class, now sharing the named type-scale token with every other card/section title in the redesign. `Slider` widget types and their `min`/`max`/`value` wiring are byte-identical, so `'el slider de volumen permite bajar hasta 0.0 (S2-R11)'` passes unmodified. - [x] 10.6 REFACTOR — confirmed the dismiss-guard test is untouched by this WU (editor sheet only, not the ringing screen): `git diff` empty for both `pantalla_alarma_sonando.dart` and `pantalla_alarma_sonando_dismiss_guard_test.dart`; also removed the now-dead `_elegirHora()` method (its only caller, the old time-picker `_PickerButton`, no longer exists). - [x] 10.7 Verify — standalone widget test green (7/7); date/fallback/sound round-trip tests green (4/4 new in `pantalla_alarmas_fecha_test.dart`); full WU10-scoped run (`editor_hora_inline_test.dart` + `pantalla_alarmas_fecha_test.dart` + `pantalla_alarmas_editor_test.dart` + the two `estado_alarmas*` guard files + all 3 `pantalla_alarma_sonando*` files + `pantalla_vacaciones_test.dart`): 82/82 green. `flutter analyze`: 1 issue, identical to baseline. **`size:exception` recorded.** Realized: **993 changed lines** (891+/102-) across 21 files — over the 500-650 forecast, same "a strict-TDD commit carries its test files, and any ARB touch drags 13 generated files with it" pattern as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514). Breakdown: `lib/` production code alone is 429 lines (`pantalla_alarmas.dart` 123+/102-, new `editor_hora_inline.dart` 204+/0-) — within the forecast band on its own; the 3 new/modified test files add 376 lines and the 4 new ARB keys drag in 13 regenerated `lib/l10n/gen/*.dart` files plus the 2 ARB sources for 188 more. Justification unchanged from the forecast table's own footnote ‡: "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)." ## 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. - [x] 11.1 RED — update the two non-guard test files for the full-bleed blurred-art layout, giant 88px time (`heroTime`, wrapped in `FittedBox(fit: BoxFit.scaleDown)` per the text-scaling rule), 3 fixed snooze tiles (3/5/10 min, 10 highlighted), full-width stop pill. **Correction found at apply time**: the pre-existing "3/5/10 mas el personalizado" test (snoozeMinutos=7 growing a 4th button) is exactly the behavior this WU replaces — rewrote it in place (not deleted) to assert the new "always exactly 3, no 4th tile" contract, plus 2 new tests pinning which tile is `FilledButton` (highlighted) vs `OutlinedButton`. - [x] 11.2 RED — the status label renders "Subiendo volumen" (or localized equivalent) with no accompanying numeric value that changes over time. New ARB key `alarmVolumeRisingStatus` carries NO placeholder at all (unlike `alarmFadeInSummary`, which has `{seconds}`) — by construction this label can never grow a live counter without a deliberate key change. Gated on `alarma.fadeInSegundos > 0`: the existing `_montarPantalla` test helper in `pantalla_alarma_sonando_test.dart` already carried an unused `fadeInSegundos` parameter defaulting to 0, confirming this gate was anticipated ahead of this WU. - [x] 11.3 GREEN — restyle to a full-bleed art background; replace the `Wrap` of snooze chips with 3 fixed tiles; replace the glass-card container. No per-station artwork exists in this codebase (`Emisora.favicon` is a network URL — rendering it via `Image.network` in a widget test hangs/throws without a mocked `HttpClient`), so the existing bundled `alarm_music.png` asset is reused, blurred (`ImageFiltered`, sigma 44) and stretched — decorative, not spec-tested. - [x] 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. `_bannerFalloDetencion` is BYTE-IDENTICAL to its pre-WU11 form (same l10n keys, same widget structure) — only repositioned, never rewritten, so every existing force-stop test (SS-3a/SS-3b/SS-3c/RES-2) keeps passing unmodified. **Design decision, not specified by any ADR (WU11 has none)**: the "Subiendo volumen" status dot is intentionally NOT animated/pulsing — an `AnimationController.repeat()` here would reintroduce the exact "`pumpAndSettle()` never terminates" hazard WU5 documented for `VisualizadorAudio`. - [x] 11.5 REFACTOR — ran `pantalla_alarma_sonando_dismiss_guard_test.dart` **unchanged**: all 8 cases green, byte-identical, no edit needed or made. - [x] 11.6 Verify — force-stop banner still appears on a simulated stop failure (all 3 force-stop scenarios green, unmodified); dismiss-guard test file diff is empty against BOTH `HEAD` and `main`. **`size:exception` recorded.** Realized: **530 changed lines** (440+/90-) across 3 files against the 200-300 forecast — `lib/` production code alone is 302 lines (248+/54-), essentially at the top edge of the forecast band by itself; the 2 modified test files add 228 lines, the same "a strict-TDD commit carries its test files" pattern as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514) — this WU touches no ARB keys beyond the 1 new `alarmVolumeRisingStatus` string, so no 13-file l10n/gen inflation this time; test growth alone accounts for the overage. Not pre-flagged for exception in the forecast table (unlike WU10), but the same "one cohesive visual+behavioral restyle to one safety-critical screen" reasoning applies: splitting the background/snooze-tiles/ stop-pill/status-label changes into separate commits would leave an inconsistent intermediate UI on the ringing screen specifically — the one screen in this whole branch where an inconsistent intermediate state is least acceptable. ## 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 diff` success criterion. - [x] 13.1 RED — the Ecualizador screen renders exactly **5** vertical sliders (regression guard — reject any future 7-slider change on sight). **Corrected at apply time**: named test file `ecualizador_widget_test.dart` did not exist before this commit (`ecualizador_widget.dart` had zero coverage) — created new, plus the screen-level assertion in `pantalla_ajustes_ecualizador_test.dart` (the spec scenario's own GIVEN is "the Ecualizador settings screen"). - [x] 13.2 RED — `servicio_presets_personalizados_test.dart`: save/list/delete round-trip for a named custom preset via its **own** SharedPreferences key `eq_custom_presets_v1`. - [x] 13.3 RED — submitting an empty/whitespace-only preset name shows a validation message and persists nothing. Covered at both the state layer (new `estado_ecualizador_presets_personalizados_test.dart` — see 13.7's note) and the UI layer (`pantalla_ajustes_ecualizador_test.dart`'s "Guardar como preset" dialog scenarios). - [x] 13.4 RED — the "Emisoras con ajuste propio" drill-down lists exactly the stations present in `presetsPorEmisora`. - [x] 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. - [x] 13.6 GREEN — create `lib/servicios/servicio_presets_personalizados.dart` (**new file**, own SharedPreferences key). **Do NOT add any member to `servicio_ecualizador.dart`.** `lib/modelos/preset_ecualizador.dart` stays unchanged — a user preset is a `PresetEcualizador` with a user-supplied `nombre`; `toJson`/`desdeJson` already exist. - [x] 13.7 GREEN — extend `EstadoEcualizador` with `presetsPersonalizados`, `guardarPresetPersonalizado(nombre)`, `eliminarPresetPersonalizado(nombre)`, reading/writing only through the new service. **Correction found at apply time**: the new custom-preset LOAD (`cargarPresetsPersonalizados()`) is deliberately NOT folded into `cargarPersistido()` — that method is exercised ~30 times by `estado_ecualizador_test.dart` (one of the three protected EQ test files) via Fakes for `servicio`/`dispositivoAudio` ONLY, with zero SharedPreferences awareness anywhere in that file; adding a third always-real-by-default collaborator to it would have introduced real SharedPreferences I/O into every one of those cases. Called explicitly from the screen's `initState` instead (same pattern `refrescarDispositivoActual` already uses). New coverage lives in a NEW file, `test/estado/estado_ecualizador_presets_personalizados_test.dart` — NOT added to `estado_ecualizador_test.dart`, which stays unmodified. - [x] 13.8 GREEN — restyle `lib/widgets/ecualizador_widget.dart` in place: strip the internal title + preset chip row (re-verified at the CURRENT lines 59-77 of the pre-WU13 file, not trusted from the design doc's reference), restyle the 5 `Card`/`RotatedBox`/`Slider` tiles to the vertical-track look (token-driven accent colour, `AnimatedOpacity` grey-out), add the `habilitado` parameter (disables + greys every slider). `PresetsEcualizadorWidget` additively widened with an optional `personalizados` list (default empty) so custom presets can join the chip row without a second implementation. Kept the `for (int i = 0; i < 5; i++)` loop bound literally `5`. - [x] 13.9 GREEN — build out `lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart` (the WU3a placeholder body) wiring `EstadoEcualizador.presetPrincipal` / `cambiarPresetPrincipal`, surfacing "Salida activa" (own private row, live via the existing device-change `notifyListeners()`) and the drill-down row (own private row + destination screen, station uuids resolved against `EstadoRadio.listaFavoritos` with a raw-uuid fallback for a non-favorite station). Added the base-vs-per-station explainer banner and the "Guardar como preset" action + dialog (validates via `guardarPresetPersonalizado`, shows an inline `errorText` on empty/whitespace name). - [x] 13.10 REFACTOR — reviewer check: confirmed `git diff` is **empty** for `lib/servicios/servicio_ecualizador.dart`, `lib/modelos/preset_ecualizador.dart`, and the band-application block at `lib/servicios/servicio_audio.dart:749-762` (this WU never touches `servicio_audio.dart` at all). - [x] 13.11 Verify — the three EQ test files (`servicio_ecualizador_test.dart`, `estado_ecualizador_test.dart`, `servicio_audio_eq_reapply_test.dart`) pass **unmodified**; exactly 5 sliders render (component AND screen level). Full suite: 713/713 green (2 skipped, unchanged), up from 682. `flutter analyze`: 1 issue, identical to baseline. Literal-encoding scan: zero hits. **`size:exception` recorded.** Realized: **1,871 changed lines** (1,793+/78-) across 25 files against the 400-550 forecast — same "a strict-TDD commit carries its test files, and any ARB touch drags 13 generated files with it" pattern as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514). Breakdown: `lib/` production code alone is 628 lines (`pantalla_ajustes_ecualizador.dart` +326, `ecualizador_widget.dart` restyle ~151, `estado_ecualizador.dart` +64, new `servicio_presets_personalizados.dart` +87) plus 22 ARB source lines — at the top edge of the forecast band by itself given this WU also had to build out a screen body WU3a only stubbed; the 4 test files (2 new state/service-layer files plus the widget test and the extended screen test) add 675 lines, and 11 new ARB keys drag in 546 more via the 13 regenerated `lib/l10n/gen/*.dart` files. Justification: the restyle, the new persistence service, the state-layer extension and the screen wiring (explainer/salida-activa/drill-down/ save-flow) are one cohesive vertical slice — WU13 must land as a whole before WU14 can reuse its editor component by exact runtime type; splitting further would leave either an unstyled widget with no screen consumer or a screen half-wired to a service that doesn't exist yet. ## 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**. - [x] 14.1 RED — the per-station EQ bottom sheet renders **the same `EcualizadorWidget` type** WU13 restyled (assert by `runtimeType`, so a duplicate implementation fails the test, not just a visual review). - [x] 14.2 RED — the 4 tool-tray tiles ("EQ propio", "Grabar", sleep-timer value, "Compartir") each open their own bottom sheet (Compartir invokes directly — there is no sheet content for a share action). - [x] 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`). - [x] 14.4 GREEN — restructured `pantalla_reproductor.dart` onto `PluriPushScaffold` (design ADR-2 — this screen is the documented single consumer of `titleOverride`, a centered live/not-playing `PluriStatusPill`, and of the non-default `leadingIcon: keyboard_arrow_down_rounded`): square art (`ClipRRect`, was `ClipOval`), favorite moved into the transport row (the old redundant live-indicator dot removed — the AppBar pill already covers that signal, so "favorite / stop / play-pause" matches the proposal's own "3 controles" note), single subtitle line (collapses the old `_InfoChips` `Wrap`; codec/bitrate moved to the new quality row), 4-tile tool-tray row replacing the always-expanded recording panel + separate sleep-timer button + EQ toggle. Body wrapped in `SingleChildScrollView` (was a fixed `Column` + `Spacer()`, which overflowed even a generously tall test viewport — see 2 bugfixes below). - [x] 14.5 GREEN — wired "EQ propio" to a bottom sheet hosting `EcualizadorWidget` bound via `EstadoEcualizador.presetParaEmisora(uuid)` / `guardarPresetPorEmisora(uuid, ...)`. Deliberately just the 5 sliders, no preset-chip row — the spec scenario's own wording is "5 sliders", and ADR-5's wiring table only names `EcualizadorWidget` for this consumer. - [x] 14.6 GREEN — added the quality row + "Cambiar" action and the "Compartir" tool-tray tile. **Design decision, not specified by any ADR (WU14 has none)**: this app has no per-station alternate-quality capability to invoke (a Radio Browser station is one fixed stream) — "Cambiar" reconnects the current stream (the SAME `estado.reproducir(emisora)` call the existing error-state "Retry" button already uses) instead of a dead button or an invented picker, matching this branch's "don't invent a capability absent from the domain" discipline (WU5 per-station artwork, WU9 dashed border). "Compartir" mirrors WU15's injectable `compartir` constructor-parameter pattern (defaults to the real `share_plus` call), sharing the station name + stream url. - [x] 14.7 REFACTOR — confirmed no second EQ editor file was created (`grep`-equivalent: only one `EcualizadorWidget` class exists, in `lib/widgets/ecualizador_widget.dart`, imported and reused here); confirmed the `multi-device-eq` regression scenarios in `estado_ecualizador_test.dart`'s "4-level resolution (Phase 5)" group still pass unmodified (this WU never touches `EstadoEcualizador`'s resolution logic, only calls its EXISTING `presetParaEmisora`/`guardarPresetPorEmisora` methods). Removed dead code found during the restructure: `_pulseController` (an `AnimationController` created and disposed but never actually driven by anything) and the `SingleTickerProviderStateMixin` it required. - [x] 14.8 Verify — the 3 EQ test files remain green and **unmodified**; `EcualizadorWidget` type-identity assertion passes (both via `find.byType` and a `runtimeType`-predicate structural regression guard). Full suite: 730/730 green (2 skipped, unchanged), up from 713. **Two pre-existing bugs found and fixed, surfaced by writing this screen's first-ever test coverage** (both directly blocked test coverage from working at all, so neither could be deferred): 1. `initState` called `estado.reproducir(...)` directly, which calls `notifyListeners()` **synchronously** before its first `await` when no recording needs stopping — threw "setState() or markNeedsBuild() called during build" the instant this screen mounted against a fresh Provider tree. Fixed via `WidgetsBinding.instance. addPostFrameCallback`. 2. The body `Column` had no scrollable ancestor and overflowed the default 800x600 test viewport (and would overflow on a genuinely short real device too, given the content: hero, name, subtitle, quality row, visualizer, transport, tool tray). Fixed by wrapping the body in `SingleChildScrollView` (see 14.4) — a real UX improvement, not just a test workaround. **`size:exception` recorded.** Realized: **1,335 changed lines** (1,114+/221-) across 19 files against the 450-600 forecast — same "a strict-TDD commit carries its test files" pattern as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514), though smaller this time since only 2 new ARB keys were needed (`playerToolEqLabel`, `playerQualityChangeAction` — everything else reused existing keys: `recordAction`, `recordingActiveTitle`, `sleepTimer`, `recordingActionShare`, `liveNow`, `notPlaying`, `qualityOriginal`, `qualityUnknown`). Breakdown: `lib/pantallas/pantalla_reproductor.dart` alone is 658 lines (a near-total restructure of a 907-line file, not a small patch); the new `pantalla_reproductor_test.dart` (writing coverage for a file that had ZERO before this commit, per this WU's own explicit mandate) is 519 lines; `test/helpers/fakes.dart` gained 64 (new `FakeServicioGrabacionRadioActivable` plus a `togglePlay()` override the play/pause characterization test needed); the rest is the 2-key ARB/l10n-gen cascade. Not splittable: the restructure, the tool tray, and the EQ-sheet wiring are one cohesive change to one screen — a split would leave either an unstyled screen with a tool tray that has nothing to open, or a tool tray with no restructured screen to live in. ## 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) - [x] 15.1 RED — storage progress-bar fill reflects used/total (e.g. 84/200 MB fixture) with a caption stating both values. - [x] 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. - [x] 15.3 RED — the "⋮" menu exposes exactly Rename/Share/Delete — constrained to what `servicio_grabacion_radio.dart` already exposes, no extra action. - [x] 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 instant `EstadoGrabacion` wires to a bare, real `ServicioGrabacionRadio` pointed at a real directory inside a `testWidgets()` 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 vs `listSync()`; 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: `eliminarGrabacion` and `renombrarGrabacion` both have passing unit tests in `servicio_grabacion_radio_test.dart` (plain `test()`, exercised against real files, no hang) — the one variable no diagnostic could change was `testWidgets()` 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. - [x] 15.5 GREEN — added `listarGrabaciones()` to `servicio_grabacion_radio.dart` (pure filesystem read via `listSync`/`statSync`, sorted most-recent-first, empty list for a missing directory — never throws). Also added `eliminarGrabacion(ruta)` and `renombrarGrabacion(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 plain `dart:io`, no new conceptual surface. `EstadoGrabacion` gained thin delegates for all three plus `notifyListeners()`. New `lib/modelos/archivo_grabacion.dart` (ruta, nombre, fecha, tamanoBytes — pure filesystem metadata, no audio decoding). - [x] 15.6 GREEN — built `lib/pantallas/pantalla_grabaciones.dart`: storage bar (`LinearProgressIndicator` over `EstadoGrabacion.maxBytes` vs. the summed listing), per-recording rows (name/date/duration/size) with inline play/pause, "⋮" menu (`PopupMenuButton`, exactly 3 items). New `ReproductorGrabaciones` abstraction (duration-lookup + toggle-play) keeps `just_audio.AudioPlayer` out of `ServicioAudio` (never touched — hard constraint) and out of `servicio_grabacion_radio.dart`; the real `just_audio`-backed implementation is static-review-only (same documented constraint as `PluriWaveAudioHandler` in `cola_local_test.dart` — a real `AudioPlayer` needs platform `MethodChannel`s this suite does not mock), every test injects a fake. `Share` is similarly injected (`compartir` constructor parameter), defaulting to the real `share_plus` call. 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. - [x] 15.7 REFACTOR — confirmed the menu cannot expose a 4th action (task 15.3's test asserts `PopupMenuItem` count is exactly 3); confirmed the empty-folder scenario renders `PluriEmptyState`, not a crash or exception (`tester.takeException()` asserted null). - [x] 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). - [x] 15b.1 RED — re-targeted `pantalla_ajustes_test.dart`'s "tapping the Grabaciones row" scenario: it now asserts landing on `PantallaGrabaciones` (`find.text('My recordings')`), not the settings form (`find.text('Change path')` asserted absent). - [x] 15b.2 RED — added a new scenario to `pantalla_grabaciones_test.dart`: tapping the library's settings icon pushes a second `PluriPushScaffold` showing the settings form (`find.text('Change path')`). - [x] 15b.3 GREEN — `pantalla_ajustes.dart`: the "Grabaciones" `FilaAjuste.onTap` now pushes `PantallaGrabaciones`; removed the now-unused `PantallaAjustesGrabaciones` import from this file. - [x] 15b.4 GREEN — `pantalla_grabaciones.dart`: added `actions: [IconButton(Icons.settings_outlined, ...)]` to its `PluriPushScaffold`, pushing `PantallaAjustesGrabaciones`. One new ARB key (en/es only, matching precedent): `recordingsLibrarySettingsTooltip`. - [x] 15b.5 REFACTOR — confirmed the new test needed the same `_suppressListTileInkAssertion()` helper WU3a/WU3b established (WU3b Discovery 9: any pushed screen whose `PluriGlassSurface` body contains a tappable `ListTile` needs it) — added to `pantalla_grabaciones_test.dart`, scoped to only the one new scenario that mounts `PantallaAjustesGrabaciones`. - [x] 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. ## WU16 — Connectivity banner restyle (offline / reconnect) **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. - [x] 16.1 Verify-first — run `servicio_audio_reconnect_test.dart` and inspect the reconnect controller: confirm whether it tracks an attempt count. **Result: it does NOT reach the UI.** `ControladorReconexion.intentos` exists as a getter, but `ServicioAudio` (`lib/servicios/servicio_audio.dart`) only ever reads it inside a `developer.log` debug line (line 413) — its public `estadoStream` maps every playback state to the `EstadoReproduccion` enum (no attempt-count payload) and `_handler.reconectando` is a bare bool. Wiring a count to the UI would require adding a getter/stream to `servicio_audio.dart`, which is one of the four files this change must keep at an **empty git diff vs `main`** — structurally blocked, not just untested. Shipping without the counter per 16.4, exactly as the risk register anticipated. - [x] 16.2 RED — 3 new `testWidgets` cases added to `reconnect_ui_test.dart` (no attempt-count test, per 16.1): reconnecting tints the spinner + status label with `offlineAccent`; plain `cargando` keeps the default spinner colour (regression guard proving the tint is reconnect-specific, not blanket-loading); error tints the retry icon + status label with `offlineAccent`. Confirmed RED: 2 of the 3 failed (`Actual: `) against the pre-restyle widget. - [x] 16.3 GREEN — `lib/widgets/mini_reproductor.dart`: the status-label `Text` and the reconnect spinner / error retry icon now read `context.pluriTokens.offlineAccent` (WU1's previously-unused token, whose own doc comment already named it for this WU) whenever the stream reports `reconectando` or `error`. Plain `cargando` explicitly keeps `color: null` (the theme default) — verified by 16.2's regression test. - [x] 16.4 GREEN — conditional step confirmed moot: no attempt-count label added, no new plumbing introduced. - [x] 16.5 REFACTOR — `TarjetaEmisoraShimmer` (`lib/widgets/tarjeta_emisora.dart`, consumed by `pantalla_buscar.dart`) is untouched by this commit — confirmed via `git diff --stat`, zero lines. - [x] 16.6 Verify — scoped suite green: 16/16 (5 `reconnect_ui_test.dart` + 8 `servicio_audio_reconnect_test.dart` [byte-identical, unmodified] + 3 `mini_reproductor_configurar_test.dart` re-run as an adjacent-file regression check). `flutter analyze`: 1 issue, identical to baseline. Scoped `dart format`: reformatted the hand-written production file once (whitespace only), stable on re-run. Literal-encoding scan: zero hits on the 2 touched files. ## 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` - [x] 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. **Discovered at apply time**: a pre-existing, UNRELATED `PluriOnboardingDialog` (a "what's new"/help-content modal loaded from markdown assets, shown from `app.dart`'s launch flow) already exists — it is NOT the mockup's welcome screen and is not touched or replaced by this WU. - [x] 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. - [x] 17.3 RED — tapping "Empezar a escuchar" lands on the Escuchar tab (index 0) with the welcome route removed from the back stack. Test starts `EstadoNavegacionRaiz` on a DIFFERENT tab first so a pass actually proves `irA()` ran, not just that escuchar was already the default. - [x] 17.4 GREEN — built `lib/pantallas/pantalla_bienvenida.dart` as a full-screen route (bare `Scaffold`, no `PluriPushScaffold` — this is an entry screen with no back affordance, not a second-level pushed screen); content only (logo / headline / body / 3 bullets / 1 CTA) — no PRO pill, no trial duration, no price, no secondary link. Content is the mockup's screen 14 copy verbatim MINUS its entire monetization block (the "14 días PRO gratis / luego 2,99 €/año" pricing card and the "Seguir con la versión gratuita" link), Spanish re-cast to the app's established voseo register (the raw mockup HTML uses "tú" form; the other ~750 lines of `app_es.arb` consistently use voseo — e.g. "Elegí", "Guardá", "querés" — matched that instead of copying the mockup's grammar verbatim) and "auto" instead of the mockup's "coche" (matching the one existing precedent at `localMusicSectionDescription`). The 3 bullet icon colours reuse existing named tokens (`electricMagenta`/`liveGreen`/`warmCoral`) that happen to be the exact hex values the mockup already used for those 3 icons — no new raw `Color(0x...)` literal introduced. `SingleChildScrollView` used instead of a `Column` + `Spacer()` (the exact overflow hazard WU14 already hit and fixed) since this screen carries meaningfully more content than a typical card. 9 new ARB keys, en/es only (`welcomeHeadline`, `welcomeBody`, `welcomeBullet{1,2,3}{Title,Subtitle}`, `welcomeCtaLabel`). - [x] 17.5 GREEN — CTA wired to `context.read().irA(RaizPluriWave.escuchar)` then `Navigator.of(context).pop()`. **Scope note**: this WU's own verify command and task list only cover `pantalla_bienvenida_test.dart` — wiring the screen into the REAL first-launch flow (i.e. `app.dart`/ `main.dart` deciding *when* to push it, analogous to `ServicioContenidoApp.debeMostrarInicio()`'s pattern for `PluriOnboardingDialog`) is NOT part of WU17 as scoped and was deliberately not added here to avoid inventing untested persistence/launch semantics. Flagged as a likely WU17b gap (same shape as WU15/WU15b). - [x] 17.6 REFACTOR — whole-repo grep confirmed clean: `\bPRO\b` (word-boundary) has exactly one hit in all of `lib/` — this WU's own doc comment describing the guard (never rendered). The unqualified substring "PRO" also matches pre-existing, unrelated `"PROGRAMADOS"` (vacation-alarms section title, WU9) and `pluri_premium_widgets.dart` (a pre-existing decorative-hero-card widget file name, "premium" as in visual styling, not a paid tier) — both false positives for a naive substring check, neither a monetization string. No day-count trial phrase, no €/$ amount, no "gratuita"/"gratis"/"free version" string anywhere in `lib/l10n/app_*.arb` or `lib/`. - [x] 17.7 Verify — all 3 scenario tests green; grep scan clean. ## WU17b — Wire the welcome screen into the first-launch flow **Not in the original plan — added to close the WU17 gap noted above, same shape as WU15/WU15b.** WU17 built `PantallaBienvenida` fully tested and committed, but left it unreachable from the app: `rg "PantallaBienvenida" lib/app.dart lib/main.dart` found nothing. This work unit exists solely to fix that. **Commit**: `fix(bienvenida): wire the welcome screen into the first-launch flow` **Depends on**: WU17 **Spec refs**: `onboarding-welcome` — Full-Screen Welcome Route (reachability; the render/content/no-monetization/CTA scenarios stay WU17's own, unmodified) **Verify**: `flutter test test/servicios/servicio_bienvenida_test.dart test/pantallas/pantalla_bienvenida_primer_lanzamiento_test.dart test/pantallas/pantalla_bienvenida_test.dart test/widget_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')` **New tests**: `test/servicios/servicio_bienvenida_test.dart`, `test/pantallas/pantalla_bienvenida_primer_lanzamiento_test.dart` **Modified tests**: none (WU17's own `pantalla_bienvenida_test.dart` re-run unmodified as a regression check) **Coexistence ruling applied.** `PluriOnboardingDialog` (an unrelated, pre-existing "what's new"/help-content modal loaded from markdown assets) is NOT deleted, merged, or replaced — first-launch welcome and a what's-new modal are different things, confirmed by reading `assets/content/onboarding/en.md`: it is a detailed feature-reference walkthrough plus per-version update notes, structurally different content from the welcome screen's 3-bullet marketing intro. Both now run from `app.dart`'s `_PaginaPrincipalState`, in this order on every cold start: the welcome screen's once-ever check resolves FIRST, then the pre-existing what's-new dialog's own independent per-version due-or-not check runs exactly as it did before this WU. Sequencing (not two independent fire-and-forget calls) is what prevents the two from ever racing onto the screen at the same time. - [x] 17b.1 RED — `test/servicios/servicio_bienvenida_test.dart`: a new `ServicioBienvenida` (mirroring `ServicioContenidoApp`'s injectable-`SharedPreferences`, versioned-key convention, S3-R4) is due before it has ever been marked seen, is not due after `marcarBienvenidaVista()`, and respects a seen flag already persisted by a prior launch (`SharedPreferences.setMockInitialValues`). - [x] 17b.2 RED — `test/pantallas/pantalla_bienvenida_primer_lanzamiento_test.dart`: a new `PantallaBienvenida.mostrarSiProcede(context)` static method (mirroring `PluriOnboardingDialog.mostrarSiProcede`'s check-then-show-then-mark-seen shape) pushes the welcome screen on a first launch (no seen flag persisted), does NOT push it when the seen flag is already persisted, and — in one continuous session — persists the flag after being shown once so a later check in the same run skips it. - [x] 17b.3 GREEN — created `lib/servicios/servicio_bienvenida.dart` (`ServicioBienvenida`, key `pluri_bienvenida_vista_v1`, plain one-time boolean — no version comparison needed, unlike `ServicioContenidoApp`, since this welcome is a single first-impression surface, not a per-version one). - [x] 17b.4 GREEN — added the static `PantallaBienvenida.mostrarSiProcede(BuildContext)` method to the existing `pantalla_bienvenida.dart` file (no new wrapper class needed — unlike `PluriOnboardingDialog`, there is only one call shape here); updated the class doc comment to describe how it coexists with `PluriOnboardingDialog`. - [x] 17b.5 GREEN — wired `app.dart`: imported `pantalla_bienvenida.dart`; renamed the existing `_onboardingInicialSolicitado` guard flag to `_flujoPrimerLanzamientoSolicitado` (it now covers the combined sequence, not only the dialog); added `_mostrarFlujoPrimerLanzamiento()`, which awaits `PantallaBienvenida.mostrarSiProcede(context)` then calls the pre-existing, untouched `_mostrarOnboardingInicial()` — replacing the single `unawaited(_mostrarOnboardingInicial())` call site with `unawaited(_mostrarFlujoPrimerLanzamiento())`. - [x] 17b.6 REFACTOR — confirmed `_mostrarOnboardingInicial()`'s own body (900ms delay, `_alarmaSonandoActiva` guard, `PluriOnboardingDialog.mostrarSiProcede` call) is byte-for-byte unchanged — only its call site moved one level deeper into the new sequencing method. No scenario in `PantallaBienvenida`'s own WU17 test file needed to change (all 3 pass unmodified, confirming the CTA/content/no-monetization behavior is untouched). - [x] 17b.7 Verify — scoped suite green: 13/13 (3 `servicio_bienvenida_test.dart` + 3 `pantalla_bienvenida_primer_lanzamiento_test.dart` + 3 `pantalla_bienvenida_test.dart` [byte-identical, unmodified] + 4 `widget_test.dart`). `flutter analyze`: 1 issue, identical to baseline. Scoped `dart format`: reformatted 1 of 5 touched files (whitespace-only string-literal wrapping), stable on re-run. Literal-encoding scan: one console-rendering false positive on the pre-existing "días" string (verified byte-correct UTF-8 via a direct file read with the encoding pinned), zero real corruption. ## 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) - [x] 18.1 RED — `test/l10n/arb_parity_test.dart` (+ `test/l10n/arb_test_helpers.dart` shared reader): an ARB-parity test asserting all 13 `lib/l10n/app_*.arb` files contain an identical key set, plus a `navHome`-exists check (task 18.2's first half). Confirmed RED against the pre-merge tree: 11 locales missing 72 keys each. - [x] 18.1b RED — `test/l10n/arb_anti_copy_test.dart` + `test/l10n/identical_value_allowlist.dart` (the anti-copy guard). **Scope correction found at apply time**: read literally ("for every non-`es` locale, collects every key whose value is byte-identical") this must cover the WHOLE file, not only WU18's own new keys — a narrower scope would leave 183 pre-existing identical values (mostly genuine `pt`/`es` Iberian-Romance cognates, plus international genre names, "OK", brand names, symbols) completely unaudited. Built the allowlist to cover both: 207 total entries (after de-duplicating two detection-script bugs found along the way — see 18.5's note), each with an individual justification comment, grouped by category for readability but never wildcarded. **`en` is deliberately excluded** from this guard's locale loop (see the test file's own doc comment): `en` and `es` have been hand-maintained together by every WU throughout this whole change (`en`/`es`-only new keys, never left lagging), so `en` never carries the "silently fell back to the template" risk this guard exists to catch — unlike the other 11, which this WU batch-translates for the first time. - [x] 18.2 RED — `navHome` exists as a key in all 13 locales (part of 18.1's parity test); `git diff` for this commit touches exactly **11** `navHome` **value** lines, confirmed via `git diff | grep -c '"navHome"'` = 22 (11 removed + 11 added) after the merge (en/es were WU1's job, task 1.9, untouched here). - [x] 18.3 GREEN — added exactly **72** new keys (measured, not the ~35-45 estimate) to all 13 ARB files, with real, idiomatic translations for the 11 non-en/es locales (ar, bn, de, fr, hi, id, it, ja, pt, ru, zh) — vocabulary cross-checked against ~20 already-translated sibling keys in each locale (e.g. "station"/"favorites"/ "preset"/"OK"/genre-name conventions, register — `du`-informal `de`, `vous`-formal `fr`, `tu`-informal `it`, polite-imperative `ru`/`ja`/`hi`/`bn`, `você`-imperative `pt` with no explicit pronoun) so new copy reads as part of the same voice as existing copy, not machine-translated in isolation. 7 of the 72 keys carry ICU placeholders/plurals; their `@key` metadata blocks were added to all 11 locales matching the template's own established convention of replicating metadata into every locale file (confirmed via a diff scan: the 53 pre-existing metadata blocks shared between `es` and a sample locale were byte-identical). Arabic and Russian plurals use full CLDR categories (`one`/`two`/`few`/`other` and `one`/`few`/`other`) matching the precedent already set by the pre-existing `stationCount` key, rather than the simpler `=1`/`other` the ES/EN template itself uses for these two new keys. - [x] 18.4 GREEN — set `navHome` in the remaining 11 locales to a real translation of "Listen"/"Escuchar" (ar استماع, bn শোনা, de Hören, fr Écouter, hi सुनना, id Dengar, it Ascolta, ja 聴く, pt Ouvir, ru Слушать, zh 收听) — each cross-checked against that locale's OWN sibling nav-tab labels (`navSearch`/`navFavorites`/ etc.) for register/part-of-speech consistency, not a mechanical copy of "Listen" or "Escuchar". - [x] 18.5 REFACTOR — re-ran the parity + anti-copy guard; **two detection-script bugs found and fixed along the way, not left in the final allowlist**: (1) the first draft's collision scanner used PowerShell's default `-eq`, which is **case-insensitive** — it falsely flagged 5 pairs (e.g. `de/durationMinutesOnly` "min" vs "Min") as collisions; re-scanned with `-ceq` (case-sensitive, matching Dart's own `==`) and removed the 5 false positives. (2) the first allowlist draft was missing 7 genuine collisions the script had already found but a manual transcription pass dropped (`settingsGroupAudioTitle` ×4, `endField`/`endLabel` fr, `alarmFadeInLabel` pt) — added them back. Final state: zero missing/extra keys across all 13 files; anti-copy guard's own "no stale entries" self-check (added as this test's second assertion, not just the one the task named) also passes, so the allowlist is verified as exactly the current, real collision set — 207 entries, none stale, none missing. - [x] 18.6 Verify — full `flutter test`: **747/747 green, 2 skipped** (up from the 737-baseline this batch started from — +6 WU17b tests + +4 WU18 tests, reconciles exactly). `flutter analyze`: 1 issue, identical to baseline. Scoped `dart format` (11 regenerated `lib/l10n/gen/*.dart` + 4 new `test/l10n/*.dart` files, ARB JSON files are not `dart format` targets): reformatted 3 of 15 files (whitespace/line-wrapping only), stable on re-run. Grep confirms zero `\bPRO\b` (case-sensitive), €, $, "gratis"/"gratuit"/"free version", or day-count trial-phrase hits in any `app_*.arb` file. Literal-encoding scan (Arabic, Bengali, Devanagari, Japanese, Chinese, Cyrillic all touched): zero mojibake hits across all 11 modified ARB files, the 11 regenerated `gen/*.dart` files, and the 4 new test files; spot-checked several locales' rendered text directly to confirm. --- ## Final Verification Sequence (run once, after WU18's commit) All 10 steps run and confirmed after the WU18 commit: 1. [x] `flutter test` — full suite green: **747/747, 2 skipped**. 2. [x] `flutter analyze` — clean: 1 issue, identical to the pre-branch baseline. 3. [x] `dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM main -- '*.dart')` — no diff over the files this change touched. **NEVER bare `dart format .`** — it reformats 27 unrelated pre-existing files and would break the empty-diff guards in steps 4 and 5 below (Engram `reference/dart-format-scope-hazard`, id 2511). 4. [x] `git diff --stat lib/servicios/navegacion_auto.dart lib/servicios/servicio_ecualizador.dart` — both empty. 5. [x] `git diff lib/servicios/servicio_audio.dart` — band-application block (`749-762`) unchanged. 6. [x] Grep `lib/` for `countrycodes` — zero hits. 7. [x] Grep `lib/l10n/app_*.arb` for "PRO", a currency amount, or a day-count trial phrase — zero hits. 8. [x] Confirm all 13 ARB files have identical key sets and `navHome` present in each — 466 real keys each, confirmed. 9. [x] Confirm the equalizer screen renders exactly 5 sliders — confirmed. 10. [x] Run `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` in isolation — 13/13 passes, file diff empty vs `main`. **All 18 work units (+ WU15b, WU17b, and the pre-existing controller-dispose bugfix) are now complete.** Branch `feat/rediseno-funcional` is ready for `sdd-verify`. ## Next Step `sdd-verify` — validate the completed implementation against `specs/*/spec.md`; `sdd-apply`'s work on this change is done.