Files
pluriwave/lib/widgets/ecualizador_widget.dart
T
FreeTLab 533896b9fa fix(ecualizador): header switch, banner radius, preset chips, glow thumb
Audit 11.1 (t4:566): the master enable switch moves to
PluriPushScaffold's header actions -- was the body's first
SwitchListTile row. Its realtime/pending explainer subtitle stays
behind as a plain caption so no information is lost.

Audit 11.2 (t4:571): explainer banner radius is 16 (a local one-off,
matching neither of the 3 named tokens), not radiusSm's 14.

Audit 11.3 (t4:574-577): preset chips are solid brand-teal with dark
text when active, listSurface + a faint border when not -- was
Material's own ChoiceChip theming (primaryContainer/grey).

Audit 11.5 (t4:585): band sliders use a new 20x20 _GlowSliderThumbShape
(brand-teal blurred glow + solid thumb), replacing the Material
default round thumb.

Audit 11.7 (t4:584): the dB label is brand teal at 90% alpha, not
liveGreen -- a leftover wrong colour family 11.6's slider-only fix
never touched.

Updated 2 pre-existing tests (pantalla_ajustes_ecualizador_test.dart,
pantalla_ajustes_test.dart) to locate the enable switch by key instead
of by the "Enable equalizer" text it no longer renders next to.
2026-07-30 16:14:11 +02:00

283 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/preset_ecualizador.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import 'pluri_glass_surface.dart';
class EcualizadorWidget extends StatefulWidget {
final PresetEcualizador preset;
final void Function(PresetEcualizador) onCambio;
/// Design ADR-5: greys and disables every slider when the equalizer
/// itself is off, instead of leaving fully-interactive controls that
/// silently do nothing.
final bool habilitado;
const EcualizadorWidget({
super.key,
required this.preset,
required this.onCambio,
this.habilitado = true,
});
@override
State<EcualizadorWidget> createState() => _EcualizadorWidgetState();
}
class _EcualizadorWidgetState extends State<EcualizadorWidget> {
late List<double> _bandas;
final List<String> _etiquetas = ['60Hz', '250Hz', '1kHz', '4kHz', '16kHz'];
@override
void initState() {
super.initState();
_bandas = List.from(widget.preset.bandas);
}
@override
void didUpdateWidget(EcualizadorWidget old) {
super.didUpdateWidget(old);
if (old.preset.nombre != widget.preset.nombre) {
setState(() => _bandas = List.from(widget.preset.bandas));
}
}
void _actualizarBanda(int index, double valor) {
setState(() => _bandas[index] = valor);
widget.onCambio(
PresetEcualizador(nombre: 'Personalizado', bandas: List.from(_bandas)),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.pluriTokens;
final l10n = AppLocalizations.of(context);
// Design ADR-5: the title + preset Chip row that used to live here is
// gone — the pushed screen's own 56px header carries the title now, and
// the preset chip ROW (a different, still-reusable widget,
// [PresetsEcualizadorWidget] below) is rendered by the caller alongside
// this widget instead of being duplicated inside it.
return PluriGlassSurface(
borderRadius: BorderRadius.circular(tokens.radiusLg),
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (int i = 0; i < 5; i++)
Expanded(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: widget.habilitado ? 1.0 : 0.4,
child: Card(
color: tokens.listSurface.withValues(alpha: 0.6),
margin: const EdgeInsets.symmetric(horizontal: 4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.radiusSm),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 4,
),
child: Column(
children: [
SizedBox(
// Audit 11.4: the prototype draws the band column at
// 280 (t4 line 581); 152 left the sliders 46% short.
height: 280,
child: Semantics(
slider: true,
enabled: widget.habilitado,
label: l10n.equalizerBandLabel(_etiquetas[i]),
value: l10n.equalizerBandValue(
_bandas[i].toStringAsFixed(1),
),
child: RotatedBox(
quarterTurns: 3,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 5,
// Audit 11.6: the prototype's band fill is
// brand teal (t4 line 585). `liveGreen` is
// the LIVE badge colour and reading it here
// put the sliders in the wrong colour family.
activeTrackColor: PluriWaveTokens.brand,
thumbColor: PluriWaveTokens.brand,
inactiveTrackColor:
theme.colorScheme.surfaceContainerHighest,
overlayColor: PluriWaveTokens.brand
.withValues(alpha: 0.15),
// Audit 11.5 (t4 line 585): a 20x20 thumb
// with a 14px brand-teal glow -- was the
// Material default round thumb shape.
thumbShape: const _GlowSliderThumbShape(),
),
child: Slider(
value: _bandas[i],
min: -12.0,
max: 12.0,
divisions: 24,
onChanged:
widget.habilitado
? (v) => _actualizarBanda(i, v)
: null,
),
),
),
),
),
Text(
'${_bandas[i].toStringAsFixed(1)}dB',
// Audit 11.7 (t4 line 584): the prototype's dB
// label is brand teal at 90% alpha -- `liveGreen`
// is the LIVE-badge colour, an unrelated wrong
// family untouched by 11.6's slider-only fix.
style: theme.textTheme.labelSmall?.copyWith(
color: PluriWaveTokens.brand.withValues(alpha: 0.9),
fontWeight: FontWeight.w700,
),
),
Text(
_etiquetas[i],
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
],
),
);
}
}
/// Audit 11.5 (t4 line 585): `width:6px;border-radius:3px` track with a
/// `20x20` thumb carrying `box-shadow:0 0 14px rgba(33,212,217,.6)` --
/// Material's stock `RoundSliderThumbShape` has neither the exact size nor
/// a coloured glow (its own elevation shadow is a neutral drop shadow, not
/// brand-tinted). Paints a soft blurred glow first, then the solid thumb
/// on top, both centred on the slider's reported thumb position.
class _GlowSliderThumbShape extends SliderComponentShape {
const _GlowSliderThumbShape();
static const _radius = 10.0;
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) =>
const Size(_radius * 2, _radius * 2);
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
required bool isDiscrete,
required TextPainter labelPainter,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required double value,
required double textScaleFactor,
required Size sizeWithOverflow,
}) {
final canvas = context.canvas;
final color = sliderTheme.thumbColor ?? PluriWaveTokens.brand;
final glowPaint =
Paint()
..color = color.withValues(alpha: 0.6)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 7);
canvas.drawCircle(center, _radius + 4, glowPaint);
final thumbPaint = Paint()..color = color;
canvas.drawCircle(center, _radius, thumbPaint);
}
}
String _nombrePreset(AppLocalizations l10n, String nombre) {
return switch (nombre) {
'Flat' => l10n.equalizerPresetFlat,
'Rock' => l10n.equalizerPresetRock,
'Pop' => l10n.equalizerPresetPop,
'Bass Boost' => l10n.equalizerPresetBassBoost,
'Jazz' => l10n.equalizerPresetJazz,
'Voz' => l10n.equalizerPresetVoice,
'Personalizado' => l10n.equalizerPresetCustom,
_ => nombre,
};
}
class PresetsEcualizadorWidget extends StatelessWidget {
final PresetEcualizador presetActual;
final void Function(PresetEcualizador) onSeleccionar;
/// User-saved custom presets (WU13, `eq-custom-presets` spec), appended
/// after the 6 fixed ones. Additive-only parameter — defaults to empty so
/// this stays the same widget, not a new one (design ADR-5: "stays
/// as-is" means no restyle, not that it can never gain new data to show).
/// `_nombrePreset`'s default case already falls through to the raw name,
/// so a custom preset's chip label needs no special-casing here.
final List<PresetEcualizador> personalizados;
const PresetsEcualizadorWidget({
super.key,
required this.presetActual,
required this.onSeleccionar,
this.personalizados = const [],
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final todos = [...PresetEcualizador.presets, ...personalizados];
return Wrap(
spacing: 8,
runSpacing: 6,
children:
todos.map((p) {
final selected = p.nombre == presetActual.nombre;
// Audit 11.3 (t4 lines 574-577): a solid brand-teal chip with
// dark text when active, `listSurface` + a faint border when
// not -- was Material's own `ChoiceChip` theming
// (`primaryContainer` selected / translucent grey unselected).
return ChoiceChip(
label: Text(_nombrePreset(l10n, p.nombre)),
labelStyle: TextStyle(
fontWeight: FontWeight.w800,
color:
selected
? const Color(0xFF062126)
: const Color(0xFFF2F7FA),
),
selected: selected,
showCheckmark: false,
selectedColor: PluriWaveTokens.brand,
backgroundColor: PluriWaveTokens.dark.listSurface,
side: BorderSide(
color:
selected
? Colors.transparent
: Colors.white.withValues(alpha: 0.09),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onSelected: (_) => onSeleccionar(p),
);
}).toList(),
);
}
}