Files

727 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <https://api.radio-browser.info/>.
> 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 <Widget>[],
this.bottom, // optional persistent footer (e.g. a CTA)
this.floatingActionButton,
});
static const double headerHeight = 56;
static Future<T?> push<T>(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<EstadoRadio>()` 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<EstadoRadio>`-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<List<dynamic>> _getJson(String path, Map<String, String> 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<List<Emisora>> _get(String path, Map<String, String> params) async {
final lista = await _getJson(path, {'lastcheckok': '1', ...params});
final emisoras = lista.cast<Map<String, dynamic>>()
.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<String, dynamic> 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<List<PaisRadio>> obtenerPaises() async {
final lista = await _getJson('/json/countries', const {});
return lista
.whereType<Map<String, dynamic>>()
.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<PaisRadio> paises`, `bool cargandoPaises`,
`Future<void> 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<PresetEcualizador> 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<RangoVacaciones> vacacionesProximas({DateTime? ahora}); // inicio > hoy, asc by inicio
List<RangoVacaciones> 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<AlarmaMusical> pausadas; // activa && !sonarEnVacaciones
final List<AlarmaMusical> 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<EstadoRadio, T>` per scalar**, never `Consumer<EstadoRadio>` 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<EstadoRadio>().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 a **scoped** `dart format` gate every commit — never bare
`dart format .`, which reformats 27 unrelated pre-existing files on this machine and would break the empty-`git diff`
guards on `navegacion_auto.dart`, `servicio_ecualizador.dart` and `servicio_audio.dart`
(Engram `reference/dart-format-scope-hazard`, id 2511). **`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.