From c9fe0ad6518e18bd429e8392d4d2e058688f86a2 Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 29 Jul 2026 12:53:12 +0200 Subject: [PATCH] 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. --- lib/estado/estado_ecualizador.dart | 64 ++++ lib/l10n/app_en.arb | 11 + lib/l10n/app_es.arb | 11 + lib/l10n/gen/app_localizations.dart | 66 ++++ lib/l10n/gen/app_localizations_ar.dart | 37 ++ lib/l10n/gen/app_localizations_bn.dart | 37 ++ lib/l10n/gen/app_localizations_de.dart | 37 ++ lib/l10n/gen/app_localizations_en.dart | 36 ++ lib/l10n/gen/app_localizations_es.dart | 37 ++ lib/l10n/gen/app_localizations_fr.dart | 37 ++ lib/l10n/gen/app_localizations_hi.dart | 37 ++ lib/l10n/gen/app_localizations_id.dart | 37 ++ lib/l10n/gen/app_localizations_it.dart | 37 ++ lib/l10n/gen/app_localizations_ja.dart | 37 ++ lib/l10n/gen/app_localizations_pt.dart | 37 ++ lib/l10n/gen/app_localizations_ru.dart | 37 ++ lib/l10n/gen/app_localizations_zh.dart | 37 ++ .../ajustes/pantalla_ajustes_ecualizador.dart | 328 +++++++++++++++++- .../servicio_presets_personalizados.dart | 88 +++++ lib/widgets/ecualizador_widget.dart | 151 ++++---- openspec/changes/rediseno-funcional/tasks.md | 73 +++- ...ualizador_presets_personalizados_test.dart | 161 +++++++++ test/helpers/fakes.dart | 22 ++ .../pantalla_ajustes_ecualizador_test.dart | 278 ++++++++++++++- .../servicio_presets_personalizados_test.dart | 126 +++++++ test/widgets/ecualizador_widget_test.dart | 95 +++++ 26 files changed, 1857 insertions(+), 97 deletions(-) create mode 100644 lib/servicios/servicio_presets_personalizados.dart create mode 100644 test/estado/estado_ecualizador_presets_personalizados_test.dart create mode 100644 test/servicios/servicio_presets_personalizados_test.dart create mode 100644 test/widgets/ecualizador_widget_test.dart diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index 3d15a62..c3a5dc9 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -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 _nombresPlataforma = {}; + /// User-named custom presets (WU13). Loaded explicitly via + /// [cargarPresetsPersonalizados], not as part of [cargarPersistido] — + /// see that method's doc for why. + List _presetsPersonalizados = []; + PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat; bool _activo = true; @@ -88,6 +103,9 @@ class EstadoEcualizador extends ChangeNotifier { Map get presetsMatriz => Map.unmodifiable(_presetsMatriz); + List 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 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 guardarPresetPersonalizado(String nombre) async { + final nombreValido = nombre.trim(); + if (nombreValido.isEmpty) return false; + + final preset = PresetEcualizador( + nombre: nombreValido, + bandas: List.from(_presetActual.bandas), + ); + await _presetsPersonalizadosService.guardar(preset); + _presetsPersonalizados = await _presetsPersonalizadosService.listar(); + notifyListeners(); + return true; + } + + /// Removes the custom preset named [nombre], if present. + Future 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. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cbb3835..bfa68c3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 70a30e2..52d320b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 303407b..3188235 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -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: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index c431288..374fff2 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -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 => 'المحطة المفضلة'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 8f9dc69..1b34647 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -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 => 'পছন্দের স্টেশন'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 0bbb3a6..4d0946f 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 533fe09..3fefb0f 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 35133e8..43965f1 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index f397658..8685b00 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index c94e04b..b4319a9 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -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 => 'पसंदीदा स्टेशन'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 9f50a4c..5f30325 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 16d05c3..2fd1497 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 1ecd392..34e77fb 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -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 => '優先局'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 844e8b8..d82330a 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 1bb6707..f6c7298 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -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 => 'Предпочитаемая станция'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 846839d..e07daf5 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -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 => '首选电台'; diff --git a/lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart b/lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart index 2ee1859..4d8c1e8 100644 --- a/lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart +++ b/lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart @@ -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(); + // 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( 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 _abrirDialogoGuardarPreset( + BuildContext context, + EstadoEcualizador eq, +) { + return showDialog( + 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 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 _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), + ), + ], + ); + } +} diff --git a/lib/servicios/servicio_presets_personalizados.dart b/lib/servicios/servicio_presets_personalizados.dart new file mode 100644 index 0000000..81c4ba4 --- /dev/null +++ b/lib/servicios/servicio_presets_personalizados.dart @@ -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 _resolverPrefs() async => + _prefs ?? SharedPreferences.getInstance(); + + /// Returns every saved custom preset, in save order. + Future> 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 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 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 _leer(SharedPreferences prefs) { + final raw = prefs.getString(_keyPresetsPersonalizados); + if (raw == null || raw.isEmpty) return []; + try { + final data = jsonDecode(raw) as List; + final resultado = parseListaTolerante( + 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 _guardarTodos( + SharedPreferences prefs, + List presets, + ) async { + final serializado = presets.map((p) => p.toJson()).toList(); + await prefs.setString(_keyPresetsPersonalizados, jsonEncode(serializado)); + } +} diff --git a/lib/widgets/ecualizador_widget.dart b/lib/widgets/ecualizador_widget.dart index 0d89a2e..64c8dec 100644 --- a/lib/widgets/ecualizador_widget.dart +++ b/lib/widgets/ecualizador_widget.dart @@ -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 { 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 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)), diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index 925be72..b4e2e73 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -51,7 +51,7 @@ | 9 | `feat(vacaciones): add vacation range manager screen` | 8 | 350-450 | Medium | Monitor | | 10 | `feat(alarmas): rewrite alarm editor with inline time widget` | 8 | 500-650 | High | **Yes — indivisible new widget**‡ | | 11 | `feat(alarma-sonando): restyle ringing screen, drop live countdown label` | 1 | 200-300 | Low (safety-critical review attention: High) | No | -| 13 | `feat(eq): restyle equalizer screen and add custom presets` | 3a | 400-550 | Medium-High | Monitor | +| 13 | `feat(eq): restyle equalizer screen and add custom presets` | 3a | ~~400-550~~ → **REALIZED: 1,871** (1,793+ / 78-, 25 files) | Medium-High | **Yes — retroactive, see WU13 section** | | 14 | `feat(reproductor): restructure full player with tool-tray and EQ sheet` | 13 | 450-600 | Medium-High | Monitor | | 15 | `feat(grabaciones): add recordings library screen` | 3b | ~~300-400~~ → **REALIZED: 1,767** (1,767+ / 0-, 22 files) | Medium | Monitor§ | | 15b | `fix(grabaciones): wire the recordings library into Settings navigation` | 15 | 60-100 | Low | No | @@ -735,32 +735,67 @@ Stations-With-Own-EQ Drill-Down, Base-vs-Per-Station Explainer Preserved, Active > **Hazard, restated: persistence for custom presets must NOT go in `servicio_ecualizador.dart`.** That file has an > empty-`git diff` success criterion. -- [ ] 13.1 RED — the Ecualizador screen renders exactly **5** vertical sliders (regression guard — reject any - future 7-slider change on sight). -- [ ] 13.2 RED — `servicio_presets_personalizados_test.dart`: save/list/delete round-trip for a named custom preset +- [x] 13.1 RED — the Ecualizador screen renders exactly **5** vertical sliders (regression guard — reject any + future 7-slider change on sight). **Corrected at apply time**: named test file `ecualizador_widget_test.dart` + did not exist before this commit (`ecualizador_widget.dart` had zero coverage) — created new, plus the + screen-level assertion in `pantalla_ajustes_ecualizador_test.dart` (the spec scenario's own GIVEN is "the + Ecualizador settings screen"). +- [x] 13.2 RED — `servicio_presets_personalizados_test.dart`: save/list/delete round-trip for a named custom preset via its **own** SharedPreferences key `eq_custom_presets_v1`. -- [ ] 13.3 RED — submitting an empty/whitespace-only preset name shows a validation message and persists nothing. -- [ ] 13.4 RED — the "Emisoras con ajuste propio" drill-down lists exactly the stations present in +- [x] 13.3 RED — submitting an empty/whitespace-only preset name shows a validation message and persists nothing. + Covered at both the state layer (new `estado_ecualizador_presets_personalizados_test.dart` — see 13.7's note) + and the UI layer (`pantalla_ajustes_ecualizador_test.dart`'s "Guardar como preset" dialog scenarios). +- [x] 13.4 RED — the "Emisoras con ajuste propio" drill-down lists exactly the stations present in `presetsPorEmisora`. -- [ ] 13.5 RED — the "Salida activa" row is visible on the main screen (not only Advanced) and updates on a +- [x] 13.5 RED — the "Salida activa" row is visible on the main screen (not only Advanced) and updates on a simulated device change; the base-vs-per-station explainer banner is visible. -- [ ] 13.6 GREEN — create `lib/servicios/servicio_presets_personalizados.dart` (**new file**, own SharedPreferences +- [x] 13.6 GREEN — create `lib/servicios/servicio_presets_personalizados.dart` (**new file**, own SharedPreferences key). **Do NOT add any member to `servicio_ecualizador.dart`.** `lib/modelos/preset_ecualizador.dart` stays unchanged — a user preset is a `PresetEcualizador` with a user-supplied `nombre`; `toJson`/`desdeJson` already exist. -- [ ] 13.7 GREEN — extend `EstadoEcualizador` with `presetsPersonalizados`, `guardarPresetPersonalizado(nombre)`, - `eliminarPresetPersonalizado(nombre)`, reading/writing only through the new service. -- [ ] 13.8 GREEN — restyle `lib/widgets/ecualizador_widget.dart` in place: strip the internal title + preset chip - row (lines 59-77 — the pushed header now carries the title), restyle the 5 `Card`/`RotatedBox`/`Slider` - tiles to the vertical-track look, add the `habilitado` parameter. Keep the `for (int i = 0; i < 5; i++)` +- [x] 13.7 GREEN — extend `EstadoEcualizador` with `presetsPersonalizados`, `guardarPresetPersonalizado(nombre)`, + `eliminarPresetPersonalizado(nombre)`, reading/writing only through the new service. **Correction found at + apply time**: the new custom-preset LOAD (`cargarPresetsPersonalizados()`) is deliberately NOT folded into + `cargarPersistido()` — that method is exercised ~30 times by `estado_ecualizador_test.dart` (one of the three + protected EQ test files) via Fakes for `servicio`/`dispositivoAudio` ONLY, with zero SharedPreferences + awareness anywhere in that file; adding a third always-real-by-default collaborator to it would have + introduced real SharedPreferences I/O into every one of those cases. Called explicitly from the screen's + `initState` instead (same pattern `refrescarDispositivoActual` already uses). New coverage lives in a NEW + file, `test/estado/estado_ecualizador_presets_personalizados_test.dart` — NOT added to + `estado_ecualizador_test.dart`, which stays unmodified. +- [x] 13.8 GREEN — restyle `lib/widgets/ecualizador_widget.dart` in place: strip the internal title + preset chip + row (re-verified at the CURRENT lines 59-77 of the pre-WU13 file, not trusted from the design doc's + reference), restyle the 5 `Card`/`RotatedBox`/`Slider` tiles to the vertical-track look (token-driven accent + colour, `AnimatedOpacity` grey-out), add the `habilitado` parameter (disables + greys every slider). + `PresetsEcualizadorWidget` additively widened with an optional `personalizados` list (default empty) so + custom presets can join the chip row without a second implementation. Kept the `for (int i = 0; i < 5; i++)` loop bound literally `5`. -- [ ] 13.9 GREEN — build `lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart` wiring - `EstadoEcualizador.presetPrincipal` / `cambiarPresetPrincipal`, surfacing "Salida activa" and the drill-down - row. -- [ ] 13.10 REFACTOR — reviewer check: confirm `git diff` is **empty** for `lib/servicios/servicio_ecualizador.dart`, +- [x] 13.9 GREEN — build out `lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart` (the WU3a placeholder body) + wiring `EstadoEcualizador.presetPrincipal` / `cambiarPresetPrincipal`, surfacing "Salida activa" (own private + row, live via the existing device-change `notifyListeners()`) and the drill-down row (own private row + + destination screen, station uuids resolved against `EstadoRadio.listaFavoritos` with a raw-uuid fallback for + a non-favorite station). Added the base-vs-per-station explainer banner and the "Guardar como preset" action + + dialog (validates via `guardarPresetPersonalizado`, shows an inline `errorText` on empty/whitespace name). +- [x] 13.10 REFACTOR — reviewer check: confirmed `git diff` is **empty** for `lib/servicios/servicio_ecualizador.dart`, `lib/modelos/preset_ecualizador.dart`, and the band-application block at - `lib/servicios/servicio_audio.dart:749-762`. -- [ ] 13.11 Verify — the three EQ test files pass **unmodified**; exactly 5 sliders render. + `lib/servicios/servicio_audio.dart:749-762` (this WU never touches `servicio_audio.dart` at all). +- [x] 13.11 Verify — the three EQ test files (`servicio_ecualizador_test.dart`, `estado_ecualizador_test.dart`, + `servicio_audio_eq_reapply_test.dart`) pass **unmodified**; exactly 5 sliders render (component AND screen + level). Full suite: 713/713 green (2 skipped, unchanged), up from 682. `flutter analyze`: 1 issue, identical + to baseline. Literal-encoding scan: zero hits. + +**`size:exception` recorded.** Realized: **1,871 changed lines** (1,793+/78-) across 25 files against the 400-550 +forecast — same "a strict-TDD commit carries its test files, and any ARB touch drags 13 generated files with it" +pattern as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514). Breakdown: `lib/` production +code alone is 628 lines (`pantalla_ajustes_ecualizador.dart` +326, `ecualizador_widget.dart` restyle ~151, +`estado_ecualizador.dart` +64, new `servicio_presets_personalizados.dart` +87) plus 22 ARB source lines — at the top +edge of the forecast band by itself given this WU also had to build out a screen body WU3a only stubbed; the 4 +test files (2 new state/service-layer files plus the widget test and the extended screen test) add 675 lines, and +11 new ARB keys drag in 546 more via the 13 regenerated `lib/l10n/gen/*.dart` files. Justification: the restyle, +the new persistence service, the state-layer extension and the screen wiring (explainer/salida-activa/drill-down/ +save-flow) are one cohesive vertical slice — WU13 must land as a whole before WU14 can reuse its editor component +by exact runtime type; splitting further would leave either an unstyled widget with no screen consumer or a screen +half-wired to a service that doesn't exist yet. ## WU14 — Reproductor completo + per-station EQ sheet diff --git a/test/estado/estado_ecualizador_presets_personalizados_test.dart b/test/estado/estado_ecualizador_presets_personalizados_test.dart new file mode 100644 index 0000000..051aead --- /dev/null +++ b/test/estado/estado_ecualizador_presets_personalizados_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; + +import '../helpers/fakes.dart'; + +/// WU13 task 13.7 — `EstadoEcualizador`'s new custom-preset members +/// (`presetsPersonalizados`, `guardarPresetPersonalizado`, +/// `eliminarPresetPersonalizado`), reading/writing only through the new +/// `ServicioPresetsPersonalizados` (design ADR-5 hazard box). +/// +/// **Correction found at apply time, deliberate**: this is a NEW file, not +/// an addition to `estado_ecualizador_test.dart`. That file is one of the +/// three EQ test files this change's master guard requires to pass +/// **unmodified** — it is exercised ~30 times via `cargarPersistido()` +/// using Fakes for `servicio`/`dispositivoAudio` ONLY, with no +/// SharedPreferences awareness anywhere in the file. Because of that, +/// `cargarPersistido()` itself is intentionally left untouched by this WU +/// (see `cargarPresetsPersonalizados` below) rather than folding a third, +/// always-real-by-default collaborator into a method exercised by a file +/// that must never change. +void main() { + EstadoEcualizador buildEq({FakeServicioPresetsPersonalizados? servicio}) { + return EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(), + presetsPersonalizadosService: + servicio ?? FakeServicioPresetsPersonalizados(), + ); + } + + test('presetsPersonalizados starts empty before loading', () { + final eq = buildEq(); + + expect(eq.presetsPersonalizados, isEmpty); + eq.dispose(); + }); + + test( + 'cargarPresetsPersonalizados populates presetsPersonalizados from the service', + () async { + final fakeServicio = FakeServicioPresetsPersonalizados(); + final preexistente = PresetEcualizador( + nombre: 'Ya guardado', + bandas: [1, 2, 3, 4, 5], + ); + await fakeServicio.guardar(preexistente); + final eq = buildEq(servicio: fakeServicio); + + await eq.cargarPresetsPersonalizados(); + + expect(eq.presetsPersonalizados, hasLength(1)); + expect(eq.presetsPersonalizados.single.nombre, equals('Ya guardado')); + eq.dispose(); + }, + ); + + test( + 'guardarPresetPersonalizado saves the CURRENT effective preset bands under the given name', + () async { + final eq = buildEq(); + await eq.cambiarPreset(PresetEcualizador.jazz); + + final guardado = await eq.guardarPresetPersonalizado('Mi preset'); + + expect(guardado, isTrue); + expect(eq.presetsPersonalizados, hasLength(1)); + expect(eq.presetsPersonalizados.single.nombre, equals('Mi preset')); + expect( + eq.presetsPersonalizados.single.bandas, + equals(PresetEcualizador.jazz.bandas), + ); + eq.dispose(); + }, + ); + + test('guardarPresetPersonalizado notifies listeners on success', () async { + final eq = buildEq(); + var avisos = 0; + eq.addListener(() => avisos++); + + await eq.guardarPresetPersonalizado('Preset con nombre'); + + expect(avisos, greaterThanOrEqualTo(1)); + eq.dispose(); + }); + + test( + 'guardarPresetPersonalizado rejects an empty name: no save, returns false', + () async { + final eq = buildEq(); + + final guardado = await eq.guardarPresetPersonalizado(''); + + expect(guardado, isFalse); + expect(eq.presetsPersonalizados, isEmpty); + eq.dispose(); + }, + ); + + test( + 'guardarPresetPersonalizado rejects a whitespace-only name: no save, returns false', + () async { + final eq = buildEq(); + + final guardado = await eq.guardarPresetPersonalizado(' '); + + expect(guardado, isFalse); + expect(eq.presetsPersonalizados, isEmpty); + eq.dispose(); + }, + ); + + test( + 'guardarPresetPersonalizado trims surrounding whitespace from a valid name', + () async { + final eq = buildEq(); + + final guardado = await eq.guardarPresetPersonalizado(' Con espacios '); + + expect(guardado, isTrue); + expect(eq.presetsPersonalizados.single.nombre, equals('Con espacios')); + eq.dispose(); + }, + ); + + test( + 'eliminarPresetPersonalizado removes a previously saved preset', + () async { + final eq = buildEq(); + await eq.guardarPresetPersonalizado('Para borrar'); + expect(eq.presetsPersonalizados, hasLength(1)); + + await eq.eliminarPresetPersonalizado('Para borrar'); + + expect(eq.presetsPersonalizados, isEmpty); + eq.dispose(); + }, + ); + + test( + 'guardarPresetPersonalizado reads/writes only through the injected service ' + '(never touches ServicioEcualizador)', + () async { + final fakeEcualizador = FakeServicioEcualizador(); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: fakeEcualizador, + presetsPersonalizadosService: FakeServicioPresetsPersonalizados(), + ); + + await eq.guardarPresetPersonalizado('Aislado'); + + // ServicioEcualizador's own config is completely untouched by the + // custom-preset save — it lives in a separate service (ADR-5 hazard + // box), not folded into servicio_ecualizador.dart's persistence. + expect(fakeEcualizador.config.porEmisora, isEmpty); + eq.dispose(); + }, + ); +} diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 6c2c2f7..5a1bccb 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -11,6 +11,7 @@ import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart'; import 'package:pluriwave/servicios/servicio_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_favoritos.dart'; import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; +import 'package:pluriwave/servicios/servicio_presets_personalizados.dart'; import 'package:pluriwave/servicios/servicio_radio.dart'; class FakeServicioAudio extends ServicioAudio { @@ -477,6 +478,27 @@ class FakeServicioEcualizador extends ServicioEcualizador { } } +/// In-memory fake for [ServicioPresetsPersonalizados] (WU13). Avoids any +/// real SharedPreferences I/O in state-layer tests, matching every other +/// `Fake*` service in this file. +class FakeServicioPresetsPersonalizados extends ServicioPresetsPersonalizados { + final List _presets = []; + + @override + Future> listar() async => List.from(_presets); + + @override + Future guardar(PresetEcualizador preset) async { + _presets.removeWhere((p) => p.nombre == preset.nombre); + _presets.add(preset); + } + + @override + Future eliminar(String nombre) async { + _presets.removeWhere((p) => p.nombre == nombre); + } +} + /// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual]. /// /// Used to test the graceful-failure path in [EstadoEcualizador.cargarPersistido]. diff --git a/test/pantallas/ajustes/pantalla_ajustes_ecualizador_test.dart b/test/pantallas/ajustes/pantalla_ajustes_ecualizador_test.dart index 164ffd1..ac69d7a 100644 --- a/test/pantallas/ajustes/pantalla_ajustes_ecualizador_test.dart +++ b/test/pantallas/ajustes/pantalla_ajustes_ecualizador_test.dart @@ -5,6 +5,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/dispositivo_audio.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_ecualizador.dart'; import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; import 'package:provider/provider.dart'; @@ -17,6 +20,12 @@ import '../../helpers/fakes_alarmas.dart'; /// a [PluriPushScaffold] and its moved controls (the enable switch) still /// respond exactly as they did inside the old `_SeccionEcualizador`. /// +/// WU13 restyled the header away (design ADR-5 — the strip that used to +/// duplicate "Equalizer" as `EcualizadorWidget`'s own internal title is +/// gone), added the base-vs-per-station explainer, the "Salida activa" +/// row, the "Emisoras con ajuste propio" drill-down and the custom-preset +/// save flow — see the `WU13` group below. +/// /// Pre-existing project constraint (see `pantalla_ajustes_test.dart`): /// PluriGlassSurface paints a background over ListTile's ink layer (here, /// via SwitchListTile), which Flutter flags as a warning-level assertion, @@ -43,17 +52,33 @@ void main() { '${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json', ); - Future crearEstado() async { + Future crearEstado({ + Map? porEmisora, + FakeServicioDispositivoAudio? dispositivoAudio, + bool eqMultiDeviceEnabled = false, + List favoritosIniciales = const [], + }) async { + final favoritos = FakeServicioFavoritos(); + for (final emisora in favoritosIniciales) { + await favoritos.agregar(emisora); + } final estado = EstadoRadio( audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), + favoritos: favoritos, radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), + servicioEcualizador: FakeServicioEcualizador( + porEmisora: porEmisora, + eqMultiDeviceEnabled: eqMultiDeviceEnabled, + ), servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + dispositivoAudio: dispositivoAudio, resolverArchivoCustom: archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.ecualizador.cargarPersistido(); + if (favoritosIniciales.isNotEmpty) { + await estado.cargarFavoritos(); + } return estado; } @@ -82,10 +107,11 @@ void main() { await tester.pumpWidget(buildScreen(estado)); await tester.pumpAndSettle(); - // "Equalizer" also appears inside EcualizadorWidget's own pre-existing - // internal header, which WU3a does not touch (ecualizador_widget.dart's - // header strip is WU13's job per design ADR-5) — so we assert on the - // AppBar's title specifically rather than a bare text match. + // WU13 stripped EcualizadorWidget's own internal "Equalizer" title + + // preset Chip row (design ADR-5) — the pushed screen's AppBar is now + // the ONLY place this title renders. Still asserting on the AppBar + // specifically (not a bare text match) keeps this test meaningful even + // if a future WU reintroduces a second on-screen occurrence. expect(find.byType(PluriPushScaffold), findsOneWidget); final appBar = tester.widget(find.byType(AppBar)); expect((appBar.title as Text).data, equals('Equalizer')); @@ -107,4 +133,242 @@ void main() { expect(estado.ecualizador.activo, equals(!before)); }); + + group('WU13 — restyle, custom presets, salida activa, drill-down', () { + testWidgets('exactly 5 sliders render on the Ecualizador screen', ( + tester, + ) async { + // Spec `eq-custom-presets` "Five-Band Equalizer (Regression Guard)": + // GIVEN the user opens the Ecualizador screen THEN exactly 5 sliders + // render — asserted here at the SCREEN level (task 13.1's own GIVEN), + // in addition to the component-level guard in + // `ecualizador_widget_test.dart`. + _suppressListTileInkAssertion(); + final estado = await crearEstado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pumpAndSettle(); + + expect(find.byType(Slider), findsNWidgets(5)); + }); + + testWidgets('base-vs-per-station explainer banner is visible', ( + tester, + ) async { + _suppressListTileInkAssertion(); + final estado = await crearEstado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('eq-base-explainer-banner')), findsOneWidget); + }); + + testWidgets( + 'Salida activa row is visible and shows a default label when no device is tracked', + (tester) async { + _suppressListTileInkAssertion(); + final estado = await crearEstado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('eq-active-output-row')), findsOneWidget); + expect(find.text('Active output'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('eq-active-output-row')), + matching: find.text("This device's speaker"), + ), + findsOneWidget, + ); + }, + ); + + testWidgets('Salida activa row updates on a simulated device change', ( + tester, + ) async { + _suppressListTileInkAssertion(); + final dispositivoAudio = FakeServicioDispositivoAudio(); + final estado = await crearEstado( + dispositivoAudio: dispositivoAudio, + eqMultiDeviceEnabled: true, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pumpAndSettle(); + + dispositivoAudio.emitirDispositivo( + const DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'Auriculares BT', + ), + ); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byKey(const Key('eq-active-output-row')), + matching: find.text('Auriculares BT'), + ), + findsOneWidget, + ); + }); + + testWidgets( + 'Emisoras con ajuste propio drill-down lists exactly the overridden stations', + (tester) async { + _suppressListTileInkAssertion(); + const favorita = Emisora( + uuid: 'uuid-favorita', + nombre: 'Radio Favorita', + url: 'https://example.com/favorita', + ); + final estado = await crearEstado( + porEmisora: { + 'uuid-favorita': PresetEcualizador.rock, + 'uuid-no-favorita': PresetEcualizador.jazz, + }, + favoritosIniciales: const [favorita], + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('eq-stations-own-eq-row')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('eq-stations-own-eq-count'))) + .data, + equals('2'), + ); + + await tester.tap(find.byKey(const Key('eq-stations-own-eq-row'))); + await tester.pumpAndSettle(); + + // The favorite station resolves to its display name; the other + // uuid (never seen among favorites) falls back to the raw uuid — + // same fallback chain `nombreVisible` already uses for devices. + expect(find.text('Radio Favorita'), findsOneWidget); + expect(find.text('uuid-no-favorita'), findsOneWidget); + }, + ); + + testWidgets( + 'drill-down shows an empty state when no station has its own EQ', + (tester) async { + _suppressListTileInkAssertion(); + final estado = await crearEstado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('eq-stations-own-eq-row'))); + await tester.pumpAndSettle(); + + expect(find.text('No stations have their own EQ yet.'), findsOneWidget); + }, + ); + + testWidgets( + 'Guardar como preset persists the current bands; the preset appears in the chip row', + (tester) async { + _suppressListTileInkAssertion(); + final estado = await crearEstado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.byKey(const Key('eq-save-preset-action')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('eq-save-preset-action'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('eq-save-preset-name-field')), + 'Mi preset', + ); + await tester.tap( + find.byKey(const Key('eq-save-preset-confirm-button')), + ); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Mi preset'), findsOneWidget); + expect( + estado.ecualizador.presetsPersonalizados.map((p) => p.nombre), + contains('Mi preset'), + ); + }, + ); + + testWidgets( + 'Guardar como preset with an empty name shows a validation message and persists nothing', + (tester) async { + _suppressListTileInkAssertion(); + final estado = await crearEstado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.byKey(const Key('eq-save-preset-action')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('eq-save-preset-action'))); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const Key('eq-save-preset-confirm-button')), + ); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Enter a name for the preset.'), findsOneWidget); + expect(estado.ecualizador.presetsPersonalizados, isEmpty); + }, + ); + + testWidgets( + 'Guardar como preset with a whitespace-only name shows the same validation message', + (tester) async { + _suppressListTileInkAssertion(); + final estado = await crearEstado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.byKey(const Key('eq-save-preset-action')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('eq-save-preset-action'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('eq-save-preset-name-field')), + ' ', + ); + await tester.tap( + find.byKey(const Key('eq-save-preset-confirm-button')), + ); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(estado.ecualizador.presetsPersonalizados, isEmpty); + }, + ); + }); } diff --git a/test/servicios/servicio_presets_personalizados_test.dart b/test/servicios/servicio_presets_personalizados_test.dart new file mode 100644 index 0000000..4848019 --- /dev/null +++ b/test/servicios/servicio_presets_personalizados_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; +import 'package:pluriwave/servicios/servicio_presets_personalizados.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// WU13 task 13.2 — persistence for user-named custom EQ presets. +/// +/// Design ADR-5 hazard box: this lives in its OWN file and OWN +/// SharedPreferences key (`eq_custom_presets_v1`), deliberately separate +/// from `ServicioEcualizador` — that file has an empty-`git diff` success +/// criterion for this change, so persistence for custom presets must never +/// be added there. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('listar returns an empty list when nothing was saved yet', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioPresetsPersonalizados(prefs: prefs); + + expect(await servicio.listar(), isEmpty); + }); + + test('guardar then listar round-trips a named custom preset', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioPresetsPersonalizados(prefs: prefs); + // Not `const`: PresetEcualizador's constructor asserts `bandas.length + // == 5`, and `List.length` is not constant-foldable in an assert here + // (same const-eval limitation the branch already hit for + // `DateTime(...)` fixtures in WU9) — the model's own static presets + // (e.g. `PresetEcualizador.flat`) are declared `final`, not `const`, + // for the same reason. + final preset = PresetEcualizador( + nombre: 'Mi preset', + bandas: [1.0, -2.0, 3.0, -4.0, 5.0], + ); + + await servicio.guardar(preset); + final guardados = await servicio.listar(); + + expect(guardados, hasLength(1)); + expect(guardados.single, equals(preset)); + }); + + test('guardar persists under its OWN SharedPreferences key', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioPresetsPersonalizados(prefs: prefs); + final preset = PresetEcualizador(nombre: 'Otro', bandas: [0, 0, 0, 0, 0]); + + await servicio.guardar(preset); + + expect(prefs.getString('eq_custom_presets_v1'), isNotNull); + }); + + test('guardar with a repeated name replaces the previous entry', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioPresetsPersonalizados(prefs: prefs); + final v1 = PresetEcualizador(nombre: 'Mismo', bandas: [1, 1, 1, 1, 1]); + final v2 = PresetEcualizador(nombre: 'Mismo', bandas: [2, 2, 2, 2, 2]); + + await servicio.guardar(v1); + await servicio.guardar(v2); + final guardados = await servicio.listar(); + + expect(guardados, hasLength(1)); + expect(guardados.single.bandas, equals(v2.bandas)); + }); + + test('guardar preserves insertion order across multiple presets', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioPresetsPersonalizados(prefs: prefs); + final primero = PresetEcualizador( + nombre: 'Primero', + bandas: [0, 0, 0, 0, 0], + ); + final segundo = PresetEcualizador( + nombre: 'Segundo', + bandas: [0, 0, 0, 0, 0], + ); + + await servicio.guardar(primero); + await servicio.guardar(segundo); + final guardados = await servicio.listar(); + + expect(guardados.map((p) => p.nombre).toList(), ['Primero', 'Segundo']); + }); + + test('eliminar removes the named preset only', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioPresetsPersonalizados(prefs: prefs); + final conservar = PresetEcualizador( + nombre: 'Conservar', + bandas: [0, 0, 0, 0, 0], + ); + final borrar = PresetEcualizador(nombre: 'Borrar', bandas: [0, 0, 0, 0, 0]); + await servicio.guardar(conservar); + await servicio.guardar(borrar); + + await servicio.eliminar('Borrar'); + final guardados = await servicio.listar(); + + expect(guardados, hasLength(1)); + expect(guardados.single.nombre, equals('Conservar')); + }); + + test('eliminar on an unknown name is a safe no-op', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioPresetsPersonalizados(prefs: prefs); + + await servicio.eliminar('no existe'); + + expect(await servicio.listar(), isEmpty); + }); + + test( + 'a corrupt top-level payload degrades to an empty list, never throws', + () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('eq_custom_presets_v1', 'not valid json{{{'); + final servicio = ServicioPresetsPersonalizados(prefs: prefs); + + await expectLater(servicio.listar(), completion(isEmpty)); + }, + ); +} diff --git a/test/widgets/ecualizador_widget_test.dart b/test/widgets/ecualizador_widget_test.dart new file mode 100644 index 0000000..22c8caf --- /dev/null +++ b/test/widgets/ecualizador_widget_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; +import 'package:pluriwave/widgets/ecualizador_widget.dart'; + +/// WU13 task 13.1 — first-class regression guard (design ADR-5, spec +/// `eq-custom-presets` "Five-Band Equalizer"): the equalizer widget must +/// always render exactly 5 vertical sliders. Band count is device-reported +/// via `just_audio`'s `AndroidEqualizer` (spike, Engram id 2498), not +/// app-chosen — any future change rendering 7 sliders is rejected on sight. +/// +/// **Correction found at apply time**: `tasks.md`'s WU13 Verify command +/// names this file as if it already existed ("Modified tests: +/// ecualizador_widget_test.dart"). No such file existed before this commit +/// (`ecualizador_widget.dart` had zero test coverage) — created new instead. +void main() { + Widget buildWidget({ + PresetEcualizador? preset, + bool habilitado = true, + void Function(PresetEcualizador)? onCambio, + }) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: EcualizadorWidget( + preset: preset ?? PresetEcualizador.flat, + habilitado: habilitado, + onCambio: onCambio ?? (_) {}, + ), + ), + ); + } + + testWidgets('renders exactly 5 vertical sliders, one per band', ( + tester, + ) async { + await tester.pumpWidget(buildWidget()); + + expect(find.byType(Slider), findsNWidgets(5)); + }); + + testWidgets('renders exactly 5 sliders regardless of the preset selected', ( + tester, + ) async { + // Regression guard restated: a future change proposing 7 sliders (the + // rejected mockup band count) must fail this test regardless of which + // fixed preset is active. + await tester.pumpWidget(buildWidget(preset: PresetEcualizador.jazz)); + + expect(find.byType(Slider), findsNWidgets(5)); + }); + + testWidgets('dragging a slider reports the updated band back via onCambio', ( + tester, + ) async { + PresetEcualizador? reportado; + await tester.pumpWidget(buildWidget(onCambio: (p) => reportado = p)); + + final slider = tester.widget(find.byType(Slider).first); + slider.onChanged?.call(6.0); + await tester.pump(); + + expect(reportado, isNotNull); + expect(reportado!.bandas.length, 5); + expect(reportado!.bandas.first, 6.0); + }); + + testWidgets( + 'habilitado: false disables every slider (greyed, non-interactive)', + (tester) async { + await tester.pumpWidget(buildWidget(habilitado: false)); + + final sliders = tester.widgetList(find.byType(Slider)); + expect(sliders, hasLength(5)); + for (final slider in sliders) { + expect(slider.onChanged, isNull); + } + }, + ); + + testWidgets('habilitado: true (default) keeps every slider interactive', ( + tester, + ) async { + await tester.pumpWidget(buildWidget()); + + final sliders = tester.widgetList(find.byType(Slider)); + expect(sliders, hasLength(5)); + for (final slider in sliders) { + expect(slider.onChanged, isNotNull); + } + }); +}