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.
557 lines
19 KiB
Dart
557 lines
19 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../estado/estado_ecualizador.dart';
|
|
import '../../estado/estado_radio.dart';
|
|
import '../../l10n/gen/app_localizations.dart';
|
|
import '../../modelos/preset_ecualizador.dart';
|
|
import '../../tema/pluriwave_theme.dart';
|
|
import '../../tema/pluriwave_tokens.dart';
|
|
import '../../widgets/ecualizador_widget.dart';
|
|
import '../../widgets/pluri_glass_surface.dart';
|
|
import '../../widgets/pluri_layout.dart';
|
|
import '../../widgets/pluri_push_scaffold.dart';
|
|
|
|
/// AUDIO group · "Ecualizador" (design ADR-3). Body moved verbatim from the
|
|
/// former `_SeccionEcualizador` in `pantalla_ajustes.dart` — only the panel
|
|
/// header row (icon + title + status chip) was removed, since
|
|
/// [PluriPushScaffold] now carries the title and the very next row already
|
|
/// shows the same active/disabled state.
|
|
///
|
|
/// WU13 (design ADR-5) restyled [EcualizadorWidget] itself and added the
|
|
/// base-vs-per-station explainer, the "Salida activa" summary row, the
|
|
/// "Emisoras con ajuste propio" drill-down, and the "Guardar como preset"
|
|
/// custom-preset flow — see `_CuerpoEcualizador` below.
|
|
///
|
|
/// Audit 11.10/11.8 (t4 lines 570-602, item 19): the prototype draws 4
|
|
/// SEPARATE cards (explainer banner / band sliders / "Salida activa +
|
|
/// Guardar como preset + Restablecer a plano" / "Emisoras con ajuste
|
|
/// propio"), not one shared glass surface wrapping everything — and adds
|
|
/// a "Restablecer a plano" action the build never had. `_CuerpoEcualizador`
|
|
/// no longer wraps its children in one outer `PluriGlassSurface`; each
|
|
/// section now either already draws its own background
|
|
/// ([EcualizadorWidget], unchanged) or gained one here.
|
|
class PantallaAjustesEcualizador extends StatelessWidget {
|
|
const PantallaAjustesEcualizador({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Audit 11.1 (t4 line 566): the master enable switch lives in the
|
|
// HEADER, not as the body's first row -- read here (a second,
|
|
// cheap watch alongside _CuerpoEcualizadorState's own Consumer2)
|
|
// purely to feed PluriPushScaffold.actions.
|
|
final eq = context.watch<EstadoEcualizador>();
|
|
return PluriPushScaffold(
|
|
title: AppLocalizations.of(context).equalizerTitle,
|
|
actions: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Switch(
|
|
key: const ValueKey('eq-master-switch'),
|
|
value: eq.activo,
|
|
onChanged: eq.cambiarActivo,
|
|
// t4 line 569: brand-teal track, white thumb -- the default
|
|
// Material thumb colour already renders white when "on".
|
|
activeTrackColor: PluriWaveTokens.brand,
|
|
),
|
|
),
|
|
],
|
|
body: ListView(
|
|
padding: PluriLayout.pageContentPadding,
|
|
children: const [_CuerpoEcualizador()],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CuerpoEcualizador extends StatefulWidget {
|
|
const _CuerpoEcualizador();
|
|
|
|
@override
|
|
State<_CuerpoEcualizador> createState() => _CuerpoEcualizadorState();
|
|
}
|
|
|
|
class _CuerpoEcualizadorState extends State<_CuerpoEcualizador> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final eq = context.read<EstadoEcualizador>();
|
|
// Fire-and-forget, mirroring the established
|
|
// `pantalla_ajustes_salida_audio.dart` pattern: both calls are
|
|
// genuinely async (a SharedPreferences read / a native re-query), so
|
|
// their completion never lands inside THIS build — no
|
|
// "setState() during build" risk.
|
|
unawaited(eq.cargarPresetsPersonalizados());
|
|
unawaited(eq.refrescarDispositivoActual());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is
|
|
// only consulted for the current station + favorite flag and for
|
|
// resolving station names in the "ajuste propio" drill-down.
|
|
return Consumer2<EstadoRadio, EstadoEcualizador>(
|
|
builder: (ctx, estado, eq, _) {
|
|
final disponible = eq.disponible;
|
|
final l10n = AppLocalizations.of(ctx);
|
|
final emisoraActual = estado.emisoraActual;
|
|
final mostrarModoPorEmisora =
|
|
emisoraActual != null && estado.emisoraActualEsFavorita;
|
|
final usandoEqPropio = eq.emisoraActualTienePresetPropio;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Audit 11.1 (t4 line 566): the enable switch itself now lives
|
|
// in PluriPushScaffold's header (see PantallaAjustesEcualizador
|
|
// above) -- this stays behind only as the explanatory caption
|
|
// the old SwitchListTile's subtitle carried, so that
|
|
// information is not lost.
|
|
Text(
|
|
disponible
|
|
? l10n.equalizerRealtimeSubtitle
|
|
: l10n.equalizerPendingSubtitle,
|
|
style: Theme.of(ctx).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (mostrarModoPorEmisora) ...[
|
|
const SizedBox(height: 8),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(l10n.equalizerPerStationTitle),
|
|
subtitle: Text(
|
|
usandoEqPropio
|
|
? l10n.equalizerPerStationActive(emisoraActual.nombre)
|
|
: l10n.equalizerPerStationMain(emisoraActual.nombre),
|
|
),
|
|
value: usandoEqPropio,
|
|
onChanged:
|
|
(usarPropio) =>
|
|
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
// CARD 1 (t4 line 571) — already its own tinted Container.
|
|
_BannerExplicacionBase(l10n: l10n),
|
|
const SizedBox(height: 16),
|
|
// Bare row, no card (t4 lines 573-578) — unchanged.
|
|
PresetsEcualizadorWidget(
|
|
presetActual: eq.presetActual,
|
|
personalizados: eq.presetsPersonalizados,
|
|
onSeleccionar: (p) => eq.cambiarPreset(p),
|
|
),
|
|
const SizedBox(height: 14),
|
|
// CARD 2 (t4 lines 580-590) — EcualizadorWidget already draws
|
|
// its own PluriGlassSurface (design ADR-5), unchanged here.
|
|
EcualizadorWidget(
|
|
preset: eq.presetActual,
|
|
habilitado: eq.activo,
|
|
onCambio: (p) => eq.cambiarPreset(p),
|
|
),
|
|
const SizedBox(height: 14),
|
|
// CARD 3 (t4 lines 592-598): Salida activa / Guardar como
|
|
// preset / Restablecer a plano, grouped.
|
|
_TarjetaSalidaYAcciones(
|
|
eq: eq,
|
|
l10n: l10n,
|
|
onGuardarPreset: () => _abrirDialogoGuardarPreset(context, eq),
|
|
),
|
|
const SizedBox(height: 14),
|
|
// CARD 4 (t4 lines 600-602) — its own PluriGlassSurface now.
|
|
_FilaEmisorasConAjustePropio(eq: eq, estado: estado, l10n: l10n),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _abrirDialogoGuardarPreset(
|
|
BuildContext context,
|
|
EstadoEcualizador eq,
|
|
) {
|
|
return showDialog<void>(
|
|
context: context,
|
|
builder: (_) => _DialogoGuardarPreset(eq: eq),
|
|
);
|
|
}
|
|
|
|
/// Base-vs-per-station explainer (spec `eq-custom-presets` "Base-vs-Per-
|
|
/// Station Explainer Preserved"): always visible, distinguishing the base
|
|
/// (global/device) EQ this screen edits from a station's own override.
|
|
class _BannerExplicacionBase extends StatelessWidget {
|
|
const _BannerExplicacionBase({required this.l10n});
|
|
|
|
final AppLocalizations l10n;
|
|
|
|
/// Audit 11.2 (t4 line 571): 16 -- doesn't match any of
|
|
/// [PluriWaveTokens]'s three named radii (14/18/30), so this stays a
|
|
/// local one-off constant (same precedent as `_stopButtonRadius` in
|
|
/// `pantalla_alarma_sonando.dart`).
|
|
static const _bannerRadius = 16.0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.pluriTokens;
|
|
return Container(
|
|
key: const Key('eq-base-explainer-banner'),
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: tokens.liveGreen.withValues(alpha: 0.09),
|
|
borderRadius: BorderRadius.circular(_bannerRadius),
|
|
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.info_outline_rounded, size: 20, color: tokens.liveGreen),
|
|
const SizedBox(width: 11),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.equalizerBaseExplainer,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "Salida activa" row (spec `eq-custom-presets` "Active Output Surfaced on
|
|
/// the Main Screen"): surfaced here instead of only inside the Advanced
|
|
/// (multi-device) screen, and kept live via the SAME `notifyListeners()`
|
|
/// calls `_onDispositivoCambiado` already fires — no new plumbing needed
|
|
/// beyond reading `dispositivoActualId` here.
|
|
class _FilaSalidaActiva extends StatelessWidget {
|
|
const _FilaSalidaActiva({required this.eq, required this.l10n});
|
|
|
|
final EstadoEcualizador eq;
|
|
final AppLocalizations l10n;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final deviceId = eq.dispositivoActualId;
|
|
final nombre =
|
|
deviceId == null
|
|
? l10n.equalizerActiveOutputDefault
|
|
: eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId));
|
|
|
|
return Padding(
|
|
key: const Key('eq-active-output-row'),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.speaker_group_rounded, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.equalizerActiveOutputLabel,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
),
|
|
Flexible(
|
|
child: Text(
|
|
nombre,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.end,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Audit 11.8 (t4 lines 592-598): groups "Salida activa", "Guardar como
|
|
/// preset" and the entirely-missing "Restablecer a plano" into ONE card —
|
|
/// the build previously drew the first as a bare row and the second as a
|
|
/// floating, right-aligned button at the bottom of the whole screen.
|
|
/// "Restablecer a plano" is not a new capability: `PresetEcualizador.flat`
|
|
/// is the SAME preset the "Plano" chip in [PresetsEcualizadorWidget]
|
|
/// already applies via `EstadoEcualizador.cambiarPreset` — this is just a
|
|
/// second, prototype-mandated entry point to it.
|
|
class _TarjetaSalidaYAcciones extends StatelessWidget {
|
|
const _TarjetaSalidaYAcciones({
|
|
required this.eq,
|
|
required this.l10n,
|
|
required this.onGuardarPreset,
|
|
});
|
|
|
|
final EstadoEcualizador eq;
|
|
final AppLocalizations l10n;
|
|
final VoidCallback onGuardarPreset;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PluriGlassSurface(
|
|
key: const Key('eq-card-output-actions'),
|
|
borderRadius: BorderRadius.circular(context.pluriTokens.radiusMd),
|
|
padding: EdgeInsets.zero,
|
|
child: Column(
|
|
children: [
|
|
_FilaSalidaActiva(eq: eq, l10n: l10n),
|
|
const Divider(height: 1),
|
|
_FilaAccionTarjeta(
|
|
key: const Key('eq-save-preset-action'),
|
|
icon: Icons.bookmark_add_outlined,
|
|
label: l10n.equalizerSaveAsPresetAction,
|
|
trailing: const Icon(Icons.chevron_right_rounded, size: 19),
|
|
onTap: onGuardarPreset,
|
|
),
|
|
const Divider(height: 1),
|
|
_FilaAccionTarjeta(
|
|
key: const Key('eq-reset-flat-action'),
|
|
icon: Icons.restart_alt_rounded,
|
|
label: l10n.equalizerResetToFlatAction,
|
|
onTap: () => eq.cambiarPreset(PresetEcualizador.flat),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One tappable icon+label row inside [_TarjetaSalidaYAcciones] — shared
|
|
/// shape for "Guardar como preset" and "Restablecer a plano" (t4 lines
|
|
/// 595, 597: identical row layout, only "Guardar..." has a trailing
|
|
/// chevron since it opens a dialog rather than acting immediately).
|
|
class _FilaAccionTarjeta extends StatelessWidget {
|
|
const _FilaAccionTarjeta({
|
|
super.key,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.trailing,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final Widget? trailing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 21),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
),
|
|
if (trailing != null) trailing!,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "Emisoras con ajuste propio" drill-down row (spec `eq-custom-presets`
|
|
/// "Stations-With-Own-EQ Drill-Down"): sourced from the existing
|
|
/// `presetsPorEmisora` map, no new state.
|
|
///
|
|
/// Audit 11.10 (t4 lines 600-602): CARD 4 — now wraps itself in its own
|
|
/// `PluriGlassSurface` instead of being one more row inside a shared
|
|
/// glass block.
|
|
class _FilaEmisorasConAjustePropio extends StatelessWidget {
|
|
const _FilaEmisorasConAjustePropio({
|
|
required this.eq,
|
|
required this.estado,
|
|
required this.l10n,
|
|
});
|
|
|
|
final EstadoEcualizador eq;
|
|
final EstadoRadio estado;
|
|
final AppLocalizations l10n;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final uuids = eq.presetsPorEmisora.keys.toList();
|
|
return PluriGlassSurface(
|
|
key: const Key('eq-card-stations-own-eq'),
|
|
borderRadius: BorderRadius.circular(context.pluriTokens.radiusMd),
|
|
padding: EdgeInsets.zero,
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
key: const Key('eq-stations-own-eq-row'),
|
|
borderRadius: BorderRadius.circular(context.pluriTokens.radiusMd),
|
|
onTap:
|
|
() => PluriPushScaffold.push(
|
|
context,
|
|
(_) => _PantallaEmisorasConAjustePropio(
|
|
uuids: uuids,
|
|
nombrePorUuid: (uuid) => _resolverNombreEmisora(estado, uuid),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.tune_rounded, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.equalizerStationsWithOwnEqTitle,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
Text(
|
|
l10n.equalizerStationsWithOwnEqSubtitle,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
key: const Key('eq-stations-own-eq-count'),
|
|
'${uuids.length}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(width: 4),
|
|
const Icon(Icons.chevron_right_rounded, size: 20),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Resolves a station uuid to its display name via [EstadoRadio.listaFavoritos]
|
|
/// (the only local, synchronous list of known stations) — falls back to the
|
|
/// raw uuid for a station that has its own EQ but is not (or no longer) a
|
|
/// favorite, e.g. one set from the player's per-station EQ sheet (WU14).
|
|
String _resolverNombreEmisora(EstadoRadio estado, String uuid) {
|
|
for (final emisora in estado.listaFavoritos) {
|
|
if (emisora.uuid == uuid) return emisora.nombre;
|
|
}
|
|
return uuid;
|
|
}
|
|
|
|
/// Destination screen for the drill-down row above.
|
|
class _PantallaEmisorasConAjustePropio extends StatelessWidget {
|
|
const _PantallaEmisorasConAjustePropio({
|
|
required this.uuids,
|
|
required this.nombrePorUuid,
|
|
});
|
|
|
|
final List<String> uuids;
|
|
final String Function(String uuid) nombrePorUuid;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return PluriPushScaffold(
|
|
title: l10n.equalizerStationsWithOwnEqTitle,
|
|
body:
|
|
uuids.isEmpty
|
|
? Center(child: Text(l10n.equalizerStationsWithOwnEqEmpty))
|
|
: ListView.separated(
|
|
padding: PluriLayout.pageContentPadding,
|
|
itemCount: uuids.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: 4),
|
|
itemBuilder: (ctx, i) {
|
|
final uuid = uuids[i];
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.radio_rounded, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(nombrePorUuid(uuid))),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "Guardar como preset" dialog (spec `eq-custom-presets` "Custom Preset
|
|
/// Save" / "Custom Preset Naming Validates Non-Empty Input").
|
|
class _DialogoGuardarPreset extends StatefulWidget {
|
|
const _DialogoGuardarPreset({required this.eq});
|
|
|
|
final EstadoEcualizador eq;
|
|
|
|
@override
|
|
State<_DialogoGuardarPreset> createState() => _DialogoGuardarPresetState();
|
|
}
|
|
|
|
class _DialogoGuardarPresetState extends State<_DialogoGuardarPreset> {
|
|
late final TextEditingController _nombreCtrl;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_nombreCtrl = TextEditingController();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nombreCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _confirmar() async {
|
|
final guardado = await widget.eq.guardarPresetPersonalizado(
|
|
_nombreCtrl.text,
|
|
);
|
|
if (!mounted) return;
|
|
if (!guardado) {
|
|
setState(
|
|
() =>
|
|
_error =
|
|
AppLocalizations.of(context).equalizerSavePresetEmptyNameError,
|
|
);
|
|
return;
|
|
}
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return AlertDialog(
|
|
title: Text(l10n.equalizerSavePresetDialogTitle),
|
|
content: TextField(
|
|
key: const Key('eq-save-preset-name-field'),
|
|
controller: _nombreCtrl,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.equalizerSavePresetNameLabel,
|
|
errorText: _error,
|
|
),
|
|
),
|
|
actions: [
|
|
FilledButton(
|
|
key: const Key('eq-save-preset-confirm-button'),
|
|
onPressed: _confirmar,
|
|
child: Text(l10n.equalizerSavePresetConfirm),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|