46 KiB
Design: Functional Redesign (rediseno-funcional)
Reads:
openspec/changes/rediseno-funcional/proposal.md(authoritative), Engramsdd/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:
- Tokens become an API, not a convention. A second
ThemeExtensionholds the named type scale so screens stop copy-pasting.copyWith(fontWeight: w900, letterSpacing: …). Colour tokens join the existingPluriWaveTokens. - 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. APluriPushScaffoldprimitive with nobottomNavigationBarparameter makes the violation unrepresentable. - Every new capability extends an existing
ChangeNotifier. The change adds exactly one new provider (EstadoNavegacionRaiz). Everything else is new methods onEstadoRadio/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 ThemeExtensions.
| 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
PluriWaveTokensbecause they must participate inlerpand 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.
- De-literalise the theme.
pluriwave_theme.dart:12hardcodesColor(0xFF7EE4C2)and line 15 hardcodesColor(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 rawColor(0x…)literal" becomes enforceable rather than aspirational — the token definitions stay the only place literals live (already documented atpluriwave_tokens.dart:45-47). eyebrowLabelnever appliestoUpperCase(). 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.
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.
titleis aString, not aWidget, precisely soscreenTitleis applied once inside the primitive instead of copy-pasted across 15 screens.titleOverrideexists for exactly one known consumer — the full player's centeredEN DIRECTOpill — and is documented as such. A second consumer means this ADR is revisited, not that the parameter is quietly generalised.leadingIconexists becausepantalla_reproductor.dartdismisses withkeyboard_arrow_down, not a back arrow. It is still one pushed route with one back affordance; only the glyph differs.static pushcentralises 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 Scaffolds 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).
- Cut
_SeccionXinto its new file. Rename to_CuerpoX. Do not touch anything below the panel header row. - Delete only the panel header (icon + title
Row) — the 56 pxPluriPushScaffoldheader now carries it. - Add the public wrapper:
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.
// 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
// 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.
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
orderparameter sent. Not because the default is convenient, but because the screen sorts client-side by localized name anyway: the API orders by rawnamebyte 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 (_getalready drops stations with emptyuuid/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:
- the request path;
lastcheckok=1present;hidebroken=truepresent;- a non-empty
User-Agentheader; - the exact
order/reverse/limit/offsetthis method sends; - result ordering — the assertion that makes the extraction safe. Feed a fixture with deliberately shuffled
bitrate/clickcount/votesand 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.
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:
EcualizadorWidgetreads noProviderand 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 readingparams.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.
// 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);
// 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.
rangoVacacionesActivodelegates to the existingRangoVacaciones.contiene(fecha)(alarma_musical.dart:243), which already handles theactivoflag and day granularity. Do not reimplement date math — a second implementation is a second set of off-by-one bugs.impactoDeRango's predicate mirrors the scheduler exactly. The scheduler pauses when!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)(servicio_programacion_alarmas.dart:150). Sopausadasisalarmas.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.
- The hero is a
StatelessWidget. NoState, no cachedEmisora, no localbool _reproduciendo. - Reads use
context.select<EstadoRadio, T>per scalar, neverConsumer<EstadoRadio>over the whole notifier.EstadoRadionotifies on audio buffer events; the codebase already fights this (MemoLista,orden_emisoras.dart:24-30, exists for exactly this reason, andpantalla_inicio_rebuild_test.dartpins rebuild scoping). AConsumerhere would rebuild the hero many times per second. - Transport actions call the same
EstadoRadiomethods the full player calls. No new playback methods. - The waveform is reused as-is.
VisualizadorAudiois already fully parameterised —barras,color,altura(visualizador_audio.dart:18-30). Escuchar passesbarras: 30,altura: 26,color: liveGreen; the full player keeps its own values. Zero change tovisualizador_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.
MiniReproductorcallscontext.read<EstadoRadio>().configurarLocalizaciones(...)indidChangeDependencies(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: passvisible: falseand returnSizedBox.shrink()frombuild, keeping theStatemounted.
_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.
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 +
ChangeNotifieris the codebase's only state mechanism (six providers inapp.dart:47-73). A seventh is idiomatic; anything else is a second parallel mechanism for the same job. - The need already exists.
_PaginaPrincipalStatealready doessetState(() => _indice = 3)from async alarm handlers atapp.dart:310,333and350. Those becomeirA(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 = 3means "Alarmas" today only by counting. Declaration order is the tab order, and becomes the single source shared by_paginasand_navItems. _paginasstaysconst.- It makes the success criterion mechanical: a test calls
irA(RaizPluriWave.favoritos), asserts Favoritos rendered and thatNavigatordepth did not change — i.e. "switches tab without pushing a route".
Rejected alternatives.
| Option | Why rejected |
|---|---|
Pass an onVerTodas callback into PantallaInicio |
Forces _paginas to stop being const, and only solves one link. The alarm handlers need the same capability, and a third consumer is likely. Prop-drilling grows a parameter per link. |
InheritedWidget / InheritedNotifier scope |
A second state-propagation mechanism beside Provider — which is itself built on InheritedWidget. No gain. |
GlobalKey<_PaginaPrincipalState> or a static |
Mutating another State's private field from anywhere is the classic "setState called after dispose". Untestable. |
go_router / declarative routing |
The app has exactly two Navigator.push call sites (app.dart:387 and PantallaReproductor.abrir). Introducing a routing framework to solve a tab switch is a rewrite disguised as a fix. It is the right move if deep links multiply — its own change. |
Sequencing refinement. Ship EstadoNavegacionRaiz in WU1, not WU5. WU1 already edits app.dart's nav wiring
for the Escuchar rename; landing the notifier later means editing the same lines twice and rebasing WU5 over WU1's
churn. WU5 then only consumes irA(RaizPluriWave.favoritos) from "Ver todas".
Blast radius. New lib/estado/estado_navegacion.dart, app.dart (+1 provider, _indice → watch, 3 setState
sites converted), pantalla_inicio.dart (WU5, one call). Tests: test/widget_test.dart nav assertions, new
test/estado/estado_navegacion_test.dart. Owner: WU1 (consumed by WU5).
Component inventory
New
| Component | File | WU |
|---|---|---|
PluriWaveTypography (theme extension) |
lib/tema/pluriwave_typography.dart |
WU1 |
PluriPushScaffold |
lib/widgets/pluri_push_scaffold.dart |
WU1 |
EstadoNavegacionRaiz + RaizPluriWave |
lib/estado/estado_navegacion.dart |
WU1 |
FilaAjuste / GrupoAjustes (nav row + group card) |
lib/pantallas/ajustes/widgets/fila_ajuste.dart |
WU3a |
| 12 settings detail screens | lib/pantallas/ajustes/*.dart |
WU3a / WU3b |
PaisRadio |
lib/modelos/pais_radio.dart |
WU7 |
PantallaPaises |
lib/pantallas/pantalla_paises.dart |
WU7 |
ImpactoVacaciones |
lib/modelos/alarma_musical.dart (beside RangoVacaciones) |
WU9 |
PantallaVacaciones |
lib/pantallas/pantalla_vacaciones.dart |
WU9 |
| Inline giant time editor | lib/widgets/editor_hora_inline.dart |
WU10 |
ServicioPresetsPersonalizados |
lib/servicios/servicio_presets_personalizados.dart |
WU13 |
PantallaGrabaciones (library) |
lib/pantallas/pantalla_grabaciones.dart |
WU15 |
PantallaBienvenida |
lib/pantallas/pantalla_bienvenida.dart |
WU17 |
Reused unchanged
VisualizadorAudio (already parameterised: barras/color/altura) · PluriWaveScaffold · PluriGlassSurface ·
PluriLayout (one added constant) · PluriEmptyState · PluriStatusPill · TarjetaEmisora ·
TarjetaEmisoraShimmer · PluriBottomNavigation · PluriIcon / PluriIconGlyph · PresetsEcualizadorWidget ·
PresetEcualizador · ServicioEcualizador · ServicioAudio band block · navegacion_auto.dart.
Reused with a restyle
EcualizadorWidget (5 sliders, +habilitado) · MiniReproductor (+visible, +altura) ·
PluriScreenHeader (root heroes only — never the pushed header) · PluriWaveTokens (+3 colours).
State-management ruling
Consistent with the existing Provider / ChangeNotifier architecture. No new mechanism.
| Rule | Detail |
|---|---|
| One new provider, total | EstadoNavegacionRaiz. Every other capability extends an existing notifier. |
| Respect notifier ownership | EstadoRadio constructs and disposes EstadoEcualizador / EstadoGrabacion / EstadoBusqueda; app.dart:60-68 exposes them via ListenableProvider with no dispose callback. Never wrap these in a ChangeNotifierProvider. |
| Reads | context.select in hero/list widgets (rebuild scoping is already load-bearing — see MemoLista); context.read inside callbacks; Consumer only when the whole notifier is genuinely needed. |
State is for ephemeral UI only |
Text controllers, expansion flags, drag offsets. No domain state in State, ever (see ADR-7). |
| Pushed routes inherit providers | MultiProvider wraps MaterialApp, so no state is passed through constructors (ADR-3). |
| New queries are pure | ADR-6's additions read; they do not schedule, persist or notify. |
Test strategy per work unit
Runner: flutter test. flutter analyze and a scoped dart format gate every commit — never bare
dart format ., which reformats 27 unrelated pre-existing files on this machine and would break the empty-git diff
guards on navegacion_auto.dart, servicio_ecualizador.dart and servicio_audio.dart
(Engram reference/dart-format-scope-hazard, id 2511). flutter build is never run.
Strict TDD is ON: tests first, red before green, for every unit — with the single documented exception in WU7 step (a).
| WU | Tests written first | Must pass unmodified |
|---|---|---|
| WU1 | New: token presence + lerp + extension retrieval; PluriPushScaffold renders one 56 px AppBar + back affordance and exposes no bottom nav; the five roots build zero Scaffolds; 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:
countrycodesappears nowhere inlib/(the deprecated endpoint, Engram 2500).- No new raw
Color(0x…)literal outsidelib/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.