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:
@@ -6,6 +6,9 @@ import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.media.audiofx.Visualizer
|
||||
import android.app.AlarmManager
|
||||
@@ -31,6 +34,7 @@ class MainActivity : AudioServiceActivity() {
|
||||
private val visualizerChannel = "pluriwave/audio_visualizer"
|
||||
private val alarmChannel = "pluriwave/alarm_scheduler"
|
||||
private val fileActionsChannel = "pluriwave/file_actions"
|
||||
private val audioDevicesChannel = "pluriwave/audio_devices"
|
||||
private val visualizerPermissionRequestCode = 4821
|
||||
private val notificationPermissionRequestCode = 4822
|
||||
private var visualizer: Visualizer? = null
|
||||
@@ -39,8 +43,16 @@ class MainActivity : AudioServiceActivity() {
|
||||
private var alarmMethodChannel: MethodChannel? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Audio devices channel state
|
||||
private var audioDevicesSink: EventChannel.EventSink? = null
|
||||
private var audioDeviceCallback: AudioDeviceCallback? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// --- Audio Devices Channel ---
|
||||
setupAudioDevicesChannel(flutterEngine)
|
||||
|
||||
EventChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
visualizerChannel
|
||||
@@ -601,10 +613,149 @@ class MainActivity : AudioServiceActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Audio Devices Channel
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun setupAudioDevicesChannel(flutterEngine: FlutterEngine) {
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
|
||||
MethodChannel(messenger, audioDevicesChannel).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getActiveDevice" -> {
|
||||
val device = getActiveAudioDevice()
|
||||
Log.d(tag, "audio_devices.getActiveDevice -> $device")
|
||||
result.success(device)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(messenger, audioDevicesChannel).setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
audioDevicesSink = events
|
||||
registerAudioDeviceCallback()
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
unregisterAudioDeviceCallback()
|
||||
audioDevicesSink = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun registerAudioDeviceCallback() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val callback = object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<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() {
|
||||
if (activeInstance === this) {
|
||||
activeInstance = null
|
||||
}
|
||||
unregisterAudioDeviceCallback()
|
||||
stopVisualizer()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@ import UIKit
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
AudioDevicesPlugin.register(with: engineBridge.pluginRegistry.registrar(forPlugin: "AudioDevicesPlugin")!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -25,6 +25,7 @@ import 'widgets/pluri_onboarding_dialog.dart';
|
||||
import 'widgets/pluri_wave_scaffold.dart';
|
||||
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
||||
import 'servicios/servicio_alarmas_android.dart';
|
||||
import 'servicios/servicio_dispositivo_audio.dart';
|
||||
|
||||
class PluriWaveApp extends StatelessWidget {
|
||||
const PluriWaveApp({super.key, this.prefs});
|
||||
@@ -37,7 +38,13 @@ class PluriWaveApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)),
|
||||
ChangeNotifierProvider(
|
||||
create:
|
||||
(_) => EstadoRadio(
|
||||
prefs: prefs,
|
||||
dispositivoAudio: ServicioDispositivoAudioReal(),
|
||||
),
|
||||
),
|
||||
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
|
||||
// (they need its services and callbacks at construction); these
|
||||
// providers only expose the instances, so they declare no dispose
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../modelos/dispositivo_audio.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_dispositivo_audio.dart';
|
||||
import '../servicios/servicio_ecualizador.dart';
|
||||
|
||||
/// Equalizer state extracted from `EstadoRadio` (S4-R1).
|
||||
@@ -11,33 +15,65 @@ import '../servicios/servicio_ecualizador.dart';
|
||||
/// [ServicioEcualizador] and their application through [ServicioAudio].
|
||||
/// Notifies ONLY its own listeners — EQ changes must not rebuild
|
||||
/// `EstadoRadio` consumers (S4-R1-A, S4-R5).
|
||||
///
|
||||
/// Multi-device EQ extension (Phase 5): when [eqMultiDeviceEnabled] is true,
|
||||
/// resolves the active preset through a 4-level hierarchy:
|
||||
/// 1. presetsMatriz["stationUuid:deviceId"]
|
||||
/// 2. presetsEmisoraMap[stationUuid]
|
||||
/// 3. presetsDispositivo[deviceId]
|
||||
/// 4. presetPrincipal
|
||||
///
|
||||
/// When the toggle is false, resolution falls back to the original 2-level
|
||||
/// hierarchy (station → global) — zero behavioral change vs. prior releases.
|
||||
class EstadoEcualizador extends ChangeNotifier {
|
||||
EstadoEcualizador({
|
||||
required this.audio,
|
||||
ServicioEcualizador? servicio,
|
||||
ServicioDispositivoAudio? dispositivoAudio,
|
||||
String? Function()? emisoraActualUuid,
|
||||
}) : servicio = servicio ?? ServicioEcualizador(),
|
||||
_dispositivoAudio = dispositivoAudio,
|
||||
_emisoraActualUuid = emisoraActualUuid ?? (() => null);
|
||||
|
||||
final ServicioAudio audio;
|
||||
final ServicioEcualizador servicio;
|
||||
final ServicioDispositivoAudio? _dispositivoAudio;
|
||||
|
||||
/// Callback into the owner (EstadoRadio) for the currently playing station;
|
||||
/// keeps this notifier free of any station-list coupling.
|
||||
final String? Function() _emisoraActualUuid;
|
||||
|
||||
final Map<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 _presetActual = PresetEcualizador.flat;
|
||||
bool _activo = true;
|
||||
bool _eqMultiDeviceEnabled = false;
|
||||
String? _dispositivoActualId;
|
||||
StreamSubscription<DispositivoAudio>? _deviceSub;
|
||||
|
||||
PresetEcualizador get presetActual => _presetActual;
|
||||
PresetEcualizador get presetPrincipal => _presetPrincipal;
|
||||
bool get activo => _activo;
|
||||
bool get disponible => audio.ecualizadorDisponible;
|
||||
bool get eqMultiDeviceEnabled => _eqMultiDeviceEnabled;
|
||||
String? get dispositivoActualId => _dispositivoActualId;
|
||||
|
||||
Map<String, PresetEcualizador> get presetsPorEmisora =>
|
||||
Map.unmodifiable(_presetsEmisoraMap);
|
||||
|
||||
Map<String, PresetEcualizador> get presetsDispositivo =>
|
||||
Map.unmodifiable(_presetsDispositivo);
|
||||
|
||||
Map<String, PresetEcualizador> get presetsMatriz =>
|
||||
Map.unmodifiable(_presetsMatriz);
|
||||
|
||||
bool get emisoraActualTienePresetPropio {
|
||||
final uuid = _emisoraActualUuid();
|
||||
if (uuid == null) return false;
|
||||
@@ -52,26 +88,116 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
PresetEcualizador presetParaEmisora(String uuid) =>
|
||||
_presetsEmisoraMap[uuid] ?? _presetPrincipal;
|
||||
|
||||
/// 4-level resolution hierarchy (ADR-4, spec requirement).
|
||||
///
|
||||
/// When [eqMultiDeviceEnabled] is false, falls back to 2-level (station → global).
|
||||
PresetEcualizador presetEfectivo({
|
||||
required String stationUuid,
|
||||
required String deviceId,
|
||||
}) {
|
||||
if (!_eqMultiDeviceEnabled) {
|
||||
// Original 2-level behavior: station → global.
|
||||
return _presetsEmisoraMap[stationUuid] ?? _presetPrincipal;
|
||||
}
|
||||
|
||||
// Level 1: station × device matrix.
|
||||
final matrizKey = '$stationUuid:$deviceId';
|
||||
if (_presetsMatriz.containsKey(matrizKey)) {
|
||||
return _presetsMatriz[matrizKey]!;
|
||||
}
|
||||
// Level 2: station-only.
|
||||
if (_presetsEmisoraMap.containsKey(stationUuid)) {
|
||||
return _presetsEmisoraMap[stationUuid]!;
|
||||
}
|
||||
// Level 3: device-only.
|
||||
if (_presetsDispositivo.containsKey(deviceId)) {
|
||||
return _presetsDispositivo[deviceId]!;
|
||||
}
|
||||
// Level 4: global fallback.
|
||||
return _presetPrincipal;
|
||||
}
|
||||
|
||||
/// Resolves the effective preset for the current station and device.
|
||||
PresetEcualizador _resolverPresetActivo() {
|
||||
final uuid = _emisoraActualUuid();
|
||||
if (!_eqMultiDeviceEnabled) {
|
||||
return uuid != null
|
||||
? (_presetsEmisoraMap[uuid] ?? _presetPrincipal)
|
||||
: _presetPrincipal;
|
||||
}
|
||||
final deviceId = _dispositivoActualId;
|
||||
if (uuid == null || deviceId == null) {
|
||||
return _presetPrincipal;
|
||||
}
|
||||
return presetEfectivo(stationUuid: uuid, deviceId: deviceId);
|
||||
}
|
||||
|
||||
/// Loads the persisted EQ configuration and applies it to the audio engine.
|
||||
Future<void> cargarPersistido() async {
|
||||
try {
|
||||
final config = await servicio.cargar();
|
||||
_presetPrincipal = config.principal;
|
||||
_presetActual = config.principal;
|
||||
_activo = config.activo;
|
||||
_eqMultiDeviceEnabled = config.eqMultiDeviceEnabled;
|
||||
_presetsEmisoraMap
|
||||
..clear()
|
||||
..addAll(config.porEmisora);
|
||||
_presetsDispositivo
|
||||
..clear()
|
||||
..addAll(config.presetsDispositivo);
|
||||
_presetsMatriz
|
||||
..clear()
|
||||
..addAll(config.presetsMatriz);
|
||||
|
||||
// Resolve active preset and apply it.
|
||||
_presetActual = _resolverPresetActivo();
|
||||
await audio.setEcualizadorActivo(_activo);
|
||||
await audio.aplicarPreset(_presetPrincipal);
|
||||
await audio.aplicarPreset(_presetActual);
|
||||
|
||||
// Subscribe to device changes only when toggle is on.
|
||||
_configurarSuscripcionDispositivo();
|
||||
} catch (_) {
|
||||
_presetPrincipal = PresetEcualizador.flat;
|
||||
_presetActual = PresetEcualizador.flat;
|
||||
_activo = true;
|
||||
_eqMultiDeviceEnabled = false;
|
||||
_presetsEmisoraMap.clear();
|
||||
_presetsDispositivo.clear();
|
||||
_presetsMatriz.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to the device change stream if multi-device is enabled.
|
||||
void _configurarSuscripcionDispositivo() {
|
||||
_deviceSub?.cancel();
|
||||
_deviceSub = null;
|
||||
|
||||
final svc = _dispositivoAudio;
|
||||
if (!_eqMultiDeviceEnabled || svc == null) return;
|
||||
|
||||
_deviceSub = svc.onDispositivoCambiado.listen(_onDispositivoCambiado);
|
||||
}
|
||||
|
||||
/// Called when a device change event arrives.
|
||||
Future<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
|
||||
/// WITHOUT persisting it (used when switching stations).
|
||||
Future<void> aplicarPresetActivo(PresetEcualizador preset) async {
|
||||
@@ -79,6 +205,20 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
await audio.aplicarPreset(preset);
|
||||
}
|
||||
|
||||
/// Enables or disables the multi-device EQ feature toggle.
|
||||
///
|
||||
/// When disabled, the device stream subscription is cancelled and resolution
|
||||
/// immediately collapses to 2-level (station → global).
|
||||
Future<void> cambiarMultiDeviceEnabled(
|
||||
bool habilitado, {
|
||||
bool notificar = true,
|
||||
}) async {
|
||||
_eqMultiDeviceEnabled = habilitado;
|
||||
await servicio.guardarToggleMultiDispositivo(habilitado);
|
||||
_configurarSuscripcionDispositivo();
|
||||
if (notificar) notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cambiarPresetPrincipal(
|
||||
PresetEcualizador preset, {
|
||||
bool notificar = true,
|
||||
@@ -184,24 +324,49 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
Future<void> importarConfiguracion({
|
||||
required PresetEcualizador principal,
|
||||
required Map<String, PresetEcualizador> porEmisora,
|
||||
Map<String, PresetEcualizador>? presetsDispositivo,
|
||||
Map<String, PresetEcualizador>? presetsMatriz,
|
||||
bool? eqMultiDeviceEnabled,
|
||||
}) async {
|
||||
_presetPrincipal = principal;
|
||||
_presetsEmisoraMap
|
||||
..clear()
|
||||
..addAll(porEmisora);
|
||||
if (presetsDispositivo != null) {
|
||||
_presetsDispositivo
|
||||
..clear()
|
||||
..addAll(presetsDispositivo);
|
||||
}
|
||||
if (presetsMatriz != null) {
|
||||
_presetsMatriz
|
||||
..clear()
|
||||
..addAll(presetsMatriz);
|
||||
}
|
||||
if (eqMultiDeviceEnabled != null) {
|
||||
_eqMultiDeviceEnabled = eqMultiDeviceEnabled;
|
||||
}
|
||||
|
||||
await servicio.guardarConfiguracion(
|
||||
ConfiguracionEcualizador(
|
||||
principal: _presetPrincipal,
|
||||
porEmisora: _presetsEmisoraMap,
|
||||
activo: _activo,
|
||||
eqMultiDeviceEnabled: _eqMultiDeviceEnabled,
|
||||
presetsDispositivo: _presetsDispositivo,
|
||||
presetsMatriz: _presetsMatriz,
|
||||
),
|
||||
);
|
||||
|
||||
final uuid = _emisoraActualUuid();
|
||||
await aplicarPresetActivo(
|
||||
uuid == null ? _presetPrincipal : presetParaEmisora(uuid),
|
||||
);
|
||||
final presetEfectivoActual =
|
||||
uuid == null ? _presetPrincipal : _resolverPresetActivo();
|
||||
await aplicarPresetActivo(presetEfectivoActual);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_deviceSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'estado_ecualizador.dart';
|
||||
import 'estado_grabacion.dart';
|
||||
import 'orden_emisoras.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_dispositivo_audio.dart';
|
||||
import '../servicios/servicio_ecualizador.dart';
|
||||
import '../servicios/servicio_export_import.dart';
|
||||
import '../servicios/servicio_favoritos.dart';
|
||||
@@ -38,6 +39,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
ServicioFavoritos? favoritos,
|
||||
ServicioRadio? radio,
|
||||
ServicioEcualizador? servicioEcualizador,
|
||||
ServicioDispositivoAudio? dispositivoAudio,
|
||||
ServicioGrabacionRadio? servicioGrabacion,
|
||||
SharedPreferences? prefs,
|
||||
Future<File> Function()? resolverArchivoCustom,
|
||||
@@ -47,11 +49,13 @@ class EstadoRadio extends ChangeNotifier {
|
||||
radio = radio ?? ServicioRadio(),
|
||||
servicioEcualizador =
|
||||
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
|
||||
_dispositivoAudio = dispositivoAudio,
|
||||
_prefs = prefs,
|
||||
_resolverArchivoCustom = resolverArchivoCustom {
|
||||
ecualizador = EstadoEcualizador(
|
||||
audio: this.audio,
|
||||
servicio: this.servicioEcualizador,
|
||||
dispositivoAudio: _dispositivoAudio,
|
||||
emisoraActualUuid: () => emisoraActual?.uuid,
|
||||
);
|
||||
grabacion = EstadoGrabacion(
|
||||
@@ -77,6 +81,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final ServicioRadio radio;
|
||||
final ServicioEcualizador servicioEcualizador;
|
||||
|
||||
/// Optional device service — null means no device subscription established.
|
||||
/// Wired in production from app.dart; defaults to null in tests.
|
||||
final ServicioDispositivoAudio? _dispositivoAudio;
|
||||
|
||||
/// Domain notifiers extracted from this class (S4). Created and disposed
|
||||
/// here (they need EstadoRadio's services and callbacks at construction);
|
||||
/// exposed app-wide through ListenableProviders in app.dart.
|
||||
@@ -547,8 +555,9 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
|
||||
|
||||
/// Genera el JSON de toda la configuración (v2 — portabilidad completa).
|
||||
/// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4).
|
||||
/// Genera el JSON de toda la configuración (v3 — portabilidad completa
|
||||
/// con presets por dispositivo y matriz multi-device).
|
||||
/// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4).
|
||||
Future<Map<String, dynamic>> exportarConfig() async {
|
||||
final favs = await favoritos.obtenerTodos();
|
||||
final grupos = await favoritos.obtenerGrupos();
|
||||
@@ -572,6 +581,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
emisoraPreferidaUuid: _emisoraPreferidaUuid,
|
||||
ordenListas: _ordenListas.name,
|
||||
timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos,
|
||||
// v3 extensions — multi-device EQ fields.
|
||||
presetsPorDispositivo: ecualizador.presetsDispositivo,
|
||||
presetsMatriz: ecualizador.presetsMatriz,
|
||||
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -584,10 +597,11 @@ class EstadoRadio extends ChangeNotifier {
|
||||
_exportImport.importar(raw);
|
||||
|
||||
/// Importa configuración desde un JSON exportado previamente.
|
||||
/// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa).
|
||||
/// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa),
|
||||
/// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device).
|
||||
Future<void> importarConfig(Map<String, dynamic> data) async {
|
||||
final version = data['version'] as int? ?? 1;
|
||||
if (version > 2) throw Exception(_textos.unsupportedConfigVersion);
|
||||
if (version > 3) throw Exception(_textos.unsupportedConfigVersion);
|
||||
|
||||
final prefs = await _resolverPrefs();
|
||||
|
||||
@@ -637,9 +651,41 @@ class EstadoRadio extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
|
||||
// v3 extensions: per-device and matrix presets.
|
||||
Map<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(
|
||||
principal: presetPrincipal,
|
||||
porEmisora: presetsPorEmisora,
|
||||
presetsDispositivo: presetsDispositivo,
|
||||
presetsMatriz: presetsMatriz,
|
||||
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
|
||||
);
|
||||
|
||||
// ── Alarmas (v2) ──────────────────────────────────────────────────────
|
||||
@@ -748,6 +794,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
grabacion.dispose();
|
||||
audio.dispose();
|
||||
timer.dispose();
|
||||
_dispositivoAudio?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -616,5 +616,16 @@
|
||||
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
|
||||
"alarmIconLabel": "Musical alarm",
|
||||
"vacationIconLabel": "Vacation mode",
|
||||
"streamUrlHint": "https://stream.example.com:8000/radio"
|
||||
"streamUrlHint": "https://stream.example.com:8000/radio",
|
||||
"advancedEqSectionTitle": "Advanced Equalization Options",
|
||||
"advancedEqEnableToggle": "Enable per-device EQ",
|
||||
"advancedEqEnableToggleSubtitle": "Automatically apply a different EQ preset when your audio output changes.",
|
||||
"advancedEqKnownDevicesTitle": "Known audio devices",
|
||||
"advancedEqKnownDevicesEmpty": "No audio devices recorded yet. Connect a device to get started.",
|
||||
"advancedEqDevicePresetLabel": "Preset: {presetName}",
|
||||
"@advancedEqDevicePresetLabel": {
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,5 +579,16 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"advancedEqSectionTitle": "Opciones avanzadas de ecualización",
|
||||
"advancedEqEnableToggle": "Activar EQ por dispositivo",
|
||||
"advancedEqEnableToggleSubtitle": "Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.",
|
||||
"advancedEqKnownDevicesTitle": "Dispositivos de audio conocidos",
|
||||
"advancedEqKnownDevicesEmpty": "No hay dispositivos registrados. Conectá uno para empezar.",
|
||||
"advancedEqDevicePresetLabel": "Preset: {presetName}",
|
||||
"@advancedEqDevicePresetLabel": {
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2269,6 +2269,42 @@ abstract class AppLocalizations {
|
||||
/// In es, this message translates to:
|
||||
/// **'https://stream.example.com:8000/radio'**
|
||||
String get streamUrlHint;
|
||||
|
||||
/// No description provided for @advancedEqSectionTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Opciones avanzadas de ecualización'**
|
||||
String get advancedEqSectionTitle;
|
||||
|
||||
/// No description provided for @advancedEqEnableToggle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Activar EQ por dispositivo'**
|
||||
String get advancedEqEnableToggle;
|
||||
|
||||
/// No description provided for @advancedEqEnableToggleSubtitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'**
|
||||
String get advancedEqEnableToggleSubtitle;
|
||||
|
||||
/// No description provided for @advancedEqKnownDevicesTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Dispositivos de audio conocidos'**
|
||||
String get advancedEqKnownDevicesTitle;
|
||||
|
||||
/// No description provided for @advancedEqKnownDevicesEmpty.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'No hay dispositivos registrados. Conectá uno para empezar.'**
|
||||
String get advancedEqKnownDevicesEmpty;
|
||||
|
||||
/// No description provided for @advancedEqDevicePresetLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Preset: {presetName}'**
|
||||
String advancedEqDevicePresetLabel(Object presetName);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1225,4 +1225,26 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1232,4 +1232,26 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1242,4 +1242,26 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1228,4 +1228,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Advanced Equalization Options';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Enable per-device EQ';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Automatically apply a different EQ preset when your audio output changes.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Known audio devices';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No audio devices recorded yet. Connect a device to get started.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,4 +1237,26 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1247,4 +1247,26 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1231,4 +1231,26 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1236,4 +1236,26 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1242,4 +1242,26 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1192,4 +1192,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1234,4 +1234,26 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1238,4 +1238,26 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1187,4 +1187,26 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||
|
||||
@override
|
||||
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
|
||||
|
||||
@override
|
||||
String get advancedEqEnableToggleSubtitle =>
|
||||
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
|
||||
|
||||
@override
|
||||
String get advancedEqKnownDevicesEmpty =>
|
||||
'No hay dispositivos registrados. Conectá uno para empezar.';
|
||||
|
||||
@override
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -60,6 +60,8 @@ class _AjustesContent extends StatelessWidget {
|
||||
children: const [
|
||||
_SeccionEcualizador(),
|
||||
SizedBox(height: 12),
|
||||
_SeccionEcualizadorAvanzado(),
|
||||
SizedBox(height: 12),
|
||||
_SeccionGrabaciones(),
|
||||
SizedBox(height: 12),
|
||||
_SeccionTimerSueno(),
|
||||
@@ -652,6 +654,126 @@ class _SeccionEcualizador extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Advanced Equalization Options" settings section (Phase 7, multi-device EQ).
|
||||
///
|
||||
/// Always shows the feature toggle so the user can discover it. When the
|
||||
/// toggle is OFF, the device list is completely absent (not just invisible),
|
||||
/// matching the spec scenario "Settings section is absent when toggle is off".
|
||||
class _SeccionEcualizadorAvanzado extends StatelessWidget {
|
||||
const _SeccionEcualizadorAvanzado();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final eq = context.watch<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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,23 @@ class ConfiguracionEcualizador {
|
||||
required this.principal,
|
||||
required this.porEmisora,
|
||||
this.activo = true,
|
||||
this.eqMultiDeviceEnabled = false,
|
||||
this.presetsDispositivo = const {},
|
||||
this.presetsMatriz = const {},
|
||||
});
|
||||
|
||||
final PresetEcualizador principal;
|
||||
final Map<String, PresetEcualizador> porEmisora;
|
||||
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 {
|
||||
@@ -22,6 +34,9 @@ class ServicioEcualizador {
|
||||
static const _keyPresetPrincipal = 'eq_preset_principal_v1';
|
||||
static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1';
|
||||
static const _keyActivo = 'eq_activo_v1';
|
||||
static const _keyMultiDeviceEnabled = 'eq_multi_device_enabled_v1';
|
||||
static const _keyPresetsPorDispositivo = 'eq_preset_por_dispositivo_v1';
|
||||
static const _keyPresetsMatriz = 'eq_presets_matriz_v1';
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
|
||||
@@ -33,10 +48,15 @@ class ServicioEcualizador {
|
||||
final prefs = await _resolverPrefs();
|
||||
final principal = _leerPresetPrincipal(prefs);
|
||||
final porEmisora = _leerPresetsPorEmisora(prefs);
|
||||
final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
|
||||
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
|
||||
return ConfiguracionEcualizador(
|
||||
principal: principal,
|
||||
porEmisora: porEmisora,
|
||||
activo: prefs.getBool(_keyActivo) ?? true,
|
||||
eqMultiDeviceEnabled: prefs.getBool(_keyMultiDeviceEnabled) ?? false,
|
||||
presetsDispositivo: presetsDispositivo,
|
||||
presetsMatriz: presetsMatriz,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +92,86 @@ class ServicioEcualizador {
|
||||
);
|
||||
await _guardarPresetsPorEmisora(prefs, config.porEmisora);
|
||||
await prefs.setBool(_keyActivo, config.activo);
|
||||
await prefs.setBool(_keyMultiDeviceEnabled, config.eqMultiDeviceEnabled);
|
||||
await _guardarMapa(
|
||||
prefs,
|
||||
_keyPresetsPorDispositivo,
|
||||
config.presetsDispositivo,
|
||||
);
|
||||
await _guardarMapa(prefs, _keyPresetsMatriz, config.presetsMatriz);
|
||||
}
|
||||
|
||||
/// Persists the multi-device feature toggle.
|
||||
Future<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) {
|
||||
|
||||
@@ -6,21 +6,29 @@ import '../modelos/preset_ecualizador.dart';
|
||||
|
||||
/// Owns the backup (export/import) JSON serialization (S4-R4).
|
||||
///
|
||||
/// The v2 envelope produced here is byte-compatible with the legacy format
|
||||
/// previously assembled inline by `EstadoRadio.exportarConfig` and
|
||||
/// pretty-printed by `pantalla_ajustes.dart`, so existing exports keep
|
||||
/// round-tripping. State APPLICATION (writing favorites, EQ, alarms back
|
||||
/// into the app) stays in `EstadoRadio.importarConfig` — this service only
|
||||
/// owns serialization, parsing and the envelope shape.
|
||||
/// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and
|
||||
/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the
|
||||
/// export stays at v2 for backward compat with the old app. State APPLICATION
|
||||
/// (writing favorites, EQ, alarms back into the app) stays in
|
||||
/// `EstadoRadio.importarConfig` — this service only owns serialization,
|
||||
/// parsing and the envelope shape.
|
||||
class ServicioExportImport {
|
||||
const ServicioExportImport();
|
||||
|
||||
/// Current backup schema version (v2 — full portability).
|
||||
static const int versionActual = 2;
|
||||
/// Current backup schema version (v3 — multi-device EQ).
|
||||
static const int versionActual = 3;
|
||||
|
||||
/// Builds the v2 export envelope. Key set and semantics must stay exactly
|
||||
/// as the legacy inline export so old backups remain importable:
|
||||
/// the `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
|
||||
/// Legacy v2 version constant kept for clarity.
|
||||
static const int versionV2 = 2;
|
||||
|
||||
/// Builds the export envelope.
|
||||
///
|
||||
/// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null),
|
||||
/// [versionActual] (3) is written. When both are omitted the call behaves
|
||||
/// identically to the original v2 format (version key stays 2) so old
|
||||
/// backups keep round-tripping without version bumps.
|
||||
///
|
||||
/// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
|
||||
/// and passes through untouched (no re-parsing here).
|
||||
Map<String, dynamic> construirExportacion({
|
||||
required List<GrupoFavoritos> gruposFavoritos,
|
||||
@@ -33,9 +41,18 @@ class ServicioExportImport {
|
||||
required String ordenListas,
|
||||
required List<int> timerSuenoPresetsSegundos,
|
||||
DateTime? exportadoEn,
|
||||
// v3 extensions — omitting these produces a v2-compatible export.
|
||||
Map<String, PresetEcualizador>? presetsPorDispositivo,
|
||||
Map<String, PresetEcualizador>? presetsMatriz,
|
||||
bool? eqMultiDeviceEnabled,
|
||||
}) {
|
||||
return {
|
||||
'version': versionActual,
|
||||
final tieneExtensionesV3 =
|
||||
presetsPorDispositivo != null ||
|
||||
presetsMatriz != null ||
|
||||
eqMultiDeviceEnabled != null;
|
||||
|
||||
final envelope = <String, dynamic>{
|
||||
'version': tieneExtensionesV3 ? versionActual : versionV2,
|
||||
'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(),
|
||||
// Favorites + groups (preserves grupo_id assignments per station).
|
||||
// The protected "sin asignar" group is implicit and never exported.
|
||||
@@ -47,7 +64,7 @@ class ServicioExportImport {
|
||||
'favoritos': favoritos.map((e) => e.toMap()).toList(),
|
||||
// Custom stations.
|
||||
'emisorasCustom': emisorasCustom.map((e) => e.toMap()).toList(),
|
||||
// Equalizer.
|
||||
// Equalizer (base fields — present in all versions).
|
||||
'presetPrincipalEcualizador': presetPrincipal.toJson(),
|
||||
'presetsEcualizador': presetsPorEmisora.map(
|
||||
(uuid, preset) => MapEntry(uuid, preset.toJson()),
|
||||
@@ -59,6 +76,19 @@ class ServicioExportImport {
|
||||
'ordenListas': ordenListas,
|
||||
'timerSuenoPresetsSegundos': timerSuenoPresetsSegundos,
|
||||
};
|
||||
|
||||
// v3 extensions: only written when explicitly provided.
|
||||
if (tieneExtensionesV3) {
|
||||
envelope['presetsPorDispositivo'] = (presetsPorDispositivo ?? {}).map(
|
||||
(deviceId, preset) => MapEntry(deviceId, preset.toJson()),
|
||||
);
|
||||
envelope['presetsMatriz'] = (presetsMatriz ?? {}).map(
|
||||
(clave, preset) => MapEntry(clave, preset.toJson()),
|
||||
);
|
||||
envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false;
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
|
||||
/// Serializes an export envelope to pretty-printed JSON (same formatting
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/modelos/dispositivo_audio.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
@@ -48,4 +49,493 @@ void main() {
|
||||
estado.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 5: 4-level resolution hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — 4-level resolution (Phase 5)', () {
|
||||
const btDevice = DispositivoAudio(
|
||||
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'BT Speaker',
|
||||
);
|
||||
const stationUuid = 'station-uuid-123';
|
||||
|
||||
EstadoEcualizador buildEq({
|
||||
FakeServicioEcualizador? servicio,
|
||||
FakeServicioDispositivoAudio? dispositivoAudio,
|
||||
bool multiDeviceEnabled = true,
|
||||
String? emisoraActualUuid,
|
||||
}) {
|
||||
return EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio:
|
||||
servicio ??
|
||||
FakeServicioEcualizador(eqMultiDeviceEnabled: multiDeviceEnabled),
|
||||
dispositivoAudio: dispositivoAudio ?? FakeServicioDispositivoAudio(),
|
||||
emisoraActualUuid: () => emisoraActualUuid,
|
||||
);
|
||||
}
|
||||
|
||||
// 5.1a — matrix entry wins over all others
|
||||
test(
|
||||
'5.1a matrix entry (stationUuid:deviceId) wins top priority',
|
||||
() async {
|
||||
final matrizPreset = PresetEcualizador.bassBoost;
|
||||
final stationPreset = PresetEcualizador.rock;
|
||||
final devicePreset = PresetEcualizador.jazz;
|
||||
final globalPreset = PresetEcualizador.pop;
|
||||
|
||||
final eq = buildEq(
|
||||
servicio: FakeServicioEcualizador(
|
||||
principal: globalPreset,
|
||||
porEmisora: {stationUuid: stationPreset},
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {btDevice.id: devicePreset},
|
||||
presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset},
|
||||
),
|
||||
emisoraActualUuid: stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(
|
||||
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
|
||||
equals(matrizPreset),
|
||||
);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
// 5.1b — falls back to station when no matrix entry
|
||||
test('5.1b falls back to station preset when no matrix entry', () async {
|
||||
final stationPreset = PresetEcualizador.rock;
|
||||
final devicePreset = PresetEcualizador.jazz;
|
||||
final globalPreset = PresetEcualizador.pop;
|
||||
|
||||
final eq = buildEq(
|
||||
servicio: FakeServicioEcualizador(
|
||||
principal: globalPreset,
|
||||
porEmisora: {stationUuid: stationPreset},
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {btDevice.id: devicePreset},
|
||||
presetsMatriz: {},
|
||||
),
|
||||
emisoraActualUuid: stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(
|
||||
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
|
||||
equals(stationPreset),
|
||||
);
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
// 5.1c — falls back to device when no station preset
|
||||
test(
|
||||
'5.1c falls back to device preset when no station or matrix entry',
|
||||
() async {
|
||||
final devicePreset = PresetEcualizador.jazz;
|
||||
final globalPreset = PresetEcualizador.pop;
|
||||
|
||||
final eq = buildEq(
|
||||
servicio: FakeServicioEcualizador(
|
||||
principal: globalPreset,
|
||||
porEmisora: {},
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {btDevice.id: devicePreset},
|
||||
presetsMatriz: {},
|
||||
),
|
||||
emisoraActualUuid: stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(
|
||||
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
|
||||
equals(devicePreset),
|
||||
);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
// 5.1d — falls back to global when nothing else matches
|
||||
test(
|
||||
'5.1d falls back to global preset when no matrix, station, or device',
|
||||
() async {
|
||||
final globalPreset = PresetEcualizador.pop;
|
||||
|
||||
final eq = buildEq(
|
||||
servicio: FakeServicioEcualizador(
|
||||
principal: globalPreset,
|
||||
porEmisora: {},
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {},
|
||||
presetsMatriz: {},
|
||||
),
|
||||
emisoraActualUuid: stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(
|
||||
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
|
||||
equals(globalPreset),
|
||||
);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
// 5.1e — toggle off collapses to 2-level
|
||||
test(
|
||||
'5.1e toggle off: presetEfectivo uses only station → global',
|
||||
() async {
|
||||
final stationPreset = PresetEcualizador.rock;
|
||||
final devicePreset = PresetEcualizador.jazz;
|
||||
final matrizPreset = PresetEcualizador.bassBoost;
|
||||
|
||||
final eq = buildEq(
|
||||
servicio: FakeServicioEcualizador(
|
||||
principal: PresetEcualizador.flat,
|
||||
porEmisora: {stationUuid: stationPreset},
|
||||
eqMultiDeviceEnabled: false,
|
||||
presetsDispositivo: {btDevice.id: devicePreset},
|
||||
presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset},
|
||||
),
|
||||
multiDeviceEnabled: false,
|
||||
emisoraActualUuid: stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
// When toggle is off, device and matrix entries must be ignored
|
||||
expect(
|
||||
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
|
||||
equals(stationPreset),
|
||||
);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
// 5.1f — toggle off with no station falls back to global
|
||||
test('5.1f toggle off with no station: falls back to global', () async {
|
||||
final devicePreset = PresetEcualizador.jazz;
|
||||
|
||||
final eq = buildEq(
|
||||
servicio: FakeServicioEcualizador(
|
||||
principal: PresetEcualizador.flat,
|
||||
porEmisora: {},
|
||||
eqMultiDeviceEnabled: false,
|
||||
presetsDispositivo: {btDevice.id: devicePreset},
|
||||
presetsMatriz: {},
|
||||
),
|
||||
multiDeviceEnabled: false,
|
||||
emisoraActualUuid: stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(
|
||||
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
|
||||
equals(PresetEcualizador.flat),
|
||||
);
|
||||
eq.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 5.3: Device stream subscription triggers re-resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — device stream subscription (Phase 5.3–5.4)', () {
|
||||
const stationUuid = 'station-uuid-stream';
|
||||
const btDevice = DispositivoAudio(
|
||||
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'BT Speaker',
|
||||
);
|
||||
|
||||
test('5.3a device change emits notifyListeners', () async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final fakeDispositivo = FakeServicioDispositivoAudio();
|
||||
final eq = EstadoEcualizador(
|
||||
audio: fakeAudio,
|
||||
servicio: FakeServicioEcualizador(
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {btDevice.id: PresetEcualizador.jazz},
|
||||
),
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
emisoraActualUuid: () => stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
|
||||
fakeDispositivo.emitirDispositivo(btDevice);
|
||||
// Wait for async processing
|
||||
await Future<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.5–5.6: First-seen device copies current preset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group(
|
||||
'EstadoEcualizador — first-seen device initialization (Phase 5.5–5.6)',
|
||||
() {
|
||||
const stationUuid = 'station-uuid-first-seen';
|
||||
const newDevice = DispositivoAudio(
|
||||
id: 'bt_a2dp:NEW:DE:VI:CE:00:00',
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'New BT Device',
|
||||
);
|
||||
|
||||
test('5.5a new device gets copy of current resolved preset', () async {
|
||||
final fakeServicio = FakeServicioEcualizador(
|
||||
principal: PresetEcualizador.rock,
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {},
|
||||
);
|
||||
final fakeDispositivo = FakeServicioDispositivoAudio();
|
||||
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: fakeServicio,
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
emisoraActualUuid: () => stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
fakeDispositivo.emitirDispositivo(newDevice);
|
||||
await Future<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.7–5.8: Toggle-off path uses only 2-level resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — toggle-off path (Phase 5.7–5.8)', () {
|
||||
const stationUuid = 'station-toggle-off';
|
||||
const btDevice = DispositivoAudio(
|
||||
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'BT Speaker',
|
||||
);
|
||||
|
||||
test(
|
||||
'5.7a cargarPersistido with toggle off applies 2-level resolution',
|
||||
() async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final stationPreset = PresetEcualizador.rock;
|
||||
final devicePreset = PresetEcualizador.jazz;
|
||||
|
||||
final eq = EstadoEcualizador(
|
||||
audio: fakeAudio,
|
||||
servicio: FakeServicioEcualizador(
|
||||
principal: PresetEcualizador.flat,
|
||||
porEmisora: {stationUuid: stationPreset},
|
||||
eqMultiDeviceEnabled: false,
|
||||
presetsDispositivo: {btDevice.id: devicePreset},
|
||||
),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
emisoraActualUuid: () => stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
// With toggle off, no device subscription, direct 2-level on presetEfectivo
|
||||
expect(
|
||||
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
|
||||
equals(stationPreset),
|
||||
);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('5.8a eqMultiDeviceEnabled getter reflects loaded value', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
expect(eq.eqMultiDeviceEnabled, isTrue);
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
test('5.8b toggle defaults to false and eq behaves as before', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
expect(eq.eqMultiDeviceEnabled, isFalse);
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'5.8c cambiarMultiDeviceEnabled persists toggle and notifies',
|
||||
() async {
|
||||
final fakeServicio = FakeServicioEcualizador(
|
||||
eqMultiDeviceEnabled: false,
|
||||
);
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: fakeServicio,
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
await eq.cambiarMultiDeviceEnabled(true);
|
||||
|
||||
expect(eq.eqMultiDeviceEnabled, isTrue);
|
||||
expect(fakeServicio.config.eqMultiDeviceEnabled, isTrue);
|
||||
expect(avisos, greaterThanOrEqualTo(1));
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 5.9: REFACTOR — aplicarPresetActivo path applies device-resolved preset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group(
|
||||
'EstadoEcualizador — aplicarPresetActivo uses device resolution (5.9)',
|
||||
() {
|
||||
const stationUuid = 'station-refactor';
|
||||
const btDevice = DispositivoAudio(
|
||||
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'BT Speaker',
|
||||
);
|
||||
|
||||
test(
|
||||
'5.9a aplicarPresetEmisoraActual uses device-resolved preset when toggle on',
|
||||
() async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final fakeDispositivo =
|
||||
FakeServicioDispositivoAudio()..emitirDispositivo(btDevice);
|
||||
final matrizPreset = PresetEcualizador.bassBoost;
|
||||
|
||||
final eq = EstadoEcualizador(
|
||||
audio: fakeAudio,
|
||||
servicio: FakeServicioEcualizador(
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset},
|
||||
),
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
emisoraActualUuid: () => stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
await Future<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();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/dispositivo_audio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
|
||||
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/servicio_favoritos.dart';
|
||||
import 'package:pluriwave/servicios/servicio_radio.dart';
|
||||
@@ -282,10 +284,16 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
PresetEcualizador? principal,
|
||||
Map<String, PresetEcualizador>? porEmisora,
|
||||
bool activo = true,
|
||||
bool eqMultiDeviceEnabled = false,
|
||||
Map<String, PresetEcualizador>? presetsDispositivo,
|
||||
Map<String, PresetEcualizador>? presetsMatriz,
|
||||
}) : _config = ConfiguracionEcualizador(
|
||||
principal: principal ?? PresetEcualizador.flat,
|
||||
porEmisora: porEmisora ?? {},
|
||||
activo: activo,
|
||||
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
|
||||
presetsDispositivo: presetsDispositivo ?? {},
|
||||
presetsMatriz: presetsMatriz ?? {},
|
||||
);
|
||||
|
||||
ConfiguracionEcualizador _config;
|
||||
@@ -300,6 +308,9 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
principal: preset,
|
||||
porEmisora: _config.porEmisora,
|
||||
activo: _config.activo,
|
||||
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
||||
presetsDispositivo: _config.presetsDispositivo,
|
||||
presetsMatriz: _config.presetsMatriz,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,6 +320,9 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
principal: _config.principal,
|
||||
porEmisora: _config.porEmisora,
|
||||
activo: activo,
|
||||
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
||||
presetsDispositivo: _config.presetsDispositivo,
|
||||
presetsMatriz: _config.presetsMatriz,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,6 +334,9 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
principal: _config.principal,
|
||||
porEmisora: mapa,
|
||||
activo: _config.activo,
|
||||
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
||||
presetsDispositivo: _config.presetsDispositivo,
|
||||
presetsMatriz: _config.presetsMatriz,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -331,8 +348,95 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
principal: _config.principal,
|
||||
porEmisora: mapa,
|
||||
activo: _config.activo,
|
||||
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
||||
presetsDispositivo: _config.presetsDispositivo,
|
||||
presetsMatriz: _config.presetsMatriz,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,53 +7,53 @@ import 'package:pluriwave/servicios/servicio_export_import.dart';
|
||||
void main() {
|
||||
const servicio = ServicioExportImport();
|
||||
|
||||
// Shared fixture data used across multiple tests.
|
||||
const gruposFavoritosFixture = [
|
||||
GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1),
|
||||
GrupoFavoritos(
|
||||
id: GrupoFavoritos.sinAsignarId,
|
||||
nombre: 'Sin asignar',
|
||||
orden: 0,
|
||||
protegido: true,
|
||||
),
|
||||
];
|
||||
const favoritosFixture = [
|
||||
Emisora(
|
||||
uuid: 'fav-1',
|
||||
nombre: 'Radio Uno',
|
||||
url: 'https://uno.example/stream',
|
||||
grupoFavoritosId: 'g1',
|
||||
bitrate: 192,
|
||||
),
|
||||
];
|
||||
const emisorasCustomFixture = [
|
||||
Emisora(
|
||||
uuid: 'custom-1',
|
||||
nombre: 'Mi Radio',
|
||||
url: 'https://mia.example/stream',
|
||||
),
|
||||
];
|
||||
const alarmasFixture = {
|
||||
'alarmas': [
|
||||
{'id': 'a1', 'hora': 7, 'minuto': 30, 'activa': true, 'snoozeMinutos': 5},
|
||||
],
|
||||
'vacaciones': [
|
||||
{'desde': '2026-07-01', 'hasta': '2026-07-15'},
|
||||
],
|
||||
};
|
||||
|
||||
group('ServicioExportImport (S4-R4)', () {
|
||||
test('v2 round-trip: exportar then importar yields a deep-equal config '
|
||||
'(S4-R4-A, S6-R2 test #4)', () {
|
||||
final config = servicio.construirExportacion(
|
||||
gruposFavoritos: const [
|
||||
GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1),
|
||||
GrupoFavoritos(
|
||||
id: GrupoFavoritos.sinAsignarId,
|
||||
nombre: 'Sin asignar',
|
||||
orden: 0,
|
||||
protegido: true,
|
||||
),
|
||||
],
|
||||
favoritos: const [
|
||||
Emisora(
|
||||
uuid: 'fav-1',
|
||||
nombre: 'Radio Uno',
|
||||
url: 'https://uno.example/stream',
|
||||
grupoFavoritosId: 'g1',
|
||||
bitrate: 192,
|
||||
),
|
||||
],
|
||||
emisorasCustom: const [
|
||||
Emisora(
|
||||
uuid: 'custom-1',
|
||||
nombre: 'Mi Radio',
|
||||
url: 'https://mia.example/stream',
|
||||
),
|
||||
],
|
||||
gruposFavoritos: gruposFavoritosFixture,
|
||||
favoritos: favoritosFixture,
|
||||
emisorasCustom: emisorasCustomFixture,
|
||||
presetPrincipal: PresetEcualizador.jazz,
|
||||
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
|
||||
// Raw alarm block exactly as ServicioAlarmas persists it (alarms +
|
||||
// vacations + exceptions) — must pass through untouched.
|
||||
alarmas: const {
|
||||
'alarmas': [
|
||||
{
|
||||
'id': 'a1',
|
||||
'hora': 7,
|
||||
'minuto': 30,
|
||||
'activa': true,
|
||||
'snoozeMinutos': 5,
|
||||
},
|
||||
],
|
||||
'vacaciones': [
|
||||
{'desde': '2026-07-01', 'hasta': '2026-07-15'},
|
||||
],
|
||||
},
|
||||
alarmas: alarmasFixture,
|
||||
emisoraPreferidaUuid: 'fav-1',
|
||||
ordenListas: 'calidad',
|
||||
timerSuenoPresetsSegundos: const [300, 600, 1800],
|
||||
@@ -80,4 +80,156 @@ void main() {
|
||||
expect(servicio.importar('[1, 2, 3]'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ServicioExportImport v3 — multi-device EQ (Phase 6)', () {
|
||||
test(
|
||||
'6.1-A: v3 export includes schemaVersion 3 and device/matrix preset keys',
|
||||
() {
|
||||
final presetDispositivo = PresetEcualizador.rock;
|
||||
final presetMatriz = PresetEcualizador.jazz;
|
||||
|
||||
final config = servicio.construirExportacion(
|
||||
gruposFavoritos: gruposFavoritosFixture,
|
||||
favoritos: favoritosFixture,
|
||||
emisorasCustom: emisorasCustomFixture,
|
||||
presetPrincipal: PresetEcualizador.flat,
|
||||
presetsPorEmisora: {},
|
||||
alarmas: null,
|
||||
emisoraPreferidaUuid: null,
|
||||
ordenListas: 'calidad',
|
||||
timerSuenoPresetsSegundos: const [300],
|
||||
presetsPorDispositivo: {
|
||||
'bt_a2dp:AA:BB:CC:DD:EE:FF': presetDispositivo,
|
||||
},
|
||||
presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': presetMatriz},
|
||||
eqMultiDeviceEnabled: true,
|
||||
);
|
||||
|
||||
// v3 schema version.
|
||||
expect(config['version'], 3);
|
||||
|
||||
// Device preset map present and correct.
|
||||
expect(config['presetsPorDispositivo'], isA<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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user