feat(eq): add per-device equalizer with 4-level preset resolution

Introduce multi-device EQ support allowing each audio output device
(built-in speaker, wired, USB, individual Bluetooth by MAC) to have
its own equalizer preset, combined with existing per-station presets
for a full station×device matrix.

- Add DispositivoAudio model and ServicioDispositivoAudio interface
- Add Android platform channel (AudioDeviceCallback) for device detection
- Add iOS AudioDevicesPlugin (AVAudioSession route tracking)
- Extend ServicioEcualizador with device and matrix persistence keys
- Implement 4-level resolution: matrix > station > device > global
- Add advanced EQ settings section with feature toggle (off by default)
- Extend export/import to v3 with backward compatibility
- 184 tests passing, zero analyzer issues
This commit is contained in:
2026-06-27 11:33:53 +02:00
parent 8f42e67b48
commit 4632d53eb8
36 changed files with 2850 additions and 64 deletions
+122
View File
@@ -60,6 +60,8 @@ class _AjustesContent extends StatelessWidget {
children: const [
_SeccionEcualizador(),
SizedBox(height: 12),
_SeccionEcualizadorAvanzado(),
SizedBox(height: 12),
_SeccionGrabaciones(),
SizedBox(height: 12),
_SeccionTimerSueno(),
@@ -652,6 +654,126 @@ class _SeccionEcualizador extends StatelessWidget {
}
}
/// "Advanced Equalization Options" settings section (Phase 7, multi-device EQ).
///
/// Always shows the feature toggle so the user can discover it. When the
/// toggle is OFF, the device list is completely absent (not just invisible),
/// matching the spec scenario "Settings section is absent when toggle is off".
class _SeccionEcualizadorAvanzado extends StatelessWidget {
const _SeccionEcualizadorAvanzado();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final multiDeviceEnabled = eq.eqMultiDeviceEnabled;
final presetsDispositivo = eq.presetsDispositivo;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.devices_rounded),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.advancedEqSectionTitle,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
// The toggle uses GestureDetector + custom row instead of
// SwitchListTile to avoid Material ink assertion inside
// PluriGlassSurface's DecoratedBox. The visual result is identical
// to SwitchListTile.
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap:
() => eq.cambiarMultiDeviceEnabled(!multiDeviceEnabled),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.advancedEqEnableToggle,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
l10n.advancedEqEnableToggleSubtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
Switch.adaptive(
value: multiDeviceEnabled,
onChanged:
(habilitado) => eq.cambiarMultiDeviceEnabled(habilitado),
),
],
),
),
if (multiDeviceEnabled) ...[
const SizedBox(height: 8),
Text(
l10n.advancedEqKnownDevicesTitle,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
if (presetsDispositivo.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
l10n.advancedEqKnownDevicesEmpty,
style: Theme.of(context).textTheme.bodySmall,
),
)
else
for (final entry in presetsDispositivo.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
const Icon(Icons.headphones_rounded, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.key,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
l10n.advancedEqDevicePresetLabel(
entry.value.nombre,
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
],
],
),
);
}
}
class _SeccionOrdenListas extends StatelessWidget {
const _SeccionOrdenListas();