Files
pluriwave/openspec/changes/rediseno-funcional/design.md
T

46 KiB
Raw Blame History

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 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 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.

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 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).

  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:
    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 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.

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.presetPrincipalcambiarPresetPrincipal(...)
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.dartviolates 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.

  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.

enum RaizPluriWave { escuchar, buscar, favoritos, alarmas, ajustes }

class EstadoNavegacionRaiz extends ChangeNotifier {
  RaizPluriWave _actual = RaizPluriWave.escuchar;
  RaizPluriWave get actual => _actual;
  int get indice => _actual.index;
  void irA(RaizPluriWave raiz) {
    if (raiz == _actual) return;
    _actual = raiz;
    notifyListeners();
  }
}

Reasoning.

  • Provider + ChangeNotifier is the codebase's only state mechanism (six providers in app.dart:47-73). A seventh is idiomatic; anything else is a second parallel mechanism for the same job.
  • The need already exists. _PaginaPrincipalState already does setState(() => _indice = 3) from async alarm handlers at app.dart:310, 333 and 350. Those become irA(RaizPluriWave.alarmas) — more readable, and the alarm-deep-link behaviour becomes unit-testable without pumping the whole app.
  • The enum kills the magic numbers. _indice = 3 means "Alarmas" today only by counting. Declaration order is the tab order, and becomes the single source shared by _paginas and _navItems.
  • _paginas stays const.
  • It makes the success criterion mechanical: a test calls irA(RaizPluriWave.favoritos), asserts Favoritos rendered and that Navigator depth did not change — i.e. "switches tab without pushing a route".

Rejected alternatives.

Option Why rejected
Pass an onVerTodas callback into PantallaInicio Forces _paginas to stop being const, and only solves one link. The alarm handlers need the same capability, and a third consumer is likely. Prop-drilling grows a parameter per link.
InheritedWidget / InheritedNotifier scope A second state-propagation mechanism beside Provider — which is itself built on InheritedWidget. No gain.
GlobalKey<_PaginaPrincipalState> or a static Mutating another State's private field from anywhere is the classic "setState called after dispose". Untestable.
go_router / declarative routing The app has exactly two Navigator.push call sites (app.dart:387 and PantallaReproductor.abrir). Introducing a routing framework to solve a tab switch is a rewrite disguised as a fix. It is the right move if deep links multiply — its own change.

Sequencing refinement. Ship EstadoNavegacionRaiz in WU1, not WU5. WU1 already edits app.dart's nav wiring for the Escuchar rename; landing the notifier later means editing the same lines twice and rebasing WU5 over WU1's churn. WU5 then only consumes irA(RaizPluriWave.favoritos) from "Ver todas".

Blast radius. New lib/estado/estado_navegacion.dart, app.dart (+1 provider, _indice → watch, 3 setState sites converted), pantalla_inicio.dart (WU5, one call). Tests: test/widget_test.dart nav assertions, new test/estado/estado_navegacion_test.dart. Owner: WU1 (consumed by WU5).


Component inventory

New

Component File WU
PluriWaveTypography (theme extension) lib/tema/pluriwave_typography.dart WU1
PluriPushScaffold lib/widgets/pluri_push_scaffold.dart WU1
EstadoNavegacionRaiz + RaizPluriWave lib/estado/estado_navegacion.dart WU1
FilaAjuste / GrupoAjustes (nav row + group card) lib/pantallas/ajustes/widgets/fila_ajuste.dart WU3a
12 settings detail screens lib/pantallas/ajustes/*.dart WU3a / WU3b
PaisRadio lib/modelos/pais_radio.dart WU7
PantallaPaises lib/pantallas/pantalla_paises.dart WU7
ImpactoVacaciones lib/modelos/alarma_musical.dart (beside RangoVacaciones) WU9
PantallaVacaciones lib/pantallas/pantalla_vacaciones.dart WU9
Inline giant time editor lib/widgets/editor_hora_inline.dart WU10
ServicioPresetsPersonalizados lib/servicios/servicio_presets_personalizados.dart WU13
PantallaGrabaciones (library) lib/pantallas/pantalla_grabaciones.dart WU15
PantallaBienvenida lib/pantallas/pantalla_bienvenida.dart WU17

Reused unchanged

VisualizadorAudio (already parameterised: barras/color/altura) · PluriWaveScaffold · PluriGlassSurface · PluriLayout (one added constant) · PluriEmptyState · PluriStatusPill · TarjetaEmisora · TarjetaEmisoraShimmer · PluriBottomNavigation · PluriIcon / PluriIconGlyph · PresetsEcualizadorWidget · PresetEcualizador · ServicioEcualizador · ServicioAudio band block · navegacion_auto.dart.

Reused with a restyle

EcualizadorWidget (5 sliders, +habilitado) · MiniReproductor (+visible, +altura) · PluriScreenHeader (root heroes only — never the pushed header) · PluriWaveTokens (+3 colours).


State-management ruling

Consistent with the existing Provider / ChangeNotifier architecture. No new mechanism.

Rule Detail
One new provider, total EstadoNavegacionRaiz. Every other capability extends an existing notifier.
Respect notifier ownership EstadoRadio constructs and disposes EstadoEcualizador / EstadoGrabacion / EstadoBusqueda; app.dart:60-68 exposes them via ListenableProvider with no dispose callback. Never wrap these in a ChangeNotifierProvider.
Reads context.select in hero/list widgets (rebuild scoping is already load-bearing — see MemoLista); context.read inside callbacks; Consumer only when the whole notifier is genuinely needed.
State is for ephemeral UI only Text controllers, expansion flags, drag offsets. No domain state in State, ever (see ADR-7).
Pushed routes inherit providers MultiProvider wraps MaterialApp, so no state is passed through constructors (ADR-3).
New queries are pure ADR-6's additions read; they do not schedule, persist or notify.

Test strategy per work unit

Runner: flutter test. flutter analyze and a scoped dart format gate every commit — never bare dart format ., which reformats 27 unrelated pre-existing files on this machine and would break the empty-git diff guards on navegacion_auto.dart, servicio_ecualizador.dart and servicio_audio.dart (Engram reference/dart-format-scope-hazard, id 2511). flutter build is never run. Strict TDD is ON: tests first, red before green, for every unit — with the single documented exception in WU7 step (a).

WU Tests written first Must pass unmodified
WU1 New: token presence + lerp + extension retrieval; PluriPushScaffold renders one 56 px AppBar + back affordance and exposes no bottom nav; the five roots build zero 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:

  • 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.