feat(eq): restyle equalizer screen and add custom presets

Restyle the Ecualizador settings screen to the new visual language while
keeping the equalizer at 5 bands (spike-resolved, Engram id 2498 - band
count is device-reported via just_audio's AndroidEqualizer, not app-chosen;
the approved mockup's 7 sliders would silently no-op on typical hardware).

- Restyle EcualizadorWidget in place: strip its internal title + preset
  chip row (the pushed screen's header now carries the title), add a
  habilitado parameter that greys/disables every slider when EQ is off.
  Widen PresetsEcualizadorWidget additively (personalizados param) so
  custom presets can join the chip row without a second implementation.
- Add servicio_presets_personalizados.dart (new file, own SharedPreferences
  key eq_custom_presets_v1) for custom EQ preset persistence - kept out of
  servicio_ecualizador.dart, which has an empty-git-diff success criterion
  for this change. preset_ecualizador.dart is unchanged: a custom preset is
  just a PresetEcualizador with a user-supplied name.
- Extend EstadoEcualizador with presetsPersonalizados,
  guardarPresetPersonalizado (validates non-empty name),
  eliminarPresetPersonalizado. The load is a new explicit
  cargarPresetsPersonalizados(), deliberately NOT folded into
  cargarPersistido(): that method is exercised ~30 times by
  estado_ecualizador_test.dart (protected, must stay unmodified) via Fakes
  only, with no SharedPreferences awareness in that file.
- Build out the Ecualizador screen body: base-vs-per-station explainer
  banner, a "Salida activa" row surfaced on the main screen (previously
  Advanced-only), an "Emisoras con ajuste propio" drill-down sourced from
  the existing presetsPorEmisora map, and a "Guardar como preset" action.

New coverage lives in new files rather than touching the three protected
EQ test files: ecualizador_widget_test.dart (component-level, did not
exist before this commit), servicio_presets_personalizados_test.dart, and
estado_ecualizador_presets_personalizados_test.dart. servicio_ecualizador.dart,
servicio_audio.dart and the three protected EQ test files keep an empty
git diff. Full suite: 713/713 green (2 skipped, unchanged), up from 682.

size:exception - realized 1,954 changed lines (25 files, plus this docs
update) against the 400-550 forecast: lib/ + ARB alone is ~650 lines, near
the top of the forecast band by itself since this WU also had to build out
a screen body WU3a only stubbed; the rest is 4 test files (675 lines) and
11 new ARB keys regenerating 13 lib/l10n/gen files (~546 lines) - the same
pattern every prior work unit in this branch has hit. Not splittable: WU14
reuses this unit's editor component by exact runtime type and cannot begin
until this lands as a whole.
This commit is contained in:
2026-07-29 12:53:12 +02:00
parent e1732af222
commit c9fe0ad651
26 changed files with 1857 additions and 97 deletions
+64
View File
@@ -7,6 +7,7 @@ import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_presets_personalizados.dart';
/// Equalizer state extracted from `EstadoRadio` (S4-R1).
///
@@ -30,8 +31,11 @@ class EstadoEcualizador extends ChangeNotifier {
required this.audio,
ServicioEcualizador? servicio,
ServicioDispositivoAudio? dispositivoAudio,
ServicioPresetsPersonalizados? presetsPersonalizadosService,
String? Function()? emisoraActualUuid,
}) : servicio = servicio ?? ServicioEcualizador(),
_presetsPersonalizadosService =
presetsPersonalizadosService ?? ServicioPresetsPersonalizados(),
_dispositivoAudio = dispositivoAudio,
_emisoraActualUuid = emisoraActualUuid ?? (() => null);
@@ -39,6 +43,12 @@ class EstadoEcualizador extends ChangeNotifier {
final ServicioEcualizador servicio;
final ServicioDispositivoAudio? _dispositivoAudio;
/// Persistence for user-named custom presets (design ADR-5 hazard box).
/// Deliberately a SEPARATE service/key from [servicio] — see
/// [cargarPresetsPersonalizados] for why its load is not folded into
/// [cargarPersistido].
final ServicioPresetsPersonalizados _presetsPersonalizadosService;
/// Callback into the owner (EstadoRadio) for the currently playing station;
/// keeps this notifier free of any station-list coupling.
final String? Function() _emisoraActualUuid;
@@ -61,6 +71,11 @@ class EstadoEcualizador extends ChangeNotifier {
/// every session without needing a SharedPreferences key or migration.
final Map<String, String> _nombresPlataforma = {};
/// User-named custom presets (WU13). Loaded explicitly via
/// [cargarPresetsPersonalizados], not as part of [cargarPersistido] —
/// see that method's doc for why.
List<PresetEcualizador> _presetsPersonalizados = [];
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
PresetEcualizador _presetActual = PresetEcualizador.flat;
bool _activo = true;
@@ -88,6 +103,9 @@ class EstadoEcualizador extends ChangeNotifier {
Map<String, PresetEcualizador> get presetsMatriz =>
Map.unmodifiable(_presetsMatriz);
List<PresetEcualizador> get presetsPersonalizados =>
List.unmodifiable(_presetsPersonalizados);
bool get emisoraActualTienePresetPropio {
final uuid = _emisoraActualUuid();
if (uuid == null) return false;
@@ -424,6 +442,52 @@ class EstadoEcualizador extends ChangeNotifier {
}
}
/// Loads the persisted custom-preset list (WU13, `eq-custom-presets`
/// spec — the Settings EQ screen's preset chip row).
///
/// Deliberately NOT part of [cargarPersistido]: that method is exercised
/// roughly 30 times by `estado_ecualizador_test.dart` — one of this
/// change's protected EQ test files, required to pass **unmodified** —
/// via Fakes for [servicio]/[_dispositivoAudio] only, with no
/// SharedPreferences awareness anywhere in that file. Folding a third,
/// always-real-by-default collaborator into [cargarPersistido] would
/// introduce a real SharedPreferences call into every one of those
/// cases. Called explicitly by the Settings EQ screen instead, the same
/// way `refrescarDispositivoActual` is already called from screen
/// `initState`, not from [cargarPersistido].
Future<void> cargarPresetsPersonalizados() async {
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
notifyListeners();
}
/// Saves the CURRENT effective preset's bands (`presetActual`) as a new
/// named custom preset (spec "Custom Preset Save").
///
/// Returns `false` — and persists nothing — when [nombre] is empty or
/// whitespace-only (spec "Custom Preset Naming Validates Non-Empty
/// Input"), the same non-crashing validate-before-persist shape
/// [renombrarDispositivo] already uses elsewhere in this class.
Future<bool> guardarPresetPersonalizado(String nombre) async {
final nombreValido = nombre.trim();
if (nombreValido.isEmpty) return false;
final preset = PresetEcualizador(
nombre: nombreValido,
bandas: List<double>.from(_presetActual.bandas),
);
await _presetsPersonalizadosService.guardar(preset);
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
notifyListeners();
return true;
}
/// Removes the custom preset named [nombre], if present.
Future<void> eliminarPresetPersonalizado(String nombre) async {
await _presetsPersonalizadosService.eliminar(nombre);
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
notifyListeners();
}
/// Persists a custom display name for [deviceId].
///
/// Empty names are silently ignored so the existing name is preserved.
+11
View File
@@ -95,6 +95,17 @@
"equalizerPerStationTitle": "Use custom EQ for this favorite",
"equalizerPerStationActive": "Active for {stationName}",
"equalizerPerStationMain": "Using main EQ for {stationName}",
"equalizerBaseExplainer": "This is the base equalizer: it applies to every station without its own setting. A station's own EQ is set from its own playback screen and overrides this one.",
"equalizerActiveOutputLabel": "Active output",
"equalizerActiveOutputDefault": "This device's speaker",
"equalizerStationsWithOwnEqTitle": "Stations with their own EQ",
"equalizerStationsWithOwnEqSubtitle": "Ignore this base equalizer",
"equalizerStationsWithOwnEqEmpty": "No stations have their own EQ yet.",
"equalizerSaveAsPresetAction": "Save as preset",
"equalizerSavePresetDialogTitle": "Save as preset",
"equalizerSavePresetNameLabel": "Preset name",
"equalizerSavePresetEmptyNameError": "Enter a name for the preset.",
"equalizerSavePresetConfirm": "Save",
"preferredStationTitle": "Preferred station",
"preferredStationDescription": "Preselected for new alarms and available for quick playback.",
"preferredStationNoStationsTitle": "No stations available yet",
+11
View File
@@ -95,6 +95,17 @@
"equalizerPerStationTitle": "Usar EQ propio para esta favorita",
"equalizerPerStationActive": "Activo para {stationName}",
"equalizerPerStationMain": "Usando EQ principal para {stationName}",
"equalizerBaseExplainer": "Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.",
"equalizerActiveOutputLabel": "Salida activa",
"equalizerActiveOutputDefault": "El altavoz de este dispositivo",
"equalizerStationsWithOwnEqTitle": "Emisoras con ajuste propio",
"equalizerStationsWithOwnEqSubtitle": "Ignoran este ecualizador base",
"equalizerStationsWithOwnEqEmpty": "Ninguna emisora tiene ajuste propio todavía.",
"equalizerSaveAsPresetAction": "Guardar como preset",
"equalizerSavePresetDialogTitle": "Guardar como preset",
"equalizerSavePresetNameLabel": "Nombre del preset",
"equalizerSavePresetEmptyNameError": "Ingresá un nombre para el preset.",
"equalizerSavePresetConfirm": "Guardar",
"preferredStationTitle": "Emisora preferida",
"preferredStationDescription": "Se preselecciona al crear alarmas y puede iniciarse como reproducción rápida.",
"preferredStationNoStationsTitle": "Todavía no hay emisoras disponibles",
+66
View File
@@ -496,6 +496,72 @@ abstract class AppLocalizations {
/// **'Usando EQ principal para {stationName}'**
String equalizerPerStationMain(Object stationName);
/// No description provided for @equalizerBaseExplainer.
///
/// In es, this message translates to:
/// **'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.'**
String get equalizerBaseExplainer;
/// No description provided for @equalizerActiveOutputLabel.
///
/// In es, this message translates to:
/// **'Salida activa'**
String get equalizerActiveOutputLabel;
/// No description provided for @equalizerActiveOutputDefault.
///
/// In es, this message translates to:
/// **'El altavoz de este dispositivo'**
String get equalizerActiveOutputDefault;
/// No description provided for @equalizerStationsWithOwnEqTitle.
///
/// In es, this message translates to:
/// **'Emisoras con ajuste propio'**
String get equalizerStationsWithOwnEqTitle;
/// No description provided for @equalizerStationsWithOwnEqSubtitle.
///
/// In es, this message translates to:
/// **'Ignoran este ecualizador base'**
String get equalizerStationsWithOwnEqSubtitle;
/// No description provided for @equalizerStationsWithOwnEqEmpty.
///
/// In es, this message translates to:
/// **'Ninguna emisora tiene ajuste propio todavía.'**
String get equalizerStationsWithOwnEqEmpty;
/// No description provided for @equalizerSaveAsPresetAction.
///
/// In es, this message translates to:
/// **'Guardar como preset'**
String get equalizerSaveAsPresetAction;
/// No description provided for @equalizerSavePresetDialogTitle.
///
/// In es, this message translates to:
/// **'Guardar como preset'**
String get equalizerSavePresetDialogTitle;
/// No description provided for @equalizerSavePresetNameLabel.
///
/// In es, this message translates to:
/// **'Nombre del preset'**
String get equalizerSavePresetNameLabel;
/// No description provided for @equalizerSavePresetEmptyNameError.
///
/// In es, this message translates to:
/// **'Ingresá un nombre para el preset.'**
String get equalizerSavePresetEmptyNameError;
/// No description provided for @equalizerSavePresetConfirm.
///
/// In es, this message translates to:
/// **'Guardar'**
String get equalizerSavePresetConfirm;
/// No description provided for @preferredStationTitle.
///
/// In es, this message translates to:
+37
View File
@@ -223,6 +223,43 @@ class AppLocalizationsAr extends AppLocalizations {
return 'استخدام المعادل الرئيسي لـ $stationName';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'المحطة المفضلة';
+37
View File
@@ -226,6 +226,43 @@ class AppLocalizationsBn extends AppLocalizations {
return '$stationName-এর জন্য মূল ইকুয়ালাইজার ব্যবহার করা হচ্ছে';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'পছন্দের স্টেশন';
+37
View File
@@ -226,6 +226,43 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Haupt-EQ für $stationName wird verwendet';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'Bevorzugter Sender';
+36
View File
@@ -224,6 +224,42 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Using main EQ for $stationName';
}
@override
String get equalizerBaseExplainer =>
'This is the base equalizer: it applies to every station without its own setting. A station\'s own EQ is set from its own playback screen and overrides this one.';
@override
String get equalizerActiveOutputLabel => 'Active output';
@override
String get equalizerActiveOutputDefault => 'This device\'s speaker';
@override
String get equalizerStationsWithOwnEqTitle => 'Stations with their own EQ';
@override
String get equalizerStationsWithOwnEqSubtitle => 'Ignore this base equalizer';
@override
String get equalizerStationsWithOwnEqEmpty =>
'No stations have their own EQ yet.';
@override
String get equalizerSaveAsPresetAction => 'Save as preset';
@override
String get equalizerSavePresetDialogTitle => 'Save as preset';
@override
String get equalizerSavePresetNameLabel => 'Preset name';
@override
String get equalizerSavePresetEmptyNameError =>
'Enter a name for the preset.';
@override
String get equalizerSavePresetConfirm => 'Save';
@override
String get preferredStationTitle => 'Preferred station';
+37
View File
@@ -225,6 +225,43 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Usando EQ principal para $stationName';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'Emisora preferida';
+37
View File
@@ -227,6 +227,43 @@ class AppLocalizationsFr extends AppLocalizations {
return 'EQ principal utilisé pour $stationName';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'Station préférée';
+37
View File
@@ -225,6 +225,43 @@ class AppLocalizationsHi extends AppLocalizations {
return '$stationName के लिए मुख्य EQ इस्तेमाल हो रहा है';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'पसंदीदा स्टेशन';
+37
View File
@@ -225,6 +225,43 @@ class AppLocalizationsId extends AppLocalizations {
return 'Menggunakan EQ utama untuk $stationName';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'Stasiun pilihan';
+37
View File
@@ -225,6 +225,43 @@ class AppLocalizationsIt extends AppLocalizations {
return 'EQ principale in uso per $stationName';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'Emittente preferita';
+37
View File
@@ -217,6 +217,43 @@ class AppLocalizationsJa extends AppLocalizations {
return '$stationName でメインEQを使用中';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => '優先局';
+37
View File
@@ -224,6 +224,43 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Usando o EQ principal para $stationName';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'Estação preferida';
+37
View File
@@ -226,6 +226,43 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Используется основной EQ для $stationName';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => 'Предпочитаемая станция';
+37
View File
@@ -216,6 +216,43 @@ class AppLocalizationsZh extends AppLocalizations {
return '正在为 $stationName 使用主均衡器';
}
@override
String get equalizerBaseExplainer =>
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
@override
String get equalizerActiveOutputLabel => 'Salida activa';
@override
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
@override
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
@override
String get equalizerStationsWithOwnEqSubtitle =>
'Ignoran este ecualizador base';
@override
String get equalizerStationsWithOwnEqEmpty =>
'Ninguna emisora tiene ajuste propio todavía.';
@override
String get equalizerSaveAsPresetAction => 'Guardar como preset';
@override
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
@override
String get equalizerSavePresetNameLabel => 'Nombre del preset';
@override
String get equalizerSavePresetEmptyNameError =>
'Ingresá un nombre para el preset.';
@override
String get equalizerSavePresetConfirm => 'Guardar';
@override
String get preferredStationTitle => '首选电台';
@@ -1,9 +1,12 @@
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 '../../tema/pluriwave_theme.dart';
import '../../widgets/ecualizador_widget.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
@@ -15,8 +18,10 @@ import '../../widgets/pluri_push_scaffold.dart';
/// [PluriPushScaffold] now carries the title and the very next row already
/// shows the same active/disabled state.
///
/// This body is a placeholder pending WU13's restyle (ADR-3's own component
/// inventory: "body rewritten by WU13").
/// 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.
class PantallaAjustesEcualizador extends StatelessWidget {
const PantallaAjustesEcualizador({super.key});
@@ -30,13 +35,32 @@ class PantallaAjustesEcualizador extends StatelessWidget {
);
}
class _CuerpoEcualizador extends StatelessWidget {
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.
// 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;
@@ -77,16 +101,34 @@ class _CuerpoEcualizador extends StatelessWidget {
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
),
],
const SizedBox(height: 12),
_BannerExplicacionBase(l10n: l10n),
const SizedBox(height: 8),
_FilaSalidaActiva(eq: eq, l10n: l10n),
const SizedBox(height: 4),
_FilaEmisorasConAjustePropio(eq: eq, estado: estado, l10n: l10n),
const SizedBox(height: 12),
PresetsEcualizadorWidget(
presetActual: eq.presetActual,
personalizados: eq.presetsPersonalizados,
onSeleccionar: (p) => eq.cambiarPreset(p),
),
const SizedBox(height: 12),
EcualizadorWidget(
preset: eq.presetActual,
habilitado: eq.activo,
onCambio: (p) => eq.cambiarPreset(p),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
key: const Key('eq-save-preset-action'),
onPressed: () => _abrirDialogoGuardarPreset(context, eq),
icon: const Icon(Icons.bookmark_add_outlined, size: 20),
label: Text(l10n.equalizerSaveAsPresetAction),
),
),
],
),
);
@@ -94,3 +136,281 @@ class _CuerpoEcualizador extends StatelessWidget {
);
}
}
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(vertical: 4),
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,
),
),
],
),
);
}
}
/// "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.
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 Material(
type: MaterialType.transparency,
child: InkWell(
key: const Key('eq-stations-own-eq-row'),
borderRadius: BorderRadius.circular(context.pluriTokens.radiusSm),
onTap:
() => PluriPushScaffold.push(
context,
(_) => _PantallaEmisorasConAjustePropio(
uuids: uuids,
nombrePorUuid: (uuid) => _resolverNombreEmisora(estado, uuid),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
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),
),
],
);
}
}
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/preset_ecualizador.dart';
import 'persistencia_tolerante.dart';
/// Persistence for USER-NAMED custom EQ presets (design ADR-5 hazard box,
/// `eq-custom-presets` spec — "Custom Preset Save").
///
/// Deliberately its OWN file and OWN SharedPreferences key
/// (`eq_custom_presets_v1`), never folded into [ServicioEcualizador]: that
/// file has an empty-`git diff` success criterion for this change, so
/// nothing custom-preset-related may be added there. A custom preset is
/// just a [PresetEcualizador] with a user-supplied `nombre` — the model
/// itself needs no change (`toJson`/`desdeJson` already exist).
class ServicioPresetsPersonalizados {
ServicioPresetsPersonalizados({SharedPreferences? prefs}) : _prefs = prefs;
static const _keyPresetsPersonalizados = 'eq_custom_presets_v1';
final SharedPreferences? _prefs;
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
/// Returns every saved custom preset, in save order.
Future<List<PresetEcualizador>> listar() async {
final prefs = await _resolverPrefs();
return _leer(prefs);
}
/// Saves [preset] under its own name. A preset already saved under the
/// same name is replaced (last write wins) rather than duplicated.
Future<void> guardar(PresetEcualizador preset) async {
final prefs = await _resolverPrefs();
final actuales =
_leer(prefs)
..removeWhere((p) => p.nombre == preset.nombre)
..add(preset);
await _guardarTodos(prefs, actuales);
}
/// Removes the custom preset named [nombre], if present. A safe no-op
/// when no preset with that name exists.
Future<void> eliminar(String nombre) async {
final prefs = await _resolverPrefs();
final actuales = _leer(prefs)..removeWhere((p) => p.nombre == nombre);
await _guardarTodos(prefs, actuales);
}
/// Reads the persisted list with per-entry tolerance
/// (persistence-resilience D1/D6, same shared helper `ServicioEcualizador`
/// uses): an entry that fails to parse is skipped and logged, its
/// siblings survive. A top-level decode failure degrades to an empty
/// list (also logged) — custom presets are explicit-only user writes and
/// trivially re-creatable, so there is no flag/quarantine here, matching
/// `ServicioEcualizador`'s own documented D6 asymmetry vs. Alarms/Stations.
List<PresetEcualizador> _leer(SharedPreferences prefs) {
final raw = prefs.getString(_keyPresetsPersonalizados);
if (raw == null || raw.isEmpty) return [];
try {
final data = jsonDecode(raw) as List<dynamic>;
final resultado = parseListaTolerante<PresetEcualizador>(
data,
(entrada) => PresetEcualizador.desdeJson(entrada),
subsistema: 'presets_personalizados',
coleccion: _keyPresetsPersonalizados,
);
return resultado.validas;
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'presets_personalizados',
detalle: _keyPresetsPersonalizados,
razon: e.toString(),
);
return [];
}
}
Future<void> _guardarTodos(
SharedPreferences prefs,
List<PresetEcualizador> presets,
) async {
final serializado = presets.map((p) => p.toJson()).toList();
await prefs.setString(_keyPresetsPersonalizados, jsonEncode(serializado));
}
}
+84 -67
View File
@@ -9,10 +9,16 @@ 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
@@ -50,91 +56,92 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
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: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
children: [
Text(
l10n.equalizerTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
Chip(
label: Text(
_nombrePreset(l10n, widget.preset.nombre),
style: theme.textTheme.labelMedium,
),
backgroundColor: theme.colorScheme.secondaryContainer
.withValues(alpha: 0.75),
),
],
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (int i = 0; i < 5; i++)
Expanded(
child: Card(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.35,
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,
),
margin: const EdgeInsets.symmetric(horizontal: 4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
child: Column(
children: [
SizedBox(
height: 152,
child: Semantics(
slider: true,
label: l10n.equalizerBandLabel(_etiquetas[i]),
value: l10n.equalizerBandValue(
_bandas[i].toStringAsFixed(1),
),
child: RotatedBox(
quarterTurns: 3,
child: Column(
children: [
SizedBox(
height: 152,
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,
activeTrackColor: tokens.liveGreen,
thumbColor: tokens.liveGreen,
inactiveTrackColor:
theme.colorScheme.surfaceContainerHighest,
overlayColor: tokens.liveGreen.withValues(
alpha: 0.15,
),
),
child: Slider(
value: _bandas[i],
min: -12.0,
max: 12.0,
divisions: 24,
onChanged: (v) => _actualizarBanda(i, v),
onChanged:
widget.habilitado
? (v) => _actualizarBanda(i, v)
: null,
),
),
),
),
Text(
'${_bandas[i].toStringAsFixed(1)}dB',
style: theme.textTheme.labelSmall,
),
Text(
'${_bandas[i].toStringAsFixed(1)}dB',
style: theme.textTheme.labelSmall?.copyWith(
color: tokens.liveGreen,
fontWeight: FontWeight.w700,
),
Text(
_etiquetas[i],
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
Text(
_etiquetas[i],
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
],
),
textAlign: TextAlign.center,
),
],
),
),
),
],
),
),
),
],
),
);
@@ -158,21 +165,31 @@ 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 theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final todos = [...PresetEcualizador.presets, ...personalizados];
return Wrap(
spacing: 8,
runSpacing: 6,
children:
PresetEcualizador.presets.map((p) {
todos.map((p) {
final selected = p.nombre == presetActual.nombre;
return ChoiceChip(
label: Text(_nombrePreset(l10n, p.nombre)),