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.