feat(eq): add per-device equalizer with 4-level preset resolution

Introduce multi-device EQ support allowing each audio output device
(built-in speaker, wired, USB, individual Bluetooth by MAC) to have
its own equalizer preset, combined with existing per-station presets
for a full station×device matrix.

- Add DispositivoAudio model and ServicioDispositivoAudio interface
- Add Android platform channel (AudioDeviceCallback) for device detection
- Add iOS AudioDevicesPlugin (AVAudioSession route tracking)
- Extend ServicioEcualizador with device and matrix persistence keys
- Implement 4-level resolution: matrix > station > device > global
- Add advanced EQ settings section with feature toggle (off by default)
- Extend export/import to v3 with backward compatibility
- 184 tests passing, zero analyzer issues
This commit is contained in:
2026-06-27 11:33:53 +02:00
parent 8f42e67b48
commit 4632d53eb8
36 changed files with 2850 additions and 64 deletions
@@ -6,6 +6,9 @@ import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.Uri import android.net.Uri
import android.media.audiofx.Visualizer import android.media.audiofx.Visualizer
import android.app.AlarmManager import android.app.AlarmManager
@@ -31,6 +34,7 @@ class MainActivity : AudioServiceActivity() {
private val visualizerChannel = "pluriwave/audio_visualizer" private val visualizerChannel = "pluriwave/audio_visualizer"
private val alarmChannel = "pluriwave/alarm_scheduler" private val alarmChannel = "pluriwave/alarm_scheduler"
private val fileActionsChannel = "pluriwave/file_actions" private val fileActionsChannel = "pluriwave/file_actions"
private val audioDevicesChannel = "pluriwave/audio_devices"
private val visualizerPermissionRequestCode = 4821 private val visualizerPermissionRequestCode = 4821
private val notificationPermissionRequestCode = 4822 private val notificationPermissionRequestCode = 4822
private var visualizer: Visualizer? = null private var visualizer: Visualizer? = null
@@ -39,8 +43,16 @@ class MainActivity : AudioServiceActivity() {
private var alarmMethodChannel: MethodChannel? = null private var alarmMethodChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper()) 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) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// --- Audio Devices Channel ---
setupAudioDevicesChannel(flutterEngine)
EventChannel( EventChannel(
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
visualizerChannel 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<out AudioDeviceInfo>) {
// 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<out AudioDeviceInfo>) {
// 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:<MAC>" — TYPE_BLUETOOTH_A2DP (8); MAC from AudioDeviceInfo.address
* - "usb_headset:<address>" — 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<String, Any> {
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<String, Any> {
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() { override fun onDestroy() {
if (activeInstance === this) { if (activeInstance === this) {
activeInstance = null activeInstance = null
} }
unregisterAudioDeviceCallback()
stopVisualizer() stopVisualizer()
super.onDestroy() super.onDestroy()
} }
+1
View File
@@ -12,5 +12,6 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
AudioDevicesPlugin.register(with: engineBridge.pluginRegistry.registrar(forPlugin: "AudioDevicesPlugin")!)
} }
} }
+166
View File
@@ -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:<uid|portName>" AVAudioSession.Port.bluetoothA2DP
/// "usb_headset:<uid|name>" 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)"
}
}
+8 -1
View File
@@ -25,6 +25,7 @@ import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart'; import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart'; import 'servicios/servicio_alarmas_android.dart';
import 'servicios/servicio_dispositivo_audio.dart';
class PluriWaveApp extends StatelessWidget { class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key, this.prefs}); const PluriWaveApp({super.key, this.prefs});
@@ -37,7 +38,13 @@ class PluriWaveApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)), ChangeNotifierProvider(
create:
(_) => EstadoRadio(
prefs: prefs,
dispositivoAudio: ServicioDispositivoAudioReal(),
),
),
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio // Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
// (they need its services and callbacks at construction); these // (they need its services and callbacks at construction); these
// providers only expose the instances, so they declare no dispose // providers only expose the instances, so they declare no dispose
+170 -5
View File
@@ -1,7 +1,11 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../modelos/dispositivo_audio.dart';
import '../modelos/preset_ecualizador.dart'; import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart'; import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_ecualizador.dart';
/// Equalizer state extracted from `EstadoRadio` (S4-R1). /// Equalizer state extracted from `EstadoRadio` (S4-R1).
@@ -11,33 +15,65 @@ import '../servicios/servicio_ecualizador.dart';
/// [ServicioEcualizador] and their application through [ServicioAudio]. /// [ServicioEcualizador] and their application through [ServicioAudio].
/// Notifies ONLY its own listeners — EQ changes must not rebuild /// Notifies ONLY its own listeners — EQ changes must not rebuild
/// `EstadoRadio` consumers (S4-R1-A, S4-R5). /// `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 { class EstadoEcualizador extends ChangeNotifier {
EstadoEcualizador({ EstadoEcualizador({
required this.audio, required this.audio,
ServicioEcualizador? servicio, ServicioEcualizador? servicio,
ServicioDispositivoAudio? dispositivoAudio,
String? Function()? emisoraActualUuid, String? Function()? emisoraActualUuid,
}) : servicio = servicio ?? ServicioEcualizador(), }) : servicio = servicio ?? ServicioEcualizador(),
_dispositivoAudio = dispositivoAudio,
_emisoraActualUuid = emisoraActualUuid ?? (() => null); _emisoraActualUuid = emisoraActualUuid ?? (() => null);
final ServicioAudio audio; final ServicioAudio audio;
final ServicioEcualizador servicio; final ServicioEcualizador servicio;
final ServicioDispositivoAudio? _dispositivoAudio;
/// Callback into the owner (EstadoRadio) for the currently playing station; /// Callback into the owner (EstadoRadio) for the currently playing station;
/// keeps this notifier free of any station-list coupling. /// keeps this notifier free of any station-list coupling.
final String? Function() _emisoraActualUuid; final String? Function() _emisoraActualUuid;
final Map<String, PresetEcualizador> _presetsEmisoraMap = {}; final Map<String, PresetEcualizador> _presetsEmisoraMap = {};
/// Per-device presets: deviceId → PresetEcualizador.
final Map<String, PresetEcualizador> _presetsDispositivo = {};
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
final Map<String, PresetEcualizador> _presetsMatriz = {};
PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat;
bool _activo = true; bool _activo = true;
bool _eqMultiDeviceEnabled = false;
String? _dispositivoActualId;
StreamSubscription<DispositivoAudio>? _deviceSub;
PresetEcualizador get presetActual => _presetActual; PresetEcualizador get presetActual => _presetActual;
PresetEcualizador get presetPrincipal => _presetPrincipal; PresetEcualizador get presetPrincipal => _presetPrincipal;
bool get activo => _activo; bool get activo => _activo;
bool get disponible => audio.ecualizadorDisponible; bool get disponible => audio.ecualizadorDisponible;
bool get eqMultiDeviceEnabled => _eqMultiDeviceEnabled;
String? get dispositivoActualId => _dispositivoActualId;
Map<String, PresetEcualizador> get presetsPorEmisora => Map<String, PresetEcualizador> get presetsPorEmisora =>
Map.unmodifiable(_presetsEmisoraMap); Map.unmodifiable(_presetsEmisoraMap);
Map<String, PresetEcualizador> get presetsDispositivo =>
Map.unmodifiable(_presetsDispositivo);
Map<String, PresetEcualizador> get presetsMatriz =>
Map.unmodifiable(_presetsMatriz);
bool get emisoraActualTienePresetPropio { bool get emisoraActualTienePresetPropio {
final uuid = _emisoraActualUuid(); final uuid = _emisoraActualUuid();
if (uuid == null) return false; if (uuid == null) return false;
@@ -52,26 +88,116 @@ class EstadoEcualizador extends ChangeNotifier {
PresetEcualizador presetParaEmisora(String uuid) => PresetEcualizador presetParaEmisora(String uuid) =>
_presetsEmisoraMap[uuid] ?? _presetPrincipal; _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. /// Loads the persisted EQ configuration and applies it to the audio engine.
Future<void> cargarPersistido() async { Future<void> cargarPersistido() async {
try { try {
final config = await servicio.cargar(); final config = await servicio.cargar();
_presetPrincipal = config.principal; _presetPrincipal = config.principal;
_presetActual = config.principal;
_activo = config.activo; _activo = config.activo;
_eqMultiDeviceEnabled = config.eqMultiDeviceEnabled;
_presetsEmisoraMap _presetsEmisoraMap
..clear() ..clear()
..addAll(config.porEmisora); ..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.setEcualizadorActivo(_activo);
await audio.aplicarPreset(_presetPrincipal); await audio.aplicarPreset(_presetActual);
// Subscribe to device changes only when toggle is on.
_configurarSuscripcionDispositivo();
} catch (_) { } catch (_) {
_presetPrincipal = PresetEcualizador.flat; _presetPrincipal = PresetEcualizador.flat;
_presetActual = PresetEcualizador.flat; _presetActual = PresetEcualizador.flat;
_activo = true; _activo = true;
_eqMultiDeviceEnabled = false;
_presetsEmisoraMap.clear(); _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<void> _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 /// Applies [preset] to the audio engine and tracks it as current
/// WITHOUT persisting it (used when switching stations). /// WITHOUT persisting it (used when switching stations).
Future<void> aplicarPresetActivo(PresetEcualizador preset) async { Future<void> aplicarPresetActivo(PresetEcualizador preset) async {
@@ -79,6 +205,20 @@ class EstadoEcualizador extends ChangeNotifier {
await audio.aplicarPreset(preset); 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<void> cambiarMultiDeviceEnabled(
bool habilitado, {
bool notificar = true,
}) async {
_eqMultiDeviceEnabled = habilitado;
await servicio.guardarToggleMultiDispositivo(habilitado);
_configurarSuscripcionDispositivo();
if (notificar) notifyListeners();
}
Future<void> cambiarPresetPrincipal( Future<void> cambiarPresetPrincipal(
PresetEcualizador preset, { PresetEcualizador preset, {
bool notificar = true, bool notificar = true,
@@ -184,24 +324,49 @@ class EstadoEcualizador extends ChangeNotifier {
Future<void> importarConfiguracion({ Future<void> importarConfiguracion({
required PresetEcualizador principal, required PresetEcualizador principal,
required Map<String, PresetEcualizador> porEmisora, required Map<String, PresetEcualizador> porEmisora,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
}) async { }) async {
_presetPrincipal = principal; _presetPrincipal = principal;
_presetsEmisoraMap _presetsEmisoraMap
..clear() ..clear()
..addAll(porEmisora); ..addAll(porEmisora);
if (presetsDispositivo != null) {
_presetsDispositivo
..clear()
..addAll(presetsDispositivo);
}
if (presetsMatriz != null) {
_presetsMatriz
..clear()
..addAll(presetsMatriz);
}
if (eqMultiDeviceEnabled != null) {
_eqMultiDeviceEnabled = eqMultiDeviceEnabled;
}
await servicio.guardarConfiguracion( await servicio.guardarConfiguracion(
ConfiguracionEcualizador( ConfiguracionEcualizador(
principal: _presetPrincipal, principal: _presetPrincipal,
porEmisora: _presetsEmisoraMap, porEmisora: _presetsEmisoraMap,
activo: _activo, activo: _activo,
eqMultiDeviceEnabled: _eqMultiDeviceEnabled,
presetsDispositivo: _presetsDispositivo,
presetsMatriz: _presetsMatriz,
), ),
); );
final uuid = _emisoraActualUuid(); final uuid = _emisoraActualUuid();
await aplicarPresetActivo( final presetEfectivoActual =
uuid == null ? _presetPrincipal : presetParaEmisora(uuid), uuid == null ? _presetPrincipal : _resolverPresetActivo();
); await aplicarPresetActivo(presetEfectivoActual);
notifyListeners(); notifyListeners();
} }
@override
void dispose() {
_deviceSub?.cancel();
super.dispose();
}
} }
+51 -4
View File
@@ -17,6 +17,7 @@ import 'estado_ecualizador.dart';
import 'estado_grabacion.dart'; import 'estado_grabacion.dart';
import 'orden_emisoras.dart'; import 'orden_emisoras.dart';
import '../servicios/servicio_audio.dart'; import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_export_import.dart'; import '../servicios/servicio_export_import.dart';
import '../servicios/servicio_favoritos.dart'; import '../servicios/servicio_favoritos.dart';
@@ -38,6 +39,7 @@ class EstadoRadio extends ChangeNotifier {
ServicioFavoritos? favoritos, ServicioFavoritos? favoritos,
ServicioRadio? radio, ServicioRadio? radio,
ServicioEcualizador? servicioEcualizador, ServicioEcualizador? servicioEcualizador,
ServicioDispositivoAudio? dispositivoAudio,
ServicioGrabacionRadio? servicioGrabacion, ServicioGrabacionRadio? servicioGrabacion,
SharedPreferences? prefs, SharedPreferences? prefs,
Future<File> Function()? resolverArchivoCustom, Future<File> Function()? resolverArchivoCustom,
@@ -47,11 +49,13 @@ class EstadoRadio extends ChangeNotifier {
radio = radio ?? ServicioRadio(), radio = radio ?? ServicioRadio(),
servicioEcualizador = servicioEcualizador =
servicioEcualizador ?? ServicioEcualizador(prefs: prefs), servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
_dispositivoAudio = dispositivoAudio,
_prefs = prefs, _prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom { _resolverArchivoCustom = resolverArchivoCustom {
ecualizador = EstadoEcualizador( ecualizador = EstadoEcualizador(
audio: this.audio, audio: this.audio,
servicio: this.servicioEcualizador, servicio: this.servicioEcualizador,
dispositivoAudio: _dispositivoAudio,
emisoraActualUuid: () => emisoraActual?.uuid, emisoraActualUuid: () => emisoraActual?.uuid,
); );
grabacion = EstadoGrabacion( grabacion = EstadoGrabacion(
@@ -77,6 +81,10 @@ class EstadoRadio extends ChangeNotifier {
final ServicioRadio radio; final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador; 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 /// Domain notifiers extracted from this class (S4). Created and disposed
/// here (they need EstadoRadio's services and callbacks at construction); /// here (they need EstadoRadio's services and callbacks at construction);
/// exposed app-wide through ListenableProviders in app.dart. /// exposed app-wide through ListenableProviders in app.dart.
@@ -547,8 +555,9 @@ class EstadoRadio extends ChangeNotifier {
static const _keyAlarmasConfig = 'alarmas_musicales_v1'; static const _keyAlarmasConfig = 'alarmas_musicales_v1';
/// Genera el JSON de toda la configuración (v2 — portabilidad completa). /// Genera el JSON de toda la configuración (v3 — portabilidad completa
/// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4). /// con presets por dispositivo y matriz multi-device).
/// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4).
Future<Map<String, dynamic>> exportarConfig() async { Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos(); final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos(); final grupos = await favoritos.obtenerGrupos();
@@ -572,6 +581,10 @@ class EstadoRadio extends ChangeNotifier {
emisoraPreferidaUuid: _emisoraPreferidaUuid, emisoraPreferidaUuid: _emisoraPreferidaUuid,
ordenListas: _ordenListas.name, ordenListas: _ordenListas.name,
timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos, 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); _exportImport.importar(raw);
/// Importa configuración desde un JSON exportado previamente. /// 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<void> importarConfig(Map<String, dynamic> data) async { Future<void> importarConfig(Map<String, dynamic> data) async {
final version = data['version'] as int? ?? 1; 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(); final prefs = await _resolverPrefs();
@@ -637,9 +651,41 @@ class EstadoRadio extends ChangeNotifier {
), ),
); );
// v3 extensions: per-device and matrix presets.
Map<String, PresetEcualizador>? presetsDispositivo;
Map<String, PresetEcualizador>? presetsMatriz;
bool? eqMultiDeviceEnabled;
if (version >= 3) {
final dispositivoRaw = data['presetsPorDispositivo'] as Map? ?? {};
presetsDispositivo = dispositivoRaw.map<String, PresetEcualizador>(
(deviceId, presetJson) => MapEntry(
deviceId as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
final matrizRaw = data['presetsMatriz'] as Map? ?? {};
presetsMatriz = matrizRaw.map<String, PresetEcualizador>(
(clave, presetJson) => MapEntry(
clave as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?;
}
await ecualizador.importarConfiguracion( await ecualizador.importarConfiguracion(
principal: presetPrincipal, principal: presetPrincipal,
porEmisora: presetsPorEmisora, porEmisora: presetsPorEmisora,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
); );
// ── Alarmas (v2) ────────────────────────────────────────────────────── // ── Alarmas (v2) ──────────────────────────────────────────────────────
@@ -748,6 +794,7 @@ class EstadoRadio extends ChangeNotifier {
grabacion.dispose(); grabacion.dispose();
audio.dispose(); audio.dispose();
timer.dispose(); timer.dispose();
_dispositivoAudio?.dispose();
super.dispose(); super.dispose();
} }
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}", "stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
"alarmIconLabel": "Musical alarm", "alarmIconLabel": "Musical alarm",
"vacationIconLabel": "Vacation mode", "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": {}
}
}
} }
+11
View File
@@ -579,5 +579,16 @@
"type": "int" "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": {}
}
} }
} }
+36
View File
@@ -2269,6 +2269,42 @@ abstract class AppLocalizations {
/// In es, this message translates to: /// In es, this message translates to:
/// **'https://stream.example.com:8000/radio'** /// **'https://stream.example.com:8000/radio'**
String get streamUrlHint; 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 class _AppLocalizationsDelegate
+22
View File
@@ -1225,4 +1225,26 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1232,4 +1232,26 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1242,4 +1242,26 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1228,4 +1228,26 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1237,4 +1237,26 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1247,4 +1247,26 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1231,4 +1231,26 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1236,4 +1236,26 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1242,4 +1242,26 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1192,4 +1192,26 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1234,4 +1234,26 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1238,4 +1238,26 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+22
View File
@@ -1187,4 +1187,26 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; 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';
}
} }
+47
View File
@@ -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:<MAC>"`
bluetoothA2dp,
/// USB audio device — id: `"usb_headset:<address>"`
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)';
}
+122
View File
@@ -60,6 +60,8 @@ class _AjustesContent extends StatelessWidget {
children: const [ children: const [
_SeccionEcualizador(), _SeccionEcualizador(),
SizedBox(height: 12), SizedBox(height: 12),
_SeccionEcualizadorAvanzado(),
SizedBox(height: 12),
_SeccionGrabaciones(), _SeccionGrabaciones(),
SizedBox(height: 12), SizedBox(height: 12),
_SeccionTimerSueno(), _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<EstadoEcualizador>();
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 { class _SeccionOrdenListas extends StatelessWidget {
const _SeccionOrdenListas(); const _SeccionOrdenListas();
@@ -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<DispositivoAudio> get onDispositivoCambiado;
/// Requests the current active device from the platform synchronously
/// (method channel round-trip). Returns the cached value if already known.
Future<DispositivoAudio> obtenerDispositivoActual();
/// Cancels the device-change subscription and releases resources.
Future<void> 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:<MAC>`)
/// 14 → usbAudio (`usb_headset:<address>`)
class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
static const _channelName = 'pluriwave/audio_devices';
final MethodChannel _methodChannel;
final EventChannel _eventChannel;
final _controller = StreamController<DispositivoAudio>.broadcast();
StreamSubscription<dynamic>? _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<String, dynamic>.from(event));
_dispositivoActual = device;
_controller.add(device);
}
},
onError: (_) {
/* swallow platform errors; stream continues */
},
cancelOnError: false,
);
}
@override
DispositivoAudio? get dispositivoActual => _dispositivoActual;
@override
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
@override
Future<DispositivoAudio> obtenerDispositivoActual() async {
final raw = await _methodChannel.invokeMethod<Map>('getActiveDevice');
final map = Map<String, dynamic>.from(raw ?? {});
final device = _mapToDispositivo(map);
_dispositivoActual = device;
return device;
}
@override
Future<void> dispose() async {
await _eventSub?.cancel();
await _controller.close();
}
static DispositivoAudio _mapToDispositivo(Map<String, dynamic> 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;
}
}
}
+100
View File
@@ -9,11 +9,23 @@ class ConfiguracionEcualizador {
required this.principal, required this.principal,
required this.porEmisora, required this.porEmisora,
this.activo = true, this.activo = true,
this.eqMultiDeviceEnabled = false,
this.presetsDispositivo = const {},
this.presetsMatriz = const {},
}); });
final PresetEcualizador principal; final PresetEcualizador principal;
final Map<String, PresetEcualizador> porEmisora; final Map<String, PresetEcualizador> porEmisora;
final bool activo; final bool activo;
/// Feature toggle: when false all multi-device logic is bypassed.
final bool eqMultiDeviceEnabled;
/// Per-device presets: deviceId → PresetEcualizador.
final Map<String, PresetEcualizador> presetsDispositivo;
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
final Map<String, PresetEcualizador> presetsMatriz;
} }
class ServicioEcualizador { class ServicioEcualizador {
@@ -22,6 +34,9 @@ class ServicioEcualizador {
static const _keyPresetPrincipal = 'eq_preset_principal_v1'; static const _keyPresetPrincipal = 'eq_preset_principal_v1';
static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1'; static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1';
static const _keyActivo = 'eq_activo_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; final SharedPreferences? _prefs;
@@ -33,10 +48,15 @@ class ServicioEcualizador {
final prefs = await _resolverPrefs(); final prefs = await _resolverPrefs();
final principal = _leerPresetPrincipal(prefs); final principal = _leerPresetPrincipal(prefs);
final porEmisora = _leerPresetsPorEmisora(prefs); final porEmisora = _leerPresetsPorEmisora(prefs);
final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
return ConfiguracionEcualizador( return ConfiguracionEcualizador(
principal: principal, principal: principal,
porEmisora: porEmisora, porEmisora: porEmisora,
activo: prefs.getBool(_keyActivo) ?? true, 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 _guardarPresetsPorEmisora(prefs, config.porEmisora);
await prefs.setBool(_keyActivo, config.activo); 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<void> guardarToggleMultiDispositivo(bool habilitado) async {
final prefs = await _resolverPrefs();
await prefs.setBool(_keyMultiDeviceEnabled, habilitado);
}
/// Saves a per-device preset.
Future<void> 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<void> 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<void> 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<void> eliminarPresetMatriz(String clave) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsMatriz);
mapa.remove(clave);
await _guardarMapa(prefs, _keyPresetsMatriz, mapa);
}
/// Reads a `Map<String, PresetEcualizador>` from a SharedPreferences JSON key.
Map<String, PresetEcualizador> _leerMapa(
SharedPreferences prefs,
String key,
) {
final raw = prefs.getString(key);
if (raw == null || raw.isEmpty) return {};
try {
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
return data.map(
(k, v) => MapEntry(
k,
PresetEcualizador.desdeJson(Map<String, dynamic>.from(v as Map)),
),
);
} catch (_) {
return {};
}
}
Future<void> _guardarMapa(
SharedPreferences prefs,
String key,
Map<String, PresetEcualizador> mapa,
) async {
final serializado = mapa.map((k, v) => MapEntry(k, v.toJson()));
await prefs.setString(key, jsonEncode(serializado));
} }
PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) { PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) {
+44 -14
View File
@@ -6,21 +6,29 @@ import '../modelos/preset_ecualizador.dart';
/// Owns the backup (export/import) JSON serialization (S4-R4). /// Owns the backup (export/import) JSON serialization (S4-R4).
/// ///
/// The v2 envelope produced here is byte-compatible with the legacy format /// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and
/// previously assembled inline by `EstadoRadio.exportarConfig` and /// `eqMultiDeviceEnabled`. When those optional parameters are omitted the
/// pretty-printed by `pantalla_ajustes.dart`, so existing exports keep /// export stays at v2 for backward compat with the old app. State APPLICATION
/// round-tripping. State APPLICATION (writing favorites, EQ, alarms back /// (writing favorites, EQ, alarms back into the app) stays in
/// into the app) stays in `EstadoRadio.importarConfig` — this service only /// `EstadoRadio.importarConfig` — this service only owns serialization,
/// owns serialization, parsing and the envelope shape. /// parsing and the envelope shape.
class ServicioExportImport { class ServicioExportImport {
const ServicioExportImport(); const ServicioExportImport();
/// Current backup schema version (v2full portability). /// Current backup schema version (v3multi-device EQ).
static const int versionActual = 2; static const int versionActual = 3;
/// Builds the v2 export envelope. Key set and semantics must stay exactly /// Legacy v2 version constant kept for clarity.
/// as the legacy inline export so old backups remain importable: static const int versionV2 = 2;
/// the `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
/// 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). /// and passes through untouched (no re-parsing here).
Map<String, dynamic> construirExportacion({ Map<String, dynamic> construirExportacion({
required List<GrupoFavoritos> gruposFavoritos, required List<GrupoFavoritos> gruposFavoritos,
@@ -33,9 +41,18 @@ class ServicioExportImport {
required String ordenListas, required String ordenListas,
required List<int> timerSuenoPresetsSegundos, required List<int> timerSuenoPresetsSegundos,
DateTime? exportadoEn, DateTime? exportadoEn,
// v3 extensions — omitting these produces a v2-compatible export.
Map<String, PresetEcualizador>? presetsPorDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
}) { }) {
return { final tieneExtensionesV3 =
'version': versionActual, presetsPorDispositivo != null ||
presetsMatriz != null ||
eqMultiDeviceEnabled != null;
final envelope = <String, dynamic>{
'version': tieneExtensionesV3 ? versionActual : versionV2,
'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(), 'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(),
// Favorites + groups (preserves grupo_id assignments per station). // Favorites + groups (preserves grupo_id assignments per station).
// The protected "sin asignar" group is implicit and never exported. // The protected "sin asignar" group is implicit and never exported.
@@ -47,7 +64,7 @@ class ServicioExportImport {
'favoritos': favoritos.map((e) => e.toMap()).toList(), 'favoritos': favoritos.map((e) => e.toMap()).toList(),
// Custom stations. // Custom stations.
'emisorasCustom': emisorasCustom.map((e) => e.toMap()).toList(), 'emisorasCustom': emisorasCustom.map((e) => e.toMap()).toList(),
// Equalizer. // Equalizer (base fields — present in all versions).
'presetPrincipalEcualizador': presetPrincipal.toJson(), 'presetPrincipalEcualizador': presetPrincipal.toJson(),
'presetsEcualizador': presetsPorEmisora.map( 'presetsEcualizador': presetsPorEmisora.map(
(uuid, preset) => MapEntry(uuid, preset.toJson()), (uuid, preset) => MapEntry(uuid, preset.toJson()),
@@ -59,6 +76,19 @@ class ServicioExportImport {
'ordenListas': ordenListas, 'ordenListas': ordenListas,
'timerSuenoPresetsSegundos': timerSuenoPresetsSegundos, '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 /// Serializes an export envelope to pretty-printed JSON (same formatting
+490
View File
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart';
import '../helpers/fakes.dart'; import '../helpers/fakes.dart';
@@ -48,4 +49,493 @@ void main() {
estado.dispose(); 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.35.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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 50));
expect(avisos, equals(0));
expect(fakeAudio.presetsAplicados.length, equals(initialCount));
eq.dispose();
},
);
});
// ---------------------------------------------------------------------------
// Phase 5.55.6: First-seen device copies current preset
// ---------------------------------------------------------------------------
group(
'EstadoEcualizador — first-seen device initialization (Phase 5.55.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<void>.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<void>.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.75.8: Toggle-off path uses only 2-level resolution
// ---------------------------------------------------------------------------
group('EstadoEcualizador — toggle-off path (Phase 5.75.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<void>.delayed(const Duration(milliseconds: 50));
// Clear application history from init; emit device to trigger re-apply
fakeAudio.presetsAplicados.clear();
fakeDispositivo.emitirDispositivo(btDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(fakeAudio.presetsAplicados.last, equals(matrizPreset));
eq.dispose();
},
);
},
);
} }
+104
View File
@@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; 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/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.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_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_favoritos.dart'; import 'package:pluriwave/servicios/servicio_favoritos.dart';
import 'package:pluriwave/servicios/servicio_radio.dart'; import 'package:pluriwave/servicios/servicio_radio.dart';
@@ -282,10 +284,16 @@ class FakeServicioEcualizador extends ServicioEcualizador {
PresetEcualizador? principal, PresetEcualizador? principal,
Map<String, PresetEcualizador>? porEmisora, Map<String, PresetEcualizador>? porEmisora,
bool activo = true, bool activo = true,
bool eqMultiDeviceEnabled = false,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
}) : _config = ConfiguracionEcualizador( }) : _config = ConfiguracionEcualizador(
principal: principal ?? PresetEcualizador.flat, principal: principal ?? PresetEcualizador.flat,
porEmisora: porEmisora ?? {}, porEmisora: porEmisora ?? {},
activo: activo, activo: activo,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
presetsDispositivo: presetsDispositivo ?? {},
presetsMatriz: presetsMatriz ?? {},
); );
ConfiguracionEcualizador _config; ConfiguracionEcualizador _config;
@@ -300,6 +308,9 @@ class FakeServicioEcualizador extends ServicioEcualizador {
principal: preset, principal: preset,
porEmisora: _config.porEmisora, porEmisora: _config.porEmisora,
activo: _config.activo, activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
); );
} }
@@ -309,6 +320,9 @@ class FakeServicioEcualizador extends ServicioEcualizador {
principal: _config.principal, principal: _config.principal,
porEmisora: _config.porEmisora, porEmisora: _config.porEmisora,
activo: activo, activo: activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
); );
} }
@@ -320,6 +334,9 @@ class FakeServicioEcualizador extends ServicioEcualizador {
principal: _config.principal, principal: _config.principal,
porEmisora: mapa, porEmisora: mapa,
activo: _config.activo, activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
); );
} }
@@ -331,8 +348,95 @@ class FakeServicioEcualizador extends ServicioEcualizador {
principal: _config.principal, principal: _config.principal,
porEmisora: mapa, porEmisora: mapa,
activo: _config.activo, activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
); );
} }
@override
Future<void> 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<void> guardarPresetDispositivo(
String deviceId,
PresetEcualizador preset,
) async {
final mapa = Map<String, PresetEcualizador>.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<void> guardarPresetMatriz(
String clave,
PresetEcualizador preset,
) async {
final mapa = Map<String, PresetEcualizador>.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<DispositivoAudio>.broadcast();
DispositivoAudio? _dispositivoActual;
@override
DispositivoAudio? get dispositivoActual => _dispositivoActual;
@override
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
@override
Future<DispositivoAudio> 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<void> dispose() async {
await _controller.close();
}
} }
Emisora emisoraDemo({ Emisora emisoraDemo({
+94
View File
@@ -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);
});
});
}
+218
View File
@@ -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<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ChangeNotifierProvider<EstadoIdioma>.value(value: estadoIdioma),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAjustes()),
),
);
}
Future<EstadoRadio> crearEstado({
bool eqMultiDeviceEnabled = false,
Map<String, PresetEcualizador> 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<void> 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<EstadoGrabacionRadio>.broadcast();
@override
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
@override
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
@override
Future<void> inicializar() async {}
@override
Future<void> dispose() => _controller.close();
}
Future<File> _archivoCustomVacio() async =>
File('${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json');
@@ -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<String, dynamic> 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);
});
});
}
@@ -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 = <DispositivoAudio>[];
final sub = fake.onDispositivoCambiado.listen(received.add);
fake.emitirDispositivo(deviceA);
fake.emitirDispositivo(deviceB);
await Future<void>.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);
});
});
}
@@ -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<DispositivoAudio> get onDispositivoCambiado => const Stream.empty();
@override
Future<DispositivoAudio> obtenerDispositivoActual() async {
return const DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Speaker',
);
}
@override
Future<void> 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 = <DispositivoAudio>[];
final sub = servicio.onDispositivoCambiado.listen(received.add);
await Future<void>.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 = <DispositivoAudio>[];
final sub = fake.onDispositivoCambiado.listen(received.add);
await Future<void>.delayed(const Duration(milliseconds: 10));
await sub.cancel();
expect(received, isEmpty);
await fake.dispose();
});
});
}
@@ -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),
);
});
});
}
+175 -23
View File
@@ -7,11 +7,8 @@ import 'package:pluriwave/servicios/servicio_export_import.dart';
void main() { void main() {
const servicio = ServicioExportImport(); const servicio = ServicioExportImport();
group('ServicioExportImport (S4-R4)', () { // Shared fixture data used across multiple tests.
test('v2 round-trip: exportar then importar yields a deep-equal config ' const gruposFavoritosFixture = [
'(S4-R4-A, S6-R2 test #4)', () {
final config = servicio.construirExportacion(
gruposFavoritos: const [
GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1), GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1),
GrupoFavoritos( GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId, id: GrupoFavoritos.sinAsignarId,
@@ -19,8 +16,8 @@ void main() {
orden: 0, orden: 0,
protegido: true, protegido: true,
), ),
], ];
favoritos: const [ const favoritosFixture = [
Emisora( Emisora(
uuid: 'fav-1', uuid: 'fav-1',
nombre: 'Radio Uno', nombre: 'Radio Uno',
@@ -28,32 +25,35 @@ void main() {
grupoFavoritosId: 'g1', grupoFavoritosId: 'g1',
bitrate: 192, bitrate: 192,
), ),
], ];
emisorasCustom: const [ const emisorasCustomFixture = [
Emisora( Emisora(
uuid: 'custom-1', uuid: 'custom-1',
nombre: 'Mi Radio', nombre: 'Mi Radio',
url: 'https://mia.example/stream', url: 'https://mia.example/stream',
), ),
], ];
presetPrincipal: PresetEcualizador.jazz, const alarmasFixture = {
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
// Raw alarm block exactly as ServicioAlarmas persists it (alarms +
// vacations + exceptions) — must pass through untouched.
alarmas: const {
'alarmas': [ 'alarmas': [
{ {'id': 'a1', 'hora': 7, 'minuto': 30, 'activa': true, 'snoozeMinutos': 5},
'id': 'a1',
'hora': 7,
'minuto': 30,
'activa': true,
'snoozeMinutos': 5,
},
], ],
'vacaciones': [ 'vacaciones': [
{'desde': '2026-07-01', 'hasta': '2026-07-15'}, {'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: 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: alarmasFixture,
emisoraPreferidaUuid: 'fav-1', emisoraPreferidaUuid: 'fav-1',
ordenListas: 'calidad', ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300, 600, 1800], timerSuenoPresetsSegundos: const [300, 600, 1800],
@@ -80,4 +80,156 @@ void main() {
expect(servicio.importar('[1, 2, 3]'), isNull); expect(servicio.importar('[1, 2, 3]'), isNull);
}); });
}); });
group('ServicioExportImport v3 — multi-device EQ (Phase 6)', () {
test(
'6.1-A: v3 export includes schemaVersion 3 and device/matrix preset keys',
() {
final presetDispositivo = PresetEcualizador.rock;
final presetMatriz = PresetEcualizador.jazz;
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
presetsPorDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': presetDispositivo,
},
presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': presetMatriz},
eqMultiDeviceEnabled: true,
);
// v3 schema version.
expect(config['version'], 3);
// Device preset map present and correct.
expect(config['presetsPorDispositivo'], isA<Map>());
final dispositivoMap =
config['presetsPorDispositivo'] as Map<String, dynamic>;
expect(dispositivoMap, contains('bt_a2dp:AA:BB:CC:DD:EE:FF'));
// Matrix preset map present and correct.
expect(config['presetsMatriz'], isA<Map>());
final matrizMap = config['presetsMatriz'] as Map<String, dynamic>;
expect(matrizMap, contains('fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF'));
// Toggle exported.
expect(config['eqMultiDeviceEnabled'], isTrue);
},
);
test(
'6.1-B: v3 round-trip preserves device and matrix presets exactly',
() {
final dispositivoPreset = PresetEcualizador.rock;
final matrizPreset = PresetEcualizador.jazz;
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {'fav-1': PresetEcualizador.pop},
alarmas: alarmasFixture,
emisoraPreferidaUuid: 'fav-1',
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300, 1800],
presetsPorDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': dispositivoPreset,
'wired_headset': PresetEcualizador.flat,
},
presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': matrizPreset},
eqMultiDeviceEnabled: true,
);
final json = servicio.exportar(config);
final importado = servicio.importar(json);
expect(importado, isNotNull);
expect(importado!['version'], 3);
// Device presets survive the round-trip.
final deviceMap =
importado['presetsPorDispositivo'] as Map<String, dynamic>;
expect(
deviceMap['bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(dispositivoPreset.toJson()),
);
expect(
deviceMap['wired_headset'],
equals(PresetEcualizador.flat.toJson()),
);
// Matrix presets survive the round-trip.
final matrizMap = importado['presetsMatriz'] as Map<String, dynamic>;
expect(
matrizMap['fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(matrizPreset.toJson()),
);
// Toggle survives.
expect(importado['eqMultiDeviceEnabled'], isTrue);
},
);
test(
'6.1-C: v2 file (no device fields) imports cleanly — missing keys default to empty maps',
() {
// Simulate a v2 export file (no presetsPorDispositivo / presetsMatriz).
final v2Config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.jazz,
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
// No device fields — emulates a v2 export from the old app.
);
// Verify the export has no device keys (a v2 file would not).
expect(v2Config.containsKey('presetsPorDispositivo'), isFalse);
expect(v2Config.containsKey('presetsMatriz'), isFalse);
expect(v2Config.containsKey('eqMultiDeviceEnabled'), isFalse);
// Import it — parsing should succeed.
final json = servicio.exportar(v2Config);
final importado = servicio.importar(json);
expect(importado, isNotNull);
// Missing device keys should gracefully resolve to null (caller uses ?? {}).
expect(importado!['presetsPorDispositivo'], isNull);
expect(importado['presetsMatriz'], isNull);
expect(importado['eqMultiDeviceEnabled'], isNull);
},
);
test(
'6.1-D: unknown schemaVersion (v4-future) does not crash importar()',
() {
// Simulate a future v4 export (unknown version, extra fields).
final futurista = <String, dynamic>{
'version': 99,
'presetsPorDispositivo': <String, dynamic>{},
'presetsMatriz': <String, dynamic>{},
'unknownFutureField': 'some-value',
};
final json = servicio.exportar(futurista);
// importar() should parse without throwing — version checking is the
// caller's responsibility (EstadoRadio.importarConfig).
expect(() => servicio.importar(json), returnsNormally);
final importado = servicio.importar(json);
expect(importado, isNotNull);
expect(importado!['version'], 99);
},
);
});
} }