6 Commits
Author SHA1 Message Date
FreeTLab 35df016aa3 docs(sdd): add technical design for the functional redesign 2026-07-28 18:14:07 +02:00
FreeTLab 433373ad7f docs(sdd): add delta specs for the functional redesign 2026-07-28 18:06:46 +02:00
FreeTLab f58cf8739f docs(sdd): add functional redesign proposal 2026-07-28 18:02:07 +02:00
FreeTLab 675b7fb4b7 docs(design): add Claude Design handoff bundle for the functional redesign 2026-07-28 15:58:39 +02:00
FreeTLab 17f8e69529 chore: bump version to 1.2.0+122 before the functional redesign 2026-07-28 15:58:32 +02:00
FreeTLab b183b3f3e5 fix(eq): stop the enable toggle from landing behind a disk write
cambiarActivo persisted BEFORE telling the audio engine, so two quick taps
raced on a SharedPreferences write. When the first write resolved last, the
engine received the FIRST tap's value after the second one: the checkbox read
enabled while the sound stayed flat, and toggling again could invert it the
other way. Reported as the equalizer connecting and disconnecting at random
and the checkbox disagreeing with what is audible.

Reorder to engine first, disk last. The engine call is now issued before any
await, so overlapping taps reach it in tap order and the last tap wins. Each
subsequent step re-checks _activo, so a call that a newer tap superseded
mid-flight neither applies a preset nor persists a value the user has already
changed their mind about. Persisting last also puts what the user HEARS ahead
of what is merely stored.

The regression test drives two opposite taps through a persistence fake whose
FIRST write is the slow one — the exact ordering hazard — and asserts the
engine ends matching the state the UI shows. It fails on the previous
ordering and passes on this one.

An earlier attempt serialized every engine mutation through a shared Future
lane. It fixed this case and deadlocked four widget tests: the lane field
outlived a tester.runAsync block, so a future created in the real async zone
was later chained from the fake-async zone that never advances it. Reverted
in favour of the ordering fix, which needs no cross-zone state.

Only the enable toggle is addressed here. The other reported symptom —
equalization seeming to come and go while playing — is not explained by this
race and is still open; the handler rebuilds the whole AndroidEqualizer on
every player recreation, which is the next place to look.
2026-07-28 13:32:57 +02:00
33 changed files with 6022 additions and 3 deletions
+17 -2
View File
@@ -507,14 +507,29 @@ class EstadoEcualizador extends ChangeNotifier {
return deviceId;
}
/// Enables or disables the equalizer.
///
/// Engine FIRST, disk last. The previous order persisted before telling the
/// engine, so two quick taps raced on a disk write: when the first write
/// resolved last, the engine received the FIRST tap's value after the second
/// one and the checkbox read enabled while the sound stayed flat. Issuing the
/// engine call before any `await` means overlapping taps reach the engine in
/// tap order, so the last tap always wins.
///
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight
/// owns the outcome, and this superseded call must not apply a preset or
/// persist a value the user has already changed their mind about.
Future<void> cambiarActivo(bool activo) async {
_activo = activo;
await servicio.guardarActivo(activo);
notifyListeners();
await audio.setEcualizadorActivo(activo);
if (_activo != activo) return;
if (activo) {
await audio.aplicarPreset(_presetActual);
if (_activo != activo) return;
}
notifyListeners();
await servicio.guardarActivo(activo);
}
Future<void> cambiarPreset(
@@ -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.
@@ -0,0 +1,343 @@
# Proposal: Functional Redesign (`rediseno-funcional`)
> Source of truth: `rediseno-funcional-de-aplicacion/project/PluriWave Rediseno.dc.html`, turn `t4` only.
> Binding inputs: Engram `sdd/rediseno-funcional/scope-decisions`, `sdd/rediseno-funcional/open-questions-resolved`,
> `sdd/rediseno-funcional/eq-band-spike` (id 2498), `reference/radio-browser-countries-endpoint` (id 2500),
> `preferences/verify-external-apis-officially` (id 2501), `explore-mobile`, `explore-auto`, `explore-crosscutting`.
> Do not re-open decisions recorded there.
> This artifact intentionally exceeds the default 450-word proposal budget: the change spans 14 mobile screens,
> 5 Android Auto surfaces, and 18 work units, and the delivery/risk sections are load-bearing for the apply phase.
## Intent
**Problem.** The app's current UI is a glass-everywhere magenta/coral surface built up incrementally over many
changes. Three concrete symptoms:
1. **Settings has collapsed under its own weight.** `lib/pantallas/pantalla_ajustes.dart` is 1896 lines rendering
**12 always-expanded panels** on one scroll. The mockup's "12 panels to 4 groups" framing describes the current
app *exactly* — the redesign premise is verified, not aspirational.
2. **The home tab does not do its job.** `pantalla_inicio.dart` renders a discovery browser (near-you carousel,
genre chips, trending grid) while the thing users open the app for — the player and their own stations — is
one tap away behind a pushed route.
3. **The visual language drifted from its own tokens.** The mockup's `#07121A` base and `#21D4D9` brand already
match `PluriWaveTokens`, but the list surface `#102532`, the live green `#7EE4C2`, and the offline accent are
used ad-hoc across screens, and `.copyWith(fontWeight: w900, letterSpacing: ...)` is copy-pasted in nearly every
screen file. There is no named type scale.
**Why now.** The design handoff (turn `t4`) is approved and the app is in Google Play internal testing — the
lowest-risk window to restructure navigation-adjacent UI before a public audience exists. Version is already
bumped to `1.2.0+122`; `17f8e69` on `main` is a clean rollback point.
**Success looks like.** Every mockup screen's *intent* is implemented in the new visual language, information
architecture is unchanged (5 tabs), no existing capability is lost, and no monetization surface ships.
## Standing Rule — external APIs are verified, never inferred
**Any technical doubt about connectivity to an external API or service MUST be resolved against that provider's
official documentation. Inference from existing call sites, from field names, or from what "looks right" is not
acceptable evidence.** This binds `sdd-spec`, `sdd-design`, and `sdd-apply` for this change and every change after
it. Engram: `preferences/verify-external-apis-officially` (id 2501).
Practical consequence here: no spec, design, or task may assert a Radio Browser endpoint, parameter, or response
field that has not been checked against <https://api.radio-browser.info/>. Where such a check has already been
done, the verified contract is recorded below and is authoritative.
## Reconciliation Ruling — mockup push-chrome vs. 5 tabs
The mockup states (t4 intro, line 38): *"cuatro raíces con barra globo … las pantallas de segundo nivel entran
empujadas con cabecera de 56px, flecha atrás y SIN barra de pestañas."*
**Ruling: adopt the push-chrome rule, reject the root set.**
- Every genuine second-level screen gets 56px header + back arrow + **no** tab bar: Reproductor completo (2),
Países (5), Vacaciones (9bis), Ecualizador (11), Grabaciones (12), and all new Settings detail screens.
- All **five** roots keep the tab bar: Escuchar, Buscar, Favoritos, Alarmas, Ajustes.
- **The one deliberate contradiction**: mockup screen 4 ("Tus emisoras · grupos") is *drawn* as a pushed screen with
no tab bar. In our IA it **is** the Favoritos root, so it **keeps its tab bar**.
**Reasoning.** The mockup's no-tab-bar rule is a *consequence* of its 4-root IA, not an independent design law. Its
real intent is "navigation depth removes the root switcher." Applying it literally to Favoritos would hide the tab
bar on a root tab — a navigation regression, not a redesign. The rule is honoured at the level of intent (depth
implies no tabs) and rejected at the level of the specific frame that only looked pushed because the mockup had
demoted Favoritos out of the root set. Downstream phases must not copy "no tab bar" onto Favoritos.
**Corollary ruling (not covered by the 8 resolutions):** the mockup's 4 Settings groups draw exactly 16 rows,
omitting Sleep Timer and Backup/restore. Per the standing "do not silently narrow scope" rule, **both are kept**
under the `APLICACIÓN` group. Sleep timer stays reachable from both Settings and the player tool tray.
## Scope
### In Scope
Ordered as work units (WU). This ordering is the dependency graph, not a suggestion.
| WU | Deliverable | Depends on | Est. lines | Risk |
|----|-------------|-----------|-----------|------|
| WU1 | **Design tokens + shared primitives + the Inicio-to-Escuchar rename**: add `listSurface #102532`, `liveGreen #7EE4C2`, `offlineAccent #E8879A`; named text styles (`heroTime`, `eyebrowLabel`, `cardTitle`, `screenTitle`); shared push-chrome scaffold (56px header + back, no tab bar); **retitle the first tab in `lib/app.dart` and update the `navHome` value in `app_en.arb` + `app_es.arb`** (see rename ruling below) | — | 160-260 | Low |
| WU2 | **Android Auto verification-only**: re-run `test/servicios/navegacion_auto_test.dart`, formally close A1-A5 as already-done. **Zero code changes.** | — | 0 | None |
| WU3a | **Settings shell + AUDIO/EMISORAS groups**: 12 panels to grouped nav rows, extract detail screens, controls reused verbatim | WU1 | 350-450 | Low (mechanical) |
| WU3b | **Settings GRABACIONES Y MÚSICA / APLICACIÓN groups** (incl. sleep timer + backup) | WU3a | 350-450 | Low (mechanical) |
| WU4 | **Favoritos restyle**: chip group filter replaces stacked panels, flat reorderable list (drag-to-reorder is NEW), `swap_vert` sort, custom-station CTA | WU1 | 300-400 | Medium |
| WU5 | **Escuchar restructure**: embedded player hero (square art, live pill, waveform, transport row with sleep as 5th action, tool-tray entry chip) + "Tus emisoras" favorites grid + "Ver todas" switching to the Favoritos tab (index switch, not push) | WU1, WU4 | 350-450 | Medium-High |
| WU6 | **Buscar**: discovery content (near-you / genres / trending / countries entry) becomes the tab's landing state; active-filter pills with bottom-sheet pickers; results counter; one-tap "clear N filters". Opens with an **official-docs check** on Radio Browser sort support (resolution 8, still open) | WU5 | 350-500 | Medium |
| WU7 | **Países browser (NEW screen)**: "Tus idiomas" + full alphabetical list with per-country counts, over the **verified** `/json/countries` contract below. Includes the `_get` transport extraction in `servicio_radio.dart` **plus regression coverage for the 8 existing station calls** | WU6 | 300-400 | Medium |
| WU8 | **Alarmas root restyle**: minimal cards (giant time + station + switch), **tap = edit, swipe = delete** (resolution 2), hero banner with inline "Saltar", Vacaciones inline panel becomes a summary row + chevron | WU1 | 400-550 | Medium |
| WU9 | **Vacaciones manager (NEW screen 9bis)**: active-range hero, per-alarm impact line, upcoming ranges, past-ranges history. Needs new `EstadoAlarmas` query methods | WU8 | 350-450 | Medium-High |
| WU10 | **Alarm editor sheet rewrite**: inline giant HH:MM editor (custom widget), always-visible weekday circles, restyled sliders. **Keeps** the date field, fallback-station picker, and sound dropdown (resolution 3), collapsed into an advanced section | WU8 | 500-650 | High |
| WU11 | **Alarm ringing restyle**: full-bleed blurred art, giant time, 3 snooze tiles, full-width stop pill. **Static** "Subiendo volumen" label with **no** seconds counter (resolution 4). Force-stop retry banner and single-exit dismiss guard preserved verbatim | WU1 | 200-300 | High (safety-critical) |
| ~~WU12~~ | ~~Native EQ band-count spike~~**RESOLVED before planning closed.** Tombstone row: the number is retired, not missing. Evidence in Engram id 2498, ruling below | — | 0 | — |
| WU13 | **Ecualizador settings screen — restyle at 5 bands**: header enable toggle, base-vs-per-station explainer, preset chips, **5** custom vertical sliders (mockup's 7 rejected — see below), "Guardar como preset" (NEW custom presets), "Salida activa" surfaced from Advanced, "Emisoras con ajuste propio" drill-down (NEW UI over existing `presetsPorEmisora`). **No model, persistence, or EQ-test change from the band axis.** | WU3a | 400-550 | Low-Medium |
| WU14 | **Reproductor completo restructure**: square art, favorite moved into transport row, single subtitle line, 4-tile tool-tray grid (EQ propio / Grabar / sleep-timer / Compartir) opening bottom sheets, quality row + "Cambiar". Per-station EQ sheet **shares** the WU13 editor component | WU13 | 450-600 | Medium-High |
| WU15 | **Grabaciones library (NEW list)**: storage usage bar, per-recording rows with playback, `⋮` menu = **Rename / Share / Delete** constrained to what `servicio_grabacion_radio.dart` already exposes (resolution 6) | WU3b | 300-400 | Medium |
| WU16 | **Connectivity states**: offline banner restyle, reconnect attempt counter (**verify** the reconnect controller exposes an attempt count before promising the label), shimmer skeletons already correct | WU1 | 150-250 | Low |
| WU17 | **Welcome / onboarding screen (14)**: full-screen route in the new visual language — logo, headline, body, 3 feature bullets, "Empezar a escuchar" CTA. **No PRO pill, no "14 días", no pricing card, no secondary "free version" link** (resolution 7) | WU1 | 150-200 | Low |
| WU18 | **i18n batch**: owns **both new keys AND modified values for existing keys**. ~35-45 new keys across all 13 ARB locales (~455-585 translated strings), **plus the `navHome` value in the 11 locales WU1 did not touch**. Run **once**, after WU1-WU17 land in `en`/`es` | all | 0 eng. | Low |
Total estimated changed lines: **~4,550-6,450**, excluding tests.
**Resolved ruling — the Inicio-to-Escuchar rename changes the ARB *value*, not the key.**
Verified directly: `navHome` sits at **line 4 of all 13 ARB files** (`app_en.arb` = `"Home"`, `app_es.arb` = `"Inicio"`,
and 11 further localized values — `Beranda`, `Accueil`, `Start`, `Início`, `Inizio`, `ホーム`, `首页`, `Главная`,
`الرئيسية`, `হোম`, `मुखपृष्ठ`). So this is a **value change to an existing key**, not a new key — which is why neither
WU1's original scope nor WU18's "new keys" scope covered it. Both are corrected above.
Consequence for WU18: this is **13 real translations of "Listen"**, not a find-and-replace. Only `en` and `es` are
mechanical; the other 11 need a translator, exactly like any new key.
| Option | Ruling |
|--------|--------|
| Change `navHome`'s **value** in place | **ADOPTED.** 13 value edits, zero key churn, zero call-site churn, and no window where a locale can drift out of key-set parity mid-refactor. |
| Introduce `navListen`, retire `navHome` | Rejected. |
Reasoning for the rejection: binding decision 2 keeps `PluriIconGlyph.home` as the Escuchar tab icon. "Home" therefore
already survives as this tab's internal identifier **by explicit user decision**. Renaming the ARB key to `navListen`
while the icon glyph stays `home` would make internal naming *less* consistent, not more, and would drag every call
site and any key-asserting test along for no user-visible gain.
Ownership split (so no two units edit the same line): **WU1** changes the value in `app_en.arb` and `app_es.arb` — the
two locales every slice lands in per approach point 9. **WU18** carries it to the remaining 11 locales alongside the
new-key batch.
**Resolved ruling — equalizer stays at 5 bands.** The mockup's 7-band layout (t4, screen 11) is **rejected as not
reliably implementable**. Spike evidence (Engram `sdd/rediseno-funcional/eq-band-spike`, id 2498):
- There is **no native Kotlin equalizer code at all** — zero MethodChannel/EventChannel EQ plumbing under
`android/app/src/main/kotlin`. The Kotlin side holds only the waveform downsampler and the alarm service.
- Equalization runs through `just_audio`'s `AndroidEqualizer`, which wraps the Android system `AudioEffect`
Equalizer. **Band count comes from `params.bands`, reported by the device/OS — the app does not choose it.**
Most Android devices report exactly 5.
- `lib/servicios/servicio_audio.dart:749-762` is the only place gains are applied and already guards with
`i < params.bands.length && i < preset.bandas.length`. Rendering 7 sliders would **silently no-op the last two**
on typical hardware — shipping controls that do nothing.
- `lib/modelos/preset_ecualizador.dart:5-8` hard-asserts `bandas.length == 5`.
Consequence: WU13 is a **restyle only**. No model change, no persistence change, no EQ test churn from this axis.
**Verified contract — Radio Browser countries (WU7).** Checked against <https://api.radio-browser.info/> and
<https://de1.api.radio-browser.info/>. Full detail: Engram `reference/radio-browser-countries-endpoint` (id 2500).
This closes half of open question 8. Downstream phases use these facts verbatim — do not re-derive them.
- Endpoint: **`/json/countries`**, optional substring filter `/json/countries/{filter}`.
**`/json/countrycodes` is DEPRECATED — do not use it.**
- Response fields are exactly `name` (string), `iso_3166_1` (string), and **`stationcount` (STRING, not int)**.
An `as int` cast throws at runtime. **Parse with `int.tryParse`.**
- Params/defaults: `order` (default `name`, also accepts `stationcount`), `reverse` (false), `hidebroken` (false),
`offset` (0), `limit` (100000).
Two implementation constraints this imposes:
1. **`lib/servicios/servicio_radio.dart:168` hardcodes `lastcheckok: '1'` into every request built by `_get`.**
That is a **station-only** filter and must NOT be sent to `/json/countries`. The countries call therefore
**cannot reuse `_get` as-is**.
2. **Required shape**: extract the transport loop from `_get` (server discovery, host rotation, bounded retries,
User-Agent, timeout, status check, `json.decode`) into a generic private helper returning `List<dynamic>`.
`_get` then layers the station-specific concerns on top (`lastcheckok`, `Emisora.fromApi`, `_compararCalidad`
sort); the countries call reuses **transport only**. No behaviour change for the 8 existing methods — but this
touches a **shared code path**, so WU7 must ship regression coverage for those 8 calls.
Already correct, needs no work: `_uri` (lines 103-105) forces `hidebroken=true` on every request, which is exactly
what we want given the endpoint default of `false`; mirror discovery via `all.api.radio-browser.info` matches the
documented guidance; the User-Agent is built per instance at lines 68-86.
Still open under question 8: the **search sort** control (screen 6). Not yet verified — WU6 opens with an official-
docs check before anything is specced.
### Out of Scope — explicitly
| Excluded | Why |
|----------|-----|
| **Monetization: PRO tier, 14-day trial, pricing card, IAP, subscription state, feature gating, the Settings "PRO · 11 meses" pill** | User decision (resolution 7). Zero monetization infrastructure exists today; `in_app_purchase` stays commented out at `pubspec.yaml:56`. Ships as its own SDD change at public release. Do not display a paid-tier promise the app cannot honour. |
| **Alarm-ringing live volume countdown ("Subiendo volumen · 18 s")** | Resolution 4. Requires a native to Flutter progress channel that is *deliberately* absent per a documented architecture constraint in `pantalla_alarma_sonando.dart`. Static label only. |
| **Any literal Android Auto pixel work (A1-A5)** | The app uses the classic `MediaBrowserService` model (`automotive_app_desc.xml`, no `androidx.car.app`). Android Auto's system templates render everything; the only controllable surface is `MediaItem` metadata, which already matches. A4's waveform is **non-implementable** on this platform. WU2 is verification-only. |
| Adopting the mockup's 4-root IA (Escuchar / Explorar / Alarmas / Ajustes) | Binding decision 1. Would delete the Favoritos root and demote the player to a pushed route. |
| Switching tab icons to Material Symbols | Binding decision 2. `PluriIconGlyph.*` stays; `PluriIconGlyph.home` becomes the Escuchar icon. |
| **The mockup's 7-band equalizer** | Rejected outright. Band count is device-reported via `just_audio`'s `AndroidEqualizer` (`params.bands`), not app-chosen; most devices report 5, so 2 of 7 sliders would silently do nothing. See the resolved ruling above. |
| **A runtime-dynamic band count driven by `params.bands.length`** | Technically the only correct way to support non-5-band devices, but it ripples through the fixed-length `assert(bandas.length == 5)`, the 7 SharedPreferences keys in `servicio_ecualizador.dart`, and every EQ test. **Worth its own future change** — not this one. |
| Domain/business-logic changes to the EQ resolution hierarchy | Already a 4-level matrix in `estado_ecualizador.dart`. The redesign **relocates editing surfaces only**. |
## Capabilities
> Contract with `sdd-spec`. Existing spec names verified against `openspec/specs/`.
### New Capabilities
- `app-navigation-shell`: the 5-root tab contract, the push-chrome rule (56px header + back + no tab bar) and its explicit Favoritos exemption, and root-to-root switching (Escuchar "Ver todas" to Favoritos).
- `station-discovery-browse`: discovery content as the Buscar landing state, active-filter pills, results counter, clear-all-filters, the Países browser over the verified `/json/countries` contract, and search sorting **only if** official docs confirm support.
- `favorites-organization`: chip-filtered flat list, drag-to-reorder, sort action, group management surfaced from Favoritos.
- `alarm-vacation-ranges`: active-range detection, per-alarm pause-impact computation, upcoming and past ranges.
- `recordings-library`: browsable recordings list, storage usage, per-recording rename/share/delete.
- `eq-custom-presets`: user-defined preset save/name/list, per-station EQ entry point from the player, "stations with their own EQ" drill-down.
- `onboarding-welcome`: first-run welcome surface, explicitly monetization-free.
### Modified Capabilities
- `multi-device-eq`: the per-station EQ editing surface moves into the player's tool tray and gains custom presets. **Band count stays 5 — resolved, not pending.**
- `native-alarms`: alarm-card affordances change (tap = edit, swipe = delete), vacation ranges move to a dedicated screen, ringing-screen presentation changes. **Scheduling and dismiss-guard semantics are unchanged and must not regress.**
- `android-auto-media`: **no requirement change** — verification only, listed here so `sdd-spec` records the confirmation rather than writing a delta.
## Approach
1. **Tokens before screens.** WU1 lands the named tokens and text styles first so the copy-paste
`.copyWith(fontWeight: w900)` pattern is not re-applied 14 times and then refactored 14 times.
2. **Cheap certainty first.** WU2 (Android Auto) costs nothing and formally closes 5 of the 19 mockup screens.
3. **Mechanical before creative.** The Settings restructure (WU3a/WU3b) is the largest line count in the plan but
the lowest logic risk — panels become nav rows, controls are moved verbatim. Landing it early proves the
push-chrome primitive against real screens.
4. **Restyle, do not re-architect, the EQ.** The band-count question is settled at 5. WU13 changes only the
presentation layer; `preset_ecualizador.dart`, `servicio_ecualizador.dart`, and `servicio_audio.dart` are
untouched by the band axis.
5. **Share components, do not duplicate.** WU13 (Settings EQ editor) ships *before* WU14 (player tool tray) so
the per-station EQ bottom sheet reuses the same editor widget.
6. **Verify external APIs officially, then record the contract.** Per the standing rule above. The
`/json/countries` contract is already verified and recorded; search sort is not, and WU6 starts by checking it.
7. **Characterise before refactoring shared code.** WU7 writes regression tests for the 8 existing station calls
*before* extracting transport out of `_get` — the refactor is only safe if the old behaviour is pinned first.
8. **Strict TDD throughout.** Tests are written before implementation for every WU. `flutter test` is the runner.
`flutter analyze` and `dart format .` gate every commit. **`flutter build` is never run.**
9. **Translate once.** WU18 batches all new ARB keys at the end rather than paying 13-locale cost per slice.
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `lib/tema/pluriwave_tokens.dart`, `pluriwave_theme.dart` | Modified | 3 new colour tokens, named text styles. No token removed, none changed. |
| `lib/app.dart`, `lib/widgets/pluri_bottom_navigation.dart` | Modified | **WU1** retitles the first tab to `Escuchar`; `PluriIconGlyph.home` unchanged. Tab list otherwise stable. |
| `lib/pantallas/pantalla_inicio.dart` (413 lines) | Restructured | Discovery content out, player hero + favorites grid in. |
| `lib/pantallas/pantalla_buscar.dart` (357 lines) | Restructured | Gains discovery landing state, filter pills, counter, sort. |
| `lib/pantallas/pantalla_favoritos.dart` (287 lines) | Restructured | Stacked panels to chip-filtered reorderable list. |
| `lib/pantallas/pantalla_alarmas.dart` (1383 lines) | Restructured + split | Cards simplified; vacation panel extracted to its own screen; editor sheet rewritten. |
| `lib/pantallas/pantalla_alarma_sonando.dart` (313 lines) | Restyled | Full-bleed layout. Dismiss guard + force-stop banner preserved. |
| `lib/pantallas/pantalla_ajustes.dart` (1896 lines) | Split | 12 panels to 4 grouped nav lists + ~8 new detail screen files. |
| `lib/pantallas/pantalla_reproductor.dart` (907 lines) | Restructured | Square art, transport row, 4-tile tool tray, share, quality switch. |
| `lib/pantallas/` (new files) | New | Países, Vacaciones manager, recordings library, welcome, ~8 settings detail screens. |
| `lib/modelos/preset_ecualizador.dart` | Modified | Custom presets only. `assert(bandas.length == 5)` **stays**. |
| `lib/servicios/servicio_ecualizador.dart`, `lib/servicios/servicio_audio.dart` | **Unchanged** | EQ band handling and gain application are untouched — restyle only. |
| `lib/estado/estado_alarmas.dart` | Modified | New queries: current range, per-alarm impact, past ranges. |
| `lib/servicios/servicio_radio.dart` | Modified | Extract the transport loop out of `_get` into a generic helper (WU7). `_get` keeps `lastcheckok`/`Emisora.fromApi`/sort. **Shared code path — needs regression coverage for all 8 existing station calls.** |
| `lib/estado/estado_busqueda.dart` | Modified | Country list + counts; sort parameter **only if** official docs confirm support (WU6). |
| `lib/l10n/app_*.arb` (13 files) | Modified | ~35-45 new keys each, **plus modified values for existing keys — known: `navHome`** (WU1 does `en`/`es`, WU18 does the other 11). Key sets stay identical throughout. |
| `lib/servicios/navegacion_auto.dart` | **Unchanged** | Verification only. |
| `test/` (59 files, 15,143 lines) | Modified + New | See risk register. |
## Risks
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| ~~EQ 5 to 7 bands blocked by the native layer~~**RETIRED, resolved by spike (Engram id 2498).** Band count is device-reported via `params.bands`; 7 sliders would silently no-op on typical 5-band hardware. | — | — | **Closed.** Equalizer stays at **5 bands**; WU13 is a restyle. Residual risk moves to the row below. |
| **Spec drift back toward 7 bands.** The approved mockup still *draws* 7 sliders, so a downstream phase could copy it from the design source without reading this ruling. | Medium | Medium | The 5-band ruling is stated in the WU table, the resolved ruling block, Out of Scope, Capabilities, and Success Criteria. Any spec or task asserting 7 bands is rejected on sight. `assert(bandas.length == 5)` in `preset_ecualizador.dart` is the compile-time backstop. |
| **Non-5-band devices remain unsupported.** Devices whose OS reports 6+ bands get the extra bands left at default gain. This is pre-existing behaviour, not a regression introduced here. | Low | Low | Explicitly out of scope. A runtime-dynamic band count is its own future change — it touches the fixed-length assert, the 7 SharedPreferences keys in `servicio_ecualizador.dart`, and every EQ test. |
| **`pantalla_ajustes.dart` (1896 lines) restructure.** Largest single extraction in the plan; ~8 new files. | High (it will happen) | Medium | Mechanical extraction — controls move verbatim, business logic untouched. Split across WU3a/WU3b so neither PR exceeds ~450 lines. `pantalla_ajustes_test.dart` gets a near-total rewrite, written first per strict TDD. |
| **15,143 lines of existing tests assume the current structure.** Confirmed impacted: `pantalla_inicio_test`, `pantalla_inicio_rebuild_test`, `pantalla_favoritos_plural_test`, `pantalla_buscar_shimmer_test`, `pantalla_alarmas_editor_test`, `pantalla_alarmas_fecha_test`, `pantalla_ajustes_test`, `reconnect_ui_test`, `widget_test`. The three EQ test files (`servicio_ecualizador_test`, `estado_ecualizador_test`, `servicio_audio_eq_reapply_test`) are **no longer at risk** now that band count is fixed at 5. | High | Medium | Each WU owns its test updates; no WU merges with a red suite. **Hard rule: `pantalla_alarma_sonando_dismiss_guard_test.dart` must pass unmodified** — if a restyle requires changing it, stop and escalate, because that test guards a real safety mechanism. |
| Reconnect attempt counter may not exist in the controller | Medium | Low | WU16 verifies `servicio_audio_reconnect_test.dart` first; if no attempt count is tracked, ship the banner restyle without the counter rather than adding plumbing. |
| **Refactoring `_get` in `servicio_radio.dart` regresses the 8 existing station calls.** WU7 must split transport from station-specific concerns because `lastcheckok: '1'` (line 168) cannot be sent to `/json/countries`. | Medium | **High** — every station listing in the app flows through `_get` | Pure extraction, no behaviour change intended. WU7 ships **regression coverage for all 8 existing station calls first** (strict TDD: characterisation tests before the refactor). Server discovery, host rotation, retries, User-Agent and timeout move as one block — no logic edits during the move. |
| **`stationcount` parsed as int and throws at runtime.** The `/json/countries` field is a **string**, which reads like an int. | Medium | Medium | Contract is recorded above and in Engram id 2500: parse with `int.tryParse`. Spec must state the field type explicitly; a fixture with a string `stationcount` is required in the WU7 tests. |
| Search sort support is still unverified (resolution 8, second half) | Medium | Medium | WU6 opens with an **official-docs check**, not a code spike. Per the standing rule, no spec may assert a sort parameter that has not been confirmed at api.radio-browser.info. If unsupported, drop the "Ordenar" control rather than faking client-side ordering. |
| **Downstream phases infer API behaviour instead of checking the docs.** | Medium | High | The standing rule is stated at the top of this proposal. `sdd-spec` and `sdd-design` must cite the official doc (or the recorded Engram contract) for every external-service claim. Uncited API claims are rejected. |
| Custom inline time editor (WU10) is the largest genuinely-new widget | Medium | Medium | Isolated in its own PR, never bundled. Drag/tap adjust logic is unit-tested independently of the sheet. |
| i18n volume: ~455-585 translated strings across 13 locales | High | Low (ops, not eng.) | WU18 batches once at the end. Slices land in `en`/`es` only. |
| Scope creep back into monetization via the "PRO" pill | Low | High | Explicitly excluded above and in resolution 7. Any PR reintroducing it is rejected. |
## Delivery Shape
This change is roughly **4,550-6,450 changed lines excluding tests** — an order of magnitude beyond the 400-line
review budget. A single PR is not reviewable.
**Recommendation: `feature-branch-chain`.**
- `feat/rediseno-funcional` is the tracker branch and the **only** branch that merges to `main`.
- PR #1 targets `feat/rediseno-funcional`. Each subsequent child PR targets the immediately previous PR's branch,
so review diffs stay scoped to one work unit. If GitHub shows earlier slices in a child diff, rebase/retarget
until clean.
- Rationale over `stacked-to-main`: this is a coordinated visual language change. Half-landed on `main` means a
release where some screens use the new tokens and others do not. The tracker branch gives one atomic rollback.
**Sequence** (each row is one PR):
```
PR1 WU1 tokens + push-chrome primitive (unblocks everything)
PR2 WU2 Android Auto verification (zero code, quick win)
PR3 WU3a Settings shell + AUDIO/EMISORAS
PR4 WU3b Settings GRABACIONES/APLICACIÓN
PR5 WU4 Favoritos restyle
PR6 WU5 Escuchar restructure
PR7 WU6 Buscar landing + filters (spike first)
PR8 WU7 Países browser
PR9 WU8 Alarmas root restyle
PR10 WU9 Vacaciones manager
PR11 WU10 Alarm editor sheet (own PR, never bundled)
PR12 WU11 Alarm ringing restyle (safety-critical review)
PR13 WU13 Ecualizador settings screen (5-band restyle)
PR14 WU14 Reproductor + per-station EQ sheet
PR15 WU15 Recordings library
PR16 WU16 Connectivity states
PR17 WU17 Welcome / onboarding
PR18 WU18 i18n batch, 13 locales
```
18 PRs (was 19 — the EQ spike PR is gone, resolved before planning closed).
PR2 is effort-light with no dependencies and may be pulled forward at any point.
PR13 **must** merge before PR14 so the per-station EQ sheet reuses the same editor widget.
Commits: conventional commits only. **No `Co-Authored-By`, no AI attribution, ever.**
## Rollback Plan
- The repo has **zero git tags**; the rollback convention is the version-bump commit.
- Full rollback: reset `feat/rediseno-funcional` to `17f8e69` on `main` (the `1.1.16+121` state before `1.2.0+122`).
- Partial rollback: because delivery is a feature-branch chain, any single PR can be reverted on the tracker branch
without touching `main`. Nothing reaches `main` until the tracker merges.
- WU2 (Android Auto) produces no code and needs no rollback.
- `lib/servicios/navegacion_auto.dart` is untouched, so Android Auto behaviour cannot regress.
## Dependencies
- ~~WU12 native EQ spike~~ — **resolved before planning closed**; no longer a dependency. See Engram `sdd/rediseno-funcional/eq-band-spike` (id 2498).
- **Radio Browser `/json/countries`** (WU7) — **verified** against the official docs; contract recorded above and in Engram id 2500. No longer a blocker.
- **Radio Browser search sort** (WU6) — **not yet verified.** Must be checked against the official docs before it is specced. Per the standing rule, do not assume.
- **Translation capacity** for 13 locales (WU18) — an ops dependency, not an engineering one.
- No new packages anticipated. `in_app_purchase` stays commented out.
## Success Criteria
- [ ] All 5 tabs present and named Escuchar / Buscar / Favoritos / Alarmas / Ajustes, using existing `PluriIconGlyph.*` icons, with `PluriIconGlyph.home` on Escuchar.
- [ ] Favoritos renders its tab bar (the documented exemption from the push-chrome rule).
- [ ] Every second-level screen renders a 56px header with a back affordance and **no** tab bar.
- [ ] `pantalla_ajustes.dart` root renders **4 grouped nav lists**, zero inline controls, and is under 400 lines.
- [ ] Escuchar renders an embedded player hero and a "Tus emisoras" grid sourced from favorites; "Ver todas" switches to the Favoritos tab without pushing a route.
- [ ] Discovery content (near-you / genres / trending / countries) is reachable from the Buscar tab's landing state.
- [ ] No string in the shipped build contains "PRO", a trial duration, or a price. Grep-checkable across `lib/l10n/app_*.arb`.
- [ ] The alarm-ringing status label renders without a seconds counter, and the force-stop retry banner is still present.
- [ ] `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` passes **unmodified**.
- [ ] `test/servicios/navegacion_auto_test.dart` passes with `lib/servicios/navegacion_auto.dart` unchanged (`git diff` is empty for that file).
- [ ] Alarm edit, skip-next, and delete are all still reachable (tap / hero pill / swipe respectively) — no capability lost.
- [ ] Sleep timer and backup/restore are still reachable from Settings.
- [ ] `PluriWaveTokens` exposes `listSurface`, `liveGreen`, and `offlineAccent`; no screen file introduces a new raw `Color(0x...)` literal.
- [ ] All 13 ARB files have identical key sets, with zero missing keys. `navHome` still exists as a key in all 13 — the rename changed values only.
- [ ] **No ARB file maps `navHome` to a "home" concept.** `app_en.arb` reads `"Listen"`, `app_es.arb` reads `"Escuchar"`, and `git diff` shows **13** changed `navHome` lines. A grep for the literals `"Inicio"`/`"Home"` is **not** sufficient — 11 locales express "home" in their own language (`Beranda`, `Accueil`, `Start`, `Início`, `ホーム`, `首页`, `Главная`, `الرئيسية`, `হোম`, `मुखपृष्ठ`, `Inizio`), so the check must be the 13-line diff, not a literal grep.
- [ ] `flutter test` green, `flutter analyze` clean, `dart format .` produces no diff.
- [ ] The equalizer renders exactly **5** sliders. `assert(bandas.length == 5)` in `preset_ecualizador.dart` is unchanged, and `git diff` is empty for `lib/servicios/servicio_ecualizador.dart` and the band-application block at `lib/servicios/servicio_audio.dart:749-762`.
- [ ] `test/servicios/servicio_ecualizador_test.dart`, `test/estado/estado_ecualizador_test.dart`, and `test/servicios/servicio_audio_eq_reapply_test.dart` pass **unmodified**.
- [ ] The Países browser calls **`/json/countries`**. Grep confirms `countrycodes` appears nowhere in `lib/`.
- [ ] `stationcount` is parsed with `int.tryParse`; a test fixture supplies it as a **string** and the screen renders the count without throwing.
- [ ] `lastcheckok` is **not** present in the countries request. All 8 existing station calls still send it.
- [ ] Regression tests for the 8 existing `ServicioRadio` station calls exist and pass both before and after the `_get` transport extraction.
- [ ] Every external-API claim in the spec, design, and tasks artifacts cites either the official Radio Browser docs or a recorded Engram contract. No uncited API assertions.
@@ -0,0 +1,75 @@
# Alarm Vacation Ranges Specification
## Purpose
Active vacation-range detection, per-alarm pause-impact computation,
upcoming ranges, past-ranges history, and the summary-row entry point from
the Alarmas root into a dedicated Vacaciones manager screen. Traceable to
WU8 (summary row), WU9 (manager screen + `EstadoAlarmas` queries).
## Requirements
### Requirement: Active Range Detection
`EstadoAlarmas` MUST expose a query returning the currently-active vacation
range (if today falls within one), including days remaining. It MUST
return no active range when today falls within none.
#### Scenario: Today falls within a range
- GIVEN a vacation range whose start-to-end span includes today
- WHEN the active-range query runs
- THEN it returns that range with a computed days-remaining count
#### Scenario: No active range
- GIVEN no configured range includes today
- WHEN the active-range query runs
- THEN it returns no active range
### Requirement: Per-Alarm Pause-Impact Computation
For the currently-active range, the system MUST compute which alarms are
paused (`sonarEnVacaciones == false`) and which are unaffected
(`sonarEnVacaciones == true`), producing data sufficient to render an
impact line (e.g. "Pausa 07:30 y 13:45 · 08:15 sigue").
#### Scenario: Mixed paused and unaffected alarms
- GIVEN 3 alarms exist, 2 with `sonarEnVacaciones == false` and 1 with `true`, during an active range
- WHEN the impact computation runs
- THEN it lists exactly those 2 as paused and exactly that 1 as unaffected
### Requirement: Upcoming Ranges Query
`EstadoAlarmas` MUST expose a query returning ranges that have not yet
started, ordered soonest-first.
#### Scenario: Two future ranges
- GIVEN 2 ranges with future start dates
- WHEN the upcoming-ranges query runs
- THEN both are returned, ordered by soonest start date first
### Requirement: Past-Ranges History Query
`EstadoAlarmas` MUST expose a query returning ranges whose end date has
already passed, distinct from the active and upcoming queries.
#### Scenario: A range that ended yesterday
- GIVEN a range whose end date was yesterday
- WHEN the past-ranges query runs
- THEN that range appears in the past-ranges result and in neither the active nor upcoming result
### Requirement: Vacation Summary Row Replaces the Inline Panel
The Alarmas root MUST show a summary row (range count and next-range
countdown) in place of the current full inline vacation-ranges list.
Tapping the row MUST push the dedicated Vacaciones manager screen.
#### Scenario: Tapping the summary row opens the manager screen
- GIVEN the Alarmas root shows the vacation summary row
- WHEN the user taps it
- THEN the Vacaciones manager screen opens as a second-level (push-chrome) screen with no tab bar
@@ -0,0 +1,89 @@
# App Navigation Shell Specification
## Purpose
The 5-root tab contract, the push-chrome rule for second-level screens, the
explicit Favoritos exemption from that rule, and root-to-root switching
(Escuchar "Ver todas" to Favoritos). Traceable to WU1.
## Requirements
### Requirement: Five-Tab Root Navigation
The app MUST render exactly 5 root tabs, in order: Escuchar, Buscar,
Favoritos, Alarmas, Ajustes, using the existing `PluriIconGlyph.*` icon set.
`PluriIconGlyph.home` MUST be the Escuchar tab's icon.
#### Scenario: App launch renders the 5 tabs in order
- GIVEN the app has finished cold-start
- WHEN the bottom navigation renders
- THEN exactly 5 tabs appear, labeled Escuchar, Buscar, Favoritos, Alarmas, Ajustes, in that order
#### Scenario: Escuchar uses the home glyph
- GIVEN the bottom navigation is rendered
- WHEN the Escuchar tab's icon is inspected
- THEN it is `PluriIconGlyph.home`
### Requirement: Push-Chrome on Second-Level Screens
Every genuine second-level screen (Reproductor completo, Países, Vacaciones,
Ecualizador, Grabaciones, and every new Settings detail screen) MUST render
a 56px header with a back affordance and MUST NOT render a bottom tab bar.
#### Scenario: Reproductor completo hides the tab bar
- GIVEN the user opens Reproductor completo from Escuchar
- WHEN the screen renders
- THEN it shows a 56px header with a back arrow and no bottom tab bar
#### Scenario: A Settings detail screen hides the tab bar
- GIVEN the user pushes any Settings detail screen from the Ajustes root
- WHEN the screen renders
- THEN it shows a 56px header with a back arrow and no bottom tab bar
### Requirement: Favoritos Tab-Bar Exemption
Favoritos MUST keep its bottom tab bar. This is the one deliberate exception
to the push-chrome rule: the source mockup draws this content as a pushed
screen only because its own 4-root IA had demoted Favoritos out of the root
set. In this app's 5-tab IA, Favoritos is root-level and MUST render the tab
bar like every other root.
#### Scenario: Favoritos renders with its tab bar
- GIVEN the user is on the Favoritos tab
- WHEN the screen renders
- THEN the bottom tab bar is visible, unlike every true second-level screen
### Requirement: Root-to-Root Switching Without Push
Navigating from Escuchar's "Tus emisoras" section to the full favorites list
MUST switch the active tab index to Favoritos. It MUST NOT push a new route.
#### Scenario: "Ver todas" switches tabs, not routes
- GIVEN the user is on Escuchar with 1 or more favorite stations
- WHEN they tap "Ver todas"
- THEN the active tab index becomes Favoritos
- AND no new route is pushed onto the navigation stack (back from Favoritos returns to whatever screen preceded Escuchar, not to Escuchar itself)
### Requirement: Escuchar Tab Rename Preserves the ARB Key
The former "Inicio" tab MUST be relabeled "Escuchar" (English: "Listen") by
changing the `navHome` key's VALUE, not by introducing a new key. The key
`navHome` MUST continue to exist, unchanged, in all 13 ARB locales.
#### Scenario: English and Spanish values are updated
- GIVEN `lib/l10n/app_en.arb` and `lib/l10n/app_es.arb`
- WHEN the `navHome` key is read
- THEN `app_en.arb` returns `"Listen"` and `app_es.arb` returns `"Escuchar"`
#### Scenario: Key parity across all 13 locales (edge case)
- GIVEN all 13 ARB files
- WHEN each is checked for the `navHome` key
- THEN every file still contains the key `navHome` (value content may differ), and no file is missing it
@@ -0,0 +1,104 @@
# EQ Custom Presets Specification
## Purpose
User-defined preset save/name/list on top of the existing 6 fixed presets,
a per-station EQ entry point from the player's tool tray, and a
"stations with their own EQ" drill-down. **Band count stays 5** — resolved
by spike (Engram id 2498), not a pending decision. Traceable to WU13
(Settings screen) and WU14 (player tool tray, shares WU13's editor
component).
## Requirements
### Requirement: Five-Band Equalizer (Regression Guard, First-Class)
The equalizer screen MUST render exactly **5** vertical sliders.
`assert(bandas.length == 5)` in `preset_ecualizador.dart` MUST remain
unchanged. Band count is device-reported via `just_audio`'s
`AndroidEqualizer` (`params.bands`), not app-chosen; any requirement or
implementation asserting 7 bands is rejected on sight.
#### Scenario: Ecualizador screen renders 5 sliders
- GIVEN the user opens the Ecualizador settings screen
- WHEN it renders
- THEN exactly 5 vertical sliders are shown, one per band
#### Scenario: Band-count regression is rejected (edge case)
- GIVEN a future change proposes 7 sliders or a modified `bandas.length` assert
- WHEN that change is reviewed against this requirement
- THEN it fails: 5 is the resolved, binding band count for this change
### Requirement: Custom Preset Save
The user MUST be able to save the current 5-band configuration as a new,
named custom preset, which then appears alongside the 6 fixed presets in
the preset chip row.
#### Scenario: Saving a custom preset
- GIVEN the user has adjusted the 5 sliders from a fixed preset
- WHEN they choose "Guardar como preset" and enter the name "Mi preset"
- THEN a new preset named "Mi preset" is persisted
- AND it appears in the preset chip row on next render
### Requirement: Custom Preset Naming Validates Non-Empty Input
The system MUST reject an empty or whitespace-only preset name without
crashing, and MUST NOT persist a preset in that case.
#### Scenario: Empty name is rejected
- GIVEN the "Guardar como preset" flow is open
- WHEN the user submits an empty name
- THEN a validation message is shown and no preset is saved
### Requirement: Per-Station EQ Entry Point From the Player
The player's tool tray MUST expose an "EQ propio" tile that opens a
per-station EQ bottom sheet. That sheet MUST reuse the same 5-band editor
component used by the Settings Ecualizador screen (not a separate
implementation).
#### Scenario: Opening per-station EQ from the player
- GIVEN a station is currently playing
- WHEN the user taps "EQ propio" in the tool tray
- THEN a bottom sheet opens with 5 sliders bound to that station's resolved preset
- AND changes made there apply through the existing per-station EQ persistence path
### Requirement: Stations-With-Own-EQ Drill-Down
The Settings Ecualizador screen MUST expose a row that navigates to a list
of stations that currently have a station-specific preset override,
sourced from the existing `presetsPorEmisora` map.
#### Scenario: Drill-down lists exactly the overridden stations
- GIVEN 2 stations have entries in `presetsPorEmisora` and N others do not
- WHEN the user opens the drill-down
- THEN exactly those 2 stations are listed
### Requirement: Base-vs-Per-Station Explainer Preserved
An info banner distinguishing the base (global/device) EQ from per-station
overrides MUST remain visible on the Ecualizador screen.
#### Scenario: Banner is visible on screen open
- GIVEN the user opens the Ecualizador screen
- WHEN it renders
- THEN the base-vs-per-station explainer banner is visible
### Requirement: Active Output Surfaced on the Main Screen
The "Salida activa" (active output device) row MUST be visible on the main
Ecualizador screen, not only in the Advanced sub-section.
#### Scenario: Output row updates on device change
- GIVEN the Ecualizador screen is open
- WHEN the active audio output device changes (e.g. Bluetooth connects)
- THEN the "Salida activa" row updates to reflect the new device
@@ -0,0 +1,75 @@
# Favorites Organization Specification
## Purpose
Chip-filtered flat list replacing stacked per-group panels, drag-to-reorder,
the sort action, and group management surfaced from Favoritos. Traceable to
WU4.
## Requirements
### Requirement: Chip-Filtered Flat List
Favoritos MUST render horizontally-scrollable group filter chips (e.g.
"Todas · N", one per favorite group) and a single flat list of favorite
stations filtered by the active chip, replacing the current stacked
per-group panel layout.
#### Scenario: Selecting a group chip filters the list
- GIVEN 2 or more favorite groups exist, each with members
- WHEN the user selects a specific group's chip
- THEN the flat list shows only that group's member stations
#### Scenario: "Todas" chip shows every favorite
- GIVEN the user has favorites across multiple groups
- WHEN the "Todas" chip is active
- THEN the flat list shows every favorite station regardless of group
### Requirement: Drag-to-Reorder Within the Active Filter
The user MUST be able to reorder favorite stations within the currently
active filter via a drag handle. The new order MUST persist across app
restarts.
#### Scenario: Reordering persists
- GIVEN a filtered list of 3+ favorites
- WHEN the user drags the 3rd item to the 1st position
- THEN the list reflects the new order immediately
- AND reopening the app shows the same order
### Requirement: Sort Action Using Existing Criteria
Favoritos MUST expose a sort action (`swap_vert`) using the existing
`OrdenEmisoras` criteria (name, quality). It MUST NOT introduce a sort
criterion with no backing implementation.
#### Scenario: Sort by name reorders alphabetically
- GIVEN the active filter shows 3+ favorites in a non-alphabetical order
- WHEN the user triggers sort-by-name
- THEN the list re-renders in alphabetical order
### Requirement: Group Management Reachable from Favoritos
Creating and managing favorite groups MUST be reachable directly from the
Favoritos screen, in addition to its existing entry point in Settings.
#### Scenario: Creating a group from Favoritos
- GIVEN the user is on the Favoritos screen
- WHEN they tap the create-group action
- THEN the group-creation flow opens and a newly created group appears as a new filter chip
### Requirement: Custom-Station CTA Preserved
The dashed "Añadir emisora personalizada" call-to-action MUST remain
reachable from Favoritos.
#### Scenario: Custom station CTA opens the add flow
- GIVEN the user is on Favoritos, any filter active
- WHEN they tap "Añadir emisora personalizada"
- THEN the custom-station add flow opens
@@ -0,0 +1,31 @@
# Delta for multi-device-eq
Existing spec: `openspec/specs/multi-device-eq/spec.md` (device-event
resolve-and-apply, first-seen bootstrap, cold start, connect-disconnect-
reconnect cycle, toggle-off behavior). None of that resolution logic
changes. This delta only ADDS a requirement about where the per-station
editing surface is reached from, and pins that the relocation has zero
effect on resolution. Traceable to WU14 (shares WU13's editor component).
## ADDED Requirements
### Requirement: Per-Station EQ Entry Relocates, Resolution Logic Does Not
The per-station EQ editing surface MUST be reachable from the player's
tool tray (see `eq-custom-presets`). This relocation MUST NOT alter the
existing 4-level resolution hierarchy (matrix -> station -> device ->
global) or any of its resolve-and-apply, first-seen-bootstrap, cold-start,
or connect/disconnect/reconnect behavior.
#### Scenario: Editing per-station EQ from the player uses the existing hierarchy
- GIVEN a station has an existing entry in `presetsMatriz` or `presetsPorEmisora`
- WHEN the user edits it via the player's per-station EQ sheet
- THEN the change persists through the same resolution path already covered by `multi-device-eq`'s existing tests
- AND device-change resolve-and-apply behavior for that station is unaffected
#### Scenario: Existing multi-device-eq test suite is unaffected (regression guard)
- GIVEN the existing `multi-device-eq` scenarios (device-event resolve-and-apply, first-seen bootstrap, cold start, connect-disconnect-reconnect, toggle-off)
- WHEN the per-station entry point relocates to the player
- THEN every one of those scenarios continues to pass unmodified
@@ -0,0 +1,93 @@
# Delta for native-alarms
Existing spec: `openspec/specs/native-alarms/spec.md` (native foreground
service, sole ring-audio ownership, fade-in curve, focus/no-volume-writes,
notification channel migration, "ring screen is pure UI"). None of that
native/audio behavior changes in this redesign — it is a visual restyle of
the Alarmas root, the alarm card, and the ringing screen. This delta ADDS
requirements for card affordances, the hero skip action, the editor's
preserved advanced fields, and the ringing screen's static status label.
Traceable to WU8 (card + hero), WU10 (editor), WU11 (ringing screen).
## ADDED Requirements
### Requirement: Alarm Card Tap-to-Edit, Swipe-to-Delete
The simplified alarm card (giant time + station + switch, no always-visible
action row) MUST preserve edit and delete capability: tapping the card
MUST open the editor for that alarm; swiping it MUST trigger delete
(with confirmation). No capability present in the current always-visible
button row may be lost.
#### Scenario: Tap opens the editor
- GIVEN an alarm card is visible on the Alarmas root
- WHEN the user taps it
- THEN the editor sheet opens pre-filled with that alarm's data
#### Scenario: Swipe deletes with confirmation
- GIVEN an alarm card is visible
- WHEN the user swipes it and confirms
- THEN that alarm is deleted and its card is removed from the list
### Requirement: Hero Banner Inline Skip
The Alarmas root's hero banner MUST include an inline "Saltar" action that
skips the next occurrence of the featured (soonest-firing) alarm, in
addition to any per-card skip action.
#### Scenario: Tapping "Saltar" on the hero banner skips the featured alarm
- GIVEN the hero banner shows the soonest-firing alarm
- WHEN the user taps its inline "Saltar" action
- THEN that alarm's next occurrence is marked skipped, consistent with the existing skip-next behavior
### Requirement: Alarm Editor Preserves Date, Fallback Station, and Sound Fields
The rewritten editor (giant inline HH:MM display, always-visible weekday
circles) MUST NOT drop the existing one-time date field, the
fallback-station picker, or the sound dropdown. These MUST remain
reachable, e.g. under a collapsed "Advanced" section.
#### Scenario: One-time date alarm is still creatable
- GIVEN the user is creating a new alarm
- WHEN they choose a specific one-time date instead of a weekly recurrence
- THEN the date field is reachable and the saved alarm reflects that specific date, not a weekday pattern
#### Scenario: Fallback station and sound remain settable
- GIVEN the editor's Advanced section
- WHEN the user sets a fallback station and a sound choice
- THEN both persist and are reflected the next time the alarm is edited
### Requirement: Ringing Screen Shows a Static Status Label (No Live Countdown)
The ringing screen's "Subiendo volumen" status MUST render as a static
label with no live seconds counter. Adding a live counter is out of scope:
it would require native-to-Flutter progress plumbing that is deliberately
absent from the architecture.
#### Scenario: Status label has no numeric countdown
- GIVEN an alarm is ringing and fading in
- WHEN the status label renders
- THEN it shows "Subiendo volumen" (or equivalent localized text) with no accompanying seconds value that changes over time
### Requirement: Dismiss Guard and Force-Stop Banner Survive the Restyle (First-Class Regression Guard)
The ringing screen's single-exit dismiss guard and force-stop retry banner
MUST be preserved unmodified by the visual restyle.
#### Scenario: Dismiss guard test passes unmodified
- GIVEN `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart`
- WHEN the ringing screen restyle lands
- THEN that test file passes without being modified
#### Scenario: Force-stop retry banner still appears on failure
- GIVEN a stop action fails to actually silence the ring
- WHEN the failure is detected
- THEN the force-stop retry banner is shown, in whatever position the restyled layout places it
@@ -0,0 +1,47 @@
# Onboarding Welcome Specification
## Purpose
A first-run welcome surface in the new visual language, explicitly
monetization-free. Traceable to WU17.
## Requirements
### Requirement: Full-Screen Welcome Route
The welcome screen MUST render as a full-screen route (not a modal
dialog), containing: logo mark, headline, body copy, exactly 3 feature
bullets, and exactly one primary call-to-action ("Empezar a escuchar").
#### Scenario: All elements render, one CTA
- GIVEN the app launches for the first time
- WHEN the welcome screen renders
- THEN a logo, headline, body copy, and 3 feature bullets are visible
- AND exactly 1 primary CTA is present
### Requirement: No Monetization Content (First-Class, Binding)
The welcome screen MUST NOT render a PRO pill, any trial-duration text
(e.g. a day count), any price, or a secondary "free version" link. This
follows the binding no-monetization decision for this change.
#### Scenario: Rendered content contains no monetization strings
- GIVEN the welcome screen is rendered
- WHEN its widget tree's text content is scanned
- THEN it contains none of: the substring "PRO", a currency amount, or a day-count trial phrase
- AND no secondary "free version" link is present
### Requirement: CTA Dismisses to Escuchar
Tapping the primary CTA MUST dismiss the welcome screen and land on the
Escuchar tab (index 0), with the welcome route removed from the
navigation stack.
#### Scenario: CTA navigates to Escuchar
- GIVEN the welcome screen is showing
- WHEN the user taps "Empezar a escuchar"
- THEN the app shows the Escuchar tab
- AND the welcome route is no longer on the back stack
@@ -0,0 +1,76 @@
# Recordings Library Specification
## Purpose
A browsable recordings list with storage usage and per-recording
rename/share/delete, constrained to what `servicio_grabacion_radio.dart`
already exposes. Traceable to WU15.
## Requirements
### Requirement: Storage Usage Display
The screen MUST show used vs. total storage as a progress bar with a
caption (used amount, total amount, retention/path info).
#### Scenario: Storage usage renders proportionally
- GIVEN recordings occupy 84 MB of a 200 MB budget
- WHEN the screen renders
- THEN the progress bar fill reflects 84/200 and the caption states both values
### Requirement: Browsable Recordings List
The screen MUST list individual recordings sourced from the recordings
folder, each row showing name, date, duration, and size, with inline
playback.
#### Scenario: Recordings on disk render as rows
- GIVEN 3 recording files exist in the recordings folder
- WHEN the screen loads
- THEN exactly 3 rows render, each with correct name/date/duration/size
#### Scenario: No recordings (edge case)
- GIVEN the recordings folder is empty
- WHEN the screen loads
- THEN an empty state renders, not an error
#### Scenario: Row playback starts and stops
- GIVEN a recording row is visible
- WHEN the user taps its play control
- THEN playback of that recording starts
- AND tapping again stops it
### Requirement: Per-Recording Menu Constrained to Rename/Share/Delete
The "⋮" menu on each recording row MUST expose exactly three actions —
Rename, Share, Delete — constrained to capabilities already exposed by
`servicio_grabacion_radio.dart`. It MUST NOT expose an action that service
does not support.
#### Scenario: Menu shows exactly 3 actions
- GIVEN any recording row
- WHEN its "⋮" menu opens
- THEN exactly Rename, Share, and Delete are shown, no other action
#### Scenario: Delete removes the file and its row
- GIVEN a recording row's menu is open
- WHEN the user selects Delete and confirms
- THEN the underlying file is removed and the row disappears from the list
#### Scenario: Rename updates the displayed name
- GIVEN a recording row's menu is open
- WHEN the user selects Rename and submits a new, non-empty name
- THEN the row displays the new name and it persists across a reload
#### Scenario: Share invokes the platform share sheet
- GIVEN a recording row's menu is open
- WHEN the user selects Share
- THEN the platform share sheet opens with that recording's file
@@ -0,0 +1,139 @@
# Station Discovery & Browse Specification
## Purpose
Discovery content as the Buscar tab's landing state, active-filter pills,
results counter, clear-all-filters, the Países browser over the verified
`/json/countries` contract, and a client-side search-sort control. Traceable
to WU5 (relocation), WU6 (Buscar), WU7 (Países + `_get` transport
extraction).
## Requirements
### Requirement: Buscar Landing State Shows Discovery Content
Buscar MUST render the existing discovery content (near-you, genres,
trending, countries entry point) as its landing state whenever no search
query is entered. Entering a query MUST replace that landing state with
search results.
#### Scenario: Empty query shows discovery content
- GIVEN the user opens the Buscar tab with no prior query
- WHEN the screen renders
- THEN near-you, genre, trending, and a Países entry point are visible
#### Scenario: Entering a query replaces the landing state
- GIVEN the Buscar tab is showing discovery content
- WHEN the user types a non-empty query
- THEN discovery content is replaced by the search-results view
### Requirement: Active-Filter Pills and Results Counter
The search-results view MUST show each active filter (country, language,
minimum quality) as a removable pill, and MUST show a results counter
("N RESULTADOS").
#### Scenario: Applying a filter shows a removable pill
- GIVEN the user has an active search
- WHEN they apply a country filter
- THEN a pill labeled with that country and a close (x) affordance appears
- AND the results counter reflects the filtered count
#### Scenario: Removing a pill re-runs the search without that filter
- GIVEN a country filter pill is active
- WHEN the user taps its close affordance
- THEN the filter is cleared and results/count update accordingly
### Requirement: One-Tap Clear-All-Filters on Empty Results
When 1 or more filters are active and the resulting search has zero results,
the system MUST offer a single action that clears every active filter at
once ("Quitar los N filtros").
#### Scenario: Two active filters, zero results
- GIVEN country and quality filters are both active and yield 0 results
- WHEN the empty state renders
- THEN a "Quitar los 2 filtros" action is shown
- AND tapping it clears both filters in one action, not one at a time
### Requirement: Países Browser Over the Verified Countries Contract
The Países screen (new second-level screen) MUST fetch from
**`/json/countries`** (optional substring filter `/json/countries/{filter}`)
and MUST NOT use `/json/countrycodes`, which is deprecated. It MUST parse
the `stationcount` field as a **string** via `int.tryParse`, never as a
direct `int` cast. Its request MUST NOT include `lastcheckok`, since that
parameter is meaningful only for station-listing calls.
Source: Engram `reference/radio-browser-countries-endpoint` (id 2500),
verified against <https://api.radio-browser.info/>.
#### Scenario: Countries list renders with counts
- GIVEN `/json/countries` returns a list of country objects
- WHEN the Países screen loads
- THEN "Tus idiomas" and the full alphabetical list render with each country's parsed station count
#### Scenario: `stationcount` arrives as a JSON string (edge case, critical)
- GIVEN a country entry has `"stationcount": "482"` (a JSON string, not a number)
- WHEN the screen parses and renders that entry
- THEN it displays `482` without throwing a type-cast error
#### Scenario: Countries request omits the station-only filter
- GIVEN the Países screen issues its `/json/countries` request
- WHEN the outgoing request is inspected
- THEN it does NOT include `lastcheckok`
### Requirement: Client-Side Search Sort Only
Buscar MUST expose an "Ordenar" control backed exclusively by client-side
ordering (`OrdenEmisoras`). The system MUST NOT adopt the Radio Browser
server-side `order`/`reverse` parameters for this control, because
server-side ordering sorts the full result set before `limit` is applied
while the app's client-side sort only reorders the page already fetched —
adopting it would silently change which stations users see, not just their
order. Every sort option rendered in the UI MUST map to a real
`OrdenEmisoras` case; the system MUST NOT render an option that does not
actually sort.
Source: Engram `reference/radio-browser-sort-order` (id 2505), verified
against <https://api.radio-browser.info/>.
#### Scenario: Selecting a sort option reorders the current page only
- GIVEN a set of search results is already fetched
- WHEN the user selects "Ordenar: calidad"
- THEN the currently-displayed page is reordered via `OrdenEmisoras.calidad`
- AND no new network request with an `order` parameter is issued
#### Scenario: Every rendered sort option is backed by a real case (regression guard)
- GIVEN the "Ordenar" control's rendered option list
- WHEN each option is checked against `OrdenEmisoras`
- THEN every option maps to an existing enum case with test coverage; no decorative option that does not sort is rendered
### Requirement: Existing Station Calls Unchanged by Transport Extraction
Extracting `_get`'s transport loop (server discovery, host rotation,
bounded retries, User-Agent, timeout, status check, `json.decode`) into a
shared helper for the Países call MUST NOT change the observable behavior
of the 8 existing `ServicioRadio` station methods: `obtenerPopulares`,
`obtenerTendencias`, `buscarPorNombre`, `buscarPorPais`, `buscarPorIdioma`,
`buscarPorTag`, `buscar`, `registrarClick`.
#### Scenario: Characterization tests pass unchanged before and after extraction
- GIVEN characterization tests exist for all 8 station methods
- WHEN the transport loop is extracted from `_get`
- THEN all 8 tests pass identically before and after the extraction
#### Scenario: Station calls still send `lastcheckok`
- GIVEN any of the 8 station methods issues a request
- WHEN the outgoing request is inspected
- THEN it still includes `lastcheckok=1`, unchanged from before the extraction
+1 -1
View File
@@ -1,7 +1,7 @@
name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none'
version: 1.1.16+121
version: 1.2.0+122
environment:
sdk: ^3.7.0
@@ -0,0 +1,22 @@
# CODING AGENTS: READ THIS FIRST
This is a **handoff bundle** from Claude Design (claude.ai/design).
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
## What you should do — IMPORTANT
**Read `redise-o-funcional-de-aplicaci-n/project/PluriWave Rediseno.dc.html` in full.** The user had this file open when they triggered the handoff, so it's almost certainly the primary design they want built. Read it top to bottom — don't skim. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
## About the design files
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
## Bundle contents
- `redise-o-funcional-de-aplicaci-n/README.md` — this file
- `redise-o-funcional-de-aplicaci-n/project/` — the `Rediseño funcional de aplicación` project files (HTML prototypes, assets, components)
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

File diff suppressed because it is too large Load Diff
+43
View File
@@ -1543,6 +1543,31 @@ void main() {
// builtin_speaker id collision + device removal
// ---------------------------------------------------------------------------
group('EstadoEcualizador — cambiarActivo bajo toques rápidos', () {
test('el motor acaba en el estado que muestra la UI', () async {
// cambiarActivo persists BEFORE telling the engine, so two quick taps
// race on a disk write: if the first write resolves last, the engine
// receives the FIRST tap's value after the second one — the checkbox
// says on while the sound is off.
final servicio = _FakeEcualizadorGuardadoLento();
final audio = FakeServicioAudio();
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
await eq.cargarPersistido();
audio.cambiosEcualizadorActivo.clear();
final primero = eq.cambiarActivo(false);
final segundo = eq.cambiarActivo(true);
await Future.wait([primero, segundo]);
expect(eq.activo, isTrue);
expect(
audio.cambiosEcualizadorActivo.last,
isTrue,
reason: 'the engine must end matching the state the UI shows',
);
});
});
group('EstadoEcualizador — bonded Bluetooth names', () {
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
@@ -1704,6 +1729,24 @@ void main() {
});
}
/// Fake whose [guardarActivo] stays pending until released, and releases the
/// pending writes in REVERSE order — reproducing a disk write that resolves
/// out of order between two quick taps.
class _FakeEcualizadorGuardadoLento extends FakeServicioEcualizador {
int _llamadas = 0;
@override
Future<void> guardarActivo(bool activo) async {
// The FIRST write is the slow one: that is the ordering hazard, since an
// unserialized second tap overtakes it and the slow write's engine call
// lands last.
await Future<void>.delayed(
_llamadas++ == 0 ? const Duration(milliseconds: 20) : Duration.zero,
);
await super.guardarActivo(activo);
}
}
/// Fake whose [resubscribir] stays pending until [completarResubscribir]
/// runs — creates the overlap window for the in-flight-guard test above.
class _FakeDispositivoAudioResubscribirLento