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.
This commit is contained in:
@@ -291,6 +291,9 @@ class _EscucharHero extends StatelessWidget {
|
|||||||
barras: 30,
|
barras: 30,
|
||||||
altura: 26,
|
altura: 26,
|
||||||
color: context.pluriTokens.liveGreen,
|
color: context.pluriTokens.liveGreen,
|
||||||
|
// Audit 1.7 (t4 lines 66-68): 30 discrete
|
||||||
|
// bottom-anchored bars, not a continuous stroke.
|
||||||
|
barrasDiscretas: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
|
|||||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 230)),
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 230)),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
PluriGlassSurface(
|
PluriGlassSurface(
|
||||||
|
key: const Key('player-visualizer'),
|
||||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 14,
|
horizontal: 14,
|
||||||
@@ -161,9 +162,13 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
|
|||||||
estadoStream: estado.estadoStream,
|
estadoStream: estado.estadoStream,
|
||||||
androidAudioSessionIdStream:
|
androidAudioSessionIdStream:
|
||||||
estado.audio.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,
|
color: tokens.warmCoral,
|
||||||
altura: 46,
|
altura: 40,
|
||||||
|
barrasDiscretas: true,
|
||||||
|
gradienteFinAlpha: 0.45,
|
||||||
),
|
),
|
||||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 270)),
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 270)),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ class VisualizadorAudio extends StatefulWidget {
|
|||||||
final double altura;
|
final double altura;
|
||||||
final double anchuraTotal;
|
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({
|
const VisualizadorAudio({
|
||||||
super.key,
|
super.key,
|
||||||
required this.estadoStream,
|
required this.estadoStream,
|
||||||
@@ -28,6 +40,8 @@ class VisualizadorAudio extends StatefulWidget {
|
|||||||
this.color,
|
this.color,
|
||||||
this.altura = 48,
|
this.altura = 48,
|
||||||
this.anchuraTotal = double.infinity,
|
this.anchuraTotal = double.infinity,
|
||||||
|
this.barrasDiscretas = false,
|
||||||
|
this.gradienteFinAlpha = 0.3,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -184,12 +198,21 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = widget.color ?? Theme.of(context).colorScheme.primary;
|
final color = widget.color ?? Theme.of(context).colorScheme.primary;
|
||||||
final t = _controller.value * pi * 2;
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: widget.altura,
|
height: widget.altura,
|
||||||
width: widget.anchuraTotal,
|
width: widget.anchuraTotal,
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: CustomPaint(
|
child:
|
||||||
|
widget.barrasDiscretas
|
||||||
|
? _construirBarrasDiscretas(color)
|
||||||
|
: _construirOnda(color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _construirOnda(Color color) {
|
||||||
|
final t = _controller.value * pi * 2;
|
||||||
|
return CustomPaint(
|
||||||
painter: _WaveFlowPainter(
|
painter: _WaveFlowPainter(
|
||||||
color: color,
|
color: color,
|
||||||
phase: t,
|
phase: t,
|
||||||
@@ -197,10 +220,57 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
|
|||||||
waveform: _ondaVisual,
|
waveform: _ondaVisual,
|
||||||
),
|
),
|
||||||
child: const SizedBox.expand(),
|
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 = <Widget>[];
|
||||||
|
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<double> _valoresBarras() {
|
||||||
|
if (_ondaVisual.isNotEmpty) return _ondaVisual;
|
||||||
|
return List<double>.filled(widget.barras.clamp(8, 96), 0.12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WaveFlowPainter extends CustomPainter {
|
class _WaveFlowPainter extends CustomPainter {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
|
|||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/modelos/emisora.dart';
|
import 'package:pluriwave/modelos/emisora.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
|
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
|
||||||
|
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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(
|
testWidgets(
|
||||||
'visual fidelity (audit 1.6): a station missing every meta field omits '
|
'visual fidelity (audit 1.6): a station missing every meta field omits '
|
||||||
'the line gracefully, no stray separators',
|
'the line gracefully, no stray separators',
|
||||||
|
|||||||
@@ -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(
|
testWidgets(
|
||||||
'exactly 4 tool-tray tiles render: EQ propio, Grabar, sleep timer, Compartir',
|
'exactly 4 tool-tray tiles render: EQ propio, Grabar, sleep timer, Compartir',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@@ -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<EstadoReproduccion>.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<EstadoReproduccion>.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<Container>(
|
||||||
|
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<EstadoReproduccion>.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<Container>(
|
||||||
|
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<EstadoReproduccion>.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user