Audit 11.10/11.8 (t4 lines 570-602): the prototype draws the explainer banner, the band sliders, "Salida activa + Guardar como preset + Restablecer a plano" and "Emisoras con ajuste propio" as 4 separate cards. The build nested everything in one outer PluriGlassSurface, so it read as a single merged block, and had no reset-to-flat action at all. Removes the outer wrapper from _CuerpoEcualizador. EcualizadorWidget already draws its own card (design ADR-5, unchanged); the explainer banner already has its own tinted background (unchanged); "Emisoras con ajuste propio" now wraps itself in its own PluriGlassSurface; a new _TarjetaSalidaYAcciones groups Salida activa with two tappable rows — "Guardar como preset" (relocated from a floating end-aligned button) and the new "Restablecer a plano", which applies PresetEcualizador.flat via the existing EstadoEcualizador.cambiarPreset — the same call the "Plano" preset chip already makes, not a new capability. The 5-slider band count, servicio_ecualizador.dart and preset_ecualizador.dart are untouched.
529 lines
18 KiB
Dart
529 lines
18 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 '../../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) => PluriPushScaffold(
|
|
title: AppLocalizations.of(context).equalizerTitle,
|
|
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: [
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(l10n.equalizerEnable),
|
|
subtitle: Text(
|
|
disponible
|
|
? l10n.equalizerRealtimeSubtitle
|
|
: l10n.equalizerPendingSubtitle,
|
|
),
|
|
value: eq.activo,
|
|
onChanged: eq.cambiarActivo,
|
|
),
|
|
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;
|
|
|
|
@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(tokens.radiusSm),
|
|
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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|