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.
283 lines
11 KiB
Dart
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(),
|
|
);
|
|
}
|
|
}
|