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
+126 -2
View File
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_busqueda.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_navegacion.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
@@ -177,31 +178,145 @@ void main() {
expect(await favoritos.esFavorito(custom.uuid), isTrue);
expect(find.text('Custom Uno'), findsOneWidget);
});
testWidgets(
'WU5 ADR-7 anti-cache: the Escuchar hero reflects a station changed '
'from OUTSIDE the widget tree (e.g. Android Auto / a notification '
'action), proving it caches nothing of its own',
(tester) async {
_setLargeSurfaceSize(tester);
final audio = FakeServicioAudio();
final estado = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
final estacionA = emisoraDemo(uuid: 'a', nombre: 'Estacion A');
final estacionB = emisoraDemo(uuid: 'b', nombre: 'Estacion B');
await estado.reproducir(estacionA);
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
// Bounded pump, not _pumpStableFrame/pumpAndSettle: a "reproduciendo"
// station makes VisualizadorAudio start an indeterminately-repeating
// AnimationController (visualizador_audio.dart:77, `_controller.repeat()`)
// for its animated-fallback waveform — the same class of hazard as an
// indeterminate spinner, just via animation. pumpAndSettle() would
// never return while it keeps scheduling frames.
await _pumpBounded(tester);
expect(find.text('Estacion A'), findsOneWidget);
// Mutates the underlying ServicioAudio DIRECTLY, bypassing
// EstadoRadio.reproducir() entirely — this is exactly the shape of
// navegacion_auto.dart's out-of-band mutation (Android Auto's
// playFromMediaId). EstadoRadio's own audio.estadoStream listener
// (not this test) is what is expected to pick this up and update
// emisoraActual.
await audio.reproducir(estacionB);
await _pumpBounded(tester);
expect(find.text('Estacion B'), findsOneWidget);
expect(find.text('Estacion A'), findsNothing);
},
);
testWidgets('WU5: "Ver todas" switches to the Favoritos root via '
'EstadoNavegacionRaiz.irA, without pushing a route', (tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await favoritos.agregar(emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno'));
await estado.cargarFavoritos();
final navegacion = EstadoNavegacionRaiz();
final observer = _RecordingNavigatorObserver();
await tester.pumpWidget(
_conProviders(
estado,
_testApp(const PantallaInicio(), observers: [observer]),
navegacion: navegacion,
),
);
await _pumpStableFrame(tester);
final pushesAntesDeTocar = observer.pushCount;
await tester.ensureVisible(find.text('Ver todas'));
await _pumpStableFrame(tester);
await tester.tap(find.text('Ver todas'));
await _pumpStableFrame(tester);
expect(navegacion.actual, RaizPluriWave.favoritos);
expect(
observer.pushCount,
pushesAntesDeTocar,
reason: 'switches tabs — must NOT push a new route',
);
});
}
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
/// the providers only expose the instances (no dispose callbacks).
Widget _conProviders(EstadoRadio estado, Widget child) {
/// [navegacion] defaults to a fresh [EstadoNavegacionRaiz] — harmless to
/// include for every test, only exercised by the "Ver todas" scenarios.
Widget _conProviders(
EstadoRadio estado,
Widget child, {
EstadoNavegacionRaiz? navegacion,
}) {
return MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
value: navegacion ?? EstadoNavegacionRaiz(),
),
],
child: child,
);
}
Widget _testApp(Widget body) {
Widget _testApp(Widget body, {List<NavigatorObserver> observers = const []}) {
return MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
navigatorObservers: observers,
home: Scaffold(body: body),
);
}
/// Counts route pushes so a test can assert "switched tabs, did not push".
class _RecordingNavigatorObserver extends NavigatorObserver {
int pushCount = 0;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
pushCount++;
}
}
class FakeServicioGrabacionRadio extends ServicioGrabacionRadio {
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
@@ -223,6 +338,15 @@ Future<void> _pumpStableFrame(WidgetTester tester) async {
await tester.pumpAndSettle(const Duration(milliseconds: 100));
}
/// WU5: bounded pump, safe when a "reproduciendo" station is rendered —
/// `VisualizadorAudio` starts a repeating `AnimationController` for its
/// animated-fallback waveform in that case, which `pumpAndSettle` (used by
/// `_pumpStableFrame`) would wait on forever.
Future<void> _pumpBounded(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
}
void _setLargeSurfaceSize(WidgetTester tester) {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;