feat(escuchar): replace discovery browser with embedded player and favorites grid

Restructures PantallaInicio's top of screen: a new _EscucharHero
(square art, live/offline pill, VisualizadorAudio at barras: 30 /
altura: 26 / color: liveGreen, a 5-action transport row - favorite,
EQ toggle, stop, play/pause, sleep - plus a tool-tray entry chip
opening the full player) replaces the old PluriScreenHeader hero, and
a new "Tus emisoras" section (favorites preview, capped, "Ver todas")
follows it. Per design ADR-7, EstadoRadio stays the single source of
truth: the hero is a StatelessWidget with no cached fields, reading
emisoraActual via context.select (uuid-based equality scopes rebuilds
to real station changes) and the fast-changing playback status via
StreamBuilder, the same pattern _Controles/MiniReproductor already
use. The still-present discovery sections (_seccionCercanas onward,
including the old grid) are deliberately left in place - WU6
relocates them to Buscar and deletes them from here; removing them
now would leave that content nowhere until WU6 lands.

MiniReproductor gains a `visible` parameter (default true) and a
measured `static const double altura`. app.dart passes
`visible: indice != RaizPluriWave.escuchar.index`, hiding it visually
only (SizedBox.shrink()) while Escuchar is active, since the hero
already shows the same station - the State stays mounted so its
didChangeDependencies side effect (configurarLocalizaciones, S3-R3)
keeps running regardless of tab. altura was measured empirically
(72.0, via tester.getSize) rather than guessed, backing a new derived
PluriLayout.escucharBottomChromeInset constant now wired into
PantallaInicio's own bottom padding.

"Ver todas" switches roots via EstadoNavegacionRaiz.irA(favoritos),
verified via a NavigatorObserver asserting the push count is
unchanged (switches tabs, does not push).

Fixed a pre-existing test-infrastructure gap while writing the
anti-cache test: no test in this codebase had ever exercised
ServicioAudio.androidAudioSessionIdStream against a bare
FakeServicioAudio (pantalla_reproductor.dart has always read it but
has no test file at all) - the real getter needs registrarHandler()
(main.dart, production only) and threw otherwise. Added an empty
stream override to FakeServicioAudio, matching VisualizadorAudio's
own documented no-native-session fallback.

Tests: 614 -> 618 (2 skipped, unchanged). flutter analyze unchanged
at 1 pre-existing info. git diff empty for visualizador_audio.dart
and estado_radio.dart - this WU touches neither.
This commit is contained in:
2026-07-29 00:12:05 +02:00
parent 504a13641f
commit 3a803ce2bf
24 changed files with 1038 additions and 57 deletions
@@ -79,4 +79,80 @@ void main() {
);
},
);
testWidgets(
'WU5 hazard: MiniReproductor(visible: false) renders nothing, but '
'configurarLocalizaciones still runs in didChangeDependencies '
'(hiding it structurally instead would silently break the S3-R3 '
'contract)',
(tester) async {
final estado = _EstadoRadioContador();
addTearDown(estado.dispose);
// A station IS playing — if a naive implementation hid the bar only
// because emisoraActual were null, this would render the full bar
// instead of proving `visible: false` itself suppresses it.
await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A'));
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: const MaterialApp(
locale: Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: MiniReproductor(visible: false)),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Station A'), findsNothing);
expect(
estado.llamadasConfigurar,
1,
reason:
'didChangeDependencies must still run its S3-R3 side effect '
'while visually hidden — the State stays mounted, only build() '
'is short-circuited',
);
},
);
testWidgets(
'WU5 ADR-7(b): MiniReproductor.altura matches the widget\'s actual '
'laid-out height (measured, not guessed) within a small tolerance',
(tester) async {
final estado = _EstadoRadioContador();
addTearDown(estado.dispose);
await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A'));
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: const MaterialApp(
locale: Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: MiniReproductor()),
),
),
);
await tester.pumpAndSettle();
final alturaReal = tester.getSize(find.byType(MiniReproductor)).height;
// +-4px tolerance: font metrics can shift a hair across machines: the
// constant only backs a content-padding estimate (PluriLayout.
// escucharBottomChromeInset), not a pixel-perfect layout coupling.
expect(
MiniReproductor.altura,
closeTo(alturaReal, 4),
reason:
'MiniReproductor.altura must be measured from the real layout, '
'not guessed — if this fails, re-measure via '
"tester.getSize(find.byType(MiniReproductor)) and update the "
'constant',
);
},
);
}