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')); + }); + }); }