docs(sdd): add technical design for the functional redesign
This commit is contained in:
@@ -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 <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 `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.
|
||||||
Reference in New Issue
Block a user