From 35df016aa3d3ddd98c4b62f50c8ef5784cce173f Mon Sep 17 00:00:00 2001 From: freetlab Date: Tue, 28 Jul 2026 18:14:07 +0200 Subject: [PATCH] docs(sdd): add technical design for the functional redesign --- openspec/changes/rediseno-funcional/design.md | 723 ++++++++++++++++++ 1 file changed, 723 insertions(+) create mode 100644 openspec/changes/rediseno-funcional/design.md diff --git a/openspec/changes/rediseno-funcional/design.md b/openspec/changes/rediseno-funcional/design.md new file mode 100644 index 0000000..8f39f08 --- /dev/null +++ b/openspec/changes/rediseno-funcional/design.md @@ -0,0 +1,723 @@ +# 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>() + .map(Emisora.fromApi) + .where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty) + .toList(); + emisoras.sort(_compararCalidad); + return emisoras; +} +``` + +**What stays in `_get`, and why each one matters:** + +| Concern | Why it must not sink into transport | +|---------|-------------------------------------| +| `lastcheckok: '1'` (line 168) | A station-only filter. Sending it to `/json/countries` is meaningless at best (Engram 2500). | +| `Emisora.fromApi` + empty-field filter | Countries are not stations. | +| `_compararCalidad` sort | **Transport must stay sort-agnostic.** If ordering leaked down, the countries list would come back bitrate-ordered instead of in API order (Engram 2505). | + +**What legitimately moves, and is a behaviour delta worth naming.** `_servidorActual = servidor` on success and +`_servidorActual = null` on failure move into `_getJson`. Consequence: a successful `/json/countries` call now warms the +sticky host for subsequent station calls. **Accepted** — one warm mirror per instance is the desirable behaviour, and +per-call-type sticky hosts would add state for no benefit. Reviewers should expect this and not read it as a bug. + +**`_uri` is unchanged.** It forces `hidebroken=true` on every request (line 104). The `/json/countries` documented +default is `false` (Engram 2500), so inheriting `hidebroken=true` is exactly right: broken stations stay out of the +per-country counts. Do **not** special-case countries. + +### Sorting stays client-side + +Server-side sorting is verified as supported — `order` accepts 18 values, `reverse` is a boolean defaulting to `false` +(Engram 2505). **It is not adopted.** + +**Reasoning.** Server-side `order` sorts the whole result set *before* `limit` applies; `ordenarEmisoras()` +(`orden_emisoras.dart:7`) sorts only the page already fetched. Those return **different stations**, not a different +arrangement of the same ones. All 8 station calls flow through `_get`, which already applies `_compararCalidad`. +Adopting `order` would silently change *which* stations every list in the app shows — a behavioural regression wearing +a UI feature's clothes, and outside a redesign's remit. + +**Rejected alternative, recorded because it is the obvious-looking choice:** send `order` + `reverse` to the API and +delete the client-side sort. Rejected for the paging reason above. It is the right move eventually — it would unlock +popularity / trending / random orderings that client paging cannot reproduce — but it needs its own change with its own +regression coverage for all 8 calls. + +**Extending the Buscar "Ordenar" control (WU6).** New criteria are added client-side to +`enum OrdenEmisoras { nombre, calidad }` (`orden_emisoras.dart:4`) over fields the model already carries: +`bitrate`, `votes`, `clickcount` (`lib/modelos/emisora.dart:17-19`). No API surface change, no endpoint parameter. +Constraint from Engram 2505: **do not render a sort option that does not sort** — every option maps to a real +`OrdenEmisoras` case with a test. + +### Country model and call + +```dart +// lib/modelos/pais_radio.dart (NEW) +class PaisRadio { + const PaisRadio({required this.nombre, required this.codigoIso, required this.numeroEmisoras}); + final String nombre; // 'name' + final String codigoIso; // 'iso_3166_1', ISO 3166-1 alpha-2 + final int numeroEmisoras; // 'stationcount' — a JSON STRING (Engram 2500) + + factory PaisRadio.fromApi(Map json) => PaisRadio( + nombre: json['name'] as String? ?? '', + codigoIso: (json['iso_3166_1'] as String? ?? '').toUpperCase(), + numeroEmisoras: int.tryParse('${json['stationcount'] ?? ''}') ?? 0, + ); +} +``` + +`stationcount` is parsed with `int.tryParse` over string interpolation. An `as int` cast throws at runtime — this is +the documented trap (Engram 2500), and a fixture supplying it as a **string** is mandatory in the WU7 tests. + +```dart +Future> obtenerPaises() async { + final lista = await _getJson('/json/countries', const {}); + return lista + .whereType>() + .map(PaisRadio.fromApi) + .where((p) => p.nombre.isNotEmpty && p.codigoIso.length == 2) + .toList(); +} +``` + +- **No `lastcheckok`.** That is the whole point of the extraction, and it is an explicit success criterion. +- **No `order` parameter sent.** Not because the default is convenient, but because **the screen sorts client-side by + localized name anyway**: the API orders by raw `name` byte order, which is not Spanish (or Russian, or Bengali) + collation. Remote ordering is irrelevant to a UI that promises an alphabetical list. This is consistent with the + client-side sorting ruling above. +- The `.where(…)` guard is defensive, justified by the app's own existing precedent + (`_get` already drops stations with empty `uuid`/`url`), **not** by any claim about API behaviour. + +**Where the country list lives.** Extend **`EstadoBusqueda`** with `List paises`, `bool cargandoPaises`, +`Future cargarPaises()` and an in-memory cache guard so re-entering the screen does not refetch. + +Rejected: a new `EstadoPaises` notifier. It would need its own registration in `app.dart` and would then have to hand +the selected country back to `EstadoBusqueda` to apply the filter — a two-notifier handshake for one list. + +### Characterisation-test approach (runs BEFORE the extraction) + +New file `test/servicios/servicio_radio_transporte_test.dart`. The existing `test/servicios/servicio_radio_test.dart` +is **not modified** — its passing untouched is itself a signal. Vehicle: `MockClient`, already the established pattern +there. + +For each of the 7 `_get`-based methods (`obtenerPopulares`, `obtenerTendencias`, `buscarPorNombre`, `buscarPorPais`, +`buscarPorIdioma`, `buscarPorTag`, `buscar`), pin: + +1. the request path; +2. `lastcheckok=1` present; +3. `hidebroken=true` present; +4. a non-empty `User-Agent` header; +5. the exact `order` / `reverse` / `limit` / `offset` this method sends; +6. **result ordering — the assertion that makes the extraction safe.** Feed a fixture with deliberately shuffled + `bitrate` / `clickcount` / `votes` and assert the returned UUID sequence **exactly**. Without this, a sort that + silently sinks into transport passes every other check. + +Host rotation and the attempt cap are already covered by the two existing tests; reference them, do not duplicate. + +`registrarClick` is the 8th call and does **not** use `_get` — it builds its own URI (line 317-332). Pin only its path +and that it sends *some* `User-Agent`. It hardcodes a stale `'PluriWave/0.1.0 (…)'` literal (line 325) that +`_resolverUserAgent()`'s own doc comment complains about. **Known defect, out of scope**: do not fix it here, and do +not pin the stale string either — pinning a known-wrong value converts a bug into a contract. + +**Strict-TDD sequence for WU7** (spelled out because naive "the test must fail first" does not apply to +characterisation): + +| Step | Expected | +|------|----------| +| a. Write characterisation tests against the **unmodified** `_get` | **Green** — they describe current behaviour by construction | +| b. Write `PaisRadio` + `obtenerPaises` tests | **Red** | +| c. Extract `_getJson`, implement `obtenerPaises` | All green | +| d. Re-run (a) with assertions byte-identical | **Still green** — this is the refactor's proof | + +**Blast radius.** `servicio_radio.dart` (extraction + 1 new method), new `lib/modelos/pais_radio.dart`, +`estado_busqueda.dart` (+3 members), new `lib/pantallas/pantalla_paises.dart`. Owner: **WU7** (sort criteria: **WU6**). + +--- + +## ADR-5: The shared EQ editor component + +**Decision.** Restyle the existing `EcualizadorWidget` in place. Do **not** create a second editor. + +`lib/widgets/ecualizador_widget.dart` is already the right shape: `preset` in, `onCambio` out, **zero provider reads**. +That is the reuse boundary; it just needs a restyle and two parameters. + +```dart +class EcualizadorWidget extends StatefulWidget { + const EcualizadorWidget({ + super.key, + required this.preset, + required this.onCambio, + this.habilitado = true, // NEW — greys the sliders when EQ is off + }); +} +``` + +Changes: strip the internal title + preset `Chip` row (lines 59-77) — the pushed screen's 56 px header carries it now; +restyle the five `Card`/`RotatedBox`/`Slider` tiles to the vertical-track look; add `habilitado`. `PresetsEcualizadorWidget` +(the chip row, line 157) stays as-is and is the second reusable piece. `_nombrePreset` already falls through to the raw +name (`_ => nombre`), so user presets need no change there. + +**The boundary rule, stated so WU14 cannot drift:** + +> `EcualizadorWidget` reads no `Provider` and knows nothing about stations, devices or persistence. + +| Consumer | Wiring | +|----------|--------| +| WU13 — Settings EQ screen | `EstadoEcualizador.presetPrincipal` → `cambiarPresetPrincipal(...)` | +| WU14 — player per-station sheet | `EstadoEcualizador.presetParaEmisora(uuid)` → `guardarPresetPorEmisora(uuid, ...)` | + +Same widget, two thin adapters. That is what makes reuse structural instead of aspirational, and it is why WU13 must +land before WU14 (proposal approach point 5). + +**Rejected:** a new `EditorEcualizador` file alongside the old one. Two EQ editors in the tree is the exact duplication +approach point 5 forbids, and `pantalla_ajustes.dart:22` already imports the existing one. + +**5 bands, non-negotiable.** `for (int i = 0; i < 5; i++)` (line 83) stays the literal `5`. +`assert(bandas.length == 5)` in `preset_ecualizador.dart:5-8` is untouched. `lib/servicios/servicio_ecualizador.dart` +and the gain-application block at `lib/servicios/servicio_audio.dart:749-762` produce an **empty `git diff`**. +Reviewer instruction: the restyle touches the widget that renders bands — confirm the loop bound is still 5. + +> Known cosmetic inaccuracy, explicitly not fixed here: `_etiquetas` (line 24) hardcodes +> `['60Hz','250Hz','1kHz','4kHz','16kHz']`, which are not the device-reported band centres. Pre-existing; changing it +> means reading `params.bands`, which is the runtime-dynamic band-count change the proposal puts out of scope. + +### Hazard: custom presets have an obvious wrong home + +"Guardar como preset" (WU13) needs persistence. The obvious placement — `lib/servicios/servicio_ecualizador.dart` — +**violates a hard constraint**: the success criteria require `git diff` to be empty for that file. + +**Ruling.** + +| Piece | Home | +|-------|------| +| Persistence | **New** `lib/servicios/servicio_presets_personalizados.dart`, own SharedPreferences key `eq_custom_presets_v1` | +| Exposure | `EstadoEcualizador`: `List presetsPersonalizados`, `guardarPresetPersonalizado(nombre)`, `eliminarPresetPersonalizado(nombre)` | +| Model | **`preset_ecualizador.dart` is UNCHANGED** | + +The model needs nothing: a user preset *is* a `PresetEcualizador` with a user-supplied `nombre`, and `toJson`/`desdeJson` +already exist. This refines the proposal's "`preset_ecualizador.dart` — Modified" line to "unchanged", which is strictly +safer: it makes "the 5-band assert was not touched" provable by an empty diff rather than by inspection. + +Adding a named-preset list is not a change to the EQ **resolution hierarchy**, so it stays inside the proposal's +out-of-scope boundary. + +**Blast radius.** `ecualizador_widget.dart` (restyle), new service file, `estado_ecualizador.dart` (+3 members), new +`lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart`. The three EQ test files +(`servicio_ecualizador_test`, `estado_ecualizador_test`, `servicio_audio_eq_reapply_test`) must pass **unmodified**. +Owner: **WU13**; consumed by **WU14**. + +--- + +## ADR-6: `EstadoAlarmas` query additions + +**Decision.** Four **pure query** members. None writes, none reschedules, none touches the native bridge. + +```dart +// EstadoAlarmas +RangoVacaciones? rangoVacacionesActivo({DateTime? ahora}); +List vacacionesProximas({DateTime? ahora}); // inicio > hoy, asc by inicio +List vacacionesPasadas({DateTime? ahora}); // fin < hoy, desc by fin +ImpactoVacaciones impactoDeRango(RangoVacaciones rango); +``` + +```dart +// lib/modelos/alarma_musical.dart — beside RangoVacaciones, no new file, no new import +class ImpactoVacaciones { + const ImpactoVacaciones({required this.pausadas, required this.noAfectadas}); + final List pausadas; // activa && !sonarEnVacaciones + final List noAfectadas; // activa && sonarEnVacaciones +} +``` + +**Two reuse rules that keep semantics identical to the scheduler.** + +1. `rangoVacacionesActivo` **delegates to the existing `RangoVacaciones.contiene(fecha)`** + (`alarma_musical.dart:243`), which already handles the `activo` flag and day granularity. Do not reimplement date + math — a second implementation is a second set of off-by-one bugs. +2. `impactoDeRango`'s predicate **mirrors the scheduler exactly**. The scheduler pauses when + `!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)` + (`servicio_programacion_alarmas.dart:150`). So `pausadas` is `alarmas.where((a) => a.activa && !a.sonarEnVacaciones)`. + If those two ever diverge, the Vacaciones screen lies to the user about which alarms are paused. + +**Why this is safe.** The whole surface is read-only over `_alarmas` (line 38) and `_vacaciones` (line 62). It calls +neither `guardarVacaciones` (line 222) nor any reprogramming path, so scheduling and the dismiss-guard cannot regress. +`test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` must pass **unmodified** — if a query addition can +break it, the addition was not a query. + +**Clock injection.** Every method takes `{DateTime? ahora}` defaulting to `DateTime.now()`. Tests pass a fixed instant. +Rejected: a `Clock` abstraction injected into `EstadoAlarmas` — real value, but it is a cross-cutting refactor of a +1383-line safety-critical area, and a redesign is the wrong change to carry it. + +**Explicit non-goal: no ticker.** The "QUEDAN 3 DÍAS" countdown is computed in the widget from +`rangoVacacionesActivo()`, not stored in state. Rejected alternative: a periodic timer in the notifier — it would +rebuild every alarm card on every tick and risks interleaving with the scheduling recalculation. + +**Blast radius.** `estado_alarmas.dart` (+4 query members), `alarma_musical.dart` (+1 value class), new +`lib/pantallas/pantalla_vacaciones.dart`, `pantalla_alarmas.dart` (inline panel → summary row). Owner: **WU9** +(consumed by **WU8**'s summary row). + +--- + +## ADR-7: Escuchar's embedded player + +**Decision.** `EstadoRadio` is the single source of truth. The hero is a third **view**, never a third **state**. + +Verified: `EstadoRadio.emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual` +(`estado_radio.dart:212`) is already what both `MiniReproductor` (line 44) and `PantallaReproductor` read. + +**Four binding rules.** + +1. **The hero is a `StatelessWidget`.** No `State`, no cached `Emisora`, no local `bool _reproduciendo`. +2. **Reads use `context.select` per scalar**, never `Consumer` over the whole notifier. + `EstadoRadio` notifies on audio buffer events; the codebase already fights this (`MemoLista`, + `orden_emisoras.dart:24-30`, exists for exactly this reason, and `pantalla_inicio_rebuild_test.dart` pins rebuild + scoping). A `Consumer` here would rebuild the hero many times per second. +3. **Transport actions call the same `EstadoRadio` methods the full player calls.** No new playback methods. +4. **The waveform is reused as-is.** `VisualizadorAudio` is already fully parameterised — `barras`, `color`, `altura` + (`visualizador_audio.dart:18-30`). Escuchar passes `barras: 30`, `altura: 26`, `color: liveGreen`; the full player + keeps its own values. **Zero change to `visualizador_audio.dart`.** + +### The risk, named explicitly + +**Duplicated playback state.** The failure mode is a hero that caches playback fields in `State` to dodge rebuilds. It +will drift, and the drift is not hypothetical: playback is mutated **out of band** by `lib/servicios/navegacion_auto.dart` +(Android Auto), by notification actions, and by the alarm ring. A cached hero would show a stale station while the car +head unit plays another. **This is why rule 1 is absolute**, and why the WU5 test mutates `EstadoRadio` from outside the +widget tree and asserts the hero followed. + +### Two consequences the proposal does not mention + +**a. The mini player must hide on Escuchar.** `MiniReproductor` currently renders above the tab bar on every root +(`app.dart:252`), so with a hero on Escuchar the same station appears twice. + +> **Hide it visually, not structurally.** `MiniReproductor` calls +> `context.read().configurarLocalizaciones(...)` in `didChangeDependencies` (`mini_reproductor.dart:27-38`, +> the S3-R3 contract). Removing the widget from the tree on Escuchar would stop that from running while the app sits on +> the first tab. So: pass `visible: false` and return `SizedBox.shrink()` from `build`, keeping the `State` mounted. + +`_PaginaPrincipal` owns both the tab index and the mini player, so this is one condition in the one place that knows +both facts. Rejected: an `InheritedWidget` flag, or the mini player reading the tab index itself — needless coupling. + +**b. Bottom inset must shrink accordingly.** `PluriLayout.bottomChromeInset = 146` (`pluri_layout.dart:10`) assumes the +mini player is present. Escuchar uses `bottomChromeInset - MiniReproductor.altura`, with `altura` added as a +`static const double` on `MiniReproductor` and **derived from its actual laid-out height at apply time, not guessed**. + +**Blast radius.** `pantalla_inicio.dart` (restructure), `app.dart` (mini-player visibility), `mini_reproductor.dart` +(+`visible`, +`altura`), `pluri_layout.dart` (+1 derived constant). `visualizador_audio.dart`, `estado_radio.dart`, +`pantalla_reproductor.dart` **unchanged by this ADR**. Owner: **WU5**. + +--- + +## ADR-8: Root-to-root navigation + +**Decision.** A new `EstadoNavegacionRaiz extends ChangeNotifier` in `lib/estado/estado_navegacion.dart`, registered in +the **existing** `MultiProvider`. `_PaginaPrincipalState` watches it instead of owning `_indice`. + +```dart +enum RaizPluriWave { escuchar, buscar, favoritos, alarmas, ajustes } + +class EstadoNavegacionRaiz extends ChangeNotifier { + RaizPluriWave _actual = RaizPluriWave.escuchar; + RaizPluriWave get actual => _actual; + int get indice => _actual.index; + void irA(RaizPluriWave raiz) { + if (raiz == _actual) return; + _actual = raiz; + notifyListeners(); + } +} +``` + +**Reasoning.** + +- Provider + `ChangeNotifier` is the codebase's only state mechanism (six providers in `app.dart:47-73`). A seventh is + idiomatic; anything else is a second parallel mechanism for the same job. +- **The need already exists.** `_PaginaPrincipalState` already does `setState(() => _indice = 3)` from async alarm + handlers at `app.dart:310`, `333` and `350`. Those become `irA(RaizPluriWave.alarmas)` — more readable, and the + alarm-deep-link behaviour becomes unit-testable without pumping the whole app. +- **The enum kills the magic numbers.** `_indice = 3` means "Alarmas" today only by counting. Declaration order is the + tab order, and becomes the single source shared by `_paginas` and `_navItems`. +- `_paginas` stays `const`. +- It makes the success criterion mechanical: a test calls `irA(RaizPluriWave.favoritos)`, asserts Favoritos rendered + **and** that `Navigator` depth did not change — i.e. "switches tab without pushing a route". + +**Rejected alternatives.** + +| Option | Why rejected | +|--------|--------------| +| Pass an `onVerTodas` callback into `PantallaInicio` | Forces `_paginas` to stop being `const`, and only solves one link. The alarm handlers need the same capability, and a third consumer is likely. Prop-drilling grows a parameter per link. | +| `InheritedWidget` / `InheritedNotifier` scope | A second state-propagation mechanism beside Provider — which is itself built on `InheritedWidget`. No gain. | +| `GlobalKey<_PaginaPrincipalState>` or a static | Mutating another `State`'s private field from anywhere is the classic "setState called after dispose". Untestable. | +| `go_router` / declarative routing | The app has exactly two `Navigator.push` call sites (`app.dart:387` and `PantallaReproductor.abrir`). Introducing a routing framework to solve a tab switch is a rewrite disguised as a fix. It is the right move if deep links multiply — its own change. | + +**Sequencing refinement.** Ship `EstadoNavegacionRaiz` in **WU1**, not WU5. WU1 already edits `app.dart`'s nav wiring +for the Escuchar rename; landing the notifier later means editing the same lines twice and rebasing WU5 over WU1's +churn. WU5 then only consumes `irA(RaizPluriWave.favoritos)` from "Ver todas". + +**Blast radius.** New `lib/estado/estado_navegacion.dart`, `app.dart` (+1 provider, `_indice` → watch, 3 `setState` +sites converted), `pantalla_inicio.dart` (WU5, one call). Tests: `test/widget_test.dart` nav assertions, new +`test/estado/estado_navegacion_test.dart`. Owner: **WU1** (consumed by **WU5**). + +--- + +## Component inventory + +### New + +| Component | File | WU | +|-----------|------|----| +| `PluriWaveTypography` (theme extension) | `lib/tema/pluriwave_typography.dart` | WU1 | +| `PluriPushScaffold` | `lib/widgets/pluri_push_scaffold.dart` | WU1 | +| `EstadoNavegacionRaiz` + `RaizPluriWave` | `lib/estado/estado_navegacion.dart` | WU1 | +| `FilaAjuste` / `GrupoAjustes` (nav row + group card) | `lib/pantallas/ajustes/widgets/fila_ajuste.dart` | WU3a | +| 12 settings detail screens | `lib/pantallas/ajustes/*.dart` | WU3a / WU3b | +| `PaisRadio` | `lib/modelos/pais_radio.dart` | WU7 | +| `PantallaPaises` | `lib/pantallas/pantalla_paises.dart` | WU7 | +| `ImpactoVacaciones` | `lib/modelos/alarma_musical.dart` (beside `RangoVacaciones`) | WU9 | +| `PantallaVacaciones` | `lib/pantallas/pantalla_vacaciones.dart` | WU9 | +| Inline giant time editor | `lib/widgets/editor_hora_inline.dart` | WU10 | +| `ServicioPresetsPersonalizados` | `lib/servicios/servicio_presets_personalizados.dart` | WU13 | +| `PantallaGrabaciones` (library) | `lib/pantallas/pantalla_grabaciones.dart` | WU15 | +| `PantallaBienvenida` | `lib/pantallas/pantalla_bienvenida.dart` | WU17 | + +### Reused unchanged + +`VisualizadorAudio` (already parameterised: `barras`/`color`/`altura`) · `PluriWaveScaffold` · `PluriGlassSurface` · +`PluriLayout` (one added constant) · `PluriEmptyState` · `PluriStatusPill` · `TarjetaEmisora` · +`TarjetaEmisoraShimmer` · `PluriBottomNavigation` · `PluriIcon` / `PluriIconGlyph` · `PresetsEcualizadorWidget` · +`PresetEcualizador` · `ServicioEcualizador` · `ServicioAudio` band block · `navegacion_auto.dart`. + +### Reused with a restyle + +`EcualizadorWidget` (5 sliders, `+habilitado`) · `MiniReproductor` (`+visible`, `+altura`) · +`PluriScreenHeader` (root heroes only — never the pushed header) · `PluriWaveTokens` (+3 colours). + +--- + +## State-management ruling + +Consistent with the existing Provider / `ChangeNotifier` architecture. No new mechanism. + +| Rule | Detail | +|------|--------| +| **One new provider, total** | `EstadoNavegacionRaiz`. Every other capability extends an existing notifier. | +| **Respect notifier ownership** | `EstadoRadio` constructs and disposes `EstadoEcualizador` / `EstadoGrabacion` / `EstadoBusqueda`; `app.dart:60-68` exposes them via `ListenableProvider` with **no** dispose callback. Never wrap these in a `ChangeNotifierProvider`. | +| **Reads** | `context.select` in hero/list widgets (rebuild scoping is already load-bearing — see `MemoLista`); `context.read` inside callbacks; `Consumer` only when the whole notifier is genuinely needed. | +| **`State` is for ephemeral UI only** | Text controllers, expansion flags, drag offsets. No domain state in `State`, ever (see ADR-7). | +| **Pushed routes inherit providers** | `MultiProvider` wraps `MaterialApp`, so no state is passed through constructors (ADR-3). | +| **New queries are pure** | ADR-6's additions read; they do not schedule, persist or notify. | + +--- + +## Test strategy per work unit + +Runner: **`flutter test`**. `flutter analyze` and `dart format .` gate every commit. **`flutter build` is never run.** +Strict TDD is ON: tests first, red before green, for every unit — with the single documented exception in WU7 step (a). + +| WU | Tests written first | Must pass unmodified | +|----|---------------------|----------------------| +| WU1 | New: token presence + `lerp` + extension retrieval; `PluriPushScaffold` renders one 56 px `AppBar` + back affordance and exposes no bottom nav; the five roots build **zero** `Scaffold`s; `EstadoNavegacionRaiz` transitions + no-op on same-root. Update: `widget_test.dart` nav labels. | entire `test/tema/`, all EQ tests | +| WU2 | None — verification only | `test/servicios/navegacion_auto_test.dart`, with `git diff` empty for `lib/servicios/navegacion_auto.dart` | +| WU3a/WU3b | Rewrite `pantalla_ajustes_test.dart`: root renders 4 groups, **zero inline controls**, root file < 400 lines. Per detail screen: it renders inside a `PluriPushScaffold` and its moved controls still respond. | every service/state test — no logic moved | +| WU4 | Chip filter narrows the list; reorder persists; sort applies `OrdenEmisoras`. Update `pantalla_favoritos_plural_test.dart`. | — | +| WU5 | **Mutate `EstadoRadio` from outside the tree and assert the hero followed** (the ADR-7 anti-cache test); mini player hidden on Escuchar but `configurarLocalizaciones` still ran; "Ver todas" changes root **without** changing `Navigator` depth. Update `pantalla_inicio_test.dart`, `pantalla_inicio_rebuild_test.dart`. | `mini_reproductor_configurar_test.dart` | +| WU6 | Every rendered sort option maps to a real `OrdenEmisoras` case (Engram 2505 constraint); results counter; clear-N-filters. Update `pantalla_buscar_shimmer_test.dart`. | `estado_busqueda_test.dart` for non-sort paths | +| WU7 | **Characterisation first** (7 methods × 6 assertions, incl. exact result ordering) → green; then `PaisRadio` + `obtenerPaises` → red; then extract. Fixture supplies `stationcount` as a **string**. Assert no `lastcheckok` on `/json/countries`. | `servicio_radio_test.dart` | +| WU8 | Tap opens the editor; swipe deletes; hero "Saltar" reachable; Vacaciones is a summary row + chevron. Update `pantalla_alarmas_editor_test.dart`. | `estado_alarmas_test.dart`, `estado_alarmas_snooze_test.dart` | +| WU9 | `rangoVacacionesActivo` / `vacacionesProximas` / `vacacionesPasadas` / `impactoDeRango` with a **fixed `ahora`**; impact predicate matches `servicio_programacion_alarmas.dart:150`. | all scheduling tests | +| WU10 | Inline time editor unit-tested **standalone**, independent of the sheet (drag + tap adjust, wrap at 23:59, a11y labels). Date field, fallback station, sound dropdown still reachable. Update `pantalla_alarmas_fecha_test.dart`. | dismiss-guard test | +| WU11 | Status label renders with **no seconds counter**; force-stop retry banner present. | **`pantalla_alarma_sonando_dismiss_guard_test.dart` — hard rule: if a restyle requires editing it, STOP and escalate** | +| WU13 | Exactly **5** sliders; custom preset save → list → delete round-trips through the new service; "Emisoras con ajuste propio" count matches `presetsPorEmisora`. | `servicio_ecualizador_test.dart`, `estado_ecualizador_test.dart`, `servicio_audio_eq_reapply_test.dart` — plus empty `git diff` for `servicio_ecualizador.dart`, `preset_ecualizador.dart` and `servicio_audio.dart:749-762` | +| WU14 | The per-station sheet renders **the same `EcualizadorWidget` type** as WU13 (assert by type, so duplication fails the build); 4 tool-tray tiles open their sheets. | the three EQ tests | +| WU15 | Storage bar; per-recording rename / share / delete constrained to what `servicio_grabacion_radio.dart` exposes. | `servicio_grabacion_radio_test.dart` | +| WU16 | Offline banner restyle. **Attempt counter only if `servicio_audio_reconnect_test.dart` proves the controller tracks one** — otherwise ship without the label. Update `reconnect_ui_test.dart`. | `servicio_audio_reconnect_test.dart` | +| WU17 | Welcome renders; **no** "PRO", trial duration or price anywhere. Grep assertion over `lib/l10n/app_*.arb`. | — | +| WU18 | All 13 ARB files have identical key sets; `navHome` exists as a key in all 13; `git diff` shows exactly **13** changed `navHome` lines. | full suite | + +**Two grep-shaped gates worth automating in tests rather than by eye:** + +- `countrycodes` appears nowhere in `lib/` (the deprecated endpoint, Engram 2500). +- No new raw `Color(0x…)` literal outside `lib/tema/`. + +--- + +## Risks introduced by this design + +| Risk | Mitigation | +|------|------------| +| The `_servidorActual` sticky-host delta (ADR-4) reads like a bug in review | Named explicitly above; characterisation tests pin host rotation so the delta cannot become a regression | +| WU3a/WU3b diffs are ~2× the proposal's estimate because a verbatim move counts twice | Recorded as a measurement correction; `sdd-tasks` records `size:exception` with "move-only diff" | +| A future contributor caches playback state in the Escuchar hero | ADR-7 rule 1 plus the out-of-band mutation test; Android Auto is the concrete failure case | +| Custom-preset persistence drifts back into `servicio_ecualizador.dart` | The empty-`git diff` success criterion is the backstop; ADR-5's hazard box states the correct home | +| `PluriPushScaffold.titleOverride` gets generalised | Documented single-consumer exception; a second consumer reopens this ADR | +| `MiniReproductor.altura` is guessed rather than measured | ADR-7(b) requires deriving it from the laid-out height at apply time | + +## Next step + +`sdd-tasks` — slice these ADRs into the 18 work-unit commits on `feat/rediseno-funcional` (Engram 2504: commits, not +PRs; the remote is self-hosted Gitea and `gh` is unavailable). Note the two sequencing refinements this design makes to +the proposal: `EstadoNavegacionRaiz` moves into **WU1**, and the settings detail-screen count is **12**, not ~8.