From 4632d53eb8408929063259a2a34248daf2ecb141 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 27 Jun 2026 11:33:53 +0200 Subject: [PATCH] feat(eq): add per-device equalizer with 4-level preset resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce multi-device EQ support allowing each audio output device (built-in speaker, wired, USB, individual Bluetooth by MAC) to have its own equalizer preset, combined with existing per-station presets for a full station×device matrix. - Add DispositivoAudio model and ServicioDispositivoAudio interface - Add Android platform channel (AudioDeviceCallback) for device detection - Add iOS AudioDevicesPlugin (AVAudioSession route tracking) - Extend ServicioEcualizador with device and matrix persistence keys - Implement 4-level resolution: matrix > station > device > global - Add advanced EQ settings section with feature toggle (off by default) - Extend export/import to v3 with backward compatibility - 184 tests passing, zero analyzer issues --- .../es/freetimelab/pluriwave/MainActivity.kt | 151 ++++++ ios/Runner/AppDelegate.swift | 1 + ios/Runner/AudioDevicesPlugin.swift | 166 ++++++ lib/app.dart | 9 +- lib/estado/estado_ecualizador.dart | 175 ++++++- lib/estado/estado_radio.dart | 55 +- lib/l10n/app_en.arb | 13 +- lib/l10n/app_es.arb | 11 + lib/l10n/gen/app_localizations.dart | 36 ++ lib/l10n/gen/app_localizations_ar.dart | 22 + lib/l10n/gen/app_localizations_bn.dart | 22 + lib/l10n/gen/app_localizations_de.dart | 22 + lib/l10n/gen/app_localizations_en.dart | 22 + lib/l10n/gen/app_localizations_es.dart | 22 + lib/l10n/gen/app_localizations_fr.dart | 22 + lib/l10n/gen/app_localizations_hi.dart | 22 + lib/l10n/gen/app_localizations_id.dart | 22 + lib/l10n/gen/app_localizations_it.dart | 22 + lib/l10n/gen/app_localizations_ja.dart | 22 + lib/l10n/gen/app_localizations_pt.dart | 22 + lib/l10n/gen/app_localizations_ru.dart | 22 + lib/l10n/gen/app_localizations_zh.dart | 22 + lib/modelos/dispositivo_audio.dart | 47 ++ lib/pantallas/pantalla_ajustes.dart | 122 +++++ lib/servicios/servicio_dispositivo_audio.dart | 112 ++++ lib/servicios/servicio_ecualizador.dart | 100 ++++ lib/servicios/servicio_export_import.dart | 58 ++- test/estado/estado_ecualizador_test.dart | 490 ++++++++++++++++++ test/helpers/fakes.dart | 104 ++++ test/modelos/dispositivo_audio_test.dart | 94 ++++ test/pantallas/pantalla_ajustes_test.dart | 218 ++++++++ .../servicio_dispositivo_audio_real_test.dart | 109 ++++ .../servicio_dispositivo_audio_test.dart | 82 +++ ...ervicio_dispositivo_audio_toggle_test.dart | 73 +++ test/servicios/servicio_ecualizador_test.dart | 172 ++++++ .../servicio_export_import_test.dart | 230 ++++++-- 36 files changed, 2850 insertions(+), 64 deletions(-) create mode 100644 ios/Runner/AudioDevicesPlugin.swift create mode 100644 lib/modelos/dispositivo_audio.dart create mode 100644 lib/servicios/servicio_dispositivo_audio.dart create mode 100644 test/modelos/dispositivo_audio_test.dart create mode 100644 test/pantallas/pantalla_ajustes_test.dart create mode 100644 test/servicios/servicio_dispositivo_audio_real_test.dart create mode 100644 test/servicios/servicio_dispositivo_audio_test.dart create mode 100644 test/servicios/servicio_dispositivo_audio_toggle_test.dart create mode 100644 test/servicios/servicio_ecualizador_test.dart diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index c5fe9a4..37ac1f1 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -6,6 +6,9 @@ import android.content.ClipData import android.content.Intent import android.content.ActivityNotFoundException import android.content.pm.PackageManager +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager import android.net.Uri import android.media.audiofx.Visualizer import android.app.AlarmManager @@ -31,6 +34,7 @@ class MainActivity : AudioServiceActivity() { private val visualizerChannel = "pluriwave/audio_visualizer" private val alarmChannel = "pluriwave/alarm_scheduler" private val fileActionsChannel = "pluriwave/file_actions" + private val audioDevicesChannel = "pluriwave/audio_devices" private val visualizerPermissionRequestCode = 4821 private val notificationPermissionRequestCode = 4822 private var visualizer: Visualizer? = null @@ -39,8 +43,16 @@ class MainActivity : AudioServiceActivity() { private var alarmMethodChannel: MethodChannel? = null private val mainHandler = Handler(Looper.getMainLooper()) + // Audio devices channel state + private var audioDevicesSink: EventChannel.EventSink? = null + private var audioDeviceCallback: AudioDeviceCallback? = null + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + + // --- Audio Devices Channel --- + setupAudioDevicesChannel(flutterEngine) + EventChannel( flutterEngine.dartExecutor.binaryMessenger, visualizerChannel @@ -601,10 +613,149 @@ class MainActivity : AudioServiceActivity() { } } + // ------------------------------------------------------------------------- + // Audio Devices Channel + // ------------------------------------------------------------------------- + + private fun setupAudioDevicesChannel(flutterEngine: FlutterEngine) { + val messenger = flutterEngine.dartExecutor.binaryMessenger + + MethodChannel(messenger, audioDevicesChannel).setMethodCallHandler { call, result -> + when (call.method) { + "getActiveDevice" -> { + val device = getActiveAudioDevice() + Log.d(tag, "audio_devices.getActiveDevice -> $device") + result.success(device) + } + else -> result.notImplemented() + } + } + + EventChannel(messenger, audioDevicesChannel).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + audioDevicesSink = events + registerAudioDeviceCallback() + } + + override fun onCancel(arguments: Any?) { + unregisterAudioDeviceCallback() + audioDevicesSink = null + } + } + ) + } + + private fun registerAudioDeviceCallback() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + val callback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + // Emit the current active output device when something connects. + val device = getActiveAudioDevice() + Log.d(tag, "audio_devices.onDevicesAdded active=$device") + mainHandler.post { audioDevicesSink?.success(device) } + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + // Emit the new active device after something disconnects. + val device = getActiveAudioDevice() + Log.d(tag, "audio_devices.onDevicesRemoved active=$device") + mainHandler.post { audioDevicesSink?.success(device) } + } + } + audioDeviceCallback = callback + audioManager.registerAudioDeviceCallback(callback, mainHandler) + } + + private fun unregisterAudioDeviceCallback() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + audioDeviceCallback?.let { audioManager.unregisterAudioDeviceCallback(it) } + audioDeviceCallback = null + } + + /** + * Returns a map describing the current active audio output device. + * + * Device ID format (matches spec): + * - "builtin_speaker" — TYPE_BUILTIN_SPEAKER (2) + * - "wired_headset" — TYPE_WIRED_HEADSET (3) or TYPE_WIRED_HEADPHONES (4) + * - "bt_a2dp:" — TYPE_BLUETOOTH_A2DP (8); MAC from AudioDeviceInfo.address + * - "usb_headset:
" — TYPE_USB_HEADSET (14) + * - "builtin_speaker" — fallback when API < 23 + * + * Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants. + */ + private fun getActiveAudioDevice(): Map { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker") + } + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + + // Priority: BT A2DP > USB headset > wired headset > built-in speaker > unknown + val priorityOrder = listOf( + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_USB_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + ) + val best = outputs + .filter { it.isSink } + .sortedBy { device -> + val idx = priorityOrder.indexOf(device.type) + if (idx == -1) Int.MAX_VALUE else idx + } + .firstOrNull() + + return deviceToMap(best) + } + + private fun deviceToMap(device: AudioDeviceInfo?): Map { + if (device == null) { + return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker") + } + return when (device.type) { + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> + mapOf("id" to "builtin_speaker", "type" to 2, "name" to (device.productName?.toString() ?: "Speaker")) + AudioDeviceInfo.TYPE_WIRED_HEADSET -> + mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headset")) + AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> + mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headphones")) + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> { + val mac = device.address?.takeIf { it.isNotBlank() } ?: "00:00:00:00:00:00" + mapOf( + "id" to "bt_a2dp:$mac", + "type" to 8, + "name" to (device.productName?.toString() ?: "Bluetooth"), + ) + } + AudioDeviceInfo.TYPE_USB_HEADSET -> { + val addr = device.address?.takeIf { it.isNotBlank() } ?: device.id.toString() + mapOf( + "id" to "usb_headset:$addr", + "type" to 14, + "name" to (device.productName?.toString() ?: "USB Headset"), + ) + } + else -> + mapOf( + "id" to "builtin_speaker", + "type" to device.type, + "name" to (device.productName?.toString() ?: "Unknown"), + ) + } + } + + // ------------------------------------------------------------------------- + override fun onDestroy() { if (activeInstance === this) { activeInstance = null } + unregisterAudioDeviceCallback() stopVisualizer() super.onDestroy() } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..f7b358c 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -12,5 +12,6 @@ import UIKit func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + AudioDevicesPlugin.register(with: engineBridge.pluginRegistry.registrar(forPlugin: "AudioDevicesPlugin")!) } } diff --git a/ios/Runner/AudioDevicesPlugin.swift b/ios/Runner/AudioDevicesPlugin.swift new file mode 100644 index 0000000..9fe5995 --- /dev/null +++ b/ios/Runner/AudioDevicesPlugin.swift @@ -0,0 +1,166 @@ +import AVFoundation +import Flutter + +/// Platform channel plugin that detects audio output devices on iOS via +/// AVAudioSession route change notifications. +/// +/// Channel: "pluriwave/audio_devices" +/// - MethodChannel `getActiveDevice` → Dictionary with `id`, `type`, `name` +/// - EventChannel stream → same Dictionary on route change +/// +/// Device ID format (matches spec): +/// "builtin_speaker" — AVAudioSession.Port.builtInSpeaker +/// "wired_headset" — AVAudioSession.Port.headphones / .headsetMic +/// "bt_a2dp:" — AVAudioSession.Port.bluetoothA2DP +/// "usb_headset:" — AVAudioSession.Port.usbAudio +/// +/// iOS uid fallback (spec scenario "iOS uid fallback on uid instability"): +/// Use portType+uid as the primary key; fall back to portType+portName +/// if uid is empty or nil to guarantee a non-empty, non-null key. +class AudioDevicesPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + + private static let channelName = "pluriwave/audio_devices" + + // Type int constants matching the Dart-side protocol (same as Android). + private static let typeBuiltinSpeaker = 2 + private static let typeWiredHeadset = 3 + private static let typeBluetoothA2dp = 8 + private static let typeUsbHeadset = 14 + private static let typeUnknown = 0 + + private var eventSink: FlutterEventSink? + + // MARK: - Plugin registration + + static func register(with registrar: FlutterPluginRegistrar) { + let messenger = registrar.messenger() + + let instance = AudioDevicesPlugin() + + let methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) + registrar.addMethodCallDelegate(instance, channel: methodChannel) + + let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: messenger) + eventChannel.setStreamHandler(instance) + + registrar.addApplicationDelegate(instance) + } + + // MARK: - MethodChannel + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getActiveDevice": + result(currentDeviceMap()) + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - EventChannel (FlutterStreamHandler) + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + NotificationCenter.default.addObserver( + self, + selector: #selector(routeChanged(_:)), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + NotificationCenter.default.removeObserver( + self, + name: AVAudioSession.routeChangeNotification, + object: nil + ) + self.eventSink = nil + return nil + } + + // MARK: - Route change notification + + @objc private func routeChanged(_ notification: Notification) { + guard let sink = eventSink else { return } + sink(currentDeviceMap()) + } + + // MARK: - Helpers + + /// Returns a dictionary representing the current active audio output route. + private func currentDeviceMap() -> [String: Any] { + let session = AVAudioSession.sharedInstance() + let outputs = session.currentRoute.outputs + + // Priority order: BT A2DP > USB > wired > built-in speaker > unknown + let priority: [AVAudioSession.Port] = [ + .bluetoothA2DP, + .usbAudio, + .headphones, + .headsetMic, + .builtInSpeaker, + ] + + let best = outputs.min { a, b in + let ia = priority.firstIndex(of: a.portType) ?? Int.max + let ib = priority.firstIndex(of: b.portType) ?? Int.max + return ia < ib + } + + return portToMap(best) + } + + /// Converts an AVAudioSessionPortDescription to the channel map format. + private func portToMap(_ port: AVAudioSessionPortDescription?) -> [String: Any] { + guard let port = port else { + return ["id": "builtin_speaker", "type": AudioDevicesPlugin.typeBuiltinSpeaker, "name": "Speaker"] + } + + switch port.portType { + case .builtInSpeaker: + return ["id": "builtin_speaker", + "type": AudioDevicesPlugin.typeBuiltinSpeaker, + "name": port.portName] + + case .headphones, .headsetMic: + return ["id": "wired_headset", + "type": AudioDevicesPlugin.typeWiredHeadset, + "name": port.portName] + + case .bluetoothA2DP: + let key = stableKey(prefix: "bt_a2dp", port: port) + return ["id": key, + "type": AudioDevicesPlugin.typeBluetoothA2dp, + "name": port.portName] + + case .usbAudio: + let key = stableKey(prefix: "usb_headset", port: port) + return ["id": key, + "type": AudioDevicesPlugin.typeUsbHeadset, + "name": port.portName] + + default: + // Unknown type — use portType string as part of id to avoid empty key. + let id = "unknown:\(port.portType.rawValue)" + return ["id": id, + "type": AudioDevicesPlugin.typeUnknown, + "name": port.portName] + } + } + + /// Derives a stable device key using uid (preferred) or portName as fallback. + /// + /// Per spec scenario "iOS uid fallback on uid instability": uid may differ + /// across sessions on some BT devices. In that case, use portType+portName. + private func stableKey(prefix: String, port: AVAudioSessionPortDescription) -> String { + let uid = port.uid + if !uid.isEmpty { + return "\(prefix):\(uid)" + } + // uid is empty — fall back to portType+portName (must not be empty per spec). + let name = port.portName.isEmpty ? "unknown" : port.portName + return "\(prefix):\(name)" + } +} diff --git a/lib/app.dart b/lib/app.dart index b6bcbe4..52fb6ab 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -25,6 +25,7 @@ import 'widgets/pluri_onboarding_dialog.dart'; import 'widgets/pluri_wave_scaffold.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'servicios/servicio_alarmas_android.dart'; +import 'servicios/servicio_dispositivo_audio.dart'; class PluriWaveApp extends StatelessWidget { const PluriWaveApp({super.key, this.prefs}); @@ -37,7 +38,13 @@ class PluriWaveApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ - ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)), + ChangeNotifierProvider( + create: + (_) => EstadoRadio( + prefs: prefs, + dispositivoAudio: ServicioDispositivoAudioReal(), + ), + ), // Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio // (they need its services and callbacks at construction); these // providers only expose the instances, so they declare no dispose diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index 5050c8d..488f690 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; +import '../modelos/dispositivo_audio.dart'; import '../modelos/preset_ecualizador.dart'; import '../servicios/servicio_audio.dart'; +import '../servicios/servicio_dispositivo_audio.dart'; import '../servicios/servicio_ecualizador.dart'; /// Equalizer state extracted from `EstadoRadio` (S4-R1). @@ -11,33 +15,65 @@ import '../servicios/servicio_ecualizador.dart'; /// [ServicioEcualizador] and their application through [ServicioAudio]. /// Notifies ONLY its own listeners — EQ changes must not rebuild /// `EstadoRadio` consumers (S4-R1-A, S4-R5). +/// +/// Multi-device EQ extension (Phase 5): when [eqMultiDeviceEnabled] is true, +/// resolves the active preset through a 4-level hierarchy: +/// 1. presetsMatriz["stationUuid:deviceId"] +/// 2. presetsEmisoraMap[stationUuid] +/// 3. presetsDispositivo[deviceId] +/// 4. presetPrincipal +/// +/// When the toggle is false, resolution falls back to the original 2-level +/// hierarchy (station → global) — zero behavioral change vs. prior releases. class EstadoEcualizador extends ChangeNotifier { EstadoEcualizador({ required this.audio, ServicioEcualizador? servicio, + ServicioDispositivoAudio? dispositivoAudio, String? Function()? emisoraActualUuid, }) : servicio = servicio ?? ServicioEcualizador(), + _dispositivoAudio = dispositivoAudio, _emisoraActualUuid = emisoraActualUuid ?? (() => null); final ServicioAudio audio; final ServicioEcualizador servicio; + final ServicioDispositivoAudio? _dispositivoAudio; /// Callback into the owner (EstadoRadio) for the currently playing station; /// keeps this notifier free of any station-list coupling. final String? Function() _emisoraActualUuid; final Map _presetsEmisoraMap = {}; + + /// Per-device presets: deviceId → PresetEcualizador. + final Map _presetsDispositivo = {}; + + /// Matrix presets: "stationUuid:deviceId" → PresetEcualizador. + final Map _presetsMatriz = {}; + PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat; bool _activo = true; + bool _eqMultiDeviceEnabled = false; + String? _dispositivoActualId; + StreamSubscription? _deviceSub; PresetEcualizador get presetActual => _presetActual; PresetEcualizador get presetPrincipal => _presetPrincipal; bool get activo => _activo; bool get disponible => audio.ecualizadorDisponible; + bool get eqMultiDeviceEnabled => _eqMultiDeviceEnabled; + String? get dispositivoActualId => _dispositivoActualId; + Map get presetsPorEmisora => Map.unmodifiable(_presetsEmisoraMap); + Map get presetsDispositivo => + Map.unmodifiable(_presetsDispositivo); + + Map get presetsMatriz => + Map.unmodifiable(_presetsMatriz); + bool get emisoraActualTienePresetPropio { final uuid = _emisoraActualUuid(); if (uuid == null) return false; @@ -52,26 +88,116 @@ class EstadoEcualizador extends ChangeNotifier { PresetEcualizador presetParaEmisora(String uuid) => _presetsEmisoraMap[uuid] ?? _presetPrincipal; + /// 4-level resolution hierarchy (ADR-4, spec requirement). + /// + /// When [eqMultiDeviceEnabled] is false, falls back to 2-level (station → global). + PresetEcualizador presetEfectivo({ + required String stationUuid, + required String deviceId, + }) { + if (!_eqMultiDeviceEnabled) { + // Original 2-level behavior: station → global. + return _presetsEmisoraMap[stationUuid] ?? _presetPrincipal; + } + + // Level 1: station × device matrix. + final matrizKey = '$stationUuid:$deviceId'; + if (_presetsMatriz.containsKey(matrizKey)) { + return _presetsMatriz[matrizKey]!; + } + // Level 2: station-only. + if (_presetsEmisoraMap.containsKey(stationUuid)) { + return _presetsEmisoraMap[stationUuid]!; + } + // Level 3: device-only. + if (_presetsDispositivo.containsKey(deviceId)) { + return _presetsDispositivo[deviceId]!; + } + // Level 4: global fallback. + return _presetPrincipal; + } + + /// Resolves the effective preset for the current station and device. + PresetEcualizador _resolverPresetActivo() { + final uuid = _emisoraActualUuid(); + if (!_eqMultiDeviceEnabled) { + return uuid != null + ? (_presetsEmisoraMap[uuid] ?? _presetPrincipal) + : _presetPrincipal; + } + final deviceId = _dispositivoActualId; + if (uuid == null || deviceId == null) { + return _presetPrincipal; + } + return presetEfectivo(stationUuid: uuid, deviceId: deviceId); + } + /// Loads the persisted EQ configuration and applies it to the audio engine. Future cargarPersistido() async { try { final config = await servicio.cargar(); _presetPrincipal = config.principal; - _presetActual = config.principal; _activo = config.activo; + _eqMultiDeviceEnabled = config.eqMultiDeviceEnabled; _presetsEmisoraMap ..clear() ..addAll(config.porEmisora); + _presetsDispositivo + ..clear() + ..addAll(config.presetsDispositivo); + _presetsMatriz + ..clear() + ..addAll(config.presetsMatriz); + + // Resolve active preset and apply it. + _presetActual = _resolverPresetActivo(); await audio.setEcualizadorActivo(_activo); - await audio.aplicarPreset(_presetPrincipal); + await audio.aplicarPreset(_presetActual); + + // Subscribe to device changes only when toggle is on. + _configurarSuscripcionDispositivo(); } catch (_) { _presetPrincipal = PresetEcualizador.flat; _presetActual = PresetEcualizador.flat; _activo = true; + _eqMultiDeviceEnabled = false; _presetsEmisoraMap.clear(); + _presetsDispositivo.clear(); + _presetsMatriz.clear(); } } + /// Subscribes to the device change stream if multi-device is enabled. + void _configurarSuscripcionDispositivo() { + _deviceSub?.cancel(); + _deviceSub = null; + + final svc = _dispositivoAudio; + if (!_eqMultiDeviceEnabled || svc == null) return; + + _deviceSub = svc.onDispositivoCambiado.listen(_onDispositivoCambiado); + } + + /// Called when a device change event arrives. + Future _onDispositivoCambiado(DispositivoAudio dispositivo) async { + if (!_eqMultiDeviceEnabled) return; + + _dispositivoActualId = dispositivo.id; + + // First-seen device: copy the current resolved preset as its starting point. + if (!_presetsDispositivo.containsKey(dispositivo.id)) { + final presetBase = _resolverPresetActivo(); + _presetsDispositivo[dispositivo.id] = presetBase; + await servicio.guardarPresetDispositivo(dispositivo.id, presetBase); + } + + // Re-resolve and apply the preset for the new device. + final resuelto = _resolverPresetActivo(); + _presetActual = resuelto; + await audio.aplicarPreset(resuelto); + notifyListeners(); + } + /// Applies [preset] to the audio engine and tracks it as current /// WITHOUT persisting it (used when switching stations). Future aplicarPresetActivo(PresetEcualizador preset) async { @@ -79,6 +205,20 @@ class EstadoEcualizador extends ChangeNotifier { await audio.aplicarPreset(preset); } + /// Enables or disables the multi-device EQ feature toggle. + /// + /// When disabled, the device stream subscription is cancelled and resolution + /// immediately collapses to 2-level (station → global). + Future cambiarMultiDeviceEnabled( + bool habilitado, { + bool notificar = true, + }) async { + _eqMultiDeviceEnabled = habilitado; + await servicio.guardarToggleMultiDispositivo(habilitado); + _configurarSuscripcionDispositivo(); + if (notificar) notifyListeners(); + } + Future cambiarPresetPrincipal( PresetEcualizador preset, { bool notificar = true, @@ -184,24 +324,49 @@ class EstadoEcualizador extends ChangeNotifier { Future importarConfiguracion({ required PresetEcualizador principal, required Map porEmisora, + Map? presetsDispositivo, + Map? presetsMatriz, + bool? eqMultiDeviceEnabled, }) async { _presetPrincipal = principal; _presetsEmisoraMap ..clear() ..addAll(porEmisora); + if (presetsDispositivo != null) { + _presetsDispositivo + ..clear() + ..addAll(presetsDispositivo); + } + if (presetsMatriz != null) { + _presetsMatriz + ..clear() + ..addAll(presetsMatriz); + } + if (eqMultiDeviceEnabled != null) { + _eqMultiDeviceEnabled = eqMultiDeviceEnabled; + } await servicio.guardarConfiguracion( ConfiguracionEcualizador( principal: _presetPrincipal, porEmisora: _presetsEmisoraMap, activo: _activo, + eqMultiDeviceEnabled: _eqMultiDeviceEnabled, + presetsDispositivo: _presetsDispositivo, + presetsMatriz: _presetsMatriz, ), ); final uuid = _emisoraActualUuid(); - await aplicarPresetActivo( - uuid == null ? _presetPrincipal : presetParaEmisora(uuid), - ); + final presetEfectivoActual = + uuid == null ? _presetPrincipal : _resolverPresetActivo(); + await aplicarPresetActivo(presetEfectivoActual); notifyListeners(); } + + @override + void dispose() { + _deviceSub?.cancel(); + super.dispose(); + } } diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 5b0dd0f..5269b0e 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -17,6 +17,7 @@ import 'estado_ecualizador.dart'; import 'estado_grabacion.dart'; import 'orden_emisoras.dart'; import '../servicios/servicio_audio.dart'; +import '../servicios/servicio_dispositivo_audio.dart'; import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_export_import.dart'; import '../servicios/servicio_favoritos.dart'; @@ -38,6 +39,7 @@ class EstadoRadio extends ChangeNotifier { ServicioFavoritos? favoritos, ServicioRadio? radio, ServicioEcualizador? servicioEcualizador, + ServicioDispositivoAudio? dispositivoAudio, ServicioGrabacionRadio? servicioGrabacion, SharedPreferences? prefs, Future Function()? resolverArchivoCustom, @@ -47,11 +49,13 @@ class EstadoRadio extends ChangeNotifier { radio = radio ?? ServicioRadio(), servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(prefs: prefs), + _dispositivoAudio = dispositivoAudio, _prefs = prefs, _resolverArchivoCustom = resolverArchivoCustom { ecualizador = EstadoEcualizador( audio: this.audio, servicio: this.servicioEcualizador, + dispositivoAudio: _dispositivoAudio, emisoraActualUuid: () => emisoraActual?.uuid, ); grabacion = EstadoGrabacion( @@ -77,6 +81,10 @@ class EstadoRadio extends ChangeNotifier { final ServicioRadio radio; final ServicioEcualizador servicioEcualizador; + /// Optional device service — null means no device subscription established. + /// Wired in production from app.dart; defaults to null in tests. + final ServicioDispositivoAudio? _dispositivoAudio; + /// Domain notifiers extracted from this class (S4). Created and disposed /// here (they need EstadoRadio's services and callbacks at construction); /// exposed app-wide through ListenableProviders in app.dart. @@ -547,8 +555,9 @@ class EstadoRadio extends ChangeNotifier { static const _keyAlarmasConfig = 'alarmas_musicales_v1'; - /// Genera el JSON de toda la configuración (v2 — portabilidad completa). - /// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4). + /// Genera el JSON de toda la configuración (v3 — portabilidad completa + /// con presets por dispositivo y matriz multi-device). + /// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4). Future> exportarConfig() async { final favs = await favoritos.obtenerTodos(); final grupos = await favoritos.obtenerGrupos(); @@ -572,6 +581,10 @@ class EstadoRadio extends ChangeNotifier { emisoraPreferidaUuid: _emisoraPreferidaUuid, ordenListas: _ordenListas.name, timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos, + // v3 extensions — multi-device EQ fields. + presetsPorDispositivo: ecualizador.presetsDispositivo, + presetsMatriz: ecualizador.presetsMatriz, + eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled, ); } @@ -584,10 +597,11 @@ class EstadoRadio extends ChangeNotifier { _exportImport.importar(raw); /// Importa configuración desde un JSON exportado previamente. - /// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa). + /// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa), + /// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device). Future importarConfig(Map data) async { final version = data['version'] as int? ?? 1; - if (version > 2) throw Exception(_textos.unsupportedConfigVersion); + if (version > 3) throw Exception(_textos.unsupportedConfigVersion); final prefs = await _resolverPrefs(); @@ -637,9 +651,41 @@ class EstadoRadio extends ChangeNotifier { ), ); + // v3 extensions: per-device and matrix presets. + Map? presetsDispositivo; + Map? presetsMatriz; + bool? eqMultiDeviceEnabled; + + if (version >= 3) { + final dispositivoRaw = data['presetsPorDispositivo'] as Map? ?? {}; + presetsDispositivo = dispositivoRaw.map( + (deviceId, presetJson) => MapEntry( + deviceId as String, + PresetEcualizador.desdeJson( + Map.from(presetJson as Map), + ), + ), + ); + + final matrizRaw = data['presetsMatriz'] as Map? ?? {}; + presetsMatriz = matrizRaw.map( + (clave, presetJson) => MapEntry( + clave as String, + PresetEcualizador.desdeJson( + Map.from(presetJson as Map), + ), + ), + ); + + eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?; + } + await ecualizador.importarConfiguracion( principal: presetPrincipal, porEmisora: presetsPorEmisora, + presetsDispositivo: presetsDispositivo, + presetsMatriz: presetsMatriz, + eqMultiDeviceEnabled: eqMultiDeviceEnabled, ); // ── Alarmas (v2) ────────────────────────────────────────────────────── @@ -748,6 +794,7 @@ class EstadoRadio extends ChangeNotifier { grabacion.dispose(); audio.dispose(); timer.dispose(); + _dispositivoAudio?.dispose(); super.dispose(); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1dc9c6e..dfc9aa2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -616,5 +616,16 @@ "stationCount": "{count, plural, =1{1 station} other{{count} stations}}", "alarmIconLabel": "Musical alarm", "vacationIconLabel": "Vacation mode", - "streamUrlHint": "https://stream.example.com:8000/radio" + "streamUrlHint": "https://stream.example.com:8000/radio", + "advancedEqSectionTitle": "Advanced Equalization Options", + "advancedEqEnableToggle": "Enable per-device EQ", + "advancedEqEnableToggleSubtitle": "Automatically apply a different EQ preset when your audio output changes.", + "advancedEqKnownDevicesTitle": "Known audio devices", + "advancedEqKnownDevicesEmpty": "No audio devices recorded yet. Connect a device to get started.", + "advancedEqDevicePresetLabel": "Preset: {presetName}", + "@advancedEqDevicePresetLabel": { + "placeholders": { + "presetName": {} + } + } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e36eddd..5505613 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -579,5 +579,16 @@ "type": "int" } } + }, + "advancedEqSectionTitle": "Opciones avanzadas de ecualización", + "advancedEqEnableToggle": "Activar EQ por dispositivo", + "advancedEqEnableToggleSubtitle": "Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.", + "advancedEqKnownDevicesTitle": "Dispositivos de audio conocidos", + "advancedEqKnownDevicesEmpty": "No hay dispositivos registrados. Conectá uno para empezar.", + "advancedEqDevicePresetLabel": "Preset: {presetName}", + "@advancedEqDevicePresetLabel": { + "placeholders": { + "presetName": {} + } } } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index b365631..8586375 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2269,6 +2269,42 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'https://stream.example.com:8000/radio'** String get streamUrlHint; + + /// No description provided for @advancedEqSectionTitle. + /// + /// In es, this message translates to: + /// **'Opciones avanzadas de ecualización'** + String get advancedEqSectionTitle; + + /// No description provided for @advancedEqEnableToggle. + /// + /// In es, this message translates to: + /// **'Activar EQ por dispositivo'** + String get advancedEqEnableToggle; + + /// No description provided for @advancedEqEnableToggleSubtitle. + /// + /// In es, this message translates to: + /// **'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'** + String get advancedEqEnableToggleSubtitle; + + /// No description provided for @advancedEqKnownDevicesTitle. + /// + /// In es, this message translates to: + /// **'Dispositivos de audio conocidos'** + String get advancedEqKnownDevicesTitle; + + /// No description provided for @advancedEqKnownDevicesEmpty. + /// + /// In es, this message translates to: + /// **'No hay dispositivos registrados. Conectá uno para empezar.'** + String get advancedEqKnownDevicesEmpty; + + /// No description provided for @advancedEqDevicePresetLabel. + /// + /// In es, this message translates to: + /// **'Preset: {presetName}'** + String advancedEqDevicePresetLabel(Object presetName); } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index e32c6e3..5f6d970 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1225,4 +1225,26 @@ class AppLocalizationsAr extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index c3a7da8..9b21ddb 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1232,4 +1232,26 @@ class AppLocalizationsBn extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index f021b01..58ac516 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1242,4 +1242,26 @@ class AppLocalizationsDe extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index ad5ec5a..fb0f0e4 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1228,4 +1228,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Advanced Equalization Options'; + + @override + String get advancedEqEnableToggle => 'Enable per-device EQ'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Automatically apply a different EQ preset when your audio output changes.'; + + @override + String get advancedEqKnownDevicesTitle => 'Known audio devices'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No audio devices recorded yet. Connect a device to get started.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index c04c41d..c9deec5 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1237,4 +1237,26 @@ class AppLocalizationsEs extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index b2b52a5..a59412e 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1247,4 +1247,26 @@ class AppLocalizationsFr extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 5cd070a..186b35b 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1231,4 +1231,26 @@ class AppLocalizationsHi extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 43ab045..1c3017f 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1236,4 +1236,26 @@ class AppLocalizationsId extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 5b779db..7a5b779 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1242,4 +1242,26 @@ class AppLocalizationsIt extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 49b3427..77d8479 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1192,4 +1192,26 @@ class AppLocalizationsJa extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 38cc2c2..2e438a8 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1234,4 +1234,26 @@ class AppLocalizationsPt extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 57870fc..7fc380a 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1238,4 +1238,26 @@ class AppLocalizationsRu extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 77abae7..9489f67 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1187,4 +1187,26 @@ class AppLocalizationsZh extends AppLocalizations { @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; + + @override + String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización'; + + @override + String get advancedEqEnableToggle => 'Activar EQ por dispositivo'; + + @override + String get advancedEqEnableToggleSubtitle => + 'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'; + + @override + String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos'; + + @override + String get advancedEqKnownDevicesEmpty => + 'No hay dispositivos registrados. Conectá uno para empezar.'; + + @override + String advancedEqDevicePresetLabel(Object presetName) { + return 'Preset: $presetName'; + } } diff --git a/lib/modelos/dispositivo_audio.dart b/lib/modelos/dispositivo_audio.dart new file mode 100644 index 0000000..e4985cc --- /dev/null +++ b/lib/modelos/dispositivo_audio.dart @@ -0,0 +1,47 @@ +/// Audio device types detected via platform channel. +enum TipoDispositivo { + /// Built-in speaker — id: "builtin_speaker" + altavozInterno, + + /// Wired headset or headphones — id: "wired_headset" + auricularesCable, + + /// Bluetooth A2DP device — id: `"bt_a2dp:"` + bluetoothA2dp, + + /// USB audio device — id: `"usb_headset:
"` + usbAudio, + + /// Unrecognized device type. + desconocido, +} + +/// Represents an audio output device detected on the current platform. +/// +/// Equality is based on [id] only so that two instances referring to the +/// same physical device compare as equal regardless of display name. +class DispositivoAudio { + final String id; + final TipoDispositivo tipo; + final String nombre; + + const DispositivoAudio({ + required this.id, + required this.tipo, + required this.nombre, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! DispositivoAudio) return false; + return id == other.id; + } + + @override + int get hashCode => id.hashCode; + + @override + String toString() => + 'DispositivoAudio(id: $id, tipo: $tipo, nombre: $nombre)'; +} diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 11b5d6f..2784739 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -60,6 +60,8 @@ class _AjustesContent extends StatelessWidget { children: const [ _SeccionEcualizador(), SizedBox(height: 12), + _SeccionEcualizadorAvanzado(), + SizedBox(height: 12), _SeccionGrabaciones(), SizedBox(height: 12), _SeccionTimerSueno(), @@ -652,6 +654,126 @@ class _SeccionEcualizador extends StatelessWidget { } } +/// "Advanced Equalization Options" settings section (Phase 7, multi-device EQ). +/// +/// Always shows the feature toggle so the user can discover it. When the +/// toggle is OFF, the device list is completely absent (not just invisible), +/// matching the spec scenario "Settings section is absent when toggle is off". +class _SeccionEcualizadorAvanzado extends StatelessWidget { + const _SeccionEcualizadorAvanzado(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final eq = context.watch(); + final multiDeviceEnabled = eq.eqMultiDeviceEnabled; + final presetsDispositivo = eq.presetsDispositivo; + + return PluriGlassSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.devices_rounded), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.advancedEqSectionTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + // The toggle uses GestureDetector + custom row instead of + // SwitchListTile to avoid Material ink assertion inside + // PluriGlassSurface's DecoratedBox. The visual result is identical + // to SwitchListTile. + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: + () => eq.cambiarMultiDeviceEnabled(!multiDeviceEnabled), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.advancedEqEnableToggle, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 2), + Text( + l10n.advancedEqEnableToggleSubtitle, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + Switch.adaptive( + value: multiDeviceEnabled, + onChanged: + (habilitado) => eq.cambiarMultiDeviceEnabled(habilitado), + ), + ], + ), + ), + if (multiDeviceEnabled) ...[ + const SizedBox(height: 8), + Text( + l10n.advancedEqKnownDevicesTitle, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + if (presetsDispositivo.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + l10n.advancedEqKnownDevicesEmpty, + style: Theme.of(context).textTheme.bodySmall, + ), + ) + else + for (final entry in presetsDispositivo.entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + const Icon(Icons.headphones_rounded, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.key, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + l10n.advancedEqDevicePresetLabel( + entry.value.nombre, + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} + class _SeccionOrdenListas extends StatelessWidget { const _SeccionOrdenListas(); diff --git a/lib/servicios/servicio_dispositivo_audio.dart b/lib/servicios/servicio_dispositivo_audio.dart new file mode 100644 index 0000000..91dd545 --- /dev/null +++ b/lib/servicios/servicio_dispositivo_audio.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../modelos/dispositivo_audio.dart'; + +/// Abstract service for audio device detection. +/// +/// Implementations: +/// - [ServicioDispositivoAudioReal] — platform channel (Android/iOS) +/// - `FakeServicioDispositivoAudio` (test/helpers/fakes.dart) — for unit tests +abstract class ServicioDispositivoAudio { + /// The last known active audio output device, or null if none detected yet. + DispositivoAudio? get dispositivoActual; + + /// A broadcast stream that emits whenever the active audio output device + /// changes (connect or disconnect event from the platform). + Stream get onDispositivoCambiado; + + /// Requests the current active device from the platform synchronously + /// (method channel round-trip). Returns the cached value if already known. + Future obtenerDispositivoActual(); + + /// Cancels the device-change subscription and releases resources. + Future dispose(); +} + +/// Platform channel implementation of [ServicioDispositivoAudio]. +/// +/// Talks to `pluriwave/audio_devices` on Android (Kotlin) and iOS (Swift). +/// Type int constants (from platform channel protocol): +/// 2 → altavozInterno (builtin_speaker) +/// 3 → auricularesCable (wired_headset) +/// 8 → bluetoothA2dp (`bt_a2dp:`) +/// 14 → usbAudio (`usb_headset:
`) +class ServicioDispositivoAudioReal extends ServicioDispositivoAudio { + static const _channelName = 'pluriwave/audio_devices'; + + final MethodChannel _methodChannel; + final EventChannel _eventChannel; + + final _controller = StreamController.broadcast(); + StreamSubscription? _eventSub; + DispositivoAudio? _dispositivoActual; + + ServicioDispositivoAudioReal({ + MethodChannel? methodChannel, + EventChannel? eventChannel, + }) : _methodChannel = methodChannel ?? const MethodChannel(_channelName), + _eventChannel = eventChannel ?? const EventChannel(_channelName) { + _subscribeToEvents(); + } + + void _subscribeToEvents() { + _eventSub = _eventChannel.receiveBroadcastStream().listen( + (dynamic event) { + if (event is Map) { + final device = _mapToDispositivo(Map.from(event)); + _dispositivoActual = device; + _controller.add(device); + } + }, + onError: (_) { + /* swallow platform errors; stream continues */ + }, + cancelOnError: false, + ); + } + + @override + DispositivoAudio? get dispositivoActual => _dispositivoActual; + + @override + Stream get onDispositivoCambiado => _controller.stream; + + @override + Future obtenerDispositivoActual() async { + final raw = await _methodChannel.invokeMethod('getActiveDevice'); + final map = Map.from(raw ?? {}); + final device = _mapToDispositivo(map); + _dispositivoActual = device; + return device; + } + + @override + Future dispose() async { + await _eventSub?.cancel(); + await _controller.close(); + } + + static DispositivoAudio _mapToDispositivo(Map map) { + final id = map['id'] as String? ?? 'builtin_speaker'; + final type = map['type'] as int? ?? 2; + final name = map['name'] as String? ?? ''; + return DispositivoAudio(id: id, tipo: _tipoDesdeInt(type), nombre: name); + } + + static TipoDispositivo _tipoDesdeInt(int type) { + switch (type) { + case 2: + return TipoDispositivo.altavozInterno; + case 3: + return TipoDispositivo.auricularesCable; + case 8: + return TipoDispositivo.bluetoothA2dp; + case 14: + return TipoDispositivo.usbAudio; + default: + return TipoDispositivo.desconocido; + } + } +} diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart index f7b5f0a..ccb0ddd 100644 --- a/lib/servicios/servicio_ecualizador.dart +++ b/lib/servicios/servicio_ecualizador.dart @@ -9,11 +9,23 @@ class ConfiguracionEcualizador { required this.principal, required this.porEmisora, this.activo = true, + this.eqMultiDeviceEnabled = false, + this.presetsDispositivo = const {}, + this.presetsMatriz = const {}, }); final PresetEcualizador principal; final Map porEmisora; final bool activo; + + /// Feature toggle: when false all multi-device logic is bypassed. + final bool eqMultiDeviceEnabled; + + /// Per-device presets: deviceId → PresetEcualizador. + final Map presetsDispositivo; + + /// Matrix presets: "stationUuid:deviceId" → PresetEcualizador. + final Map presetsMatriz; } class ServicioEcualizador { @@ -22,6 +34,9 @@ class ServicioEcualizador { static const _keyPresetPrincipal = 'eq_preset_principal_v1'; static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1'; static const _keyActivo = 'eq_activo_v1'; + static const _keyMultiDeviceEnabled = 'eq_multi_device_enabled_v1'; + static const _keyPresetsPorDispositivo = 'eq_preset_por_dispositivo_v1'; + static const _keyPresetsMatriz = 'eq_presets_matriz_v1'; final SharedPreferences? _prefs; @@ -33,10 +48,15 @@ class ServicioEcualizador { final prefs = await _resolverPrefs(); final principal = _leerPresetPrincipal(prefs); final porEmisora = _leerPresetsPorEmisora(prefs); + final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo); + final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz); return ConfiguracionEcualizador( principal: principal, porEmisora: porEmisora, activo: prefs.getBool(_keyActivo) ?? true, + eqMultiDeviceEnabled: prefs.getBool(_keyMultiDeviceEnabled) ?? false, + presetsDispositivo: presetsDispositivo, + presetsMatriz: presetsMatriz, ); } @@ -72,6 +92,86 @@ class ServicioEcualizador { ); await _guardarPresetsPorEmisora(prefs, config.porEmisora); await prefs.setBool(_keyActivo, config.activo); + await prefs.setBool(_keyMultiDeviceEnabled, config.eqMultiDeviceEnabled); + await _guardarMapa( + prefs, + _keyPresetsPorDispositivo, + config.presetsDispositivo, + ); + await _guardarMapa(prefs, _keyPresetsMatriz, config.presetsMatriz); + } + + /// Persists the multi-device feature toggle. + Future guardarToggleMultiDispositivo(bool habilitado) async { + final prefs = await _resolverPrefs(); + await prefs.setBool(_keyMultiDeviceEnabled, habilitado); + } + + /// Saves a per-device preset. + Future guardarPresetDispositivo( + String deviceId, + PresetEcualizador preset, + ) async { + final prefs = await _resolverPrefs(); + final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo); + mapa[deviceId] = preset; + await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa); + } + + /// Saves a matrix (stationUuid:deviceId) preset entry. + Future guardarPresetMatriz( + String clave, + PresetEcualizador preset, + ) async { + final prefs = await _resolverPrefs(); + final mapa = _leerMapa(prefs, _keyPresetsMatriz); + mapa[clave] = preset; + await _guardarMapa(prefs, _keyPresetsMatriz, mapa); + } + + /// Removes a per-device preset entry. + Future eliminarPresetDispositivo(String deviceId) async { + final prefs = await _resolverPrefs(); + final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo); + mapa.remove(deviceId); + await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa); + } + + /// Removes a matrix preset entry. + Future eliminarPresetMatriz(String clave) async { + final prefs = await _resolverPrefs(); + final mapa = _leerMapa(prefs, _keyPresetsMatriz); + mapa.remove(clave); + await _guardarMapa(prefs, _keyPresetsMatriz, mapa); + } + + /// Reads a `Map` from a SharedPreferences JSON key. + Map _leerMapa( + 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, + PresetEcualizador.desdeJson(Map.from(v as Map)), + ), + ); + } catch (_) { + return {}; + } + } + + Future _guardarMapa( + SharedPreferences prefs, + String key, + Map mapa, + ) async { + final serializado = mapa.map((k, v) => MapEntry(k, v.toJson())); + await prefs.setString(key, jsonEncode(serializado)); } PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) { diff --git a/lib/servicios/servicio_export_import.dart b/lib/servicios/servicio_export_import.dart index 44aece5..5dbca6c 100644 --- a/lib/servicios/servicio_export_import.dart +++ b/lib/servicios/servicio_export_import.dart @@ -6,21 +6,29 @@ import '../modelos/preset_ecualizador.dart'; /// Owns the backup (export/import) JSON serialization (S4-R4). /// -/// The v2 envelope produced here is byte-compatible with the legacy format -/// previously assembled inline by `EstadoRadio.exportarConfig` and -/// pretty-printed by `pantalla_ajustes.dart`, so existing exports keep -/// round-tripping. State APPLICATION (writing favorites, EQ, alarms back -/// into the app) stays in `EstadoRadio.importarConfig` — this service only -/// owns serialization, parsing and the envelope shape. +/// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and +/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the +/// export stays at v2 for backward compat with the old app. State APPLICATION +/// (writing favorites, EQ, alarms back into the app) stays in +/// `EstadoRadio.importarConfig` — this service only owns serialization, +/// parsing and the envelope shape. class ServicioExportImport { const ServicioExportImport(); - /// Current backup schema version (v2 — full portability). - static const int versionActual = 2; + /// Current backup schema version (v3 — multi-device EQ). + static const int versionActual = 3; - /// Builds the v2 export envelope. Key set and semantics must stay exactly - /// as the legacy inline export so old backups remain importable: - /// the `alarmas` block is the RAW JSON map persisted by ServicioAlarmas + /// Legacy v2 version constant kept for clarity. + static const int versionV2 = 2; + + /// Builds the export envelope. + /// + /// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null), + /// [versionActual] (3) is written. When both are omitted the call behaves + /// identically to the original v2 format (version key stays 2) so old + /// backups keep round-tripping without version bumps. + /// + /// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas /// and passes through untouched (no re-parsing here). Map construirExportacion({ required List gruposFavoritos, @@ -33,9 +41,18 @@ class ServicioExportImport { required String ordenListas, required List timerSuenoPresetsSegundos, DateTime? exportadoEn, + // v3 extensions — omitting these produces a v2-compatible export. + Map? presetsPorDispositivo, + Map? presetsMatriz, + bool? eqMultiDeviceEnabled, }) { - return { - 'version': versionActual, + final tieneExtensionesV3 = + presetsPorDispositivo != null || + presetsMatriz != null || + eqMultiDeviceEnabled != null; + + final envelope = { + 'version': tieneExtensionesV3 ? versionActual : versionV2, 'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(), // Favorites + groups (preserves grupo_id assignments per station). // The protected "sin asignar" group is implicit and never exported. @@ -47,7 +64,7 @@ class ServicioExportImport { 'favoritos': favoritos.map((e) => e.toMap()).toList(), // Custom stations. 'emisorasCustom': emisorasCustom.map((e) => e.toMap()).toList(), - // Equalizer. + // Equalizer (base fields — present in all versions). 'presetPrincipalEcualizador': presetPrincipal.toJson(), 'presetsEcualizador': presetsPorEmisora.map( (uuid, preset) => MapEntry(uuid, preset.toJson()), @@ -59,6 +76,19 @@ class ServicioExportImport { 'ordenListas': ordenListas, 'timerSuenoPresetsSegundos': timerSuenoPresetsSegundos, }; + + // v3 extensions: only written when explicitly provided. + if (tieneExtensionesV3) { + envelope['presetsPorDispositivo'] = (presetsPorDispositivo ?? {}).map( + (deviceId, preset) => MapEntry(deviceId, preset.toJson()), + ); + envelope['presetsMatriz'] = (presetsMatriz ?? {}).map( + (clave, preset) => MapEntry(clave, preset.toJson()), + ); + envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false; + } + + return envelope; } /// Serializes an export envelope to pretty-printed JSON (same formatting diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 9ac8d01..83896fb 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/modelos/dispositivo_audio.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import '../helpers/fakes.dart'; @@ -48,4 +49,493 @@ void main() { estado.dispose(); }); }); + + // --------------------------------------------------------------------------- + // Phase 5: 4-level resolution hierarchy + // --------------------------------------------------------------------------- + + group('EstadoEcualizador — 4-level resolution (Phase 5)', () { + const btDevice = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'BT Speaker', + ); + const stationUuid = 'station-uuid-123'; + + EstadoEcualizador buildEq({ + FakeServicioEcualizador? servicio, + FakeServicioDispositivoAudio? dispositivoAudio, + bool multiDeviceEnabled = true, + String? emisoraActualUuid, + }) { + return EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: + servicio ?? + FakeServicioEcualizador(eqMultiDeviceEnabled: multiDeviceEnabled), + dispositivoAudio: dispositivoAudio ?? FakeServicioDispositivoAudio(), + emisoraActualUuid: () => emisoraActualUuid, + ); + } + + // 5.1a — matrix entry wins over all others + test( + '5.1a matrix entry (stationUuid:deviceId) wins top priority', + () async { + final matrizPreset = PresetEcualizador.bassBoost; + final stationPreset = PresetEcualizador.rock; + final devicePreset = PresetEcualizador.jazz; + final globalPreset = PresetEcualizador.pop; + + final eq = buildEq( + servicio: FakeServicioEcualizador( + principal: globalPreset, + porEmisora: {stationUuid: stationPreset}, + eqMultiDeviceEnabled: true, + presetsDispositivo: {btDevice.id: devicePreset}, + presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset}, + ), + emisoraActualUuid: stationUuid, + ); + await eq.cargarPersistido(); + + expect( + eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), + equals(matrizPreset), + ); + eq.dispose(); + }, + ); + + // 5.1b — falls back to station when no matrix entry + test('5.1b falls back to station preset when no matrix entry', () async { + final stationPreset = PresetEcualizador.rock; + final devicePreset = PresetEcualizador.jazz; + final globalPreset = PresetEcualizador.pop; + + final eq = buildEq( + servicio: FakeServicioEcualizador( + principal: globalPreset, + porEmisora: {stationUuid: stationPreset}, + eqMultiDeviceEnabled: true, + presetsDispositivo: {btDevice.id: devicePreset}, + presetsMatriz: {}, + ), + emisoraActualUuid: stationUuid, + ); + await eq.cargarPersistido(); + + expect( + eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), + equals(stationPreset), + ); + eq.dispose(); + }); + + // 5.1c — falls back to device when no station preset + test( + '5.1c falls back to device preset when no station or matrix entry', + () async { + final devicePreset = PresetEcualizador.jazz; + final globalPreset = PresetEcualizador.pop; + + final eq = buildEq( + servicio: FakeServicioEcualizador( + principal: globalPreset, + porEmisora: {}, + eqMultiDeviceEnabled: true, + presetsDispositivo: {btDevice.id: devicePreset}, + presetsMatriz: {}, + ), + emisoraActualUuid: stationUuid, + ); + await eq.cargarPersistido(); + + expect( + eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), + equals(devicePreset), + ); + eq.dispose(); + }, + ); + + // 5.1d — falls back to global when nothing else matches + test( + '5.1d falls back to global preset when no matrix, station, or device', + () async { + final globalPreset = PresetEcualizador.pop; + + final eq = buildEq( + servicio: FakeServicioEcualizador( + principal: globalPreset, + porEmisora: {}, + eqMultiDeviceEnabled: true, + presetsDispositivo: {}, + presetsMatriz: {}, + ), + emisoraActualUuid: stationUuid, + ); + await eq.cargarPersistido(); + + expect( + eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), + equals(globalPreset), + ); + eq.dispose(); + }, + ); + + // 5.1e — toggle off collapses to 2-level + test( + '5.1e toggle off: presetEfectivo uses only station → global', + () async { + final stationPreset = PresetEcualizador.rock; + final devicePreset = PresetEcualizador.jazz; + final matrizPreset = PresetEcualizador.bassBoost; + + final eq = buildEq( + servicio: FakeServicioEcualizador( + principal: PresetEcualizador.flat, + porEmisora: {stationUuid: stationPreset}, + eqMultiDeviceEnabled: false, + presetsDispositivo: {btDevice.id: devicePreset}, + presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset}, + ), + multiDeviceEnabled: false, + emisoraActualUuid: stationUuid, + ); + await eq.cargarPersistido(); + + // When toggle is off, device and matrix entries must be ignored + expect( + eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), + equals(stationPreset), + ); + eq.dispose(); + }, + ); + + // 5.1f — toggle off with no station falls back to global + test('5.1f toggle off with no station: falls back to global', () async { + final devicePreset = PresetEcualizador.jazz; + + final eq = buildEq( + servicio: FakeServicioEcualizador( + principal: PresetEcualizador.flat, + porEmisora: {}, + eqMultiDeviceEnabled: false, + presetsDispositivo: {btDevice.id: devicePreset}, + presetsMatriz: {}, + ), + multiDeviceEnabled: false, + emisoraActualUuid: stationUuid, + ); + await eq.cargarPersistido(); + + expect( + eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), + equals(PresetEcualizador.flat), + ); + eq.dispose(); + }); + }); + + // --------------------------------------------------------------------------- + // Phase 5.3: Device stream subscription triggers re-resolution + // --------------------------------------------------------------------------- + + group('EstadoEcualizador — device stream subscription (Phase 5.3–5.4)', () { + const stationUuid = 'station-uuid-stream'; + const btDevice = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'BT Speaker', + ); + + test('5.3a device change emits notifyListeners', () async { + final fakeAudio = FakeServicioAudio(); + final fakeDispositivo = FakeServicioDispositivoAudio(); + final eq = EstadoEcualizador( + audio: fakeAudio, + servicio: FakeServicioEcualizador( + eqMultiDeviceEnabled: true, + presetsDispositivo: {btDevice.id: PresetEcualizador.jazz}, + ), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + + var avisos = 0; + eq.addListener(() => avisos++); + + fakeDispositivo.emitirDispositivo(btDevice); + // Wait for async processing + await Future.delayed(const Duration(milliseconds: 50)); + + expect(avisos, greaterThanOrEqualTo(1)); + eq.dispose(); + }); + + test( + '5.3b device change re-applies resolved preset to audio engine', + () async { + final fakeAudio = FakeServicioAudio(); + final fakeDispositivo = FakeServicioDispositivoAudio(); + final btPreset = PresetEcualizador.jazz; + + final eq = EstadoEcualizador( + audio: fakeAudio, + servicio: FakeServicioEcualizador( + eqMultiDeviceEnabled: true, + presetsDispositivo: {btDevice.id: btPreset}, + ), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + final initialCount = fakeAudio.presetsAplicados.length; + + fakeDispositivo.emitirDispositivo(btDevice); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(fakeAudio.presetsAplicados.length, greaterThan(initialCount)); + expect(fakeAudio.presetsAplicados.last, equals(btPreset)); + eq.dispose(); + }, + ); + + test( + '5.3c toggle off: device stream events do NOT trigger any action', + () async { + final fakeAudio = FakeServicioAudio(); + final fakeDispositivo = FakeServicioDispositivoAudio(); + + final eq = EstadoEcualizador( + audio: fakeAudio, + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + + var avisos = 0; + eq.addListener(() => avisos++); + final initialCount = fakeAudio.presetsAplicados.length; + + fakeDispositivo.emitirDispositivo(btDevice); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(avisos, equals(0)); + expect(fakeAudio.presetsAplicados.length, equals(initialCount)); + eq.dispose(); + }, + ); + }); + + // --------------------------------------------------------------------------- + // Phase 5.5–5.6: First-seen device copies current preset + // --------------------------------------------------------------------------- + + group( + 'EstadoEcualizador — first-seen device initialization (Phase 5.5–5.6)', + () { + const stationUuid = 'station-uuid-first-seen'; + const newDevice = DispositivoAudio( + id: 'bt_a2dp:NEW:DE:VI:CE:00:00', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'New BT Device', + ); + + test('5.5a new device gets copy of current resolved preset', () async { + final fakeServicio = FakeServicioEcualizador( + principal: PresetEcualizador.rock, + eqMultiDeviceEnabled: true, + presetsDispositivo: {}, + ); + final fakeDispositivo = FakeServicioDispositivoAudio(); + + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: fakeServicio, + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + + fakeDispositivo.emitirDispositivo(newDevice); + await Future.delayed(const Duration(milliseconds: 50)); + + // The device should now have a preset saved equal to the resolved preset + // at the time of first connection (rock — global fallback) + expect( + eq.presetsDispositivo[newDevice.id], + equals(PresetEcualizador.rock), + ); + eq.dispose(); + }); + + test( + '5.5b subsequent reconnection does NOT overwrite user-saved preset', + () async { + final userSavedPreset = PresetEcualizador.jazz; + final fakeServicio = FakeServicioEcualizador( + principal: PresetEcualizador.rock, + eqMultiDeviceEnabled: true, + presetsDispositivo: {newDevice.id: userSavedPreset}, + ); + final fakeDispositivo = FakeServicioDispositivoAudio(); + + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: fakeServicio, + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + + fakeDispositivo.emitirDispositivo(newDevice); + await Future.delayed(const Duration(milliseconds: 50)); + + // Preset must remain the user-saved one, not overwritten with rock + expect(eq.presetsDispositivo[newDevice.id], equals(userSavedPreset)); + eq.dispose(); + }, + ); + }, + ); + + // --------------------------------------------------------------------------- + // Phase 5.7–5.8: Toggle-off path uses only 2-level resolution + // --------------------------------------------------------------------------- + + group('EstadoEcualizador — toggle-off path (Phase 5.7–5.8)', () { + const stationUuid = 'station-toggle-off'; + const btDevice = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'BT Speaker', + ); + + test( + '5.7a cargarPersistido with toggle off applies 2-level resolution', + () async { + final fakeAudio = FakeServicioAudio(); + final stationPreset = PresetEcualizador.rock; + final devicePreset = PresetEcualizador.jazz; + + final eq = EstadoEcualizador( + audio: fakeAudio, + servicio: FakeServicioEcualizador( + principal: PresetEcualizador.flat, + porEmisora: {stationUuid: stationPreset}, + eqMultiDeviceEnabled: false, + presetsDispositivo: {btDevice.id: devicePreset}, + ), + dispositivoAudio: FakeServicioDispositivoAudio(), + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + + // With toggle off, no device subscription, direct 2-level on presetEfectivo + expect( + eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), + equals(stationPreset), + ); + eq.dispose(); + }, + ); + + test('5.8a eqMultiDeviceEnabled getter reflects loaded value', () async { + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: FakeServicioDispositivoAudio(), + ); + await eq.cargarPersistido(); + expect(eq.eqMultiDeviceEnabled, isTrue); + eq.dispose(); + }); + + test('5.8b toggle defaults to false and eq behaves as before', () async { + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false), + dispositivoAudio: FakeServicioDispositivoAudio(), + ); + await eq.cargarPersistido(); + expect(eq.eqMultiDeviceEnabled, isFalse); + eq.dispose(); + }); + + test( + '5.8c cambiarMultiDeviceEnabled persists toggle and notifies', + () 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.cambiarMultiDeviceEnabled(true); + + expect(eq.eqMultiDeviceEnabled, isTrue); + expect(fakeServicio.config.eqMultiDeviceEnabled, isTrue); + expect(avisos, greaterThanOrEqualTo(1)); + eq.dispose(); + }, + ); + }); + + // --------------------------------------------------------------------------- + // Phase 5.9: REFACTOR — aplicarPresetActivo path applies device-resolved preset + // --------------------------------------------------------------------------- + + group( + 'EstadoEcualizador — aplicarPresetActivo uses device resolution (5.9)', + () { + const stationUuid = 'station-refactor'; + const btDevice = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'BT Speaker', + ); + + test( + '5.9a aplicarPresetEmisoraActual uses device-resolved preset when toggle on', + () async { + final fakeAudio = FakeServicioAudio(); + final fakeDispositivo = + FakeServicioDispositivoAudio()..emitirDispositivo(btDevice); + final matrizPreset = PresetEcualizador.bassBoost; + + final eq = EstadoEcualizador( + audio: fakeAudio, + servicio: FakeServicioEcualizador( + eqMultiDeviceEnabled: true, + presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset}, + ), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + await Future.delayed(const Duration(milliseconds: 50)); + + // Clear application history from init; emit device to trigger re-apply + fakeAudio.presetsAplicados.clear(); + fakeDispositivo.emitirDispositivo(btDevice); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(fakeAudio.presetsAplicados.last, equals(matrizPreset)); + eq.dispose(); + }, + ); + }, + ); } diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index f4dcbc9..8ca0e6a 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/dispositivo_audio.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart'; import 'package:pluriwave/servicios/servicio_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_favoritos.dart'; import 'package:pluriwave/servicios/servicio_radio.dart'; @@ -282,10 +284,16 @@ class FakeServicioEcualizador extends ServicioEcualizador { PresetEcualizador? principal, Map? porEmisora, bool activo = true, + bool eqMultiDeviceEnabled = false, + Map? presetsDispositivo, + Map? presetsMatriz, }) : _config = ConfiguracionEcualizador( principal: principal ?? PresetEcualizador.flat, porEmisora: porEmisora ?? {}, activo: activo, + eqMultiDeviceEnabled: eqMultiDeviceEnabled, + presetsDispositivo: presetsDispositivo ?? {}, + presetsMatriz: presetsMatriz ?? {}, ); ConfiguracionEcualizador _config; @@ -300,6 +308,9 @@ class FakeServicioEcualizador extends ServicioEcualizador { principal: preset, porEmisora: _config.porEmisora, activo: _config.activo, + eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, + presetsDispositivo: _config.presetsDispositivo, + presetsMatriz: _config.presetsMatriz, ); } @@ -309,6 +320,9 @@ class FakeServicioEcualizador extends ServicioEcualizador { principal: _config.principal, porEmisora: _config.porEmisora, activo: activo, + eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, + presetsDispositivo: _config.presetsDispositivo, + presetsMatriz: _config.presetsMatriz, ); } @@ -320,6 +334,9 @@ class FakeServicioEcualizador extends ServicioEcualizador { principal: _config.principal, porEmisora: mapa, activo: _config.activo, + eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, + presetsDispositivo: _config.presetsDispositivo, + presetsMatriz: _config.presetsMatriz, ); } @@ -331,8 +348,95 @@ class FakeServicioEcualizador extends ServicioEcualizador { principal: _config.principal, porEmisora: mapa, activo: _config.activo, + eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, + presetsDispositivo: _config.presetsDispositivo, + presetsMatriz: _config.presetsMatriz, ); } + + @override + Future guardarToggleMultiDispositivo(bool habilitado) async { + _config = ConfiguracionEcualizador( + principal: _config.principal, + porEmisora: _config.porEmisora, + activo: _config.activo, + eqMultiDeviceEnabled: habilitado, + presetsDispositivo: _config.presetsDispositivo, + presetsMatriz: _config.presetsMatriz, + ); + } + + @override + Future guardarPresetDispositivo( + String deviceId, + PresetEcualizador preset, + ) async { + final mapa = Map.from( + _config.presetsDispositivo, + ); + mapa[deviceId] = preset; + _config = ConfiguracionEcualizador( + principal: _config.principal, + porEmisora: _config.porEmisora, + activo: _config.activo, + eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, + presetsDispositivo: mapa, + presetsMatriz: _config.presetsMatriz, + ); + } + + @override + Future guardarPresetMatriz( + String clave, + PresetEcualizador preset, + ) async { + final mapa = Map.from(_config.presetsMatriz); + mapa[clave] = preset; + _config = ConfiguracionEcualizador( + principal: _config.principal, + porEmisora: _config.porEmisora, + activo: _config.activo, + eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled, + presetsDispositivo: _config.presetsDispositivo, + presetsMatriz: mapa, + ); + } +} + +/// Fake implementation of [ServicioDispositivoAudio] for unit tests. +/// +/// Use [emitirDispositivo] to push device change events without needing +/// a real platform channel. +class FakeServicioDispositivoAudio extends ServicioDispositivoAudio { + final _controller = StreamController.broadcast(); + DispositivoAudio? _dispositivoActual; + + @override + DispositivoAudio? get dispositivoActual => _dispositivoActual; + + @override + Stream get onDispositivoCambiado => _controller.stream; + + @override + Future obtenerDispositivoActual() async { + return _dispositivoActual ?? + const DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'Speaker', + ); + } + + /// Helper for tests: push a device change event. + void emitirDispositivo(DispositivoAudio dispositivo) { + _dispositivoActual = dispositivo; + _controller.add(dispositivo); + } + + @override + Future dispose() async { + await _controller.close(); + } } Emisora emisoraDemo({ diff --git a/test/modelos/dispositivo_audio_test.dart b/test/modelos/dispositivo_audio_test.dart new file mode 100644 index 0000000..f87e46b --- /dev/null +++ b/test/modelos/dispositivo_audio_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/dispositivo_audio.dart'; + +void main() { + group('TipoDispositivo', () { + test('has all required enum values', () { + expect( + TipoDispositivo.values, + containsAll([ + TipoDispositivo.altavozInterno, + TipoDispositivo.auricularesCable, + TipoDispositivo.bluetoothA2dp, + TipoDispositivo.usbAudio, + TipoDispositivo.desconocido, + ]), + ); + }); + }); + + group('DispositivoAudio', () { + const builtinSpeaker = DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'Altavoz interno', + ); + + const btDevice = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'Auriculares BT', + ); + + test('equality: same id and tipo are equal', () { + const a = DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'Altavoz', + ); + const b = DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'Different name', + ); + expect(a, equals(b)); + }); + + test('equality: different id are not equal', () { + expect(builtinSpeaker, isNot(equals(btDevice))); + }); + + test('hashCode is consistent with equality', () { + const a = DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'A', + ); + const b = DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'B', + ); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('holds all required fields', () { + expect(builtinSpeaker.id, 'builtin_speaker'); + expect(builtinSpeaker.tipo, TipoDispositivo.altavozInterno); + expect(builtinSpeaker.nombre, 'Altavoz interno'); + }); + + test('BT device id format matches spec', () { + expect(btDevice.id, 'bt_a2dp:AA:BB:CC:DD:EE:FF'); + expect(btDevice.tipo, TipoDispositivo.bluetoothA2dp); + }); + + test('wired headset type', () { + const wired = DispositivoAudio( + id: 'wired_headset', + tipo: TipoDispositivo.auricularesCable, + nombre: 'Auriculares cable', + ); + expect(wired.tipo, TipoDispositivo.auricularesCable); + }); + + test('USB audio type', () { + const usb = DispositivoAudio( + id: 'usb_headset:some_address', + tipo: TipoDispositivo.usbAudio, + nombre: 'USB Headset', + ); + expect(usb.tipo, TipoDispositivo.usbAudio); + }); + }); +} diff --git a/test/pantallas/pantalla_ajustes_test.dart b/test/pantallas/pantalla_ajustes_test.dart new file mode 100644 index 0000000..ca1649d --- /dev/null +++ b/test/pantallas/pantalla_ajustes_test.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_ecualizador.dart'; +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/preset_ecualizador.dart'; +import 'package:pluriwave/pantallas/pantalla_ajustes.dart'; +import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; + +// Pre-existing project constraint: PluriGlassSurface (a glassmorphism +// DecoratedBox) is used as the card-style container throughout PantallaAjustes. +// Flutter asserts that ListTile's ink is visible, but the assertion fires +// as a warning (not a correctness bug) — ink animations are simply not visible +// behind the backdrop-filter blur in production either. We suppress it here so +// functional tests can run against the existing UI structure. +void _suppressListTileInkAssertion() { + final original = FlutterError.onError; + FlutterError.onError = (details) { + if (details.exceptionAsString().contains( + 'ListTile background color or ink splashes may be invisible', + )) { + return; + } + original?.call(details); + }; + addTearDown(() => FlutterError.onError = original); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + // ── Helpers ──────────────────────────────────────────────────────────────── + + Widget buildAjustes(EstadoRadio estado, {EstadoIdioma? idioma}) { + final estadoIdioma = idioma ?? EstadoIdioma(); + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + ListenableProvider.value(value: estado.ecualizador), + ListenableProvider.value(value: estado.grabacion), + ChangeNotifierProvider.value(value: estadoIdioma), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: PantallaAjustes()), + ), + ); + } + + Future crearEstado({ + bool eqMultiDeviceEnabled = false, + Map presetsDispositivo = const {}, + }) async { + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador( + eqMultiDeviceEnabled: eqMultiDeviceEnabled, + presetsDispositivo: presetsDispositivo, + ), + servicioGrabacion: _FakeGrabacion(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estado.ecualizador.cargarPersistido(); + return estado; + } + + void setLargeSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + Future pumpStable(WidgetTester tester) async { + await tester.pump(); + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + } + + // ── Phase 7 tests ────────────────────────────────────────────────────────── + + group('_SeccionEcualizadorAvanzado (Phase 7)', () { + testWidgets('7.1-A: toggle OFF — advanced EQ section is visible but device ' + 'list is not shown', (tester) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); // Must be before pumpWidget. + final estado = await crearEstado(eqMultiDeviceEnabled: false); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + // Scroll to find the advanced EQ section. + await tester.scrollUntilVisible( + find.text('Advanced Equalization Options'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + // The section header must be present. + expect(find.text('Advanced Equalization Options'), findsOneWidget); + + // Toggle switch title must be present. + expect(find.text('Enable per-device EQ'), findsOneWidget); + + // When toggle is OFF, device list must NOT be rendered. + expect(find.text('Known audio devices'), findsNothing); + }); + + testWidgets( + '7.1-B: toggle ON with known devices — device list is visible', + (tester) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + final estado = await crearEstado( + eqMultiDeviceEnabled: true, + presetsDispositivo: { + 'bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.rock, + }, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Advanced Equalization Options'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + // Section header present. + expect(find.text('Advanced Equalization Options'), findsOneWidget); + + // Toggle switch title present. + expect(find.text('Enable per-device EQ'), findsOneWidget); + + // Known devices header should appear when toggle is on and there are + // known devices. + expect(find.text('Known audio devices'), findsOneWidget); + + // The device ID should appear in the list. + expect( + find.textContaining('bt_a2dp:AA:BB:CC:DD:EE:FF'), + findsOneWidget, + ); + }, + ); + + testWidgets( + '7.1-C: toggle can be flipped — tapping it enables multi-device EQ', + (tester) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + final estado = await crearEstado(eqMultiDeviceEnabled: false); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Advanced Equalization Options'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + // Initially OFF. + expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse); + + // Tap the Switch widget to toggle on. + await tester.tap(find.byType(Switch).last); + await pumpStable(tester); + + // After tap, toggle should be ON. + expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue); + }, + ); + }); +} + +// ── Infrastructure ────────────────────────────────────────────────────────── + +class _FakeGrabacion extends ServicioGrabacionRadio { + final _controller = StreamController.broadcast(); + + @override + EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva(); + + @override + Stream get estadoStream => _controller.stream; + + @override + Future inicializar() async {} + + @override + Future dispose() => _controller.close(); +} + +Future _archivoCustomVacio() async => + File('${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json'); diff --git a/test/servicios/servicio_dispositivo_audio_real_test.dart b/test/servicios/servicio_dispositivo_audio_real_test.dart new file mode 100644 index 0000000..e2db018 --- /dev/null +++ b/test/servicios/servicio_dispositivo_audio_real_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/dispositivo_audio.dart'; +import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart'; + +// Type int constants from the platform channel protocol (design doc): +// 2 = builtin_speaker, 3 = wired_headset, 8 = bt_a2dp, 14 = usb_headset + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ServicioDispositivoAudioReal', () { + const methodChannelName = 'pluriwave/audio_devices'; + + late ServicioDispositivoAudioReal servicio; + + setUp(() { + servicio = ServicioDispositivoAudioReal(); + }); + + tearDown(() async { + await servicio.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(methodChannelName), + null, + ); + }); + + void stubGetActiveDevice(Map response) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel(methodChannelName), ( + call, + ) async { + if (call.method == 'getActiveDevice') return response; + return null; + }); + } + + test('obtenerDispositivoActual maps builtin_speaker (type=2)', () async { + stubGetActiveDevice({ + 'id': 'builtin_speaker', + 'type': 2, + 'name': 'Speaker', + }); + final device = await servicio.obtenerDispositivoActual(); + expect(device.id, 'builtin_speaker'); + expect(device.tipo, TipoDispositivo.altavozInterno); + expect(device.nombre, 'Speaker'); + }); + + test('obtenerDispositivoActual maps wired_headset (type=3)', () async { + stubGetActiveDevice({ + 'id': 'wired_headset', + 'type': 3, + 'name': 'Wired Headset', + }); + final device = await servicio.obtenerDispositivoActual(); + expect(device.id, 'wired_headset'); + expect(device.tipo, TipoDispositivo.auricularesCable); + }); + + test('obtenerDispositivoActual maps bt_a2dp (type=8)', () async { + stubGetActiveDevice({ + 'id': 'bt_a2dp:AA:BB:CC:DD:EE:FF', + 'type': 8, + 'name': 'My BT Device', + }); + final device = await servicio.obtenerDispositivoActual(); + expect(device.id, 'bt_a2dp:AA:BB:CC:DD:EE:FF'); + expect(device.tipo, TipoDispositivo.bluetoothA2dp); + }); + + test('obtenerDispositivoActual maps usb_headset (type=14)', () async { + stubGetActiveDevice({ + 'id': 'usb_headset:0001:0002', + 'type': 14, + 'name': 'USB Headset', + }); + final device = await servicio.obtenerDispositivoActual(); + expect(device.id, 'usb_headset:0001:0002'); + expect(device.tipo, TipoDispositivo.usbAudio); + }); + + test('obtenerDispositivoActual maps unknown type to desconocido', () async { + stubGetActiveDevice({ + 'id': 'unknown_device', + 'type': 99, + 'name': 'Unknown', + }); + final device = await servicio.obtenerDispositivoActual(); + expect(device.tipo, TipoDispositivo.desconocido); + }); + + test('obtenerDispositivoActual updates dispositivoActual cache', () async { + stubGetActiveDevice({ + 'id': 'builtin_speaker', + 'type': 2, + 'name': 'Speaker', + }); + await servicio.obtenerDispositivoActual(); + expect(servicio.dispositivoActual?.id, 'builtin_speaker'); + }); + + test('onDispositivoCambiado is a broadcast stream', () { + expect(servicio.onDispositivoCambiado.isBroadcast, isTrue); + }); + }); +} diff --git a/test/servicios/servicio_dispositivo_audio_test.dart b/test/servicios/servicio_dispositivo_audio_test.dart new file mode 100644 index 0000000..3d7e8af --- /dev/null +++ b/test/servicios/servicio_dispositivo_audio_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/dispositivo_audio.dart'; + +import '../helpers/fakes.dart'; + +void main() { + group('ServicioDispositivoAudio (abstract contract)', () { + late FakeServicioDispositivoAudio fake; + + setUp(() { + fake = FakeServicioDispositivoAudio(); + }); + + tearDown(() async { + await fake.dispose(); + }); + + test('dispositivoActual is null initially', () { + expect(fake.dispositivoActual, isNull); + }); + + test( + 'obtenerDispositivoActual returns current device after emit', + () async { + const device = DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'Speaker', + ); + fake.emitirDispositivo(device); + + final result = await fake.obtenerDispositivoActual(); + expect(result, equals(device)); + }, + ); + + test('onDispositivoCambiado stream emits when device changes', () async { + const deviceA = DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'Speaker', + ); + const deviceB = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'BT Headphones', + ); + + final received = []; + final sub = fake.onDispositivoCambiado.listen(received.add); + + fake.emitirDispositivo(deviceA); + fake.emitirDispositivo(deviceB); + + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(received, equals([deviceA, deviceB])); + }); + + test('dispositivoActual tracks last emitted device', () { + const device = DispositivoAudio( + id: 'wired_headset', + tipo: TipoDispositivo.auricularesCable, + nombre: 'Wired Headset', + ); + fake.emitirDispositivo(device); + expect(fake.dispositivoActual, equals(device)); + }); + + test( + 'onDispositivoCambiado is a broadcast stream (multiple listeners)', + () { + expect(fake.onDispositivoCambiado.isBroadcast, isTrue); + }, + ); + + test('dispose closes the stream without error', () async { + await expectLater(fake.dispose(), completes); + }); + }); +} diff --git a/test/servicios/servicio_dispositivo_audio_toggle_test.dart b/test/servicios/servicio_dispositivo_audio_toggle_test.dart new file mode 100644 index 0000000..74b370d --- /dev/null +++ b/test/servicios/servicio_dispositivo_audio_toggle_test.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/dispositivo_audio.dart'; +import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart'; + +import '../helpers/fakes.dart'; + +/// Null (toggle-off) implementation that returns empty data without errors. +/// Represents the behavior when `eqMultiDeviceEnabled` is false. +class NullServicioDispositivoAudio extends ServicioDispositivoAudio { + @override + DispositivoAudio? get dispositivoActual => null; + + @override + Stream get onDispositivoCambiado => const Stream.empty(); + + @override + Future obtenerDispositivoActual() async { + return const DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'Speaker', + ); + } + + @override + Future dispose() async {} +} + +void main() { + group('Toggle-off path: NullServicioDispositivoAudio', () { + late NullServicioDispositivoAudio servicio; + + setUp(() { + servicio = NullServicioDispositivoAudio(); + }); + + test('dispositivoActual is always null', () { + expect(servicio.dispositivoActual, isNull); + }); + + test('onDispositivoCambiado emits no events', () async { + final received = []; + final sub = servicio.onDispositivoCambiado.listen(received.add); + await Future.delayed(const Duration(milliseconds: 10)); + await sub.cancel(); + expect(received, isEmpty); + }); + + test('obtenerDispositivoActual returns fallback builtin_speaker', () async { + final device = await servicio.obtenerDispositivoActual(); + expect(device.id, 'builtin_speaker'); + expect(device.tipo, TipoDispositivo.altavozInterno); + }); + + test('dispose completes without error', () async { + await expectLater(servicio.dispose(), completes); + }); + + test('no platform channel code is invoked (fake, not real)', () async { + // Verify that FakeServicioDispositivoAudio also produces no events + // when no emitirDispositivo is called — simulating toggle-off behavior. + final fake = FakeServicioDispositivoAudio(); + final received = []; + final sub = fake.onDispositivoCambiado.listen(received.add); + await Future.delayed(const Duration(milliseconds: 10)); + await sub.cancel(); + expect(received, isEmpty); + await fake.dispose(); + }); + }); +} diff --git a/test/servicios/servicio_ecualizador_test.dart b/test/servicios/servicio_ecualizador_test.dart new file mode 100644 index 0000000..82af042 --- /dev/null +++ b/test/servicios/servicio_ecualizador_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; +import 'package:pluriwave/servicios/servicio_ecualizador.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('ServicioEcualizador — multi-device persistence (Phase 4)', () { + test('4.1a toggle defaults to false when no SP data', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + final config = await servicio.cargar(); + expect(config.eqMultiDeviceEnabled, isFalse); + }); + + test( + '4.1b guardarToggleMultiDispositivo persists and cargar restores it', + () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + + await servicio.guardarToggleMultiDispositivo(true); + final config = await servicio.cargar(); + + expect(config.eqMultiDeviceEnabled, isTrue); + }, + ); + + test('4.1c guardarPresetDispositivo stores a device preset', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + + await servicio.guardarPresetDispositivo(deviceId, PresetEcualizador.jazz); + final config = await servicio.cargar(); + + expect( + config.presetsDispositivo[deviceId], + equals(PresetEcualizador.jazz), + ); + }); + + test( + '4.1d obtenerPresetDispositivo returns null for unknown device', + () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + final config = await servicio.cargar(); + + expect(config.presetsDispositivo['unknown_device'], isNull); + }, + ); + + test('4.1e guardarPresetMatriz stores a matrix entry', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + const clave = 'station-uuid-123:bt_a2dp:AA:BB:CC:DD:EE:FF'; + + await servicio.guardarPresetMatriz(clave, PresetEcualizador.rock); + final config = await servicio.cargar(); + + expect(config.presetsMatriz[clave], equals(PresetEcualizador.rock)); + }); + + test('4.1f multiple device presets persist independently', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + + await servicio.guardarPresetDispositivo( + 'bt_a2dp:11:22:33:44:55:66', + PresetEcualizador.rock, + ); + await servicio.guardarPresetDispositivo( + 'wired_headset', + PresetEcualizador.jazz, + ); + + final config = await servicio.cargar(); + expect( + config.presetsDispositivo['bt_a2dp:11:22:33:44:55:66'], + equals(PresetEcualizador.rock), + ); + expect( + config.presetsDispositivo['wired_headset'], + equals(PresetEcualizador.jazz), + ); + }); + + test('4.1g multiple matrix entries persist independently', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + + await servicio.guardarPresetMatriz( + 'station-A:bt_a2dp:AA:BB:CC:DD:EE:FF', + PresetEcualizador.jazz, + ); + await servicio.guardarPresetMatriz( + 'station-B:wired_headset', + PresetEcualizador.bassBoost, + ); + + final config = await servicio.cargar(); + expect( + config.presetsMatriz['station-A:bt_a2dp:AA:BB:CC:DD:EE:FF'], + equals(PresetEcualizador.jazz), + ); + expect( + config.presetsMatriz['station-B:wired_headset'], + equals(PresetEcualizador.bassBoost), + ); + }); + + test( + '4.1h existing principal/emisora fields unaffected by new fields', + () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + await servicio.guardarPrincipal(PresetEcualizador.pop); + await servicio.guardarPorEmisora('station-X', PresetEcualizador.voz); + await servicio.guardarToggleMultiDispositivo(true); + await servicio.guardarPresetDispositivo( + 'builtin_speaker', + PresetEcualizador.jazz, + ); + + final config = await servicio.cargar(); + expect(config.principal, equals(PresetEcualizador.pop)); + expect(config.porEmisora['station-X'], equals(PresetEcualizador.voz)); + expect(config.eqMultiDeviceEnabled, isTrue); + expect( + config.presetsDispositivo['builtin_speaker'], + equals(PresetEcualizador.jazz), + ); + }, + ); + + test('4.1i guardarConfiguracion with new fields round-trips', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + final config = ConfiguracionEcualizador( + principal: PresetEcualizador.rock, + porEmisora: {'station-1': PresetEcualizador.jazz}, + activo: true, + eqMultiDeviceEnabled: true, + presetsDispositivo: { + 'bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.voz, + }, + presetsMatriz: { + 'station-1:bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.bassBoost, + }, + ); + + await servicio.guardarConfiguracion(config); + final restored = await servicio.cargar(); + + expect(restored.principal, equals(PresetEcualizador.rock)); + expect(restored.porEmisora['station-1'], equals(PresetEcualizador.jazz)); + expect(restored.eqMultiDeviceEnabled, isTrue); + expect( + restored.presetsDispositivo['bt_a2dp:AA:BB:CC:DD:EE:FF'], + equals(PresetEcualizador.voz), + ); + expect( + restored.presetsMatriz['station-1:bt_a2dp:AA:BB:CC:DD:EE:FF'], + equals(PresetEcualizador.bassBoost), + ); + }); + }); +} diff --git a/test/servicios/servicio_export_import_test.dart b/test/servicios/servicio_export_import_test.dart index 5fef3a3..2dcb2c2 100644 --- a/test/servicios/servicio_export_import_test.dart +++ b/test/servicios/servicio_export_import_test.dart @@ -7,53 +7,53 @@ import 'package:pluriwave/servicios/servicio_export_import.dart'; void main() { const servicio = ServicioExportImport(); + // Shared fixture data used across multiple tests. + const gruposFavoritosFixture = [ + GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1), + GrupoFavoritos( + id: GrupoFavoritos.sinAsignarId, + nombre: 'Sin asignar', + orden: 0, + protegido: true, + ), + ]; + const favoritosFixture = [ + Emisora( + uuid: 'fav-1', + nombre: 'Radio Uno', + url: 'https://uno.example/stream', + grupoFavoritosId: 'g1', + bitrate: 192, + ), + ]; + const emisorasCustomFixture = [ + Emisora( + uuid: 'custom-1', + nombre: 'Mi Radio', + url: 'https://mia.example/stream', + ), + ]; + const alarmasFixture = { + 'alarmas': [ + {'id': 'a1', 'hora': 7, 'minuto': 30, 'activa': true, 'snoozeMinutos': 5}, + ], + 'vacaciones': [ + {'desde': '2026-07-01', 'hasta': '2026-07-15'}, + ], + }; + group('ServicioExportImport (S4-R4)', () { test('v2 round-trip: exportar then importar yields a deep-equal config ' '(S4-R4-A, S6-R2 test #4)', () { final config = servicio.construirExportacion( - gruposFavoritos: const [ - GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1), - GrupoFavoritos( - id: GrupoFavoritos.sinAsignarId, - nombre: 'Sin asignar', - orden: 0, - protegido: true, - ), - ], - favoritos: const [ - Emisora( - uuid: 'fav-1', - nombre: 'Radio Uno', - url: 'https://uno.example/stream', - grupoFavoritosId: 'g1', - bitrate: 192, - ), - ], - emisorasCustom: const [ - Emisora( - uuid: 'custom-1', - nombre: 'Mi Radio', - url: 'https://mia.example/stream', - ), - ], + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, presetPrincipal: PresetEcualizador.jazz, presetsPorEmisora: {'fav-1': PresetEcualizador.rock}, // Raw alarm block exactly as ServicioAlarmas persists it (alarms + // vacations + exceptions) — must pass through untouched. - alarmas: const { - 'alarmas': [ - { - 'id': 'a1', - 'hora': 7, - 'minuto': 30, - 'activa': true, - 'snoozeMinutos': 5, - }, - ], - 'vacaciones': [ - {'desde': '2026-07-01', 'hasta': '2026-07-15'}, - ], - }, + alarmas: alarmasFixture, emisoraPreferidaUuid: 'fav-1', ordenListas: 'calidad', timerSuenoPresetsSegundos: const [300, 600, 1800], @@ -80,4 +80,156 @@ void main() { expect(servicio.importar('[1, 2, 3]'), isNull); }); }); + + group('ServicioExportImport v3 — multi-device EQ (Phase 6)', () { + test( + '6.1-A: v3 export includes schemaVersion 3 and device/matrix preset keys', + () { + final presetDispositivo = PresetEcualizador.rock; + final presetMatriz = PresetEcualizador.jazz; + + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + presetsPorDispositivo: { + 'bt_a2dp:AA:BB:CC:DD:EE:FF': presetDispositivo, + }, + presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': presetMatriz}, + eqMultiDeviceEnabled: true, + ); + + // v3 schema version. + expect(config['version'], 3); + + // Device preset map present and correct. + expect(config['presetsPorDispositivo'], isA()); + final dispositivoMap = + config['presetsPorDispositivo'] as Map; + expect(dispositivoMap, contains('bt_a2dp:AA:BB:CC:DD:EE:FF')); + + // Matrix preset map present and correct. + expect(config['presetsMatriz'], isA()); + final matrizMap = config['presetsMatriz'] as Map; + expect(matrizMap, contains('fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF')); + + // Toggle exported. + expect(config['eqMultiDeviceEnabled'], isTrue); + }, + ); + + test( + '6.1-B: v3 round-trip preserves device and matrix presets exactly', + () { + final dispositivoPreset = PresetEcualizador.rock; + final matrizPreset = PresetEcualizador.jazz; + + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {'fav-1': PresetEcualizador.pop}, + alarmas: alarmasFixture, + emisoraPreferidaUuid: 'fav-1', + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300, 1800], + presetsPorDispositivo: { + 'bt_a2dp:AA:BB:CC:DD:EE:FF': dispositivoPreset, + 'wired_headset': PresetEcualizador.flat, + }, + presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': matrizPreset}, + eqMultiDeviceEnabled: true, + ); + + final json = servicio.exportar(config); + final importado = servicio.importar(json); + + expect(importado, isNotNull); + expect(importado!['version'], 3); + + // Device presets survive the round-trip. + final deviceMap = + importado['presetsPorDispositivo'] as Map; + expect( + deviceMap['bt_a2dp:AA:BB:CC:DD:EE:FF'], + equals(dispositivoPreset.toJson()), + ); + expect( + deviceMap['wired_headset'], + equals(PresetEcualizador.flat.toJson()), + ); + + // Matrix presets survive the round-trip. + final matrizMap = importado['presetsMatriz'] as Map; + expect( + matrizMap['fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF'], + equals(matrizPreset.toJson()), + ); + + // Toggle survives. + expect(importado['eqMultiDeviceEnabled'], isTrue); + }, + ); + + test( + '6.1-C: v2 file (no device fields) imports cleanly — missing keys default to empty maps', + () { + // Simulate a v2 export file (no presetsPorDispositivo / presetsMatriz). + final v2Config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.jazz, + presetsPorEmisora: {'fav-1': PresetEcualizador.rock}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + // No device fields — emulates a v2 export from the old app. + ); + + // Verify the export has no device keys (a v2 file would not). + expect(v2Config.containsKey('presetsPorDispositivo'), isFalse); + expect(v2Config.containsKey('presetsMatriz'), isFalse); + expect(v2Config.containsKey('eqMultiDeviceEnabled'), isFalse); + + // Import it — parsing should succeed. + final json = servicio.exportar(v2Config); + final importado = servicio.importar(json); + expect(importado, isNotNull); + + // Missing device keys should gracefully resolve to null (caller uses ?? {}). + expect(importado!['presetsPorDispositivo'], isNull); + expect(importado['presetsMatriz'], isNull); + expect(importado['eqMultiDeviceEnabled'], isNull); + }, + ); + + test( + '6.1-D: unknown schemaVersion (v4-future) does not crash importar()', + () { + // Simulate a future v4 export (unknown version, extra fields). + final futurista = { + 'version': 99, + 'presetsPorDispositivo': {}, + 'presetsMatriz': {}, + 'unknownFutureField': 'some-value', + }; + final json = servicio.exportar(futurista); + // importar() should parse without throwing — version checking is the + // caller's responsibility (EstadoRadio.importarConfig). + expect(() => servicio.importar(json), returnsNormally); + final importado = servicio.importar(json); + expect(importado, isNotNull); + expect(importado!['version'], 99); + }, + ); + }); }