Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35df016aa3 | ||
|
|
433373ad7f | ||
|
|
f58cf8739f | ||
|
|
675b7fb4b7 | ||
|
|
17f8e69529 | ||
|
|
b183b3f3e5 |
@@ -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,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)
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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
|
||||
|
||||