fix(eq): seed device ID at startup and add device management UI

Fix multi-device EQ auto-switching by calling obtenerDispositivoActual()
during cargarPersistido() to seed the initial device ID. Add device
management modal with rename support, EQ preset editing, and connection
status indicator. Translate device UI keys to all 13 locales.
This commit is contained in:
2026-06-28 11:55:15 +02:00
parent 71978de68f
commit 58922de6fc
34 changed files with 1461 additions and 51 deletions
+404
View File
@@ -538,4 +538,408 @@ void main() {
);
},
);
// ---------------------------------------------------------------------------
// eq-device-autoswitch-ux Phase 2: startup seeding + rename API
// ---------------------------------------------------------------------------
group('EstadoEcualizador — startup device seeding (eq-device-autoswitch-ux Phase 2)', () {
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Speaker',
);
// 2.1 RED — after cargarPersistido with multiDevice ON, dispositivoActualId is seeded
test('2.1 cargarPersistido seeds dispositivoActualId from obtenerDispositivoActual', () async {
final fakeDispositivo = FakeServicioDispositivoAudio()
..emitirDispositivo(btDevice); // sets _dispositivoActual
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: fakeDispositivo,
);
await eq.cargarPersistido();
expect(eq.dispositivoActualId, equals('bt_a2dp:AA:BB'));
eq.dispose();
});
// 2.2 RED — first-seen device is bootstrapped from resolved preset at startup
test('2.2 first-seen device is bootstrapped at startup with resolved preset', () async {
final fakeServicio = FakeServicioEcualizador(
principal: PresetEcualizador.rock,
eqMultiDeviceEnabled: true,
presetsDispositivo: {},
);
final fakeDispositivo = FakeServicioDispositivoAudio()
..emitirDispositivo(btDevice);
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: fakeServicio,
dispositivoAudio: fakeDispositivo,
);
await eq.cargarPersistido();
expect(
eq.presetsDispositivo[btDevice.id],
equals(PresetEcualizador.rock),
);
eq.dispose();
});
// 2.3 RED — obtenerDispositivoActual throws → fallback to 'builtin_speaker'
test('2.3 obtenerDispositivoActual throws → dispositivoActualId falls back to builtin_speaker', () async {
final fakeDispositivo = FakeServicioDispositivoAudioThrows();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: fakeDispositivo,
);
await eq.cargarPersistido(); // must not throw
expect(eq.dispositivoActualId, equals('builtin_speaker'));
eq.dispose();
});
// 2.4 RED — toggle off → obtenerDispositivoActual is never called
test('2.4 eqMultiDeviceEnabled=false → obtenerDispositivoActual NOT called', () async {
final fakeDispositivo = FakeServicioDispositivoAudioThrows();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false),
dispositivoAudio: fakeDispositivo,
);
// If obtenerDispositivoActual were called it would throw → test would fail
await expectLater(eq.cargarPersistido(), completes);
expect(eq.dispositivoActualId, isNull);
eq.dispose();
});
});
group('EstadoEcualizador — rename API (eq-device-autoswitch-ux Phase 2)', () {
// 2.6 RED — renombrarDispositivo writes to _nombresDispositivos and notifies
test('2.6a renombrarDispositivo stores name and notifyListeners', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
var avisos = 0;
eq.addListener(() => avisos++);
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'Living Room BT');
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('Living Room BT'));
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
});
// 2.6 RED — empty name is no-op (preserves existing name)
test('2.6b renombrarDispositivo with empty string is no-op', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My Headset');
await eq.renombrarDispositivo('bt_a2dp:AA:BB', '');
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('My Headset'));
eq.dispose();
});
// 2.7 RED — obtenerNombreDispositivo returns empty string for unknown device
test('2.7 obtenerNombreDispositivo returns empty string for unknown device', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
expect(eq.obtenerNombreDispositivo('unknown_device'), equals(''));
eq.dispose();
});
// 2.8 RED — nombreVisible fallback chain: custom > platform > id
test('2.8a nombreVisible returns custom name when available', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'Studio Monitors');
expect(
eq.nombreVisible('bt_a2dp:AA:BB', 'USB Audio'),
equals('Studio Monitors'),
);
eq.dispose();
});
test('2.8b nombreVisible returns platform name when no custom name', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
expect(
eq.nombreVisible('bt_a2dp:AA:BB', 'Sony WH-1000XM5'),
equals('Sony WH-1000XM5'),
);
eq.dispose();
});
test('2.8c nombreVisible returns raw deviceId when no custom and no platform name', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
expect(eq.nombreVisible('bt_a2dp:AA:BB', ''), equals('bt_a2dp:AA:BB'));
eq.dispose();
});
// nombresDispositivos getter
test('2.9 nombresDispositivos getter returns current map', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My BT');
expect(eq.nombresDispositivos['bt_a2dp:AA:BB'], equals('My BT'));
eq.dispose();
});
// 2.10 RED — _nombresDispositivos loaded from ConfiguracionEcualizador.nombresDispositivos
test('2.10 cargarPersistido loads nombresDispositivos from config', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(
nombresDispositivos: {'bt_a2dp:AA:BB': 'Loaded Name'},
),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('Loaded Name'));
eq.dispose();
});
});
// ---------------------------------------------------------------------------
// CRITICAL-1: renombrarDispositivo is a no-op when toggle is OFF
// ---------------------------------------------------------------------------
group('EstadoEcualizador — renombrarDispositivo toggle guard (CRITICAL-1)', () {
test(
'renombrarDispositivo is no-op when eqMultiDeviceEnabled is false',
() async {
final fakeServicio = FakeServicioEcualizador(eqMultiDeviceEnabled: false);
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: fakeServicio,
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
var avisos = 0;
eq.addListener(() => avisos++);
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My Device');
// No name stored, no listeners notified, service not called
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals(''));
expect(avisos, equals(0));
expect(fakeServicio.config.nombresDispositivos, isEmpty);
eq.dispose();
},
);
test(
'renombrarDispositivo works normally when eqMultiDeviceEnabled is true',
() async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
var avisos = 0;
eq.addListener(() => avisos++);
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My Device');
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('My Device'));
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
},
);
});
// ---------------------------------------------------------------------------
// CRITICAL-2: guardarPresetDispositivo method
// ---------------------------------------------------------------------------
group('EstadoEcualizador — guardarPresetDispositivo (CRITICAL-2)', () {
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Speaker',
);
test(
'guardarPresetDispositivo stores preset and notifies listeners',
() async {
final fakeServicio = FakeServicioEcualizador(
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: PresetEcualizador.flat},
);
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: fakeServicio,
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
var avisos = 0;
eq.addListener(() => avisos++);
await eq.guardarPresetDispositivo(btDevice.id, PresetEcualizador.jazz);
expect(eq.presetsDispositivo[btDevice.id], equals(PresetEcualizador.jazz));
expect(fakeServicio.config.presetsDispositivo[btDevice.id], equals(PresetEcualizador.jazz));
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
},
);
test(
'guardarPresetDispositivo is no-op when toggle is OFF',
() async {
final fakeServicio = FakeServicioEcualizador(
eqMultiDeviceEnabled: false,
presetsDispositivo: {btDevice.id: PresetEcualizador.flat},
);
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: fakeServicio,
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
var avisos = 0;
eq.addListener(() => avisos++);
await eq.guardarPresetDispositivo(btDevice.id, PresetEcualizador.jazz);
// Preset must remain unchanged
expect(eq.presetsDispositivo[btDevice.id], equals(PresetEcualizador.flat));
expect(avisos, equals(0));
eq.dispose();
},
);
test(
'guardarPresetDispositivo triggers re-resolve and apply when device is active',
() async {
final fakeAudio = FakeServicioAudio();
final fakeDispositivo = FakeServicioDispositivoAudio()
..emitirDispositivo(btDevice);
const stationUuid = 'station-save-test';
final fakeServicio = FakeServicioEcualizador(
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: PresetEcualizador.flat},
);
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: fakeServicio,
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
await Future<void>.delayed(const Duration(milliseconds: 50));
fakeAudio.presetsAplicados.clear();
await eq.guardarPresetDispositivo(btDevice.id, PresetEcualizador.rock);
// Audio was re-applied (re-resolve was triggered)
expect(fakeAudio.presetsAplicados, isNotEmpty);
// The resolved preset is the device preset (level 3, no station preset set)
expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.rock));
eq.dispose();
},
);
test(
'guardarPresetDispositivo does NOT re-apply when device is not active',
() async {
final fakeAudio = FakeServicioAudio();
final fakeServicio = FakeServicioEcualizador(
eqMultiDeviceEnabled: true,
presetsDispositivo: {
btDevice.id: PresetEcualizador.flat,
'other_device': PresetEcualizador.flat,
},
);
final fakeDispositivo = FakeServicioDispositivoAudio()
..emitirDispositivo(btDevice);
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: fakeServicio,
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => null,
);
await eq.cargarPersistido();
await Future<void>.delayed(const Duration(milliseconds: 50));
// Active device is btDevice; save for 'other_device'
fakeAudio.presetsAplicados.clear();
await eq.guardarPresetDispositivo('other_device', PresetEcualizador.rock);
// Preset saved but audio NOT re-applied since 'other_device' is not active
expect(eq.presetsDispositivo['other_device'], equals(PresetEcualizador.rock));
expect(fakeAudio.presetsAplicados, isEmpty);
eq.dispose();
},
);
});
// ---------------------------------------------------------------------------
// WARNING-3: FakeServicioEcualizador preserves nombresDispositivos
// ---------------------------------------------------------------------------
group('FakeServicioEcualizador — nombresDispositivos preservation (WARNING-3)', () {
test(
'guardarPrincipal preserves nombresDispositivos',
() async {
final fake = FakeServicioEcualizador(
nombresDispositivos: {'dev1': 'My Speaker'},
);
await fake.guardarPrincipal(PresetEcualizador.jazz);
expect(fake.config.nombresDispositivos['dev1'], equals('My Speaker'));
},
);
test(
'guardarActivo preserves nombresDispositivos',
() async {
final fake = FakeServicioEcualizador(
nombresDispositivos: {'dev1': 'My Speaker'},
);
await fake.guardarActivo(false);
expect(fake.config.nombresDispositivos['dev1'], equals('My Speaker'));
},
);
});
}