From 58922de6fc973cb87d6d3f3874015ab45d0e7ec8 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sun, 28 Jun 2026 11:54:25 +0200 Subject: [PATCH] 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. --- lib/estado/estado_ecualizador.dart | 77 ++++ lib/l10n/app_ar.arb | 17 +- lib/l10n/app_bn.arb | 17 +- lib/l10n/app_de.arb | 17 +- lib/l10n/app_en.arb | 15 +- lib/l10n/app_es.arb | 15 +- lib/l10n/app_fr.arb | 17 +- lib/l10n/app_hi.arb | 17 +- lib/l10n/app_id.arb | 17 +- lib/l10n/app_it.arb | 17 +- lib/l10n/app_ja.arb | 17 +- lib/l10n/app_pt.arb | 17 +- lib/l10n/app_ru.arb | 17 +- lib/l10n/app_zh.arb | 17 +- lib/l10n/gen/app_localizations.dart | 36 ++ lib/l10n/gen/app_localizations_ar.dart | 20 + lib/l10n/gen/app_localizations_bn.dart | 20 + lib/l10n/gen/app_localizations_de.dart | 20 + lib/l10n/gen/app_localizations_en.dart | 20 + lib/l10n/gen/app_localizations_es.dart | 20 + lib/l10n/gen/app_localizations_fr.dart | 20 + lib/l10n/gen/app_localizations_hi.dart | 20 + lib/l10n/gen/app_localizations_id.dart | 20 + lib/l10n/gen/app_localizations_it.dart | 20 + lib/l10n/gen/app_localizations_ja.dart | 20 + lib/l10n/gen/app_localizations_pt.dart | 20 + lib/l10n/gen/app_localizations_ru.dart | 20 + lib/l10n/gen/app_localizations_zh.dart | 20 + lib/pantallas/pantalla_ajustes.dart | 187 ++++++-- lib/servicios/servicio_ecualizador.dart | 44 ++ test/estado/estado_ecualizador_test.dart | 404 ++++++++++++++++++ test/helpers/fakes.dart | 41 ++ test/pantallas/pantalla_ajustes_test.dart | 186 ++++++++ test/servicios/servicio_ecualizador_test.dart | 60 +++ 34 files changed, 1461 insertions(+), 51 deletions(-) diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index 488f690..3f4c245 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -51,6 +51,9 @@ class EstadoEcualizador extends ChangeNotifier { /// Matrix presets: "stationUuid:deviceId" → PresetEcualizador. final Map _presetsMatriz = {}; + /// Custom display names for devices: deviceId → custom name. + final Map _nombresDispositivos = {}; + PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat; bool _activo = true; @@ -65,6 +68,9 @@ class EstadoEcualizador extends ChangeNotifier { bool get eqMultiDeviceEnabled => _eqMultiDeviceEnabled; String? get dispositivoActualId => _dispositivoActualId; + Map get nombresDispositivos => + Map.unmodifiable(_nombresDispositivos); + Map get presetsPorEmisora => Map.unmodifiable(_presetsEmisoraMap); @@ -148,6 +154,9 @@ class EstadoEcualizador extends ChangeNotifier { _presetsMatriz ..clear() ..addAll(config.presetsMatriz); + _nombresDispositivos + ..clear() + ..addAll(config.nombresDispositivos); // Resolve active preset and apply it. _presetActual = _resolverPresetActivo(); @@ -156,6 +165,11 @@ class EstadoEcualizador extends ChangeNotifier { // Subscribe to device changes only when toggle is on. _configurarSuscripcionDispositivo(); + + // Seed current device immediately so presets resolve before first event. + if (_eqMultiDeviceEnabled) { + await _sembrarDispositivoActual(); + } } catch (_) { _presetPrincipal = PresetEcualizador.flat; _presetActual = PresetEcualizador.flat; @@ -164,6 +178,20 @@ class EstadoEcualizador extends ChangeNotifier { _presetsEmisoraMap.clear(); _presetsDispositivo.clear(); _presetsMatriz.clear(); + _nombresDispositivos.clear(); + } + } + + /// Queries the current device and seeds [_dispositivoActualId] without + /// waiting for a stream event. Falls back to `'builtin_speaker'` on error. + Future _sembrarDispositivoActual() async { + final svc = _dispositivoAudio; + if (svc == null) return; + try { + final dispositivo = await svc.obtenerDispositivoActual(); + await _onDispositivoCambiado(dispositivo); + } catch (_) { + _dispositivoActualId = 'builtin_speaker'; } } @@ -280,6 +308,55 @@ class EstadoEcualizador extends ChangeNotifier { } } + /// Persists a custom display name for [deviceId]. + /// + /// Empty names are silently ignored so the existing name is preserved. + /// No-op when the multi-device toggle is off. + Future renombrarDispositivo(String deviceId, String nombre) async { + if (!_eqMultiDeviceEnabled) return; + final nombreTrimmed = nombre.trim(); + if (nombreTrimmed.isEmpty) return; + _nombresDispositivos[deviceId] = nombreTrimmed; + await servicio.guardarNombresDispositivos( + Map.unmodifiable(_nombresDispositivos), + ); + notifyListeners(); + } + + /// Persists a per-device EQ preset for [deviceId]. + /// + /// Guards on [_eqMultiDeviceEnabled]: no-op when toggle is off. + /// If [deviceId] is the currently active device, re-resolves and + /// applies the effective preset immediately. + Future guardarPresetDispositivo( + String deviceId, + PresetEcualizador preset, + ) async { + if (!_eqMultiDeviceEnabled) return; + _presetsDispositivo[deviceId] = preset; + await servicio.guardarPresetDispositivo(deviceId, preset); + if (_dispositivoActualId == deviceId) { + // Re-resolve using the full hierarchy (station/matrix may override). + final resuelto = _resolverPresetActivo(); + _presetActual = resuelto; + await audio.aplicarPreset(resuelto); + } + notifyListeners(); + } + + /// Returns the stored custom name for [deviceId], or an empty string if none. + String obtenerNombreDispositivo(String deviceId) => + _nombresDispositivos[deviceId] ?? ''; + + /// Resolves the display name for [deviceId] using the fallback chain: + /// custom name → [platformName] → raw [deviceId]. + String nombreVisible(String deviceId, String platformName) { + final custom = _nombresDispositivos[deviceId]; + if (custom != null && custom.isNotEmpty) return custom; + if (platformName.isNotEmpty) return platformName; + return deviceId; + } + Future cambiarActivo(bool activo) async { _activo = activo; await servicio.guardarActivo(activo); diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 99d2196..4e425b0 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "يبدأ خلال {minutes} دقيقة", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "تعديل الجهاز", + "eqDeviceNameLabel": "اسم الجهاز", + "eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة", + "eqDeviceNameConfirm": "حفظ", + "eqDeviceConnected": "متصل" +} \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 8d9fbe8..69d2340 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "{minutes} মিনিটে শুরু হবে", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "ডিভাইস সম্পাদনা করুন", + "eqDeviceNameLabel": "ডিভাইসের নাম", + "eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার", + "eqDeviceNameConfirm": "সংরক্ষণ করুন", + "eqDeviceConnected": "সংযুক্ত" +} \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 230386a..d14b738 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "Startet in {minutes} Min.", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "Gerät bearbeiten", + "eqDeviceNameLabel": "Gerätename", + "eqDeviceNameHint": "z. B. Wohnzimmer-Lautsprecher", + "eqDeviceNameConfirm": "Speichern", + "eqDeviceConnected": "Verbunden" +} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dfc9aa2..65e46c5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } + }, + "preNoticeCountdown": "Starts in {minutes} min", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "Edit device", + "eqDeviceNameLabel": "Device name", + "eqDeviceNameHint": "e.g. Living Room Speaker", + "eqDeviceNameConfirm": "Save", + "eqDeviceConnected": "Connected" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5505613..c9a16e5 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -590,5 +590,18 @@ "placeholders": { "presetName": {} } - } + }, + "preNoticeCountdown": "Empieza en {minutes} min", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "Editar dispositivo", + "eqDeviceNameLabel": "Nombre del dispositivo", + "eqDeviceNameHint": "Ej: Altavoz del living", + "eqDeviceNameConfirm": "Guardar", + "eqDeviceConnected": "Conectado" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f00acd6..8b739cb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "Démarre dans {minutes} min", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "Modifier l'appareil", + "eqDeviceNameLabel": "Nom de l'appareil", + "eqDeviceNameHint": "ex. : Enceinte salon", + "eqDeviceNameConfirm": "Enregistrer", + "eqDeviceConnected": "Connecté" +} \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index e1c3c67..9147aab 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "{minutes} मिनट में शुरू होगा", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "डिवाइस संपादित करें", + "eqDeviceNameLabel": "डिवाइस का नाम", + "eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर", + "eqDeviceNameConfirm": "सहेजें", + "eqDeviceConnected": "कनेक्टेड" +} \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4de0f91..d5922a6 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "Mulai dalam {minutes} menit", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "Edit perangkat", + "eqDeviceNameLabel": "Nama perangkat", + "eqDeviceNameHint": "Mis: Speaker ruang tamu", + "eqDeviceNameConfirm": "Simpan", + "eqDeviceConnected": "Terhubung" +} \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 041a5a7..a4cb012 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "Inizia tra {minutes} min", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "Modifica dispositivo", + "eqDeviceNameLabel": "Nome del dispositivo", + "eqDeviceNameHint": "Es: Cassa del salotto", + "eqDeviceNameConfirm": "Salva", + "eqDeviceConnected": "Connesso" +} \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ca35b37..db62826 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "{minutes}分後に開始", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "デバイスを編集", + "eqDeviceNameLabel": "デバイス名", + "eqDeviceNameHint": "例:リビングのスピーカー", + "eqDeviceNameConfirm": "保存", + "eqDeviceConnected": "接続中" +} \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 816b6e4..95120ac 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "Começa em {minutes} min", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "Editar dispositivo", + "eqDeviceNameLabel": "Nome do dispositivo", + "eqDeviceNameHint": "Ex: Caixa da sala", + "eqDeviceNameConfirm": "Salvar", + "eqDeviceConnected": "Conectado" +} \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3438258..5755b35 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "Начнётся через {minutes} мин", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "Изменить устройство", + "eqDeviceNameLabel": "Название устройства", + "eqDeviceNameHint": "Напр.: Колонка в гостиной", + "eqDeviceNameConfirm": "Сохранить", + "eqDeviceConnected": "Подключено" +} \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 32796b9..ff419e0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -627,5 +627,18 @@ "placeholders": { "presetName": {} } - } -} + }, + "preNoticeCountdown": "{minutes}分钟后开始", + "@preNoticeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "eqDeviceEditTitle": "编辑设备", + "eqDeviceNameLabel": "设备名称", + "eqDeviceNameHint": "例:客厅音箱", + "eqDeviceNameConfirm": "保存", + "eqDeviceConnected": "已连接" +} \ No newline at end of file diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 8586375..2c85629 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2305,6 +2305,42 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Preset: {presetName}'** String advancedEqDevicePresetLabel(Object presetName); + + /// No description provided for @preNoticeCountdown. + /// + /// In es, this message translates to: + /// **'Empieza en {minutes} min'** + String preNoticeCountdown(int minutes); + + /// No description provided for @eqDeviceEditTitle. + /// + /// In es, this message translates to: + /// **'Editar dispositivo'** + String get eqDeviceEditTitle; + + /// No description provided for @eqDeviceNameLabel. + /// + /// In es, this message translates to: + /// **'Nombre del dispositivo'** + String get eqDeviceNameLabel; + + /// No description provided for @eqDeviceNameHint. + /// + /// In es, this message translates to: + /// **'Ej: Altavoz del living'** + String get eqDeviceNameHint; + + /// No description provided for @eqDeviceNameConfirm. + /// + /// In es, this message translates to: + /// **'Guardar'** + String get eqDeviceNameConfirm; + + /// No description provided for @eqDeviceConnected. + /// + /// In es, this message translates to: + /// **'Conectado'** + String get eqDeviceConnected; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 12df06b..a57ac01 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1247,4 +1247,24 @@ class AppLocalizationsAr extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'الإعداد المسبق: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return 'يبدأ خلال $minutes دقيقة'; + } + + @override + String get eqDeviceEditTitle => 'تعديل الجهاز'; + + @override + String get eqDeviceNameLabel => 'اسم الجهاز'; + + @override + String get eqDeviceNameHint => 'مثال: مكبر صوت غرفة المعيشة'; + + @override + String get eqDeviceNameConfirm => 'حفظ'; + + @override + String get eqDeviceConnected => 'متصل'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index c60833d..41872da 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1254,4 +1254,24 @@ class AppLocalizationsBn extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'প্রিসেট: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return '$minutes মিনিটে শুরু হবে'; + } + + @override + String get eqDeviceEditTitle => 'ডিভাইস সম্পাদনা করুন'; + + @override + String get eqDeviceNameLabel => 'ডিভাইসের নাম'; + + @override + String get eqDeviceNameHint => 'যেমন: লিভিং রুমের স্পিকার'; + + @override + String get eqDeviceNameConfirm => 'সংরক্ষণ করুন'; + + @override + String get eqDeviceConnected => 'সংযুক্ত'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 21bcd7c..af29e52 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1264,4 +1264,24 @@ class AppLocalizationsDe extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'Preset: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return 'Startet in $minutes Min.'; + } + + @override + String get eqDeviceEditTitle => 'Gerät bearbeiten'; + + @override + String get eqDeviceNameLabel => 'Gerätename'; + + @override + String get eqDeviceNameHint => 'z. B. Wohnzimmer-Lautsprecher'; + + @override + String get eqDeviceNameConfirm => 'Speichern'; + + @override + String get eqDeviceConnected => 'Verbunden'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index fb0f0e4..31db7d9 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1250,4 +1250,24 @@ class AppLocalizationsEn extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'Preset: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return 'Starts in $minutes min'; + } + + @override + String get eqDeviceEditTitle => 'Edit device'; + + @override + String get eqDeviceNameLabel => 'Device name'; + + @override + String get eqDeviceNameHint => 'e.g. Living Room Speaker'; + + @override + String get eqDeviceNameConfirm => 'Save'; + + @override + String get eqDeviceConnected => 'Connected'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index c9deec5..55f3046 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1259,4 +1259,24 @@ class AppLocalizationsEs extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'Preset: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return 'Empieza en $minutes min'; + } + + @override + String get eqDeviceEditTitle => 'Editar dispositivo'; + + @override + String get eqDeviceNameLabel => 'Nombre del dispositivo'; + + @override + String get eqDeviceNameHint => 'Ej: Altavoz del living'; + + @override + String get eqDeviceNameConfirm => 'Guardar'; + + @override + String get eqDeviceConnected => 'Conectado'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 5bfde0e..91aaa20 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1269,4 +1269,24 @@ class AppLocalizationsFr extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'Preset : $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return 'Démarre dans $minutes min'; + } + + @override + String get eqDeviceEditTitle => 'Modifier l\'appareil'; + + @override + String get eqDeviceNameLabel => 'Nom de l\'appareil'; + + @override + String get eqDeviceNameHint => 'ex. : Enceinte salon'; + + @override + String get eqDeviceNameConfirm => 'Enregistrer'; + + @override + String get eqDeviceConnected => 'Connecté'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index a660735..b4f58e7 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1253,4 +1253,24 @@ class AppLocalizationsHi extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'प्रीसेट: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return '$minutes मिनट में शुरू होगा'; + } + + @override + String get eqDeviceEditTitle => 'डिवाइस संपादित करें'; + + @override + String get eqDeviceNameLabel => 'डिवाइस का नाम'; + + @override + String get eqDeviceNameHint => 'उदा. लिविंग रूम स्पीकर'; + + @override + String get eqDeviceNameConfirm => 'सहेजें'; + + @override + String get eqDeviceConnected => 'कनेक्टेड'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 2ea70f7..b55f965 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1258,4 +1258,24 @@ class AppLocalizationsId extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'Preset: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return 'Mulai dalam $minutes menit'; + } + + @override + String get eqDeviceEditTitle => 'Edit perangkat'; + + @override + String get eqDeviceNameLabel => 'Nama perangkat'; + + @override + String get eqDeviceNameHint => 'Mis: Speaker ruang tamu'; + + @override + String get eqDeviceNameConfirm => 'Simpan'; + + @override + String get eqDeviceConnected => 'Terhubung'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 75ed787..adbae0e 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1264,4 +1264,24 @@ class AppLocalizationsIt extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'Preset: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return 'Inizia tra $minutes min'; + } + + @override + String get eqDeviceEditTitle => 'Modifica dispositivo'; + + @override + String get eqDeviceNameLabel => 'Nome del dispositivo'; + + @override + String get eqDeviceNameHint => 'Es: Cassa del salotto'; + + @override + String get eqDeviceNameConfirm => 'Salva'; + + @override + String get eqDeviceConnected => 'Connesso'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index d943eda..19dc35f 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1214,4 +1214,24 @@ class AppLocalizationsJa extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'プリセット: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return '$minutes分後に開始'; + } + + @override + String get eqDeviceEditTitle => 'デバイスを編集'; + + @override + String get eqDeviceNameLabel => 'デバイス名'; + + @override + String get eqDeviceNameHint => '例:リビングのスピーカー'; + + @override + String get eqDeviceNameConfirm => '保存'; + + @override + String get eqDeviceConnected => '接続中'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index eddfa8d..26556ec 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1256,4 +1256,24 @@ class AppLocalizationsPt extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'Preset: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return 'Começa em $minutes min'; + } + + @override + String get eqDeviceEditTitle => 'Editar dispositivo'; + + @override + String get eqDeviceNameLabel => 'Nome do dispositivo'; + + @override + String get eqDeviceNameHint => 'Ex: Caixa da sala'; + + @override + String get eqDeviceNameConfirm => 'Salvar'; + + @override + String get eqDeviceConnected => 'Conectado'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index e14f440..9ae3e05 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1260,4 +1260,24 @@ class AppLocalizationsRu extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return 'Пресет: $presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return 'Начнётся через $minutes мин'; + } + + @override + String get eqDeviceEditTitle => 'Изменить устройство'; + + @override + String get eqDeviceNameLabel => 'Название устройства'; + + @override + String get eqDeviceNameHint => 'Напр.: Колонка в гостиной'; + + @override + String get eqDeviceNameConfirm => 'Сохранить'; + + @override + String get eqDeviceConnected => 'Подключено'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 35a359d..bd718b8 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1207,4 +1207,24 @@ class AppLocalizationsZh extends AppLocalizations { String advancedEqDevicePresetLabel(Object presetName) { return '预设:$presetName'; } + + @override + String preNoticeCountdown(int minutes) { + return '$minutes分钟后开始'; + } + + @override + String get eqDeviceEditTitle => '编辑设备'; + + @override + String get eqDeviceNameLabel => '设备名称'; + + @override + String get eqDeviceNameHint => '例:客厅音箱'; + + @override + String get eqDeviceNameConfirm => '保存'; + + @override + String get eqDeviceConnected => '已连接'; } diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 2784739..46877d5 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -16,6 +16,7 @@ import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; +import '../modelos/preset_ecualizador.dart'; import '../widgets/ecualizador_widget.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; @@ -739,33 +740,9 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget { ) 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, - ), - ], - ), - ), - ], - ), + _FilaDispositivo( + deviceId: entry.key, + preset: entry.value, ), ], ], @@ -774,6 +751,162 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget { } } +/// A single device row in the known-devices list. +/// +/// Shows a connection indicator (green dot) when [deviceId] matches the +/// currently active device. Tapping the edit icon opens [_DialogoEdicionDispositivo]. +class _FilaDispositivo extends StatelessWidget { + const _FilaDispositivo({required this.deviceId, required this.preset}); + + final String deviceId; + final PresetEcualizador preset; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final eq = context.watch(); + final isActive = eq.dispositivoActualId == deviceId; + final displayName = eq.nombreVisible(deviceId, ''); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + if (isActive) + const Icon(Icons.circle, size: 10, color: Colors.green) + else + const SizedBox(width: 10), + const SizedBox(width: 8), + const Icon(Icons.headphones_rounded, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + l10n.advancedEqDevicePresetLabel(preset.nombre), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.edit_rounded, size: 20), + tooltip: l10n.eqDeviceEditTitle, + onPressed: () => _abrirModal(context), + ), + ], + ), + ); + } + + Future _abrirModal(BuildContext context) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (ctx) => _DialogoEdicionDispositivo( + deviceId: deviceId, + preset: preset, + ), + ); + } +} + +/// Bottom sheet for editing a device's custom name and EQ preset. +class _DialogoEdicionDispositivo extends StatefulWidget { + const _DialogoEdicionDispositivo({ + required this.deviceId, + required this.preset, + }); + + final String deviceId; + final PresetEcualizador preset; + + @override + State<_DialogoEdicionDispositivo> createState() => + _DialogoEdicionDispositivoState(); +} + +class _DialogoEdicionDispositivoState + extends State<_DialogoEdicionDispositivo> { + late final TextEditingController _nombreCtrl; + late PresetEcualizador _presetActual; + + @override + void initState() { + super.initState(); + final eq = context.read(); + final displayName = eq.nombreVisible(widget.deviceId, ''); + _nombreCtrl = TextEditingController(text: displayName); + _presetActual = widget.preset; + } + + @override + void dispose() { + _nombreCtrl.dispose(); + super.dispose(); + } + + Future _guardar() async { + final eq = context.read(); + await eq.renombrarDispositivo(widget.deviceId, _nombreCtrl.text); + if (_presetActual != widget.preset) { + await eq.guardarPresetDispositivo(widget.deviceId, _presetActual); + } + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final bottom = MediaQuery.viewInsetsOf(context).bottom; + + return SingleChildScrollView( + child: Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.eqDeviceEditTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: _nombreCtrl, + autofocus: true, + decoration: InputDecoration( + labelText: l10n.eqDeviceNameLabel, + hintText: l10n.eqDeviceNameHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + EcualizadorWidget( + preset: _presetActual, + onCambio: (p) => setState(() => _presetActual = p), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _guardar, + icon: const Icon(Icons.save_rounded), + label: Text(l10n.eqDeviceNameConfirm), + ), + ], + ), + ), + ); + } +} + class _SeccionOrdenListas extends StatelessWidget { const _SeccionOrdenListas(); diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart index ccb0ddd..6ca9ce3 100644 --- a/lib/servicios/servicio_ecualizador.dart +++ b/lib/servicios/servicio_ecualizador.dart @@ -12,6 +12,7 @@ class ConfiguracionEcualizador { this.eqMultiDeviceEnabled = false, this.presetsDispositivo = const {}, this.presetsMatriz = const {}, + this.nombresDispositivos = const {}, }); final PresetEcualizador principal; @@ -26,6 +27,9 @@ class ConfiguracionEcualizador { /// Matrix presets: "stationUuid:deviceId" → PresetEcualizador. final Map presetsMatriz; + + /// Custom display names for devices: deviceId → custom name. + final Map nombresDispositivos; } class ServicioEcualizador { @@ -37,6 +41,7 @@ class ServicioEcualizador { static const _keyMultiDeviceEnabled = 'eq_multi_device_enabled_v1'; static const _keyPresetsPorDispositivo = 'eq_preset_por_dispositivo_v1'; static const _keyPresetsMatriz = 'eq_presets_matriz_v1'; + static const _keyNombresDispositivos = 'eq_nombres_dispositivos_v1'; final SharedPreferences? _prefs; @@ -50,6 +55,7 @@ class ServicioEcualizador { final porEmisora = _leerPresetsPorEmisora(prefs); final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo); final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz); + final nombresDispositivos = _leerMapaStrings(prefs, _keyNombresDispositivos); return ConfiguracionEcualizador( principal: principal, porEmisora: porEmisora, @@ -57,6 +63,7 @@ class ServicioEcualizador { eqMultiDeviceEnabled: prefs.getBool(_keyMultiDeviceEnabled) ?? false, presetsDispositivo: presetsDispositivo, presetsMatriz: presetsMatriz, + nombresDispositivos: nombresDispositivos, ); } @@ -99,6 +106,11 @@ class ServicioEcualizador { config.presetsDispositivo, ); await _guardarMapa(prefs, _keyPresetsMatriz, config.presetsMatriz); + await _guardarMapaStrings( + prefs, + _keyNombresDispositivos, + config.nombresDispositivos, + ); } /// Persists the multi-device feature toggle. @@ -145,6 +157,18 @@ class ServicioEcualizador { await _guardarMapa(prefs, _keyPresetsMatriz, mapa); } + /// Returns the persisted device custom names map. + Future> cargarNombresDispositivos() async { + final prefs = await _resolverPrefs(); + return _leerMapaStrings(prefs, _keyNombresDispositivos); + } + + /// Persists the device custom names map. + Future guardarNombresDispositivos(Map nombres) async { + final prefs = await _resolverPrefs(); + await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres); + } + /// Reads a `Map` from a SharedPreferences JSON key. Map _leerMapa( SharedPreferences prefs, @@ -174,6 +198,26 @@ class ServicioEcualizador { await prefs.setString(key, jsonEncode(serializado)); } + /// Reads a `Map` from a SharedPreferences JSON key. + Map _leerMapaStrings(SharedPreferences prefs, String key) { + final raw = prefs.getString(key); + if (raw == null || raw.isEmpty) return {}; + try { + final data = Map.from(jsonDecode(raw) as Map); + return data.map((k, v) => MapEntry(k, v as String)); + } catch (_) { + return {}; + } + } + + Future _guardarMapaStrings( + SharedPreferences prefs, + String key, + Map mapa, + ) async { + await prefs.setString(key, jsonEncode(mapa)); + } + PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) { final raw = prefs.getString(_keyPresetPrincipal); if (raw == null || raw.isEmpty) { diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 83896fb..5b988f1 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -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.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.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')); + }, + ); + }); } diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 8ca0e6a..d91e3b5 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -287,6 +287,7 @@ class FakeServicioEcualizador extends ServicioEcualizador { bool eqMultiDeviceEnabled = false, Map? presetsDispositivo, Map? presetsMatriz, + Map? nombresDispositivos, }) : _config = ConfiguracionEcualizador( principal: principal ?? PresetEcualizador.flat, porEmisora: porEmisora ?? {}, @@ -294,6 +295,7 @@ class FakeServicioEcualizador extends ServicioEcualizador { eqMultiDeviceEnabled: eqMultiDeviceEnabled, presetsDispositivo: presetsDispositivo ?? {}, presetsMatriz: presetsMatriz ?? {}, + nombresDispositivos: nombresDispositivos ?? {}, ); ConfiguracionEcualizador _config; @@ -311,6 +313,7 @@ class FakeServicioEcualizador extends ServicioEcualizador { eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, presetsDispositivo: _config.presetsDispositivo, presetsMatriz: _config.presetsMatriz, + nombresDispositivos: _config.nombresDispositivos, ); } @@ -323,6 +326,7 @@ class FakeServicioEcualizador extends ServicioEcualizador { eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, presetsDispositivo: _config.presetsDispositivo, presetsMatriz: _config.presetsMatriz, + nombresDispositivos: _config.nombresDispositivos, ); } @@ -399,8 +403,45 @@ class FakeServicioEcualizador extends ServicioEcualizador { eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, presetsDispositivo: _config.presetsDispositivo, presetsMatriz: mapa, + nombresDispositivos: _config.nombresDispositivos, ); } + + @override + Future guardarNombresDispositivos(Map nombres) async { + _config = ConfiguracionEcualizador( + principal: _config.principal, + porEmisora: _config.porEmisora, + activo: _config.activo, + eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, + presetsDispositivo: _config.presetsDispositivo, + presetsMatriz: _config.presetsMatriz, + nombresDispositivos: nombres, + ); + } +} + +/// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual]. +/// +/// Used to test the graceful-failure path in [EstadoEcualizador.cargarPersistido]. +class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio { + final _controller = StreamController.broadcast(); + + @override + DispositivoAudio? get dispositivoActual => null; + + @override + Stream get onDispositivoCambiado => _controller.stream; + + @override + Future obtenerDispositivoActual() async { + throw Exception('Platform channel error: device unavailable'); + } + + @override + Future dispose() async { + await _controller.close(); + } } /// Fake implementation of [ServicioDispositivoAudio] for unit tests. diff --git a/test/pantallas/pantalla_ajustes_test.dart b/test/pantallas/pantalla_ajustes_test.dart index ca1649d..d006b33 100644 --- a/test/pantallas/pantalla_ajustes_test.dart +++ b/test/pantallas/pantalla_ajustes_test.dart @@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_idioma.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/preset_ecualizador.dart'; import 'package:pluriwave/pantallas/pantalla_ajustes.dart'; import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; @@ -92,6 +93,42 @@ void main() { await tester.pumpAndSettle(const Duration(milliseconds: 100)); } + // Also update crearEstado to support nombresDispositivos + Future crearEstadoConNombres({ + bool eqMultiDeviceEnabled = true, + Map? presetsDispositivo, + Map? nombresDispositivos, + String? activeDeviceId, + }) async { + final fakeDispositivo = activeDeviceId != null + ? (FakeServicioDispositivoAudio() + ..emitirDispositivo( + DispositivoAudio( + id: activeDeviceId, + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'BT Speaker', + ), + )) + : null; + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador( + eqMultiDeviceEnabled: eqMultiDeviceEnabled, + presetsDispositivo: presetsDispositivo ?? + {'bt_a2dp:AA:BB': PresetEcualizador.rock}, + nombresDispositivos: nombresDispositivos ?? {}, + ), + servicioGrabacion: _FakeGrabacion(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + dispositivoAudio: fakeDispositivo, + ); + await estado.ecualizador.cargarPersistido(); + return estado; + } + // ── Phase 7 tests ────────────────────────────────────────────────────────── group('_SeccionEcualizadorAvanzado (Phase 7)', () { @@ -194,6 +231,155 @@ void main() { }, ); }); + + // ── Phase 3 (eq-device-autoswitch-ux): connection indicator + modal ────────── + + group('SeccionEcualizadorAvanzado connection indicator Phase 3', () { + // 3.1 RED — active device row shows green connection dot + testWidgets('3.1 active device row shows green connection indicator', (tester) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + const activeId = 'bt_a2dp:AA:BB'; + final estado = await crearEstadoConNombres( + eqMultiDeviceEnabled: true, + presetsDispositivo: { + activeId: PresetEcualizador.rock, + 'wired_headset': PresetEcualizador.jazz, + }, + activeDeviceId: activeId, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Known audio devices'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + // Green connection dot should be present (Icon with green color for active device) + final greenIcons = tester.widgetList(find.byType(Icon)).where( + (icon) => icon.color == Colors.green, + ); + // At least one green icon present + expect(greenIcons, isNotEmpty); + }); + + // 3.2 RED — tapping device row opens bottom sheet with TextField and EcualizadorWidget + testWidgets('3.2 tapping device row opens modal with TextField', (tester) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + final estado = await crearEstadoConNombres( + presetsDispositivo: {'bt_a2dp:AA:BB': PresetEcualizador.rock}, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Known audio devices'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + // Tap the device row (tap the edit icon or the row itself) + final editIcons = find.byIcon(Icons.edit_rounded); + expect(editIcons, findsWidgets); + await tester.tap(editIcons.first); + await pumpStable(tester); + + // Bottom sheet should appear with a TextField + expect(find.byType(TextField), findsWidgets); + }); + + // 3.6 RED — renaming in modal and confirming calls renombrarDispositivo + testWidgets('3.6 confirming rename in modal updates device name', (tester) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + const deviceId = 'bt_a2dp:AA:BB'; + final estado = await crearEstadoConNombres( + presetsDispositivo: {deviceId: PresetEcualizador.rock}, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Known audio devices'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + // Open modal + await tester.tap(find.byIcon(Icons.edit_rounded).first); + await pumpStable(tester); + + // Enter a new name + final textField = find.byType(TextField).first; + await tester.enterText(textField, 'My Living Room Speaker'); + await pumpStable(tester); + + // Tap confirm/save button + final saveButton = find.byIcon(Icons.save_rounded); + expect(saveButton, findsWidgets); + await tester.tap(saveButton.first); + await pumpStable(tester); + + // Device should now have the new name + expect( + estado.ecualizador.obtenerNombreDispositivo(deviceId), + equals('My Living Room Speaker'), + ); + }); + + // 3.7 RED — dismissing modal without confirming leaves name unchanged + testWidgets('3.7 dismissing modal without confirming leaves name unchanged', (tester) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + const deviceId = 'bt_a2dp:AA:BB'; + final estado = await crearEstadoConNombres( + presetsDispositivo: {deviceId: PresetEcualizador.rock}, + nombresDispositivos: {deviceId: 'Original Name'}, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Known audio devices'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + // Open modal + await tester.tap(find.byIcon(Icons.edit_rounded).first); + await pumpStable(tester); + + // Change the text but do NOT confirm + final textField = find.byType(TextField).first; + await tester.enterText(textField, 'New Name Not Saved'); + await pumpStable(tester); + + // Dismiss by pressing back/escape + await tester.tapAt(const Offset(100, 100)); // tap outside bottom sheet + await pumpStable(tester); + + // Name should remain unchanged + expect( + estado.ecualizador.obtenerNombreDispositivo(deviceId), + equals('Original Name'), + ); + }); + }); } // ── Infrastructure ────────────────────────────────────────────────────────── diff --git a/test/servicios/servicio_ecualizador_test.dart b/test/servicios/servicio_ecualizador_test.dart index 82af042..fc02a50 100644 --- a/test/servicios/servicio_ecualizador_test.dart +++ b/test/servicios/servicio_ecualizador_test.dart @@ -169,4 +169,64 @@ void main() { ); }); }); + + // --------------------------------------------------------------------------- + // Phase 1 (eq-device-autoswitch-ux): nombresDispositivos persistence + // --------------------------------------------------------------------------- + + group('ServicioEcualizador — nombresDispositivos (eq-device-autoswitch-ux)', () { + // 1.1 RED — cargarNombresDispositivos returns empty map when SP key absent + test('1.1 cargar returns empty nombresDispositivos when SP key absent', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + final config = await servicio.cargar(); + expect(config.nombresDispositivos, isEmpty); + }); + + // 1.2 RED — guardarNombresDispositivos writes to eq_nombres_dispositivos_v1 SP key + test('1.2 guardarNombresDispositivos persists names and cargar restores them', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + + await servicio.guardarNombresDispositivos({'bt_a2dp:AA:BB': 'Living Room BT'}); + final config = await servicio.cargar(); + + expect(config.nombresDispositivos['bt_a2dp:AA:BB'], equals('Living Room BT')); + }); + + // 1.3 RED — round-trip: save → load returns same map + test('1.3 round-trip save → cargar preserves all entries', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + const nombres = { + 'bt_a2dp:AA:BB': 'Living Room BT', + 'wired_headset': 'Office Headset', + 'builtin_speaker': 'Built-in', + }; + + await servicio.guardarNombresDispositivos(nombres); + final config = await servicio.cargar(); + + expect(config.nombresDispositivos, equals(nombres)); + }); + + // guardarConfiguracion also persists nombresDispositivos + test('1.7 guardarConfiguracion persists nombresDispositivos round-trip', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + final config = ConfiguracionEcualizador( + principal: PresetEcualizador.flat, + porEmisora: {}, + eqMultiDeviceEnabled: true, + presetsDispositivo: {}, + presetsMatriz: {}, + nombresDispositivos: {'bt_a2dp:AA:BB': 'My Speaker'}, + ); + + await servicio.guardarConfiguracion(config); + final restored = await servicio.cargar(); + + expect(restored.nombresDispositivos['bt_a2dp:AA:BB'], equals('My Speaker')); + }); + }); }