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:
@@ -42,6 +42,16 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
@override
|
||||
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
|
||||
|
||||
// WU5: the real getter needs registrarHandler() (main.dart, production
|
||||
// only) — reading it against a bare FakeServicioAudio throws
|
||||
// "registrarHandler() no fue llamado en main.dart". No test exercised
|
||||
// this getter before PantallaInicio started wiring VisualizadorAudio to
|
||||
// it (WU5's Escuchar hero, mirroring pantalla_reproductor.dart's own
|
||||
// usage, which has no test file at all). An empty stream matches
|
||||
// VisualizadorAudio's own documented no-native-session fallback.
|
||||
@override
|
||||
Stream<int?> get androidAudioSessionIdStream => const Stream<int?>.empty();
|
||||
|
||||
@override
|
||||
bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user