docs(sdd): add functional redesign proposal

This commit is contained in:
2026-07-28 18:02:07 +02:00
parent 675b7fb4b7
commit f58cf8739f
@@ -0,0 +1,343 @@
# Proposal: Functional Redesign (`rediseno-funcional`)
> Source of truth: `rediseno-funcional-de-aplicacion/project/PluriWave Rediseno.dc.html`, turn `t4` only.
> Binding inputs: Engram `sdd/rediseno-funcional/scope-decisions`, `sdd/rediseno-funcional/open-questions-resolved`,
> `sdd/rediseno-funcional/eq-band-spike` (id 2498), `reference/radio-browser-countries-endpoint` (id 2500),
> `preferences/verify-external-apis-officially` (id 2501), `explore-mobile`, `explore-auto`, `explore-crosscutting`.
> Do not re-open decisions recorded there.
> This artifact intentionally exceeds the default 450-word proposal budget: the change spans 14 mobile screens,
> 5 Android Auto surfaces, and 18 work units, and the delivery/risk sections are load-bearing for the apply phase.
## Intent
**Problem.** The app's current UI is a glass-everywhere magenta/coral surface built up incrementally over many
changes. Three concrete symptoms:
1. **Settings has collapsed under its own weight.** `lib/pantallas/pantalla_ajustes.dart` is 1896 lines rendering
**12 always-expanded panels** on one scroll. The mockup's "12 panels to 4 groups" framing describes the current
app *exactly* — the redesign premise is verified, not aspirational.
2. **The home tab does not do its job.** `pantalla_inicio.dart` renders a discovery browser (near-you carousel,
genre chips, trending grid) while the thing users open the app for — the player and their own stations — is
one tap away behind a pushed route.
3. **The visual language drifted from its own tokens.** The mockup's `#07121A` base and `#21D4D9` brand already
match `PluriWaveTokens`, but the list surface `#102532`, the live green `#7EE4C2`, and the offline accent are
used ad-hoc across screens, and `.copyWith(fontWeight: w900, letterSpacing: ...)` is copy-pasted in nearly every
screen file. There is no named type scale.
**Why now.** The design handoff (turn `t4`) is approved and the app is in Google Play internal testing — the
lowest-risk window to restructure navigation-adjacent UI before a public audience exists. Version is already
bumped to `1.2.0+122`; `17f8e69` on `main` is a clean rollback point.
**Success looks like.** Every mockup screen's *intent* is implemented in the new visual language, information
architecture is unchanged (5 tabs), no existing capability is lost, and no monetization surface ships.
## Standing Rule — external APIs are verified, never inferred
**Any technical doubt about connectivity to an external API or service MUST be resolved against that provider's
official documentation. Inference from existing call sites, from field names, or from what "looks right" is not
acceptable evidence.** This binds `sdd-spec`, `sdd-design`, and `sdd-apply` for this change and every change after
it. Engram: `preferences/verify-external-apis-officially` (id 2501).
Practical consequence here: no spec, design, or task may assert a Radio Browser endpoint, parameter, or response
field that has not been checked against <https://api.radio-browser.info/>. Where such a check has already been
done, the verified contract is recorded below and is authoritative.
## Reconciliation Ruling — mockup push-chrome vs. 5 tabs
The mockup states (t4 intro, line 38): *"cuatro raíces con barra globo … las pantallas de segundo nivel entran
empujadas con cabecera de 56px, flecha atrás y SIN barra de pestañas."*
**Ruling: adopt the push-chrome rule, reject the root set.**
- Every genuine second-level screen gets 56px header + back arrow + **no** tab bar: Reproductor completo (2),
Países (5), Vacaciones (9bis), Ecualizador (11), Grabaciones (12), and all new Settings detail screens.
- All **five** roots keep the tab bar: Escuchar, Buscar, Favoritos, Alarmas, Ajustes.
- **The one deliberate contradiction**: mockup screen 4 ("Tus emisoras · grupos") is *drawn* as a pushed screen with
no tab bar. In our IA it **is** the Favoritos root, so it **keeps its tab bar**.
**Reasoning.** The mockup's no-tab-bar rule is a *consequence* of its 4-root IA, not an independent design law. Its
real intent is "navigation depth removes the root switcher." Applying it literally to Favoritos would hide the tab
bar on a root tab — a navigation regression, not a redesign. The rule is honoured at the level of intent (depth
implies no tabs) and rejected at the level of the specific frame that only looked pushed because the mockup had
demoted Favoritos out of the root set. Downstream phases must not copy "no tab bar" onto Favoritos.
**Corollary ruling (not covered by the 8 resolutions):** the mockup's 4 Settings groups draw exactly 16 rows,
omitting Sleep Timer and Backup/restore. Per the standing "do not silently narrow scope" rule, **both are kept**
under the `APLICACIÓN` group. Sleep timer stays reachable from both Settings and the player tool tray.
## Scope
### In Scope
Ordered as work units (WU). This ordering is the dependency graph, not a suggestion.
| WU | Deliverable | Depends on | Est. lines | Risk |
|----|-------------|-----------|-----------|------|
| WU1 | **Design tokens + shared primitives + the Inicio-to-Escuchar rename**: add `listSurface #102532`, `liveGreen #7EE4C2`, `offlineAccent #E8879A`; named text styles (`heroTime`, `eyebrowLabel`, `cardTitle`, `screenTitle`); shared push-chrome scaffold (56px header + back, no tab bar); **retitle the first tab in `lib/app.dart` and update the `navHome` value in `app_en.arb` + `app_es.arb`** (see rename ruling below) | — | 160-260 | Low |
| WU2 | **Android Auto verification-only**: re-run `test/servicios/navegacion_auto_test.dart`, formally close A1-A5 as already-done. **Zero code changes.** | — | 0 | None |
| WU3a | **Settings shell + AUDIO/EMISORAS groups**: 12 panels to grouped nav rows, extract detail screens, controls reused verbatim | WU1 | 350-450 | Low (mechanical) |
| WU3b | **Settings GRABACIONES Y MÚSICA / APLICACIÓN groups** (incl. sleep timer + backup) | WU3a | 350-450 | Low (mechanical) |
| WU4 | **Favoritos restyle**: chip group filter replaces stacked panels, flat reorderable list (drag-to-reorder is NEW), `swap_vert` sort, custom-station CTA | WU1 | 300-400 | Medium |
| WU5 | **Escuchar restructure**: embedded player hero (square art, live pill, waveform, transport row with sleep as 5th action, tool-tray entry chip) + "Tus emisoras" favorites grid + "Ver todas" switching to the Favoritos tab (index switch, not push) | WU1, WU4 | 350-450 | Medium-High |
| WU6 | **Buscar**: discovery content (near-you / genres / trending / countries entry) becomes the tab's landing state; active-filter pills with bottom-sheet pickers; results counter; one-tap "clear N filters". Opens with an **official-docs check** on Radio Browser sort support (resolution 8, still open) | WU5 | 350-500 | Medium |
| WU7 | **Países browser (NEW screen)**: "Tus idiomas" + full alphabetical list with per-country counts, over the **verified** `/json/countries` contract below. Includes the `_get` transport extraction in `servicio_radio.dart` **plus regression coverage for the 8 existing station calls** | WU6 | 300-400 | Medium |
| WU8 | **Alarmas root restyle**: minimal cards (giant time + station + switch), **tap = edit, swipe = delete** (resolution 2), hero banner with inline "Saltar", Vacaciones inline panel becomes a summary row + chevron | WU1 | 400-550 | Medium |
| WU9 | **Vacaciones manager (NEW screen 9bis)**: active-range hero, per-alarm impact line, upcoming ranges, past-ranges history. Needs new `EstadoAlarmas` query methods | WU8 | 350-450 | Medium-High |
| WU10 | **Alarm editor sheet rewrite**: inline giant HH:MM editor (custom widget), always-visible weekday circles, restyled sliders. **Keeps** the date field, fallback-station picker, and sound dropdown (resolution 3), collapsed into an advanced section | WU8 | 500-650 | High |
| WU11 | **Alarm ringing restyle**: full-bleed blurred art, giant time, 3 snooze tiles, full-width stop pill. **Static** "Subiendo volumen" label with **no** seconds counter (resolution 4). Force-stop retry banner and single-exit dismiss guard preserved verbatim | WU1 | 200-300 | High (safety-critical) |
| ~~WU12~~ | ~~Native EQ band-count spike~~**RESOLVED before planning closed.** Tombstone row: the number is retired, not missing. Evidence in Engram id 2498, ruling below | — | 0 | — |
| WU13 | **Ecualizador settings screen — restyle at 5 bands**: header enable toggle, base-vs-per-station explainer, preset chips, **5** custom vertical sliders (mockup's 7 rejected — see below), "Guardar como preset" (NEW custom presets), "Salida activa" surfaced from Advanced, "Emisoras con ajuste propio" drill-down (NEW UI over existing `presetsPorEmisora`). **No model, persistence, or EQ-test change from the band axis.** | WU3a | 400-550 | Low-Medium |
| WU14 | **Reproductor completo restructure**: square art, favorite moved into transport row, single subtitle line, 4-tile tool-tray grid (EQ propio / Grabar / sleep-timer / Compartir) opening bottom sheets, quality row + "Cambiar". Per-station EQ sheet **shares** the WU13 editor component | WU13 | 450-600 | Medium-High |
| WU15 | **Grabaciones library (NEW list)**: storage usage bar, per-recording rows with playback, `⋮` menu = **Rename / Share / Delete** constrained to what `servicio_grabacion_radio.dart` already exposes (resolution 6) | WU3b | 300-400 | Medium |
| WU16 | **Connectivity states**: offline banner restyle, reconnect attempt counter (**verify** the reconnect controller exposes an attempt count before promising the label), shimmer skeletons already correct | WU1 | 150-250 | Low |
| WU17 | **Welcome / onboarding screen (14)**: full-screen route in the new visual language — logo, headline, body, 3 feature bullets, "Empezar a escuchar" CTA. **No PRO pill, no "14 días", no pricing card, no secondary "free version" link** (resolution 7) | WU1 | 150-200 | Low |
| WU18 | **i18n batch**: owns **both new keys AND modified values for existing keys**. ~35-45 new keys across all 13 ARB locales (~455-585 translated strings), **plus the `navHome` value in the 11 locales WU1 did not touch**. Run **once**, after WU1-WU17 land in `en`/`es` | all | 0 eng. | Low |
Total estimated changed lines: **~4,550-6,450**, excluding tests.
**Resolved ruling — the Inicio-to-Escuchar rename changes the ARB *value*, not the key.**
Verified directly: `navHome` sits at **line 4 of all 13 ARB files** (`app_en.arb` = `"Home"`, `app_es.arb` = `"Inicio"`,
and 11 further localized values — `Beranda`, `Accueil`, `Start`, `Início`, `Inizio`, `ホーム`, `首页`, `Главная`,
`الرئيسية`, `হোম`, `मुखपृष्ठ`). So this is a **value change to an existing key**, not a new key — which is why neither
WU1's original scope nor WU18's "new keys" scope covered it. Both are corrected above.
Consequence for WU18: this is **13 real translations of "Listen"**, not a find-and-replace. Only `en` and `es` are
mechanical; the other 11 need a translator, exactly like any new key.
| Option | Ruling |
|--------|--------|
| Change `navHome`'s **value** in place | **ADOPTED.** 13 value edits, zero key churn, zero call-site churn, and no window where a locale can drift out of key-set parity mid-refactor. |
| Introduce `navListen`, retire `navHome` | Rejected. |
Reasoning for the rejection: binding decision 2 keeps `PluriIconGlyph.home` as the Escuchar tab icon. "Home" therefore
already survives as this tab's internal identifier **by explicit user decision**. Renaming the ARB key to `navListen`
while the icon glyph stays `home` would make internal naming *less* consistent, not more, and would drag every call
site and any key-asserting test along for no user-visible gain.
Ownership split (so no two units edit the same line): **WU1** changes the value in `app_en.arb` and `app_es.arb` — the
two locales every slice lands in per approach point 9. **WU18** carries it to the remaining 11 locales alongside the
new-key batch.
**Resolved ruling — equalizer stays at 5 bands.** The mockup's 7-band layout (t4, screen 11) is **rejected as not
reliably implementable**. Spike evidence (Engram `sdd/rediseno-funcional/eq-band-spike`, id 2498):
- There is **no native Kotlin equalizer code at all** — zero MethodChannel/EventChannel EQ plumbing under
`android/app/src/main/kotlin`. The Kotlin side holds only the waveform downsampler and the alarm service.
- Equalization runs through `just_audio`'s `AndroidEqualizer`, which wraps the Android system `AudioEffect`
Equalizer. **Band count comes from `params.bands`, reported by the device/OS — the app does not choose it.**
Most Android devices report exactly 5.
- `lib/servicios/servicio_audio.dart:749-762` is the only place gains are applied and already guards with
`i < params.bands.length && i < preset.bandas.length`. Rendering 7 sliders would **silently no-op the last two**
on typical hardware — shipping controls that do nothing.
- `lib/modelos/preset_ecualizador.dart:5-8` hard-asserts `bandas.length == 5`.
Consequence: WU13 is a **restyle only**. No model change, no persistence change, no EQ test churn from this axis.
**Verified contract — Radio Browser countries (WU7).** Checked against <https://api.radio-browser.info/> and
<https://de1.api.radio-browser.info/>. Full detail: Engram `reference/radio-browser-countries-endpoint` (id 2500).
This closes half of open question 8. Downstream phases use these facts verbatim — do not re-derive them.
- Endpoint: **`/json/countries`**, optional substring filter `/json/countries/{filter}`.
**`/json/countrycodes` is DEPRECATED — do not use it.**
- Response fields are exactly `name` (string), `iso_3166_1` (string), and **`stationcount` (STRING, not int)**.
An `as int` cast throws at runtime. **Parse with `int.tryParse`.**
- Params/defaults: `order` (default `name`, also accepts `stationcount`), `reverse` (false), `hidebroken` (false),
`offset` (0), `limit` (100000).
Two implementation constraints this imposes:
1. **`lib/servicios/servicio_radio.dart:168` hardcodes `lastcheckok: '1'` into every request built by `_get`.**
That is a **station-only** filter and must NOT be sent to `/json/countries`. The countries call therefore
**cannot reuse `_get` as-is**.
2. **Required shape**: extract the transport loop from `_get` (server discovery, host rotation, bounded retries,
User-Agent, timeout, status check, `json.decode`) into a generic private helper returning `List<dynamic>`.
`_get` then layers the station-specific concerns on top (`lastcheckok`, `Emisora.fromApi`, `_compararCalidad`
sort); the countries call reuses **transport only**. No behaviour change for the 8 existing methods — but this
touches a **shared code path**, so WU7 must ship regression coverage for those 8 calls.
Already correct, needs no work: `_uri` (lines 103-105) forces `hidebroken=true` on every request, which is exactly
what we want given the endpoint default of `false`; mirror discovery via `all.api.radio-browser.info` matches the
documented guidance; the User-Agent is built per instance at lines 68-86.
Still open under question 8: the **search sort** control (screen 6). Not yet verified — WU6 opens with an official-
docs check before anything is specced.
### Out of Scope — explicitly
| Excluded | Why |
|----------|-----|
| **Monetization: PRO tier, 14-day trial, pricing card, IAP, subscription state, feature gating, the Settings "PRO · 11 meses" pill** | User decision (resolution 7). Zero monetization infrastructure exists today; `in_app_purchase` stays commented out at `pubspec.yaml:56`. Ships as its own SDD change at public release. Do not display a paid-tier promise the app cannot honour. |
| **Alarm-ringing live volume countdown ("Subiendo volumen · 18 s")** | Resolution 4. Requires a native to Flutter progress channel that is *deliberately* absent per a documented architecture constraint in `pantalla_alarma_sonando.dart`. Static label only. |
| **Any literal Android Auto pixel work (A1-A5)** | The app uses the classic `MediaBrowserService` model (`automotive_app_desc.xml`, no `androidx.car.app`). Android Auto's system templates render everything; the only controllable surface is `MediaItem` metadata, which already matches. A4's waveform is **non-implementable** on this platform. WU2 is verification-only. |
| Adopting the mockup's 4-root IA (Escuchar / Explorar / Alarmas / Ajustes) | Binding decision 1. Would delete the Favoritos root and demote the player to a pushed route. |
| Switching tab icons to Material Symbols | Binding decision 2. `PluriIconGlyph.*` stays; `PluriIconGlyph.home` becomes the Escuchar icon. |
| **The mockup's 7-band equalizer** | Rejected outright. Band count is device-reported via `just_audio`'s `AndroidEqualizer` (`params.bands`), not app-chosen; most devices report 5, so 2 of 7 sliders would silently do nothing. See the resolved ruling above. |
| **A runtime-dynamic band count driven by `params.bands.length`** | Technically the only correct way to support non-5-band devices, but it ripples through the fixed-length `assert(bandas.length == 5)`, the 7 SharedPreferences keys in `servicio_ecualizador.dart`, and every EQ test. **Worth its own future change** — not this one. |
| Domain/business-logic changes to the EQ resolution hierarchy | Already a 4-level matrix in `estado_ecualizador.dart`. The redesign **relocates editing surfaces only**. |
## Capabilities
> Contract with `sdd-spec`. Existing spec names verified against `openspec/specs/`.
### New Capabilities
- `app-navigation-shell`: the 5-root tab contract, the push-chrome rule (56px header + back + no tab bar) and its explicit Favoritos exemption, and root-to-root switching (Escuchar "Ver todas" to Favoritos).
- `station-discovery-browse`: discovery content as the Buscar landing state, active-filter pills, results counter, clear-all-filters, the Países browser over the verified `/json/countries` contract, and search sorting **only if** official docs confirm support.
- `favorites-organization`: chip-filtered flat list, drag-to-reorder, sort action, group management surfaced from Favoritos.
- `alarm-vacation-ranges`: active-range detection, per-alarm pause-impact computation, upcoming and past ranges.
- `recordings-library`: browsable recordings list, storage usage, per-recording rename/share/delete.
- `eq-custom-presets`: user-defined preset save/name/list, per-station EQ entry point from the player, "stations with their own EQ" drill-down.
- `onboarding-welcome`: first-run welcome surface, explicitly monetization-free.
### Modified Capabilities
- `multi-device-eq`: the per-station EQ editing surface moves into the player's tool tray and gains custom presets. **Band count stays 5 — resolved, not pending.**
- `native-alarms`: alarm-card affordances change (tap = edit, swipe = delete), vacation ranges move to a dedicated screen, ringing-screen presentation changes. **Scheduling and dismiss-guard semantics are unchanged and must not regress.**
- `android-auto-media`: **no requirement change** — verification only, listed here so `sdd-spec` records the confirmation rather than writing a delta.
## Approach
1. **Tokens before screens.** WU1 lands the named tokens and text styles first so the copy-paste
`.copyWith(fontWeight: w900)` pattern is not re-applied 14 times and then refactored 14 times.
2. **Cheap certainty first.** WU2 (Android Auto) costs nothing and formally closes 5 of the 19 mockup screens.
3. **Mechanical before creative.** The Settings restructure (WU3a/WU3b) is the largest line count in the plan but
the lowest logic risk — panels become nav rows, controls are moved verbatim. Landing it early proves the
push-chrome primitive against real screens.
4. **Restyle, do not re-architect, the EQ.** The band-count question is settled at 5. WU13 changes only the
presentation layer; `preset_ecualizador.dart`, `servicio_ecualizador.dart`, and `servicio_audio.dart` are
untouched by the band axis.
5. **Share components, do not duplicate.** WU13 (Settings EQ editor) ships *before* WU14 (player tool tray) so
the per-station EQ bottom sheet reuses the same editor widget.
6. **Verify external APIs officially, then record the contract.** Per the standing rule above. The
`/json/countries` contract is already verified and recorded; search sort is not, and WU6 starts by checking it.
7. **Characterise before refactoring shared code.** WU7 writes regression tests for the 8 existing station calls
*before* extracting transport out of `_get` — the refactor is only safe if the old behaviour is pinned first.
8. **Strict TDD throughout.** Tests are written before implementation for every WU. `flutter test` is the runner.
`flutter analyze` and `dart format .` gate every commit. **`flutter build` is never run.**
9. **Translate once.** WU18 batches all new ARB keys at the end rather than paying 13-locale cost per slice.
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `lib/tema/pluriwave_tokens.dart`, `pluriwave_theme.dart` | Modified | 3 new colour tokens, named text styles. No token removed, none changed. |
| `lib/app.dart`, `lib/widgets/pluri_bottom_navigation.dart` | Modified | **WU1** retitles the first tab to `Escuchar`; `PluriIconGlyph.home` unchanged. Tab list otherwise stable. |
| `lib/pantallas/pantalla_inicio.dart` (413 lines) | Restructured | Discovery content out, player hero + favorites grid in. |
| `lib/pantallas/pantalla_buscar.dart` (357 lines) | Restructured | Gains discovery landing state, filter pills, counter, sort. |
| `lib/pantallas/pantalla_favoritos.dart` (287 lines) | Restructured | Stacked panels to chip-filtered reorderable list. |
| `lib/pantallas/pantalla_alarmas.dart` (1383 lines) | Restructured + split | Cards simplified; vacation panel extracted to its own screen; editor sheet rewritten. |
| `lib/pantallas/pantalla_alarma_sonando.dart` (313 lines) | Restyled | Full-bleed layout. Dismiss guard + force-stop banner preserved. |
| `lib/pantallas/pantalla_ajustes.dart` (1896 lines) | Split | 12 panels to 4 grouped nav lists + ~8 new detail screen files. |
| `lib/pantallas/pantalla_reproductor.dart` (907 lines) | Restructured | Square art, transport row, 4-tile tool tray, share, quality switch. |
| `lib/pantallas/` (new files) | New | Países, Vacaciones manager, recordings library, welcome, ~8 settings detail screens. |
| `lib/modelos/preset_ecualizador.dart` | Modified | Custom presets only. `assert(bandas.length == 5)` **stays**. |
| `lib/servicios/servicio_ecualizador.dart`, `lib/servicios/servicio_audio.dart` | **Unchanged** | EQ band handling and gain application are untouched — restyle only. |
| `lib/estado/estado_alarmas.dart` | Modified | New queries: current range, per-alarm impact, past ranges. |
| `lib/servicios/servicio_radio.dart` | Modified | Extract the transport loop out of `_get` into a generic helper (WU7). `_get` keeps `lastcheckok`/`Emisora.fromApi`/sort. **Shared code path — needs regression coverage for all 8 existing station calls.** |
| `lib/estado/estado_busqueda.dart` | Modified | Country list + counts; sort parameter **only if** official docs confirm support (WU6). |
| `lib/l10n/app_*.arb` (13 files) | Modified | ~35-45 new keys each, **plus modified values for existing keys — known: `navHome`** (WU1 does `en`/`es`, WU18 does the other 11). Key sets stay identical throughout. |
| `lib/servicios/navegacion_auto.dart` | **Unchanged** | Verification only. |
| `test/` (59 files, 15,143 lines) | Modified + New | See risk register. |
## Risks
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| ~~EQ 5 to 7 bands blocked by the native layer~~**RETIRED, resolved by spike (Engram id 2498).** Band count is device-reported via `params.bands`; 7 sliders would silently no-op on typical 5-band hardware. | — | — | **Closed.** Equalizer stays at **5 bands**; WU13 is a restyle. Residual risk moves to the row below. |
| **Spec drift back toward 7 bands.** The approved mockup still *draws* 7 sliders, so a downstream phase could copy it from the design source without reading this ruling. | Medium | Medium | The 5-band ruling is stated in the WU table, the resolved ruling block, Out of Scope, Capabilities, and Success Criteria. Any spec or task asserting 7 bands is rejected on sight. `assert(bandas.length == 5)` in `preset_ecualizador.dart` is the compile-time backstop. |
| **Non-5-band devices remain unsupported.** Devices whose OS reports 6+ bands get the extra bands left at default gain. This is pre-existing behaviour, not a regression introduced here. | Low | Low | Explicitly out of scope. A runtime-dynamic band count is its own future change — it touches the fixed-length assert, the 7 SharedPreferences keys in `servicio_ecualizador.dart`, and every EQ test. |
| **`pantalla_ajustes.dart` (1896 lines) restructure.** Largest single extraction in the plan; ~8 new files. | High (it will happen) | Medium | Mechanical extraction — controls move verbatim, business logic untouched. Split across WU3a/WU3b so neither PR exceeds ~450 lines. `pantalla_ajustes_test.dart` gets a near-total rewrite, written first per strict TDD. |
| **15,143 lines of existing tests assume the current structure.** Confirmed impacted: `pantalla_inicio_test`, `pantalla_inicio_rebuild_test`, `pantalla_favoritos_plural_test`, `pantalla_buscar_shimmer_test`, `pantalla_alarmas_editor_test`, `pantalla_alarmas_fecha_test`, `pantalla_ajustes_test`, `reconnect_ui_test`, `widget_test`. The three EQ test files (`servicio_ecualizador_test`, `estado_ecualizador_test`, `servicio_audio_eq_reapply_test`) are **no longer at risk** now that band count is fixed at 5. | High | Medium | Each WU owns its test updates; no WU merges with a red suite. **Hard rule: `pantalla_alarma_sonando_dismiss_guard_test.dart` must pass unmodified** — if a restyle requires changing it, stop and escalate, because that test guards a real safety mechanism. |
| Reconnect attempt counter may not exist in the controller | Medium | Low | WU16 verifies `servicio_audio_reconnect_test.dart` first; if no attempt count is tracked, ship the banner restyle without the counter rather than adding plumbing. |
| **Refactoring `_get` in `servicio_radio.dart` regresses the 8 existing station calls.** WU7 must split transport from station-specific concerns because `lastcheckok: '1'` (line 168) cannot be sent to `/json/countries`. | Medium | **High** — every station listing in the app flows through `_get` | Pure extraction, no behaviour change intended. WU7 ships **regression coverage for all 8 existing station calls first** (strict TDD: characterisation tests before the refactor). Server discovery, host rotation, retries, User-Agent and timeout move as one block — no logic edits during the move. |
| **`stationcount` parsed as int and throws at runtime.** The `/json/countries` field is a **string**, which reads like an int. | Medium | Medium | Contract is recorded above and in Engram id 2500: parse with `int.tryParse`. Spec must state the field type explicitly; a fixture with a string `stationcount` is required in the WU7 tests. |
| Search sort support is still unverified (resolution 8, second half) | Medium | Medium | WU6 opens with an **official-docs check**, not a code spike. Per the standing rule, no spec may assert a sort parameter that has not been confirmed at api.radio-browser.info. If unsupported, drop the "Ordenar" control rather than faking client-side ordering. |
| **Downstream phases infer API behaviour instead of checking the docs.** | Medium | High | The standing rule is stated at the top of this proposal. `sdd-spec` and `sdd-design` must cite the official doc (or the recorded Engram contract) for every external-service claim. Uncited API claims are rejected. |
| Custom inline time editor (WU10) is the largest genuinely-new widget | Medium | Medium | Isolated in its own PR, never bundled. Drag/tap adjust logic is unit-tested independently of the sheet. |
| i18n volume: ~455-585 translated strings across 13 locales | High | Low (ops, not eng.) | WU18 batches once at the end. Slices land in `en`/`es` only. |
| Scope creep back into monetization via the "PRO" pill | Low | High | Explicitly excluded above and in resolution 7. Any PR reintroducing it is rejected. |
## Delivery Shape
This change is roughly **4,550-6,450 changed lines excluding tests** — an order of magnitude beyond the 400-line
review budget. A single PR is not reviewable.
**Recommendation: `feature-branch-chain`.**
- `feat/rediseno-funcional` is the tracker branch and the **only** branch that merges to `main`.
- PR #1 targets `feat/rediseno-funcional`. Each subsequent child PR targets the immediately previous PR's branch,
so review diffs stay scoped to one work unit. If GitHub shows earlier slices in a child diff, rebase/retarget
until clean.
- Rationale over `stacked-to-main`: this is a coordinated visual language change. Half-landed on `main` means a
release where some screens use the new tokens and others do not. The tracker branch gives one atomic rollback.
**Sequence** (each row is one PR):
```
PR1 WU1 tokens + push-chrome primitive (unblocks everything)
PR2 WU2 Android Auto verification (zero code, quick win)
PR3 WU3a Settings shell + AUDIO/EMISORAS
PR4 WU3b Settings GRABACIONES/APLICACIÓN
PR5 WU4 Favoritos restyle
PR6 WU5 Escuchar restructure
PR7 WU6 Buscar landing + filters (spike first)
PR8 WU7 Países browser
PR9 WU8 Alarmas root restyle
PR10 WU9 Vacaciones manager
PR11 WU10 Alarm editor sheet (own PR, never bundled)
PR12 WU11 Alarm ringing restyle (safety-critical review)
PR13 WU13 Ecualizador settings screen (5-band restyle)
PR14 WU14 Reproductor + per-station EQ sheet
PR15 WU15 Recordings library
PR16 WU16 Connectivity states
PR17 WU17 Welcome / onboarding
PR18 WU18 i18n batch, 13 locales
```
18 PRs (was 19 — the EQ spike PR is gone, resolved before planning closed).
PR2 is effort-light with no dependencies and may be pulled forward at any point.
PR13 **must** merge before PR14 so the per-station EQ sheet reuses the same editor widget.
Commits: conventional commits only. **No `Co-Authored-By`, no AI attribution, ever.**
## Rollback Plan
- The repo has **zero git tags**; the rollback convention is the version-bump commit.
- Full rollback: reset `feat/rediseno-funcional` to `17f8e69` on `main` (the `1.1.16+121` state before `1.2.0+122`).
- Partial rollback: because delivery is a feature-branch chain, any single PR can be reverted on the tracker branch
without touching `main`. Nothing reaches `main` until the tracker merges.
- WU2 (Android Auto) produces no code and needs no rollback.
- `lib/servicios/navegacion_auto.dart` is untouched, so Android Auto behaviour cannot regress.
## Dependencies
- ~~WU12 native EQ spike~~ — **resolved before planning closed**; no longer a dependency. See Engram `sdd/rediseno-funcional/eq-band-spike` (id 2498).
- **Radio Browser `/json/countries`** (WU7) — **verified** against the official docs; contract recorded above and in Engram id 2500. No longer a blocker.
- **Radio Browser search sort** (WU6) — **not yet verified.** Must be checked against the official docs before it is specced. Per the standing rule, do not assume.
- **Translation capacity** for 13 locales (WU18) — an ops dependency, not an engineering one.
- No new packages anticipated. `in_app_purchase` stays commented out.
## Success Criteria
- [ ] All 5 tabs present and named Escuchar / Buscar / Favoritos / Alarmas / Ajustes, using existing `PluriIconGlyph.*` icons, with `PluriIconGlyph.home` on Escuchar.
- [ ] Favoritos renders its tab bar (the documented exemption from the push-chrome rule).
- [ ] Every second-level screen renders a 56px header with a back affordance and **no** tab bar.
- [ ] `pantalla_ajustes.dart` root renders **4 grouped nav lists**, zero inline controls, and is under 400 lines.
- [ ] Escuchar renders an embedded player hero and a "Tus emisoras" grid sourced from favorites; "Ver todas" switches to the Favoritos tab without pushing a route.
- [ ] Discovery content (near-you / genres / trending / countries) is reachable from the Buscar tab's landing state.
- [ ] No string in the shipped build contains "PRO", a trial duration, or a price. Grep-checkable across `lib/l10n/app_*.arb`.
- [ ] The alarm-ringing status label renders without a seconds counter, and the force-stop retry banner is still present.
- [ ] `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` passes **unmodified**.
- [ ] `test/servicios/navegacion_auto_test.dart` passes with `lib/servicios/navegacion_auto.dart` unchanged (`git diff` is empty for that file).
- [ ] Alarm edit, skip-next, and delete are all still reachable (tap / hero pill / swipe respectively) — no capability lost.
- [ ] Sleep timer and backup/restore are still reachable from Settings.
- [ ] `PluriWaveTokens` exposes `listSurface`, `liveGreen`, and `offlineAccent`; no screen file introduces a new raw `Color(0x...)` literal.
- [ ] All 13 ARB files have identical key sets, with zero missing keys. `navHome` still exists as a key in all 13 — the rename changed values only.
- [ ] **No ARB file maps `navHome` to a "home" concept.** `app_en.arb` reads `"Listen"`, `app_es.arb` reads `"Escuchar"`, and `git diff` shows **13** changed `navHome` lines. A grep for the literals `"Inicio"`/`"Home"` is **not** sufficient — 11 locales express "home" in their own language (`Beranda`, `Accueil`, `Start`, `Início`, `ホーム`, `首页`, `Главная`, `الرئيسية`, `হোম`, `मुखपृष्ठ`, `Inizio`), so the check must be the 13-line diff, not a literal grep.
- [ ] `flutter test` green, `flutter analyze` clean, `dart format .` produces no diff.
- [ ] The equalizer renders exactly **5** sliders. `assert(bandas.length == 5)` in `preset_ecualizador.dart` is unchanged, and `git diff` is empty for `lib/servicios/servicio_ecualizador.dart` and the band-application block at `lib/servicios/servicio_audio.dart:749-762`.
- [ ] `test/servicios/servicio_ecualizador_test.dart`, `test/estado/estado_ecualizador_test.dart`, and `test/servicios/servicio_audio_eq_reapply_test.dart` pass **unmodified**.
- [ ] The Países browser calls **`/json/countries`**. Grep confirms `countrycodes` appears nowhere in `lib/`.
- [ ] `stationcount` is parsed with `int.tryParse`; a test fixture supplies it as a **string** and the screen renders the count without throwing.
- [ ] `lastcheckok` is **not** present in the countries request. All 8 existing station calls still send it.
- [ ] Regression tests for the 8 existing `ServicioRadio` station calls exist and pass both before and after the `_get` transport extraction.
- [ ] Every external-API claim in the spec, design, and tasks artifacts cites either the official Radio Browser docs or a recorded Engram contract. No uncited API assertions.