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
+191 -39
View File
@@ -7,53 +7,53 @@ import 'package:pluriwave/servicios/servicio_export_import.dart';
void main() {
const servicio = ServicioExportImport();
// Shared fixture data used across multiple tests.
const gruposFavoritosFixture = [
GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1),
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Sin asignar',
orden: 0,
protegido: true,
),
];
const favoritosFixture = [
Emisora(
uuid: 'fav-1',
nombre: 'Radio Uno',
url: 'https://uno.example/stream',
grupoFavoritosId: 'g1',
bitrate: 192,
),
];
const emisorasCustomFixture = [
Emisora(
uuid: 'custom-1',
nombre: 'Mi Radio',
url: 'https://mia.example/stream',
),
];
const alarmasFixture = {
'alarmas': [
{'id': 'a1', 'hora': 7, 'minuto': 30, 'activa': true, 'snoozeMinutos': 5},
],
'vacaciones': [
{'desde': '2026-07-01', 'hasta': '2026-07-15'},
],
};
group('ServicioExportImport (S4-R4)', () {
test('v2 round-trip: exportar then importar yields a deep-equal config '
'(S4-R4-A, S6-R2 test #4)', () {
final config = servicio.construirExportacion(
gruposFavoritos: const [
GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1),
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Sin asignar',
orden: 0,
protegido: true,
),
],
favoritos: const [
Emisora(
uuid: 'fav-1',
nombre: 'Radio Uno',
url: 'https://uno.example/stream',
grupoFavoritosId: 'g1',
bitrate: 192,
),
],
emisorasCustom: const [
Emisora(
uuid: 'custom-1',
nombre: 'Mi Radio',
url: 'https://mia.example/stream',
),
],
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.jazz,
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
// Raw alarm block exactly as ServicioAlarmas persists it (alarms +
// vacations + exceptions) — must pass through untouched.
alarmas: const {
'alarmas': [
{
'id': 'a1',
'hora': 7,
'minuto': 30,
'activa': true,
'snoozeMinutos': 5,
},
],
'vacaciones': [
{'desde': '2026-07-01', 'hasta': '2026-07-15'},
],
},
alarmas: alarmasFixture,
emisoraPreferidaUuid: 'fav-1',
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300, 600, 1800],
@@ -80,4 +80,156 @@ void main() {
expect(servicio.importar('[1, 2, 3]'), isNull);
});
});
group('ServicioExportImport v3 — multi-device EQ (Phase 6)', () {
test(
'6.1-A: v3 export includes schemaVersion 3 and device/matrix preset keys',
() {
final presetDispositivo = PresetEcualizador.rock;
final presetMatriz = PresetEcualizador.jazz;
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
presetsPorDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': presetDispositivo,
},
presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': presetMatriz},
eqMultiDeviceEnabled: true,
);
// v3 schema version.
expect(config['version'], 3);
// Device preset map present and correct.
expect(config['presetsPorDispositivo'], isA<Map>());
final dispositivoMap =
config['presetsPorDispositivo'] as Map<String, dynamic>;
expect(dispositivoMap, contains('bt_a2dp:AA:BB:CC:DD:EE:FF'));
// Matrix preset map present and correct.
expect(config['presetsMatriz'], isA<Map>());
final matrizMap = config['presetsMatriz'] as Map<String, dynamic>;
expect(matrizMap, contains('fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF'));
// Toggle exported.
expect(config['eqMultiDeviceEnabled'], isTrue);
},
);
test(
'6.1-B: v3 round-trip preserves device and matrix presets exactly',
() {
final dispositivoPreset = PresetEcualizador.rock;
final matrizPreset = PresetEcualizador.jazz;
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {'fav-1': PresetEcualizador.pop},
alarmas: alarmasFixture,
emisoraPreferidaUuid: 'fav-1',
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300, 1800],
presetsPorDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': dispositivoPreset,
'wired_headset': PresetEcualizador.flat,
},
presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': matrizPreset},
eqMultiDeviceEnabled: true,
);
final json = servicio.exportar(config);
final importado = servicio.importar(json);
expect(importado, isNotNull);
expect(importado!['version'], 3);
// Device presets survive the round-trip.
final deviceMap =
importado['presetsPorDispositivo'] as Map<String, dynamic>;
expect(
deviceMap['bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(dispositivoPreset.toJson()),
);
expect(
deviceMap['wired_headset'],
equals(PresetEcualizador.flat.toJson()),
);
// Matrix presets survive the round-trip.
final matrizMap = importado['presetsMatriz'] as Map<String, dynamic>;
expect(
matrizMap['fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(matrizPreset.toJson()),
);
// Toggle survives.
expect(importado['eqMultiDeviceEnabled'], isTrue);
},
);
test(
'6.1-C: v2 file (no device fields) imports cleanly — missing keys default to empty maps',
() {
// Simulate a v2 export file (no presetsPorDispositivo / presetsMatriz).
final v2Config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.jazz,
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
// No device fields — emulates a v2 export from the old app.
);
// Verify the export has no device keys (a v2 file would not).
expect(v2Config.containsKey('presetsPorDispositivo'), isFalse);
expect(v2Config.containsKey('presetsMatriz'), isFalse);
expect(v2Config.containsKey('eqMultiDeviceEnabled'), isFalse);
// Import it — parsing should succeed.
final json = servicio.exportar(v2Config);
final importado = servicio.importar(json);
expect(importado, isNotNull);
// Missing device keys should gracefully resolve to null (caller uses ?? {}).
expect(importado!['presetsPorDispositivo'], isNull);
expect(importado['presetsMatriz'], isNull);
expect(importado['eqMultiDeviceEnabled'], isNull);
},
);
test(
'6.1-D: unknown schemaVersion (v4-future) does not crash importar()',
() {
// Simulate a future v4 export (unknown version, extra fields).
final futurista = <String, dynamic>{
'version': 99,
'presetsPorDispositivo': <String, dynamic>{},
'presetsMatriz': <String, dynamic>{},
'unknownFutureField': 'some-value',
};
final json = servicio.exportar(futurista);
// importar() should parse without throwing — version checking is the
// caller's responsibility (EstadoRadio.importarConfig).
expect(() => servicio.importar(json), returnsNormally);
final importado = servicio.importar(json);
expect(importado, isNotNull);
expect(importado!['version'], 99);
},
);
});
}