From fcba592352546deba2b34bf88609287ffebf5a0a Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 00:04:03 +0200 Subject: [PATCH] fix(visualizador): add a discrete-bar rendering mode Audit 1.7/2.5 (t4 lines 66-68, 120-122): the prototype draws 30 discrete bottom-anchored bars (radius 2, vertical gradient); the build's VisualizadorAudio painted a single continuous oscilloscope stroke, so its `barras: 30` parameter never produced bars. Adds an opt-in `barrasDiscretas` mode (default false, byte-identical continuous rendering preserved for any other caller) plus a `gradienteFinAlpha` knob for the two screens' differing gradient end-alpha (.3 vs .45). Wires it into the Escuchar hero and the full player, correcting the player's bar count/height to match the prototype (26->30 bars, 46->40px) at the same time. --- lib/pantallas/pantalla_inicio.dart | 3 + lib/pantallas/pantalla_reproductor.dart | 9 +- lib/widgets/visualizador_audio.dart | 90 +++++++++-- test/pantallas/pantalla_inicio_test.dart | 46 ++++++ test/pantallas/pantalla_reproductor_test.dart | 29 ++++ .../visualizador_audio_barras_test.dart | 153 ++++++++++++++++++ 6 files changed, 318 insertions(+), 12 deletions(-) create mode 100644 test/widgets/visualizador_audio_barras_test.dart diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index f21ef2e..74888c3 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -291,6 +291,9 @@ class _EscucharHero extends StatelessWidget { barras: 30, altura: 26, color: context.pluriTokens.liveGreen, + // Audit 1.7 (t4 lines 66-68): 30 discrete + // bottom-anchored bars, not a continuous stroke. + barrasDiscretas: true, ), ], ), diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index 3f46e5e..faab86a 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -152,6 +152,7 @@ class _PantallaReproductorState extends State { ).pluriFadeIn(context, delay: const Duration(milliseconds: 230)), const SizedBox(height: 14), PluriGlassSurface( + key: const Key('player-visualizer'), borderRadius: BorderRadius.circular(tokens.radiusLg), padding: const EdgeInsets.symmetric( horizontal: 14, @@ -161,9 +162,13 @@ class _PantallaReproductorState extends State { estadoStream: estado.estadoStream, androidAudioSessionIdStream: estado.audio.androidAudioSessionIdStream, - barras: 26, + // Audit 2.5 (t4 lines 120-122): 30 discrete bars at 40px, + // gradient ending at 45% alpha (not the hero's 30%). + barras: 30, color: tokens.warmCoral, - altura: 46, + altura: 40, + barrasDiscretas: true, + gradienteFinAlpha: 0.45, ), ).pluriFadeIn(context, delay: const Duration(milliseconds: 270)), const SizedBox(height: 22), diff --git a/lib/widgets/visualizador_audio.dart b/lib/widgets/visualizador_audio.dart index 8f26aca..15d6133 100644 --- a/lib/widgets/visualizador_audio.dart +++ b/lib/widgets/visualizador_audio.dart @@ -20,6 +20,18 @@ class VisualizadorAudio extends StatefulWidget { final double altura; final double anchuraTotal; + /// Audit 1.7/2.5 (t4 lines 66-68, 120-122): the prototype draws `barras` + /// discrete bottom-anchored bars (radius 2, vertical gradient), not a + /// continuous oscilloscope stroke. Defaults to `false` so every OTHER + /// caller (if any is ever added) keeps the original `_WaveFlowPainter` + /// rendering unchanged. + final bool barrasDiscretas; + + /// Alpha of the gradient's BOTTOM colour stop in discrete-bar mode. The + /// two current consumers disagree (Escuchar hero: `.3`, t4 line 67; full + /// player: `.45`, t4 line 121), so this is a parameter, not a constant. + final double gradienteFinAlpha; + const VisualizadorAudio({ super.key, required this.estadoStream, @@ -28,6 +40,8 @@ class VisualizadorAudio extends StatefulWidget { this.color, this.altura = 48, this.anchuraTotal = double.infinity, + this.barrasDiscretas = false, + this.gradienteFinAlpha = 0.3, }); @override @@ -184,23 +198,79 @@ class _VisualizadorAudioState extends State @override Widget build(BuildContext context) { final color = widget.color ?? Theme.of(context).colorScheme.primary; - final t = _controller.value * pi * 2; return SizedBox( height: widget.altura, width: widget.anchuraTotal, child: RepaintBoundary( - child: CustomPaint( - painter: _WaveFlowPainter( - color: color, - phase: t, - active: _activo, - waveform: _ondaVisual, - ), - child: const SizedBox.expand(), - ), + child: + widget.barrasDiscretas + ? _construirBarrasDiscretas(color) + : _construirOnda(color), ), ); } + + Widget _construirOnda(Color color) { + final t = _controller.value * pi * 2; + return CustomPaint( + painter: _WaveFlowPainter( + color: color, + phase: t, + active: _activo, + waveform: _ondaVisual, + ), + child: const SizedBox.expand(), + ); + } + + /// Audit 1.7/2.5 (t4 lines 66-68, 120-122): `barras` discrete, + /// bottom-anchored bars — radius 2, `gap:2px`, a top-to-bottom gradient + /// from [color] to `color@gradienteFinAlpha`. Reuses the SAME + /// `_ondaVisual`/`_ondaOrganica` amplitude source the continuous painter + /// already computes (real audio-session data when available, an organic + /// synthetic fallback otherwise) — only the paint strategy changes. + Widget _construirBarrasDiscretas(Color color) { + final valores = _valoresBarras(); + final children = []; + for (var i = 0; i < valores.length; i++) { + if (i > 0) children.add(const SizedBox(width: 2)); + final valor = valores[i].clamp(0.08, 1.0); + children.add( + Expanded( + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + key: ValueKey('visualizador-barra-$i'), + height: widget.altura * valor, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color, + color.withValues(alpha: widget.gradienteFinAlpha), + ], + ), + ), + ), + ), + ), + ); + } + return Row(crossAxisAlignment: CrossAxisAlignment.end, children: children); + } + + /// `_ondaVisual` is empty until the animation controller ticks at least + /// once (only happens once playback goes active, `_onEstado`'s + /// `_controller.repeat()`) — a discrete render before that must still show + /// `barras` resting bars, not zero, so it falls back to a flat, low + /// baseline of exactly [VisualizadorAudio.barras] values (clamped the same + /// way `_ondaOrganica` clamps its own count). + List _valoresBarras() { + if (_ondaVisual.isNotEmpty) return _ondaVisual; + return List.filled(widget.barras.clamp(8, 96), 0.12); + } } class _WaveFlowPainter extends CustomPainter { diff --git a/test/pantallas/pantalla_inicio_test.dart b/test/pantallas/pantalla_inicio_test.dart index ddcef0c..8324dc6 100644 --- a/test/pantallas/pantalla_inicio_test.dart +++ b/test/pantallas/pantalla_inicio_test.dart @@ -10,6 +10,7 @@ import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/pantallas/pantalla_inicio.dart'; +import 'package:pluriwave/widgets/visualizador_audio.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -208,6 +209,51 @@ void main() { ); }); + testWidgets( + 'visual fidelity (audit 1.7): the hero visualizer renders 30 discrete ' + 'bars, not the continuous waveform stroke', + (tester) async { + // Item 16 is exactly the widget the audit warned about + // (visualizador_audio.dart:77, `_controller.repeat()`) — bounded pump + // only, never pumpAndSettle. + _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); + final sonando = emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno'); + await favoritos.agregar(sonando); + await estado.cargarFavoritos(); + await tester.runAsync(() => estado.reproducir(sonando)); + + await tester.pumpWidget( + _conProviders(estado, _testApp(const PantallaInicio())), + ); + await _pumpBounded(tester); + + expect( + find.byKey(const Key('visualizador-barra-0')), + findsOneWidget, + reason: 'prototype t4 line 66-68: 30 discrete bottom-anchored bars', + ); + expect( + find.descendant( + of: find.byType(VisualizadorAudio), + matching: find.byType(CustomPaint), + ), + findsNothing, + ); + }, + ); + testWidgets( 'visual fidelity (audit 1.6): a station missing every meta field omits ' 'the line gracefully, no stray separators', diff --git a/test/pantallas/pantalla_reproductor_test.dart b/test/pantallas/pantalla_reproductor_test.dart index 46a0fe4..77595dd 100644 --- a/test/pantallas/pantalla_reproductor_test.dart +++ b/test/pantallas/pantalla_reproductor_test.dart @@ -412,6 +412,35 @@ void main() { }, ); + testWidgets( + 'visual fidelity (audit 2.5): the visualizer renders 30 discrete ' + 'bars, not the continuous waveform stroke', + (tester) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + + final visualizador = find.byKey(const Key('player-visualizer')); + expect(visualizador, findsOneWidget); + expect( + find.descendant( + of: visualizador, + matching: find.byKey(const Key('visualizador-barra-0')), + ), + findsOneWidget, + reason: 'prototype t4 line 120-122: 30 discrete bars', + ); + expect( + find.descendant( + of: visualizador, + matching: find.byType(CustomPaint), + ), + findsNothing, + ); + }, + ); + testWidgets( 'exactly 4 tool-tray tiles render: EQ propio, Grabar, sleep timer, Compartir', (tester) async { diff --git a/test/widgets/visualizador_audio_barras_test.dart b/test/widgets/visualizador_audio_barras_test.dart new file mode 100644 index 0000000..354f7ce --- /dev/null +++ b/test/widgets/visualizador_audio_barras_test.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:pluriwave/widgets/visualizador_audio.dart'; + +/// Audit 1.7/2.5 (t4 lines 66-68, 120-122): the prototype draws 30 discrete +/// bottom-anchored bars (`border-radius:2px`, a top-to-bottom gradient), but +/// `_WaveFlowPainter` (`visualizador_audio.dart:206-275`) paints a single +/// continuous oscilloscope stroke — the `barras` parameter was inert. These +/// guards assert the actual bar COUNT and that the render is structurally +/// discrete (no `CustomPaint` stroke), not just "looks bar-ish". +void main() { + group('VisualizadorAudio.barrasDiscretas', () { + testWidgets( + 'renders exactly `barras` discrete bars, not a single CustomPaint path', + (tester) async { + final controller = StreamController.broadcast(); + addTearDown(controller.close); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VisualizadorAudio( + estadoStream: controller.stream, + barras: 30, + barrasDiscretas: true, + ), + ), + ), + ); + await tester.pump(); + + for (var i = 0; i < 30; i++) { + expect( + find.byKey(Key('visualizador-barra-$i')), + findsOneWidget, + reason: 'bar $i of 30 must exist (t4 lines 66-68: 30 bars)', + ); + } + expect( + find.byKey(const Key('visualizador-barra-30')), + findsNothing, + reason: 'exactly 30 bars, not 31+', + ); + expect( + find.descendant( + of: find.byType(VisualizadorAudio), + matching: find.byType(CustomPaint), + ), + findsNothing, + reason: + 'discrete mode must not fall back to the continuous ' + '_WaveFlowPainter stroke', + ); + }, + ); + + testWidgets('each bar has radius 2 and a top-to-bottom gradient ending ' + 'at the given alpha (t4 line 67: linear-gradient(180deg,#7EE4C2,' + 'rgba(126,228,194,.3)))', (tester) async { + final controller = StreamController.broadcast(); + addTearDown(controller.close); + const color = Color(0xFF7EE4C2); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VisualizadorAudio( + estadoStream: controller.stream, + barras: 8, + barrasDiscretas: true, + color: color, + ), + ), + ), + ); + await tester.pump(); + + final barra = tester.widget( + find.byKey(const Key('visualizador-barra-0')), + ); + final decoracion = barra.decoration as BoxDecoration; + expect( + decoracion.borderRadius, + BorderRadius.circular(2), + reason: 't4 line 67: border-radius:2px', + ); + final gradiente = decoracion.gradient as LinearGradient; + expect(gradiente.begin, Alignment.topCenter); + expect(gradiente.end, Alignment.bottomCenter); + expect(gradiente.colors.first, color); + expect(gradiente.colors.last, color.withValues(alpha: 0.3)); + }); + + testWidgets( + 'a custom gradienteFinAlpha changes only the gradient END colour ' + '(t4 line 121: the player uses rgba(244,184,96,.45), not .3)', + (tester) async { + final controller = StreamController.broadcast(); + addTearDown(controller.close); + const color = Color(0xFFF4B860); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VisualizadorAudio( + estadoStream: controller.stream, + barras: 8, + barrasDiscretas: true, + color: color, + gradienteFinAlpha: 0.45, + ), + ), + ), + ); + await tester.pump(); + + final barra = tester.widget( + find.byKey(const Key('visualizador-barra-0')), + ); + final gradiente = + (barra.decoration as BoxDecoration).gradient as LinearGradient; + expect(gradiente.colors.last, color.withValues(alpha: 0.45)); + }, + ); + + testWidgets('barrasDiscretas defaults to false — existing callers keep the ' + 'continuous CustomPaint stroke, no regression', (tester) async { + final controller = StreamController.broadcast(); + addTearDown(controller.close); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VisualizadorAudio(estadoStream: controller.stream), + ), + ), + ); + await tester.pump(); + + expect( + find.descendant( + of: find.byType(VisualizadorAudio), + matching: find.byType(CustomPaint), + ), + findsOneWidget, + ); + expect(find.byKey(const Key('visualizador-barra-0')), findsNothing); + }); + }); +}