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:
@@ -1,9 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_ecualizador.dart';
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../tema/pluriwave_theme.dart';
|
||||
import '../../widgets/ecualizador_widget.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
@@ -15,8 +18,10 @@ import '../../widgets/pluri_push_scaffold.dart';
|
||||
/// [PluriPushScaffold] now carries the title and the very next row already
|
||||
/// shows the same active/disabled state.
|
||||
///
|
||||
/// This body is a placeholder pending WU13's restyle (ADR-3's own component
|
||||
/// inventory: "body rewritten by WU13").
|
||||
/// WU13 (design ADR-5) restyled [EcualizadorWidget] itself and added the
|
||||
/// base-vs-per-station explainer, the "Salida activa" summary row, the
|
||||
/// "Emisoras con ajuste propio" drill-down, and the "Guardar como preset"
|
||||
/// custom-preset flow — see `_CuerpoEcualizador` below.
|
||||
class PantallaAjustesEcualizador extends StatelessWidget {
|
||||
const PantallaAjustesEcualizador({super.key});
|
||||
|
||||
@@ -30,13 +35,32 @@ class PantallaAjustesEcualizador extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
class _CuerpoEcualizador extends StatelessWidget {
|
||||
class _CuerpoEcualizador extends StatefulWidget {
|
||||
const _CuerpoEcualizador();
|
||||
|
||||
@override
|
||||
State<_CuerpoEcualizador> createState() => _CuerpoEcualizadorState();
|
||||
}
|
||||
|
||||
class _CuerpoEcualizadorState extends State<_CuerpoEcualizador> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final eq = context.read<EstadoEcualizador>();
|
||||
// Fire-and-forget, mirroring the established
|
||||
// `pantalla_ajustes_salida_audio.dart` pattern: both calls are
|
||||
// genuinely async (a SharedPreferences read / a native re-query), so
|
||||
// their completion never lands inside THIS build — no
|
||||
// "setState() during build" risk.
|
||||
unawaited(eq.cargarPresetsPersonalizados());
|
||||
unawaited(eq.refrescarDispositivoActual());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is
|
||||
// only consulted for the current station + favorite flag.
|
||||
// only consulted for the current station + favorite flag and for
|
||||
// resolving station names in the "ajuste propio" drill-down.
|
||||
return Consumer2<EstadoRadio, EstadoEcualizador>(
|
||||
builder: (ctx, estado, eq, _) {
|
||||
final disponible = eq.disponible;
|
||||
@@ -77,16 +101,34 @@ class _CuerpoEcualizador extends StatelessWidget {
|
||||
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_BannerExplicacionBase(l10n: l10n),
|
||||
const SizedBox(height: 8),
|
||||
_FilaSalidaActiva(eq: eq, l10n: l10n),
|
||||
const SizedBox(height: 4),
|
||||
_FilaEmisorasConAjustePropio(eq: eq, estado: estado, l10n: l10n),
|
||||
const SizedBox(height: 12),
|
||||
PresetsEcualizadorWidget(
|
||||
presetActual: eq.presetActual,
|
||||
personalizados: eq.presetsPersonalizados,
|
||||
onSeleccionar: (p) => eq.cambiarPreset(p),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
EcualizadorWidget(
|
||||
preset: eq.presetActual,
|
||||
habilitado: eq.activo,
|
||||
onCambio: (p) => eq.cambiarPreset(p),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
key: const Key('eq-save-preset-action'),
|
||||
onPressed: () => _abrirDialogoGuardarPreset(context, eq),
|
||||
icon: const Icon(Icons.bookmark_add_outlined, size: 20),
|
||||
label: Text(l10n.equalizerSaveAsPresetAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -94,3 +136,281 @@ class _CuerpoEcualizador extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _abrirDialogoGuardarPreset(
|
||||
BuildContext context,
|
||||
EstadoEcualizador eq,
|
||||
) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _DialogoGuardarPreset(eq: eq),
|
||||
);
|
||||
}
|
||||
|
||||
/// Base-vs-per-station explainer (spec `eq-custom-presets` "Base-vs-Per-
|
||||
/// Station Explainer Preserved"): always visible, distinguishing the base
|
||||
/// (global/device) EQ this screen edits from a station's own override.
|
||||
class _BannerExplicacionBase extends StatelessWidget {
|
||||
const _BannerExplicacionBase({required this.l10n});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.pluriTokens;
|
||||
return Container(
|
||||
key: const Key('eq-base-explainer-banner'),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.liveGreen.withValues(alpha: 0.09),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 20, color: tokens.liveGreen),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.equalizerBaseExplainer,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Salida activa" row (spec `eq-custom-presets` "Active Output Surfaced on
|
||||
/// the Main Screen"): surfaced here instead of only inside the Advanced
|
||||
/// (multi-device) screen, and kept live via the SAME `notifyListeners()`
|
||||
/// calls `_onDispositivoCambiado` already fires — no new plumbing needed
|
||||
/// beyond reading `dispositivoActualId` here.
|
||||
class _FilaSalidaActiva extends StatelessWidget {
|
||||
const _FilaSalidaActiva({required this.eq, required this.l10n});
|
||||
|
||||
final EstadoEcualizador eq;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final deviceId = eq.dispositivoActualId;
|
||||
final nombre =
|
||||
deviceId == null
|
||||
? l10n.equalizerActiveOutputDefault
|
||||
: eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId));
|
||||
|
||||
return Padding(
|
||||
key: const Key('eq-active-output-row'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.speaker_group_rounded, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.equalizerActiveOutputLabel,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
nombre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Emisoras con ajuste propio" drill-down row (spec `eq-custom-presets`
|
||||
/// "Stations-With-Own-EQ Drill-Down"): sourced from the existing
|
||||
/// `presetsPorEmisora` map, no new state.
|
||||
class _FilaEmisorasConAjustePropio extends StatelessWidget {
|
||||
const _FilaEmisorasConAjustePropio({
|
||||
required this.eq,
|
||||
required this.estado,
|
||||
required this.l10n,
|
||||
});
|
||||
|
||||
final EstadoEcualizador eq;
|
||||
final EstadoRadio estado;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uuids = eq.presetsPorEmisora.keys.toList();
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
key: const Key('eq-stations-own-eq-row'),
|
||||
borderRadius: BorderRadius.circular(context.pluriTokens.radiusSm),
|
||||
onTap:
|
||||
() => PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => _PantallaEmisorasConAjustePropio(
|
||||
uuids: uuids,
|
||||
nombrePorUuid: (uuid) => _resolverNombreEmisora(estado, uuid),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.tune_rounded, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.equalizerStationsWithOwnEqTitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
l10n.equalizerStationsWithOwnEqSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
key: const Key('eq-stations-own-eq-count'),
|
||||
'${uuids.length}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right_rounded, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a station uuid to its display name via [EstadoRadio.listaFavoritos]
|
||||
/// (the only local, synchronous list of known stations) — falls back to the
|
||||
/// raw uuid for a station that has its own EQ but is not (or no longer) a
|
||||
/// favorite, e.g. one set from the player's per-station EQ sheet (WU14).
|
||||
String _resolverNombreEmisora(EstadoRadio estado, String uuid) {
|
||||
for (final emisora in estado.listaFavoritos) {
|
||||
if (emisora.uuid == uuid) return emisora.nombre;
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/// Destination screen for the drill-down row above.
|
||||
class _PantallaEmisorasConAjustePropio extends StatelessWidget {
|
||||
const _PantallaEmisorasConAjustePropio({
|
||||
required this.uuids,
|
||||
required this.nombrePorUuid,
|
||||
});
|
||||
|
||||
final List<String> uuids;
|
||||
final String Function(String uuid) nombrePorUuid;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriPushScaffold(
|
||||
title: l10n.equalizerStationsWithOwnEqTitle,
|
||||
body:
|
||||
uuids.isEmpty
|
||||
? Center(child: Text(l10n.equalizerStationsWithOwnEqEmpty))
|
||||
: ListView.separated(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
itemCount: uuids.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 4),
|
||||
itemBuilder: (ctx, i) {
|
||||
final uuid = uuids[i];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.radio_rounded, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(nombrePorUuid(uuid))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Guardar como preset" dialog (spec `eq-custom-presets` "Custom Preset
|
||||
/// Save" / "Custom Preset Naming Validates Non-Empty Input").
|
||||
class _DialogoGuardarPreset extends StatefulWidget {
|
||||
const _DialogoGuardarPreset({required this.eq});
|
||||
|
||||
final EstadoEcualizador eq;
|
||||
|
||||
@override
|
||||
State<_DialogoGuardarPreset> createState() => _DialogoGuardarPresetState();
|
||||
}
|
||||
|
||||
class _DialogoGuardarPresetState extends State<_DialogoGuardarPreset> {
|
||||
late final TextEditingController _nombreCtrl;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nombreCtrl = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nombreCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _confirmar() async {
|
||||
final guardado = await widget.eq.guardarPresetPersonalizado(
|
||||
_nombreCtrl.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!guardado) {
|
||||
setState(
|
||||
() =>
|
||||
_error =
|
||||
AppLocalizations.of(context).equalizerSavePresetEmptyNameError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.equalizerSavePresetDialogTitle),
|
||||
content: TextField(
|
||||
key: const Key('eq-save-preset-name-field'),
|
||||
controller: _nombreCtrl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.equalizerSavePresetNameLabel,
|
||||
errorText: _error,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
key: const Key('eq-save-preset-confirm-button'),
|
||||
onPressed: _confirmar,
|
||||
child: Text(l10n.equalizerSavePresetConfirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user