# Design: Functional Redesign (`rediseno-funcional`)
> Reads: `openspec/changes/rediseno-funcional/proposal.md` (authoritative), Engram `sdd/rediseno-funcional/explore-mobile`,
> `explore-auto`, `explore-crosscutting`, `scope-decisions` (2493), `open-questions-resolved` (2497), `eq-band-spike` (2498),
> `search-countries-spike` (2499), `reference/radio-browser-countries-endpoint` (2500),
> `reference/radio-browser-sort-order` (2505), `preferences/verify-external-apis-officially` (2501),
> `sdd/rediseno-funcional/delivery-strategy` (2504), `reference/git-hosting-gitea` (2503).
>
> Every external-API claim below cites Engram id 2500 or 2505, both verified against .
> No API behaviour in this document is inferred.
## Technical Approach
The redesign is delivered as **presentation-layer surgery over an unchanged domain**. Three structural moves carry it:
1. **Tokens become an API, not a convention.** A second `ThemeExtension` holds the named type scale so screens stop
copy-pasting `.copyWith(fontWeight: w900, letterSpacing: …)`. Colour tokens join the existing `PluriWaveTokens`.
2. **Chrome becomes structural, not declarative.** The tab bar is owned by `_PaginaPrincipal`, so "second-level screens
have no tab bar" is enforced by *being a pushed route* rather than by a flag. A `PluriPushScaffold` primitive with no
`bottomNavigationBar` parameter makes the violation unrepresentable.
3. **Every new capability extends an existing `ChangeNotifier`.** The change adds exactly **one** new provider
(`EstadoNavegacionRaiz`). Everything else is new methods on `EstadoRadio` / `EstadoBusqueda` / `EstadoEcualizador` /
`EstadoAlarmas`, or provider-free widgets.
Non-goals restated so they are not rediscovered downstream: no monetization, no routing framework, no EQ band-count
change, no scheduling-semantics change, no `lib/servicios/navegacion_auto.dart` edit.
## Quick path for a reviewer
| Read this first | Because |
|-----------------|---------|
| ADR-2 (push chrome) | Decides the shape of ~15 screens. Everything in WU3a-WU17 composes with it. |
| ADR-4 (`ServicioRadio`) | The only shared code path touched. Highest blast radius per line. |
| ADR-8 (root navigation) | Removes three magic-number `setState` sites and unlocks the Escuchar success criterion. |
| ADR-5 hazard box | Custom EQ presets have an obvious wrong home that violates a hard constraint. |
---
## ADR-1: Token and text-scale architecture
**Decision.** Split by concern across two `ThemeExtension`s.
| Concern | Home | Access |
|---------|------|--------|
| New colours `listSurface`, `liveGreen`, `offlineAccent` | New fields on the existing `PluriWaveTokens` (`lib/tema/pluriwave_tokens.dart`) | `context.pluriTokens.listSurface` |
| Named text styles | **New** `PluriWaveTypography` extension (`lib/tema/pluriwave_typography.dart`), built inside `PluriWaveTheme.dark()` from the same `GoogleFonts.plusJakartaSansTextTheme` instance | `context.pluriType.cardTitle` |
The named style set is **closed at six**. A seventh requires amending this ADR.
| Style | Value | Used by |
|-------|-------|---------|
| `heroTime` | 88 / w800 / height 1.0 / ls -2.0 | Alarm ringing, inline time editor (WU10, WU11) |
| `sectionTitle` | 23 / w800 / ls -0.6 | Root screen section headings |
| `screenTitle` | 19 / w800 / ls -0.4 | `PluriPushScaffold` header title (ADR-2) |
| `cardTitle` | 14.5 / w700 | Station cards, settings rows, alarm cards |
| `bodyStrong` | 13 / w600 | Card subtitles, meta lines |
| `eyebrowLabel` | 11 / w800 / ls 0.8 | `EN DIRECTO`, `PROGRAMADOS`, settings group headers |
**Reasoning.**
- Colours belong in `PluriWaveTokens` because they must participate in `lerp` and the extension already exists.
Text styles do not belong there: the constructor is already 13 required parameters and every added field costs four
edits (field, constructor, `copyWith`, `lerp`). Mixing a type scale into a colour/geometry token bag makes both worse.
- Building the styles inside `PluriWaveTheme.dark()` is what makes them correct: that is the only place the
Plus Jakarta Sans family is resolved. A style constructed anywhere else has to re-merge the family, which is the
copy-paste we are eliminating.
- Access mirrors the two existing extensions (`pluriTokens`, `pluriMotion`); a third is a pattern the codebase already
uses twice, not a new mechanism.
**Rejected alternatives.**
| Option | Why rejected |
|--------|--------------|
| Redefine Material roles (`headlineSmall` = 19px screen title) in `textTheme` | `AppBar`, `ListTile`, `Chip`, `Slider` and every Material widget consume those roles. Redefining them silently restyles the whole app and breaks widget tests that assert current sizing. |
| `static const TextStyle` on `PluriWaveTokens` | A `const TextStyle` cannot carry the runtime-resolved Google Fonts family. Every call site would re-merge it — the exact pattern being killed. |
| `extension on TextTheme` getters | Cheapest to write, but cannot be themed, lerped, or overridden in tests, and it disguises design tokens as Material roles. |
| One mega-extension holding colours + type | 20+ required constructor parameters; colour and type churn become the same merge conflict. |
**Two rules that ride along.**
1. **De-literalise the theme.** `pluriwave_theme.dart:12` hardcodes `Color(0xFF7EE4C2)` and line 15 hardcodes
`Color(0xFF102532)`. WU1 replaces both with the new token fields. Rendered output is byte-identical; this is how
the success criterion "no screen file introduces a new raw `Color(0x…)` literal" becomes enforceable rather than
aspirational — the token definitions stay the only place literals live (already documented at
`pluriwave_tokens.dart:45-47`).
2. **`eyebrowLabel` never applies `toUpperCase()`.** Casing is a locale-hostile transform: Turkish dotless i breaks,
and `ホーム` / `首页` / `الرئيسية` / `হোম` / `मुखपृष्ठ` / `Главная` have no case at all. The style carries weight and
letter-spacing only; the ARB value is authored in its display form per locale.
**Text scaling.** `heroTime` at 88 px renders at 176 px under a 2.0 text scaler. Every `heroTime` call site must be
wrapped in `FittedBox(fit: BoxFit.scaleDown)`. Precedent: `PluriScreenHeader` already branches on
`MediaQuery.textScalerOf(context).scale(1) >= 1.25` (`pluri_premium_widgets.dart:31-32`).
**Blast radius.** `pluriwave_tokens.dart` (+3 fields × 4 sites), new `pluriwave_typography.dart`, `pluriwave_theme.dart`
(register extension, de-literalise 2 colours). Existing `test/tema/` suite must pass unmodified — if it does not, the
token change was not additive. Owner: **WU1**.
---
## ADR-2: The push-chrome primitive
**Decision.** Chrome is decided by *route topology*, not by a parameter.
| Screen kind | Shape | Tab bar |
|-------------|-------|---------|
| Root (5 tabs) | Plain scrollable `Widget`. Constructs **no** `Scaffold`. Lives in `_PaginaPrincipal._paginas`. | Yes — drawn by `_PaginaPrincipal` |
| Second level | `PluriPushScaffold`, entered via `Navigator.push` | No — structurally impossible |
**This is why the Favoritos exemption costs zero code.** Favoritos is a root, therefore a body widget, therefore it
keeps the tab bar. Downstream phases cannot accidentally "copy the mockup" onto it because there is no switch to flip.
**API.**
```dart
class PluriPushScaffold extends StatelessWidget {
const PluriPushScaffold({
super.key,
required this.title, // String — styled with PluriWaveTypography.screenTitle
required this.body,
this.titleOverride, // Widget — documented single exception, see below
this.leadingIcon = Icons.arrow_back_rounded,
this.onBack, // defaults to Navigator.maybePop
this.actions = const [],
this.bottom, // optional persistent footer (e.g. a CTA)
this.floatingActionButton,
});
static const double headerHeight = 56;
static Future push(BuildContext context, WidgetBuilder builder);
}
```
**The load-bearing part of this API is what it does not have: no `bottomNavigationBar` parameter.**
**Reasoning.**
- `title` is a `String`, not a `Widget`, precisely so `screenTitle` is applied once inside the primitive instead of
copy-pasted across 15 screens. `titleOverride` exists for exactly one known consumer — the full player's centered
`EN DIRECTO` pill — and is documented as such. A second consumer means this ADR is revisited, not that the parameter
is quietly generalised.
- `leadingIcon` exists because `pantalla_reproductor.dart` dismisses with `keyboard_arrow_down`, not a back arrow. It
is still one pushed route with one back affordance; only the glyph differs.
- `static push` centralises route construction so the transition is uniform and so a test can assert "pushed, not
index-switched". Precedent already in the codebase: `PantallaReproductor.abrir` (`pantalla_reproductor.dart:29`).
**Composition.** `PluriPushScaffold` returns `PluriWaveScaffold(appBar: …, body: …)` — it reuses the aurora gradient,
orbs and noise layer unchanged. Header is a plain `AppBar` with `toolbarHeight: 56`; the existing `AppBarTheme`
(`pluriwave_theme.dart:29-35`) already supplies transparent background, zero elevation and the correct foreground.
Body padding uses `PluriLayout.pageContentPadding`; pushed screens use **no** `bottomChromeInset` (no bottom chrome).
**Rejected alternatives.**
| Option | Why rejected |
|--------|--------------|
| One scaffold with `chrome: PluriChrome.root \| .pushed` | Makes the wrong state representable and requires 20 screens to remember the right value. Type-level separation cannot be forgotten. |
| Glass (`BackdropFilter`) header per pushed screen | Costs a full-screen blur pass on ~15 screens for a surface that already sits on the aurora gradient. Glass stays reserved for the bottom nav and elevated cards, matching the mockup's "glass only on chrome and the active card". |
| Reuse `PluriScreenHeader` as the pushed header | It is a 56-px-tall *card* only in name — it renders a banner image, three orbs, a gradient scrim and a 56 px gradient glyph badge. Wrong component, wrong cost. It stays the ROOT-screen hero. |
| Wrap roots in their own `Scaffold` and hide the nav bar | Two `Scaffold`s means two `ScaffoldMessenger` targets; the app shows SnackBars from `app.dart:164`. Breaks silently. |
**Enforcement test (WU1).** Mount each of the five root screens bare and assert `find.byType(Scaffold)` is empty; mount
a `PluriPushScaffold` and assert exactly one `AppBar` of height 56 and one back affordance. This turns two success
criteria into mechanical checks.
**Blast radius.** New `lib/widgets/pluri_push_scaffold.dart` + its test. **WU1 converts zero screens** — that is what
keeps WU1 at its 160-260 line budget. Consumers land per-WU: 12 settings detail screens (WU3a/WU3b), Países (WU7),
Vacaciones (WU9), Ecualizador (WU13), Reproductor (WU14), Grabaciones (WU15), Bienvenida (WU17).
---
## ADR-3: Settings decomposition
**Decision.** Root file stays where it is; detail screens get a new folder; section bodies move verbatim.
```
lib/pantallas/pantalla_ajustes.dart <- STAYS (target < 400 lines)
lib/pantallas/ajustes/
widgets/fila_ajuste.dart <- nav row + group card primitives
pantalla_ajustes_ecualizador.dart (AUDIO) <- body rewritten by WU13
pantalla_ajustes_salida_audio.dart (AUDIO)
pantalla_ajustes_timer_sueno.dart (AUDIO)
pantalla_ajustes_grupos_favoritos.dart (EMISORAS)
pantalla_ajustes_emisora_preferida.dart (EMISORAS)
pantalla_ajustes_emisoras_personalizadas.dart(EMISORAS)
pantalla_ajustes_orden_listas.dart (EMISORAS)
pantalla_ajustes_grabaciones.dart (GRABACIONES Y MÚSICA)
pantalla_ajustes_musica_local.dart (GRABACIONES Y MÚSICA)
pantalla_ajustes_idioma.dart (APLICACIÓN)
pantalla_ajustes_backup.dart (APLICACIÓN)
pantalla_ajustes_info.dart (APLICACIÓN)
```
**Why the root file does not move.** `lib/app.dart:19` imports it, and so does every existing test. Moving it churns
imports for zero architectural gain. The 12 sections move; the entry point does not.
**Group mapping — all 12 sections are placed, none is dropped.**
| Group | Rows | From |
|-------|------|------|
| AUDIO | Ecualizador · Salida de audio · Temporizador de sueño | `_SeccionEcualizador` (694), `_SeccionEcualizadorAvanzado` (782) + `_FilaDispositivo` (906) + `_DialogoEdicionDispositivo` (1013), `_SeccionTimerSueno` (399) + `_FormularioDuracionTimer` (604) |
| EMISORAS | Grupos de favoritos · Emisora preferida · Emisoras personalizadas · Orden de listas | `_SeccionGruposFavoritos` (1187), `_SeccionEmisoraPreferida` (1342), `_SeccionEmisoras` (1459) + `_FormularioEmisora` (1553), `_SeccionOrdenListas` (1133) |
| GRABACIONES Y MÚSICA | Grabaciones · Música local | `_SeccionGrabaciones` (92), `_SeccionMusicaLocal` (291) |
| APLICACIÓN | Idioma · Copia de seguridad · Información | `_SeccionIdioma` (492) + `_IdiomaDisponible` (597), `_SeccionBackup` (1669), `_SeccionInfo` (1791) |
Sleep timer and backup are present, as the proposal's corollary ruling requires.
**The verbatim-move rule (three mechanical steps per section).**
1. Cut `_SeccionX` into its new file. Rename to `_CuerpoX`. **Do not touch anything below the panel header row.**
2. Delete only the panel header (icon + title `Row`) — the 56 px `PluriPushScaffold` header now carries it.
3. Add the public wrapper:
```dart
class PantallaAjustesX extends StatelessWidget {
const PantallaAjustesX({super.key});
@override
Widget build(BuildContext context) => PluriPushScaffold(
title: AppLocalizations.of(context).settingsXTitle,
body: ListView(padding: PluriLayout.pageContentPadding, children: const [_CuerpoX()]),
);
}
```
The resulting diff inside every `_CuerpoX` is "header removed, everything else identical". That is what a reviewer
checks, and it is checkable by eye.
**State threading: there is none, deliberately.**
`MultiProvider` wraps `MaterialApp` (`app.dart:46-88`), so the `Navigator` — and therefore every pushed route — is a
descendant of all six providers. A detail screen calls `context.read()` and gets the same instance the
root had.
> **Rule: detail screens read providers directly. No state object is ever passed through a constructor.**
Rejected: constructor-injecting notifiers. It would force tests to fake at the widget boundary instead of the provider
boundary — the opposite of the existing `test/helpers/` fake pattern — and it breaks provider identity across hot reload.
`showModalBottomSheet` inside detail screens keeps `useRootNavigator: false` (the default), so sheets stay inside the
same provider scope; the existing `Consumer`-inside-builder pattern (`app.dart:406`) continues to work.
**Honest correction to the proposal.** The proposal says "~8 new settings detail screen files". The real number is
**12** — one per existing section. And the line estimate undercounts: `git diff` counts a verbatim move as
add + delete, so WU3a's true diff is roughly **800-1000 changed lines**, of which ~85 % is relocated-not-modified code.
This is a *measurement* correction, not a scope change. `sdd-tasks` should record `size:exception` for WU3a/WU3b with
"move-only diff" as the justification rather than trying to hit 450.
**Blast radius.** `pantalla_ajustes.dart` (−~1500, +~250), 12 new files, 1 new widget file. `app.dart` **unchanged**.
`test/pantallas/pantalla_ajustes_test.dart` rewritten. Owner: **WU3a** (AUDIO + EMISORAS), **WU3b** (the other two
groups).
---
## ADR-4: `ServicioRadio` transport extraction
**Decision.** Extract transport into `_getJson`, leave every station-specific concern in `_get`.
```dart
// TRANSPORT ONLY — no filters, no models, no ordering.
Future> _getJson(String path, Map params) async {
await _descubrirServidores(); // moved verbatim
// host rotation · bounded retries · User-Agent · timeout · status check ·
// json.decode · sticky-host bookkeeping — all moved as ONE block, no logic edits.
}
// STATION LAYER — unchanged responsibilities.
Future> _get(String path, Map params) async {
final lista = await _getJson(path, {'lastcheckok': '1', ...params});
final emisoras = lista.cast