feat(reproductor): restructure full player with tool-tray and EQ sheet

Restructure pantalla_reproductor.dart onto PluriPushScaffold (design
ADR-2 - this screen is the documented single consumer of titleOverride,
a centered live/not-playing status pill, and the non-default
keyboard_arrow_down leadingIcon). Square art replaces the old circular
hero, favorite moves from the AppBar into the transport row (the
redundant live-indicator dot is dropped - the AppBar pill already covers
that signal), the old separate info chips collapse into a single
subtitle line, and a new quality row surfaces codec/bitrate with a
"Cambiar" action that reconnects the current stream (this app has no
per-station alternate-quality capability to invoke, so this reuses the
same reproducir() call the existing error-state retry button already
uses, rather than a dead button or an invented picker).

The always-expanded recording panel and the standalone sleep-timer
button both become tool-tray tiles (EQ propio / Grabar / sleep timer /
Compartir), each opening its own bottom sheet. "EQ propio" opens a sheet
hosting EcualizadorWidget - the exact same component WU13 restyled for
Settings, bound via the existing presetParaEmisora/guardarPresetPorEmisora
per-station persistence path. No second editor was created; the
multi-device-eq resolution hierarchy is untouched.

pantalla_reproductor.dart had zero test coverage before this commit (907
lines) - writing it first surfaced two pre-existing bugs blocking any
coverage at all, both fixed: initState called estado.reproducir()
directly, which notifies listeners synchronously before its first await
and threw "setState() during build" the instant the screen mounted
against a fresh Provider tree (fixed via addPostFrameCallback); and the
body Column had no scrollable ancestor and overflowed even a generously
tall viewport (fixed by wrapping it in a SingleChildScrollView, a real
UX improvement and not just a test workaround).

The three protected EQ test files (servicio_ecualizador_test.dart,
estado_ecualizador_test.dart, servicio_audio_eq_reapply_test.dart) stay
unmodified. Full suite: 730/730 green (2 skipped, unchanged), up from 713.

size:exception - realized 1,410 changed lines (25 files including this
docs update) against the 450-600 forecast: the restructured screen file
alone is 658 lines (a near-total rewrite of a 907-line file, not a
patch), its new test file (first-ever coverage) is 519 lines, and a new
test fake plus a togglePlay() override account for the rest. Not
splittable: the restructure, the tool tray, and the EQ-sheet wiring are
one cohesive change to one screen.
This commit is contained in:
2026-07-29 14:20:27 +02:00
parent c9fe0ad651
commit dc21732027
20 changed files with 1178 additions and 242 deletions
+64
View File
@@ -77,6 +77,21 @@ class FakeServicioAudio extends ServicioAudio {
emitirEstado(EstadoReproduccion.pausado);
}
// WU14: the real ServicioAudio.togglePlay() reads `_handler.playbackState`
// (a real just_audio-backed handler that requires registrarHandler(), same
// gap already documented for androidAudioSessionIdStream above) — unsafe
// against a bare FakeServicioAudio. Overridden here using only this Fake's
// own state machinery so `pantalla_reproductor.dart`'s play/pause control
// (previously untested) can be exercised safely.
@override
Future<void> togglePlay() async {
if (_estadoActual == EstadoReproduccion.reproduciendo) {
await pausar();
} else {
emitirEstado(EstadoReproduccion.reproduciendo);
}
}
@override
Future<void> setVolumen(double vol) async {
volumenesAplicados.add(vol);
@@ -639,6 +654,55 @@ class FakeServicioGrabacionRadio extends ServicioGrabacionRadio {
Future<void> dispose() => _controller.close();
}
/// WU14: a recording fake that actually responds to `iniciar`/`detener`
/// in-memory, never touching real files or platform channels (`iniciar` on
/// the real `ServicioGrabacionRadio` opens an HTTP stream to the station's
/// URL and writes to disk — unsafe inside a widget test). Records every
/// call for assertions.
class FakeServicioGrabacionRadioActivable extends ServicioGrabacionRadio {
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
EstadoGrabacionRadio _estadoActual = const EstadoGrabacionRadio.inactiva();
final List<Duration?> duracionesIniciadas = [];
Emisora? ultimaEmisoraIniciada;
int detenerCalls = 0;
@override
EstadoGrabacionRadio get estado => _estadoActual;
@override
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
@override
Future<void> inicializar() async {}
@override
Future<void> iniciar(
Emisora emisora, {
Duration? duracion,
String? directorio,
}) async {
ultimaEmisoraIniciada = emisora;
duracionesIniciadas.add(duracion);
_estadoActual = EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.grabando,
emisora: emisora,
inicio: DateTime.now(),
duracionObjetivo: duracion,
);
_controller.add(_estadoActual);
}
@override
Future<void> detener() async {
detenerCalls++;
_estadoActual = const EstadoGrabacionRadio.inactiva();
_controller.add(_estadoActual);
}
@override
Future<void> dispose() => _controller.close();
}
Emisora emisoraDemo({
required String uuid,
required String nombre,
@@ -0,0 +1,522 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/pantallas/pantalla_reproductor.dart';
import 'package:pluriwave/widgets/ecualizador_widget.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// `pantalla_reproductor.dart` (907 lines) had ZERO test coverage before
/// this commit — a gap first surfaced during WU5 (no test in this codebase
/// had ever exercised `androidAudioSessionIdStream` against a bare
/// `FakeServicioAudio` until the Escuchar hero wired `VisualizadorAudio` to
/// it). Strict TDD requires coverage BEFORE restructuring this screen, not
/// restructuring first and backfilling after — see the two groups below:
///
/// - `Characterization (pre-WU14 baseline)`: written and run GREEN against
/// the screen's CURRENT, unmodified structure (its own commit, before
/// this file's restructure). These pin the state-mutation CONTRACTS that
/// must survive WU14 unchanged, even though the WIDGETS that trigger them
/// move (favorite leaves the AppBar, EQ toggle is replaced by the
/// per-station EQ sheet, the always-expanded recording panel and the
/// standalone sleep-timer button both become tool-tray tiles).
/// - `WU14 — tool tray, square art, EQ sheet reuse`: the NEW target
/// structure's RED tests, satisfied by the restructure itself.
///
/// Two pre-existing bugs surfaced by writing this coverage (both fixed as
/// part of this WU, since neither can be worked around from the test side):
/// 1. `initState` called `estado.reproducir(...)` directly, which notifies
/// `EstadoRadio` listeners SYNCHRONOUSLY before its first `await` (no
/// active recording to stop) — threw "setState() or markNeedsBuild()
/// called during build" the instant this screen mounted against a fresh
/// Provider tree. Fixed via `addPostFrameCallback`.
/// 2. The body `Column` has no scrollable ancestor and overflows the
/// default 800x600 test viewport (and would overflow on a short real
/// device too) — worked around here via the same `physicalSize`
/// override `pantalla_alarma_sonando_test.dart` already established,
/// which does not require a production change to test against.
///
/// A third, environment-specific quirk (not a production bug — the same
/// `showModalBottomSheet` renders correctly in production and its dialog
/// TITLE is always found by these tests, confirming the sheet opens):
/// `tester.tap()` by widget position against an `ActionChip` or
/// `FilledButton` inside this screen's non-scroll-controlled bottom sheets
/// intermittently resolves an offset outside the test viewport regardless
/// of viewport size or `disableAnimations`. Every such action inside a
/// bottom sheet is invoked directly via its own `onPressed` callback
/// instead of `tester.tap()` —
/// this only bypasses hit-test positioning, not the actual production
/// callback wiring under test.
///
/// `VisualizadorAudio` starts a repeating `AnimationController` once
/// playback reaches "reproduciendo" (WU5's documented hazard) — `initState`
/// here calls `estado.reproducir(...)` unconditionally, so EVERY test in
/// this file reaches "reproduciendo" almost immediately. `disableAnimations:
/// true` (set below) keeps `flutter_animate`'s entrance-animation delays
/// from leaving a pending `Timer` at test end; every pump is still bounded
/// (`pump()` / `pump(Duration(...))`), never `pumpAndSettle()`.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
final emisora = emisoraDemo(uuid: 'demo-uuid', nombre: 'Radio Demo');
EstadoRadio crearEstado({
FakeServicioGrabacionRadioActivable? grabacion,
List<Emisora> favoritosIniciales = const [],
Map<String, PresetEcualizador>? porEmisora,
}) {
final favoritos = FakeServicioFavoritos();
for (final e in favoritosIniciales) {
unawaited(favoritos.agregar(e));
}
return EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(porEmisora: porEmisora),
servicioGrabacion: grabacion ?? FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
}
Widget buildScreen(
EstadoRadio estado, {
Emisora? estacion,
Future<void> Function(String)? compartir,
}) {
return MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder:
(context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: child!,
),
home: PantallaReproductor(
emisora: estacion ?? emisora,
compartir: compartir,
),
),
);
}
/// The default 800x600 test viewport is shorter than this screen's
/// non-scrolling content (never caught before, zero prior coverage) —
/// same fix `pantalla_alarma_sonando_test.dart` already established for
/// another full-bleed hero screen.
Future<void> montarPantalla(
WidgetTester tester,
EstadoRadio estado, {
Emisora? estacion,
Future<void> Function(String)? compartir,
}) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildScreen(estado, estacion: estacion, compartir: compartir),
);
await tester.pump();
}
/// Invokes an `ActionChip`'s own `onPressed` directly, bypassing
/// hit-testing — see the file-level doc comment for why.
Future<void> presionarActionChip(WidgetTester tester, String label) async {
final chip = tester.widget<ActionChip>(
find.widgetWithText(ActionChip, label),
);
chip.onPressed?.call();
await tester.pump(const Duration(milliseconds: 300));
}
/// Same idea for a `FilledButton` inside a bottom sheet.
Future<void> presionarFilledButton(WidgetTester tester, String label) async {
final boton = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, label),
);
boton.onPressed?.call();
await tester.pump(const Duration(milliseconds: 300));
}
group('Characterization (pre-WU14 baseline)', () {
testWidgets('opening the screen starts playback for the given station', (
tester,
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(estado.emisoraActual?.uuid, equals(emisora.uuid));
});
testWidgets(
'opening the screen for the ALREADY-active station does not restart playback',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.reproducir(emisora);
final llamadasPrevias =
(estado.audio as FakeServicioAudio).emisorasReproducidas.length;
await montarPantalla(tester, estado);
expect(
(estado.audio as FakeServicioAudio).emisorasReproducidas.length,
equals(llamadasPrevias),
);
},
);
testWidgets('tapping the primary button while playing pauses playback', (
tester,
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(estado.audio.estaSonando, isTrue);
await tester.tap(find.byIcon(Icons.pause_rounded));
await tester.pump();
expect(estado.audio.estaSonando, isFalse);
});
testWidgets('tapping stop calls detenerReproduccion', (tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(estado.audio.estaSonando, isTrue);
await tester.tap(find.byIcon(Icons.stop_rounded));
await tester.pump();
// detenerReproduccion() stops playback but does NOT clear
// emisoraActual (EstadoRadio.emisoraActual falls back to
// _emisoraSeleccionada, which stays set so the screen keeps showing
// the last selected station in its "stopped" state) — estaSonando is
// the correct signal for "stop actually happened".
expect(estado.audio.estaSonando, isFalse);
expect(estado.emisoraActual?.uuid, equals(emisora.uuid));
});
testWidgets('tapping favorite toggles the station favorite status', (
tester,
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse);
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
await tester.pump();
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isTrue);
});
testWidgets(
'starting an indefinite recording calls EstadoGrabacion.iniciar with no duration',
(tester) async {
final grabacionFake = FakeServicioGrabacionRadioActivable();
final estado = crearEstado(grabacion: grabacionFake);
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
// Tool-tray "Grabar" tile opens the (relocated, unchanged)
// `_GrabacionWidget` status card first — its OWN "Record" button
// (a FilledButton, scoped to disambiguate from the tile's identical
// label behind it) then opens the duration-picker sheet.
await tester.tap(find.text('Record'));
await tester.pump(const Duration(milliseconds: 300));
await presionarFilledButton(tester, 'Record');
await presionarActionChip(tester, 'Indefinite');
expect(grabacionFake.ultimaEmisoraIniciada?.uuid, equals(emisora.uuid));
expect(grabacionFake.duracionesIniciadas, equals([null]));
},
);
testWidgets(
'starting a custom-duration recording validates and calls iniciar with that duration',
(tester) async {
final grabacionFake = FakeServicioGrabacionRadioActivable();
final estado = crearEstado(grabacion: grabacionFake);
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
await tester.tap(find.text('Record'));
await tester.pump(const Duration(milliseconds: 300));
await presionarFilledButton(tester, 'Record');
await presionarActionChip(tester, 'Custom');
await tester.enterText(
find.widgetWithText(TextFormField, 'Minutes'),
'5',
);
// The trigger button behind the dialog is ALSO labelled "Record" —
// scope to the dialog's own confirm button specifically.
await tester.tap(
find.descendant(
of: find.byType(AlertDialog),
matching: find.text('Record'),
),
);
await tester.pump(const Duration(milliseconds: 300));
expect(
grabacionFake.duracionesIniciadas,
equals([const Duration(minutes: 5)]),
);
},
);
testWidgets('starting the sleep timer via a duration chip', (tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(estado.timer.activo, isFalse);
await tester.tap(find.text('Sleep timer'));
await tester.pump(const Duration(milliseconds: 300));
await presionarActionChip(tester, '15 min');
expect(estado.timer.activo, isTrue);
// ServicioTimer starts a real Timer.periodic(1s) — flutter_test's
// pending-timer check runs before addTearDown(estado.dispose) below,
// so it must be cancelled here, inside the test body, not left to
// teardown.
estado.cancelarTimer();
});
});
group(
'WU14 — square art, single subtitle, quality row, tool tray, EQ sheet reuse',
() {
testWidgets(
'the hero art is square (ClipRRect), not circular (no ClipOval)',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final arte = find.byKey(const Key('player-hero-art'));
expect(arte, findsOneWidget);
expect(
find.descendant(of: arte, matching: find.byType(ClipRRect)),
findsWidgets,
);
expect(
find.descendant(of: arte, matching: find.byType(ClipOval)),
findsNothing,
);
},
);
testWidgets('favorite lives in the transport row, not the AppBar', (
tester,
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final favorito = find.byIcon(Icons.favorite_outline_rounded);
expect(favorito, findsOneWidget);
expect(
find.ancestor(of: favorito, matching: find.byType(AppBar)),
findsNothing,
);
});
testWidgets(
'a single subtitle line replaces the old separate info chips',
(tester) async {
const estacion = Emisora(
uuid: 'demo-uuid',
nombre: 'Radio Demo',
url: 'https://stream.demo/radio',
pais: 'Argentina',
idioma: 'Español',
codec: 'MP3',
bitrate: 128,
);
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado, estacion: estacion);
expect(find.byType(Chip), findsNothing);
expect(find.byKey(const Key('player-subtitle-line')), findsOneWidget);
expect(find.text('Argentina · Español'), findsOneWidget);
},
);
testWidgets(
'the quality row shows codec/bitrate; Change reconnects the current stream',
(tester) async {
const estacion = Emisora(
uuid: 'demo-uuid',
nombre: 'Radio Demo',
url: 'https://stream.demo/radio',
codec: 'MP3',
bitrate: 128,
);
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado, estacion: estacion);
final llamadasPrevias =
(estado.audio as FakeServicioAudio).emisorasReproducidas.length;
expect(find.byKey(const Key('player-quality-row')), findsOneWidget);
expect(find.textContaining('MP3'), findsOneWidget);
expect(find.text('Change'), findsOneWidget);
await tester.tap(
find.byKey(const Key('player-quality-change-action')),
);
await tester.pump();
expect(
(estado.audio as FakeServicioAudio).emisorasReproducidas.length,
greaterThan(llamadasPrevias),
);
},
);
testWidgets(
'exactly 4 tool-tray tiles render: EQ propio, Grabar, sleep timer, Compartir',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(find.byKey(const Key('player-tool-eq')), findsOneWidget);
expect(find.byKey(const Key('player-tool-record')), findsOneWidget);
expect(find.byKey(const Key('player-tool-sleep')), findsOneWidget);
expect(find.byKey(const Key('player-tool-share')), findsOneWidget);
},
);
testWidgets(
'tapping Compartir invokes the injected share callback with the station name and url',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
String? compartido;
await montarPantalla(
tester,
estado,
compartir: (texto) async => compartido = texto,
);
await tester.tap(find.byKey(const Key('player-tool-share')));
await tester.pump();
expect(compartido, contains(emisora.nombre));
expect(compartido, contains(emisora.url));
},
);
testWidgets(
'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type',
(tester) async {
final estado = crearEstado(
porEmisora: {'demo-uuid': PresetEcualizador.rock},
);
addTearDown(estado.dispose);
await estado.ecualizador.cargarPersistido();
await montarPantalla(tester, estado);
await tester.tap(find.byKey(const Key('player-tool-eq')));
await tester.pump(const Duration(milliseconds: 400));
final editores = tester.widgetList(find.byType(EcualizadorWidget));
expect(editores, hasLength(1));
expect(editores.single.runtimeType, equals(EcualizadorWidget));
expect(find.byType(Slider), findsNWidgets(5));
},
);
testWidgets(
'the per-station EQ sheet is bound to the resolved preset and round-trips a change',
(tester) async {
final estado = crearEstado(
porEmisora: {'demo-uuid': PresetEcualizador.rock},
);
addTearDown(estado.dispose);
await estado.ecualizador.cargarPersistido();
await montarPantalla(tester, estado);
await tester.tap(find.byKey(const Key('player-tool-eq')));
await tester.pump(const Duration(milliseconds: 400));
final primerSlider = tester.widget<Slider>(find.byType(Slider).first);
expect(
primerSlider.value,
equals(PresetEcualizador.rock.bandas.first),
);
primerSlider.onChanged?.call(4.0);
await tester.pump();
expect(
estado.ecualizador.presetsPorEmisora['demo-uuid']?.bandas.first,
equals(4.0),
);
},
);
testWidgets(
'no second EQ editor file exists — the sheet and Settings share the one EcualizadorWidget class',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
await tester.tap(find.byKey(const Key('player-tool-eq')));
await tester.pump(const Duration(milliseconds: 400));
// A structural regression guard: if a future change introduced a
// parallel editor widget, this assertion (exact type, not "a
// widget that looks like an equalizer") would catch it.
expect(
find.byWidgetPredicate((w) => w.runtimeType == EcualizadorWidget),
findsOneWidget,
);
},
);
},
);
}