2 Commits
Author SHA1 Message Date
FreeTLab d1d4afb88f i18n(eq): translate advanced EQ keys to all 11 remaining locales
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
Add advancedEq* translations for ar, bn, de, fr, hi, id, it, ja, pt,
ru, zh — matching register and style conventions of each locale.
2026-06-27 11:38:45 +02:00
FreeTLab 4632d53eb8 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
2026-06-27 11:33:53 +02:00
47 changed files with 2980 additions and 75 deletions
@@ -6,6 +6,9 @@ import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.Uri import android.net.Uri
import android.media.audiofx.Visualizer import android.media.audiofx.Visualizer
import android.app.AlarmManager import android.app.AlarmManager
@@ -31,6 +34,7 @@ class MainActivity : AudioServiceActivity() {
private val visualizerChannel = "pluriwave/audio_visualizer" private val visualizerChannel = "pluriwave/audio_visualizer"
private val alarmChannel = "pluriwave/alarm_scheduler" private val alarmChannel = "pluriwave/alarm_scheduler"
private val fileActionsChannel = "pluriwave/file_actions" private val fileActionsChannel = "pluriwave/file_actions"
private val audioDevicesChannel = "pluriwave/audio_devices"
private val visualizerPermissionRequestCode = 4821 private val visualizerPermissionRequestCode = 4821
private val notificationPermissionRequestCode = 4822 private val notificationPermissionRequestCode = 4822
private var visualizer: Visualizer? = null private var visualizer: Visualizer? = null
@@ -39,8 +43,16 @@ class MainActivity : AudioServiceActivity() {
private var alarmMethodChannel: MethodChannel? = null private var alarmMethodChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
// Audio devices channel state
private var audioDevicesSink: EventChannel.EventSink? = null
private var audioDeviceCallback: AudioDeviceCallback? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// --- Audio Devices Channel ---
setupAudioDevicesChannel(flutterEngine)
EventChannel( EventChannel(
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
visualizerChannel visualizerChannel
@@ -601,10 +613,149 @@ class MainActivity : AudioServiceActivity() {
} }
} }
// -------------------------------------------------------------------------
// Audio Devices Channel
// -------------------------------------------------------------------------
private fun setupAudioDevicesChannel(flutterEngine: FlutterEngine) {
val messenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(messenger, audioDevicesChannel).setMethodCallHandler { call, result ->
when (call.method) {
"getActiveDevice" -> {
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.getActiveDevice -> $device")
result.success(device)
}
else -> result.notImplemented()
}
}
EventChannel(messenger, audioDevicesChannel).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
audioDevicesSink = events
registerAudioDeviceCallback()
}
override fun onCancel(arguments: Any?) {
unregisterAudioDeviceCallback()
audioDevicesSink = null
}
}
)
}
private fun registerAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val callback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
// Emit the current active output device when something connects.
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.onDevicesAdded active=$device")
mainHandler.post { audioDevicesSink?.success(device) }
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
// Emit the new active device after something disconnects.
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.onDevicesRemoved active=$device")
mainHandler.post { audioDevicesSink?.success(device) }
}
}
audioDeviceCallback = callback
audioManager.registerAudioDeviceCallback(callback, mainHandler)
}
private fun unregisterAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioDeviceCallback?.let { audioManager.unregisterAudioDeviceCallback(it) }
audioDeviceCallback = null
}
/**
* Returns a map describing the current active audio output device.
*
* Device ID format (matches spec):
* - "builtin_speaker" — TYPE_BUILTIN_SPEAKER (2)
* - "wired_headset" — TYPE_WIRED_HEADSET (3) or TYPE_WIRED_HEADPHONES (4)
* - "bt_a2dp:<MAC>" — TYPE_BLUETOOTH_A2DP (8); MAC from AudioDeviceInfo.address
* - "usb_headset:<address>" — TYPE_USB_HEADSET (14)
* - "builtin_speaker" — fallback when API < 23
*
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
*/
private fun getActiveAudioDevice(): Map<String, Any> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker")
}
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
// Priority: BT A2DP > USB headset > wired headset > built-in speaker > unknown
val priorityOrder = listOf(
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
)
val best = outputs
.filter { it.isSink }
.sortedBy { device ->
val idx = priorityOrder.indexOf(device.type)
if (idx == -1) Int.MAX_VALUE else idx
}
.firstOrNull()
return deviceToMap(best)
}
private fun deviceToMap(device: AudioDeviceInfo?): Map<String, Any> {
if (device == null) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker")
}
return when (device.type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER ->
mapOf("id" to "builtin_speaker", "type" to 2, "name" to (device.productName?.toString() ?: "Speaker"))
AudioDeviceInfo.TYPE_WIRED_HEADSET ->
mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headset"))
AudioDeviceInfo.TYPE_WIRED_HEADPHONES ->
mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headphones"))
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> {
val mac = device.address?.takeIf { it.isNotBlank() } ?: "00:00:00:00:00:00"
mapOf(
"id" to "bt_a2dp:$mac",
"type" to 8,
"name" to (device.productName?.toString() ?: "Bluetooth"),
)
}
AudioDeviceInfo.TYPE_USB_HEADSET -> {
val addr = device.address?.takeIf { it.isNotBlank() } ?: device.id.toString()
mapOf(
"id" to "usb_headset:$addr",
"type" to 14,
"name" to (device.productName?.toString() ?: "USB Headset"),
)
}
else ->
mapOf(
"id" to "builtin_speaker",
"type" to device.type,
"name" to (device.productName?.toString() ?: "Unknown"),
)
}
}
// -------------------------------------------------------------------------
override fun onDestroy() { override fun onDestroy() {
if (activeInstance === this) { if (activeInstance === this) {
activeInstance = null activeInstance = null
} }
unregisterAudioDeviceCallback()
stopVisualizer() stopVisualizer()
super.onDestroy() super.onDestroy()
} }
+1
View File
@@ -12,5 +12,6 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
AudioDevicesPlugin.register(with: engineBridge.pluginRegistry.registrar(forPlugin: "AudioDevicesPlugin")!)
} }
} }
+166
View File
@@ -0,0 +1,166 @@
import AVFoundation
import Flutter
/// Platform channel plugin that detects audio output devices on iOS via
/// AVAudioSession route change notifications.
///
/// Channel: "pluriwave/audio_devices"
/// - MethodChannel `getActiveDevice` → Dictionary with `id`, `type`, `name`
/// - EventChannel stream → same Dictionary on route change
///
/// Device ID format (matches spec):
/// "builtin_speaker" — AVAudioSession.Port.builtInSpeaker
/// "wired_headset" — AVAudioSession.Port.headphones / .headsetMic
/// "bt_a2dp:<uid|portName>" — AVAudioSession.Port.bluetoothA2DP
/// "usb_headset:<uid|name>" — AVAudioSession.Port.usbAudio
///
/// iOS uid fallback (spec scenario "iOS uid fallback on uid instability"):
/// Use portType+uid as the primary key; fall back to portType+portName
/// if uid is empty or nil to guarantee a non-empty, non-null key.
class AudioDevicesPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private static let channelName = "pluriwave/audio_devices"
// Type int constants matching the Dart-side protocol (same as Android).
private static let typeBuiltinSpeaker = 2
private static let typeWiredHeadset = 3
private static let typeBluetoothA2dp = 8
private static let typeUsbHeadset = 14
private static let typeUnknown = 0
private var eventSink: FlutterEventSink?
// MARK: - Plugin registration
static func register(with registrar: FlutterPluginRegistrar) {
let messenger = registrar.messenger()
let instance = AudioDevicesPlugin()
let methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
registrar.addMethodCallDelegate(instance, channel: methodChannel)
let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: messenger)
eventChannel.setStreamHandler(instance)
registrar.addApplicationDelegate(instance)
}
// MARK: - MethodChannel
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getActiveDevice":
result(currentDeviceMap())
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - EventChannel (FlutterStreamHandler)
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
NotificationCenter.default.addObserver(
self,
selector: #selector(routeChanged(_:)),
name: AVAudioSession.routeChangeNotification,
object: nil
)
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
NotificationCenter.default.removeObserver(
self,
name: AVAudioSession.routeChangeNotification,
object: nil
)
self.eventSink = nil
return nil
}
// MARK: - Route change notification
@objc private func routeChanged(_ notification: Notification) {
guard let sink = eventSink else { return }
sink(currentDeviceMap())
}
// MARK: - Helpers
/// Returns a dictionary representing the current active audio output route.
private func currentDeviceMap() -> [String: Any] {
let session = AVAudioSession.sharedInstance()
let outputs = session.currentRoute.outputs
// Priority order: BT A2DP > USB > wired > built-in speaker > unknown
let priority: [AVAudioSession.Port] = [
.bluetoothA2DP,
.usbAudio,
.headphones,
.headsetMic,
.builtInSpeaker,
]
let best = outputs.min { a, b in
let ia = priority.firstIndex(of: a.portType) ?? Int.max
let ib = priority.firstIndex(of: b.portType) ?? Int.max
return ia < ib
}
return portToMap(best)
}
/// Converts an AVAudioSessionPortDescription to the channel map format.
private func portToMap(_ port: AVAudioSessionPortDescription?) -> [String: Any] {
guard let port = port else {
return ["id": "builtin_speaker", "type": AudioDevicesPlugin.typeBuiltinSpeaker, "name": "Speaker"]
}
switch port.portType {
case .builtInSpeaker:
return ["id": "builtin_speaker",
"type": AudioDevicesPlugin.typeBuiltinSpeaker,
"name": port.portName]
case .headphones, .headsetMic:
return ["id": "wired_headset",
"type": AudioDevicesPlugin.typeWiredHeadset,
"name": port.portName]
case .bluetoothA2DP:
let key = stableKey(prefix: "bt_a2dp", port: port)
return ["id": key,
"type": AudioDevicesPlugin.typeBluetoothA2dp,
"name": port.portName]
case .usbAudio:
let key = stableKey(prefix: "usb_headset", port: port)
return ["id": key,
"type": AudioDevicesPlugin.typeUsbHeadset,
"name": port.portName]
default:
// Unknown type — use portType string as part of id to avoid empty key.
let id = "unknown:\(port.portType.rawValue)"
return ["id": id,
"type": AudioDevicesPlugin.typeUnknown,
"name": port.portName]
}
}
/// Derives a stable device key using uid (preferred) or portName as fallback.
///
/// Per spec scenario "iOS uid fallback on uid instability": uid may differ
/// across sessions on some BT devices. In that case, use portType+portName.
private func stableKey(prefix: String, port: AVAudioSessionPortDescription) -> String {
let uid = port.uid
if !uid.isEmpty {
return "\(prefix):\(uid)"
}
// uid is empty — fall back to portType+portName (must not be empty per spec).
let name = port.portName.isEmpty ? "unknown" : port.portName
return "\(prefix):\(name)"
}
}
+8 -1
View File
@@ -25,6 +25,7 @@ import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart'; import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart'; import 'servicios/servicio_alarmas_android.dart';
import 'servicios/servicio_dispositivo_audio.dart';
class PluriWaveApp extends StatelessWidget { class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key, this.prefs}); const PluriWaveApp({super.key, this.prefs});
@@ -37,7 +38,13 @@ class PluriWaveApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)), ChangeNotifierProvider(
create:
(_) => EstadoRadio(
prefs: prefs,
dispositivoAudio: ServicioDispositivoAudioReal(),
),
),
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio // Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
// (they need its services and callbacks at construction); these // (they need its services and callbacks at construction); these
// providers only expose the instances, so they declare no dispose // providers only expose the instances, so they declare no dispose
+170 -5
View File
@@ -1,7 +1,11 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../modelos/dispositivo_audio.dart';
import '../modelos/preset_ecualizador.dart'; import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart'; import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_ecualizador.dart';
/// Equalizer state extracted from `EstadoRadio` (S4-R1). /// Equalizer state extracted from `EstadoRadio` (S4-R1).
@@ -11,33 +15,65 @@ import '../servicios/servicio_ecualizador.dart';
/// [ServicioEcualizador] and their application through [ServicioAudio]. /// [ServicioEcualizador] and their application through [ServicioAudio].
/// Notifies ONLY its own listeners — EQ changes must not rebuild /// Notifies ONLY its own listeners — EQ changes must not rebuild
/// `EstadoRadio` consumers (S4-R1-A, S4-R5). /// `EstadoRadio` consumers (S4-R1-A, S4-R5).
///
/// Multi-device EQ extension (Phase 5): when [eqMultiDeviceEnabled] is true,
/// resolves the active preset through a 4-level hierarchy:
/// 1. presetsMatriz["stationUuid:deviceId"]
/// 2. presetsEmisoraMap[stationUuid]
/// 3. presetsDispositivo[deviceId]
/// 4. presetPrincipal
///
/// When the toggle is false, resolution falls back to the original 2-level
/// hierarchy (station → global) — zero behavioral change vs. prior releases.
class EstadoEcualizador extends ChangeNotifier { class EstadoEcualizador extends ChangeNotifier {
EstadoEcualizador({ EstadoEcualizador({
required this.audio, required this.audio,
ServicioEcualizador? servicio, ServicioEcualizador? servicio,
ServicioDispositivoAudio? dispositivoAudio,
String? Function()? emisoraActualUuid, String? Function()? emisoraActualUuid,
}) : servicio = servicio ?? ServicioEcualizador(), }) : servicio = servicio ?? ServicioEcualizador(),
_dispositivoAudio = dispositivoAudio,
_emisoraActualUuid = emisoraActualUuid ?? (() => null); _emisoraActualUuid = emisoraActualUuid ?? (() => null);
final ServicioAudio audio; final ServicioAudio audio;
final ServicioEcualizador servicio; final ServicioEcualizador servicio;
final ServicioDispositivoAudio? _dispositivoAudio;
/// Callback into the owner (EstadoRadio) for the currently playing station; /// Callback into the owner (EstadoRadio) for the currently playing station;
/// keeps this notifier free of any station-list coupling. /// keeps this notifier free of any station-list coupling.
final String? Function() _emisoraActualUuid; final String? Function() _emisoraActualUuid;
final Map<String, PresetEcualizador> _presetsEmisoraMap = {}; final Map<String, PresetEcualizador> _presetsEmisoraMap = {};
/// Per-device presets: deviceId → PresetEcualizador.
final Map<String, PresetEcualizador> _presetsDispositivo = {};
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
final Map<String, PresetEcualizador> _presetsMatriz = {};
PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat;
bool _activo = true; bool _activo = true;
bool _eqMultiDeviceEnabled = false;
String? _dispositivoActualId;
StreamSubscription<DispositivoAudio>? _deviceSub;
PresetEcualizador get presetActual => _presetActual; PresetEcualizador get presetActual => _presetActual;
PresetEcualizador get presetPrincipal => _presetPrincipal; PresetEcualizador get presetPrincipal => _presetPrincipal;
bool get activo => _activo; bool get activo => _activo;
bool get disponible => audio.ecualizadorDisponible; bool get disponible => audio.ecualizadorDisponible;
bool get eqMultiDeviceEnabled => _eqMultiDeviceEnabled;
String? get dispositivoActualId => _dispositivoActualId;
Map<String, PresetEcualizador> get presetsPorEmisora => Map<String, PresetEcualizador> get presetsPorEmisora =>
Map.unmodifiable(_presetsEmisoraMap); Map.unmodifiable(_presetsEmisoraMap);
Map<String, PresetEcualizador> get presetsDispositivo =>
Map.unmodifiable(_presetsDispositivo);
Map<String, PresetEcualizador> get presetsMatriz =>
Map.unmodifiable(_presetsMatriz);
bool get emisoraActualTienePresetPropio { bool get emisoraActualTienePresetPropio {
final uuid = _emisoraActualUuid(); final uuid = _emisoraActualUuid();
if (uuid == null) return false; if (uuid == null) return false;
@@ -52,26 +88,116 @@ class EstadoEcualizador extends ChangeNotifier {
PresetEcualizador presetParaEmisora(String uuid) => PresetEcualizador presetParaEmisora(String uuid) =>
_presetsEmisoraMap[uuid] ?? _presetPrincipal; _presetsEmisoraMap[uuid] ?? _presetPrincipal;
/// 4-level resolution hierarchy (ADR-4, spec requirement).
///
/// When [eqMultiDeviceEnabled] is false, falls back to 2-level (station → global).
PresetEcualizador presetEfectivo({
required String stationUuid,
required String deviceId,
}) {
if (!_eqMultiDeviceEnabled) {
// Original 2-level behavior: station → global.
return _presetsEmisoraMap[stationUuid] ?? _presetPrincipal;
}
// Level 1: station × device matrix.
final matrizKey = '$stationUuid:$deviceId';
if (_presetsMatriz.containsKey(matrizKey)) {
return _presetsMatriz[matrizKey]!;
}
// Level 2: station-only.
if (_presetsEmisoraMap.containsKey(stationUuid)) {
return _presetsEmisoraMap[stationUuid]!;
}
// Level 3: device-only.
if (_presetsDispositivo.containsKey(deviceId)) {
return _presetsDispositivo[deviceId]!;
}
// Level 4: global fallback.
return _presetPrincipal;
}
/// Resolves the effective preset for the current station and device.
PresetEcualizador _resolverPresetActivo() {
final uuid = _emisoraActualUuid();
if (!_eqMultiDeviceEnabled) {
return uuid != null
? (_presetsEmisoraMap[uuid] ?? _presetPrincipal)
: _presetPrincipal;
}
final deviceId = _dispositivoActualId;
if (uuid == null || deviceId == null) {
return _presetPrincipal;
}
return presetEfectivo(stationUuid: uuid, deviceId: deviceId);
}
/// Loads the persisted EQ configuration and applies it to the audio engine. /// Loads the persisted EQ configuration and applies it to the audio engine.
Future<void> cargarPersistido() async { Future<void> cargarPersistido() async {
try { try {
final config = await servicio.cargar(); final config = await servicio.cargar();
_presetPrincipal = config.principal; _presetPrincipal = config.principal;
_presetActual = config.principal;
_activo = config.activo; _activo = config.activo;
_eqMultiDeviceEnabled = config.eqMultiDeviceEnabled;
_presetsEmisoraMap _presetsEmisoraMap
..clear() ..clear()
..addAll(config.porEmisora); ..addAll(config.porEmisora);
_presetsDispositivo
..clear()
..addAll(config.presetsDispositivo);
_presetsMatriz
..clear()
..addAll(config.presetsMatriz);
// Resolve active preset and apply it.
_presetActual = _resolverPresetActivo();
await audio.setEcualizadorActivo(_activo); await audio.setEcualizadorActivo(_activo);
await audio.aplicarPreset(_presetPrincipal); await audio.aplicarPreset(_presetActual);
// Subscribe to device changes only when toggle is on.
_configurarSuscripcionDispositivo();
} catch (_) { } catch (_) {
_presetPrincipal = PresetEcualizador.flat; _presetPrincipal = PresetEcualizador.flat;
_presetActual = PresetEcualizador.flat; _presetActual = PresetEcualizador.flat;
_activo = true; _activo = true;
_eqMultiDeviceEnabled = false;
_presetsEmisoraMap.clear(); _presetsEmisoraMap.clear();
_presetsDispositivo.clear();
_presetsMatriz.clear();
} }
} }
/// Subscribes to the device change stream if multi-device is enabled.
void _configurarSuscripcionDispositivo() {
_deviceSub?.cancel();
_deviceSub = null;
final svc = _dispositivoAudio;
if (!_eqMultiDeviceEnabled || svc == null) return;
_deviceSub = svc.onDispositivoCambiado.listen(_onDispositivoCambiado);
}
/// Called when a device change event arrives.
Future<void> _onDispositivoCambiado(DispositivoAudio dispositivo) async {
if (!_eqMultiDeviceEnabled) return;
_dispositivoActualId = dispositivo.id;
// First-seen device: copy the current resolved preset as its starting point.
if (!_presetsDispositivo.containsKey(dispositivo.id)) {
final presetBase = _resolverPresetActivo();
_presetsDispositivo[dispositivo.id] = presetBase;
await servicio.guardarPresetDispositivo(dispositivo.id, presetBase);
}
// Re-resolve and apply the preset for the new device.
final resuelto = _resolverPresetActivo();
_presetActual = resuelto;
await audio.aplicarPreset(resuelto);
notifyListeners();
}
/// Applies [preset] to the audio engine and tracks it as current /// Applies [preset] to the audio engine and tracks it as current
/// WITHOUT persisting it (used when switching stations). /// WITHOUT persisting it (used when switching stations).
Future<void> aplicarPresetActivo(PresetEcualizador preset) async { Future<void> aplicarPresetActivo(PresetEcualizador preset) async {
@@ -79,6 +205,20 @@ class EstadoEcualizador extends ChangeNotifier {
await audio.aplicarPreset(preset); await audio.aplicarPreset(preset);
} }
/// Enables or disables the multi-device EQ feature toggle.
///
/// When disabled, the device stream subscription is cancelled and resolution
/// immediately collapses to 2-level (station → global).
Future<void> cambiarMultiDeviceEnabled(
bool habilitado, {
bool notificar = true,
}) async {
_eqMultiDeviceEnabled = habilitado;
await servicio.guardarToggleMultiDispositivo(habilitado);
_configurarSuscripcionDispositivo();
if (notificar) notifyListeners();
}
Future<void> cambiarPresetPrincipal( Future<void> cambiarPresetPrincipal(
PresetEcualizador preset, { PresetEcualizador preset, {
bool notificar = true, bool notificar = true,
@@ -184,24 +324,49 @@ class EstadoEcualizador extends ChangeNotifier {
Future<void> importarConfiguracion({ Future<void> importarConfiguracion({
required PresetEcualizador principal, required PresetEcualizador principal,
required Map<String, PresetEcualizador> porEmisora, required Map<String, PresetEcualizador> porEmisora,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
}) async { }) async {
_presetPrincipal = principal; _presetPrincipal = principal;
_presetsEmisoraMap _presetsEmisoraMap
..clear() ..clear()
..addAll(porEmisora); ..addAll(porEmisora);
if (presetsDispositivo != null) {
_presetsDispositivo
..clear()
..addAll(presetsDispositivo);
}
if (presetsMatriz != null) {
_presetsMatriz
..clear()
..addAll(presetsMatriz);
}
if (eqMultiDeviceEnabled != null) {
_eqMultiDeviceEnabled = eqMultiDeviceEnabled;
}
await servicio.guardarConfiguracion( await servicio.guardarConfiguracion(
ConfiguracionEcualizador( ConfiguracionEcualizador(
principal: _presetPrincipal, principal: _presetPrincipal,
porEmisora: _presetsEmisoraMap, porEmisora: _presetsEmisoraMap,
activo: _activo, activo: _activo,
eqMultiDeviceEnabled: _eqMultiDeviceEnabled,
presetsDispositivo: _presetsDispositivo,
presetsMatriz: _presetsMatriz,
), ),
); );
final uuid = _emisoraActualUuid(); final uuid = _emisoraActualUuid();
await aplicarPresetActivo( final presetEfectivoActual =
uuid == null ? _presetPrincipal : presetParaEmisora(uuid), uuid == null ? _presetPrincipal : _resolverPresetActivo();
); await aplicarPresetActivo(presetEfectivoActual);
notifyListeners(); notifyListeners();
} }
@override
void dispose() {
_deviceSub?.cancel();
super.dispose();
}
} }
+51 -4
View File
@@ -17,6 +17,7 @@ import 'estado_ecualizador.dart';
import 'estado_grabacion.dart'; import 'estado_grabacion.dart';
import 'orden_emisoras.dart'; import 'orden_emisoras.dart';
import '../servicios/servicio_audio.dart'; import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_export_import.dart'; import '../servicios/servicio_export_import.dart';
import '../servicios/servicio_favoritos.dart'; import '../servicios/servicio_favoritos.dart';
@@ -38,6 +39,7 @@ class EstadoRadio extends ChangeNotifier {
ServicioFavoritos? favoritos, ServicioFavoritos? favoritos,
ServicioRadio? radio, ServicioRadio? radio,
ServicioEcualizador? servicioEcualizador, ServicioEcualizador? servicioEcualizador,
ServicioDispositivoAudio? dispositivoAudio,
ServicioGrabacionRadio? servicioGrabacion, ServicioGrabacionRadio? servicioGrabacion,
SharedPreferences? prefs, SharedPreferences? prefs,
Future<File> Function()? resolverArchivoCustom, Future<File> Function()? resolverArchivoCustom,
@@ -47,11 +49,13 @@ class EstadoRadio extends ChangeNotifier {
radio = radio ?? ServicioRadio(), radio = radio ?? ServicioRadio(),
servicioEcualizador = servicioEcualizador =
servicioEcualizador ?? ServicioEcualizador(prefs: prefs), servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
_dispositivoAudio = dispositivoAudio,
_prefs = prefs, _prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom { _resolverArchivoCustom = resolverArchivoCustom {
ecualizador = EstadoEcualizador( ecualizador = EstadoEcualizador(
audio: this.audio, audio: this.audio,
servicio: this.servicioEcualizador, servicio: this.servicioEcualizador,
dispositivoAudio: _dispositivoAudio,
emisoraActualUuid: () => emisoraActual?.uuid, emisoraActualUuid: () => emisoraActual?.uuid,
); );
grabacion = EstadoGrabacion( grabacion = EstadoGrabacion(
@@ -77,6 +81,10 @@ class EstadoRadio extends ChangeNotifier {
final ServicioRadio radio; final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador; final ServicioEcualizador servicioEcualizador;
/// Optional device service — null means no device subscription established.
/// Wired in production from app.dart; defaults to null in tests.
final ServicioDispositivoAudio? _dispositivoAudio;
/// Domain notifiers extracted from this class (S4). Created and disposed /// Domain notifiers extracted from this class (S4). Created and disposed
/// here (they need EstadoRadio's services and callbacks at construction); /// here (they need EstadoRadio's services and callbacks at construction);
/// exposed app-wide through ListenableProviders in app.dart. /// exposed app-wide through ListenableProviders in app.dart.
@@ -547,8 +555,9 @@ class EstadoRadio extends ChangeNotifier {
static const _keyAlarmasConfig = 'alarmas_musicales_v1'; static const _keyAlarmasConfig = 'alarmas_musicales_v1';
/// Genera el JSON de toda la configuración (v2 — portabilidad completa). /// Genera el JSON de toda la configuración (v3 — portabilidad completa
/// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4). /// con presets por dispositivo y matriz multi-device).
/// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4).
Future<Map<String, dynamic>> exportarConfig() async { Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos(); final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos(); final grupos = await favoritos.obtenerGrupos();
@@ -572,6 +581,10 @@ class EstadoRadio extends ChangeNotifier {
emisoraPreferidaUuid: _emisoraPreferidaUuid, emisoraPreferidaUuid: _emisoraPreferidaUuid,
ordenListas: _ordenListas.name, ordenListas: _ordenListas.name,
timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos, timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos,
// v3 extensions — multi-device EQ fields.
presetsPorDispositivo: ecualizador.presetsDispositivo,
presetsMatriz: ecualizador.presetsMatriz,
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled,
); );
} }
@@ -584,10 +597,11 @@ class EstadoRadio extends ChangeNotifier {
_exportImport.importar(raw); _exportImport.importar(raw);
/// Importa configuración desde un JSON exportado previamente. /// Importa configuración desde un JSON exportado previamente.
/// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa). /// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa),
/// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device).
Future<void> importarConfig(Map<String, dynamic> data) async { Future<void> importarConfig(Map<String, dynamic> data) async {
final version = data['version'] as int? ?? 1; final version = data['version'] as int? ?? 1;
if (version > 2) throw Exception(_textos.unsupportedConfigVersion); if (version > 3) throw Exception(_textos.unsupportedConfigVersion);
final prefs = await _resolverPrefs(); final prefs = await _resolverPrefs();
@@ -637,9 +651,41 @@ class EstadoRadio extends ChangeNotifier {
), ),
); );
// v3 extensions: per-device and matrix presets.
Map<String, PresetEcualizador>? presetsDispositivo;
Map<String, PresetEcualizador>? presetsMatriz;
bool? eqMultiDeviceEnabled;
if (version >= 3) {
final dispositivoRaw = data['presetsPorDispositivo'] as Map? ?? {};
presetsDispositivo = dispositivoRaw.map<String, PresetEcualizador>(
(deviceId, presetJson) => MapEntry(
deviceId as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
final matrizRaw = data['presetsMatriz'] as Map? ?? {};
presetsMatriz = matrizRaw.map<String, PresetEcualizador>(
(clave, presetJson) => MapEntry(
clave as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?;
}
await ecualizador.importarConfiguracion( await ecualizador.importarConfiguracion(
principal: presetPrincipal, principal: presetPrincipal,
porEmisora: presetsPorEmisora, porEmisora: presetsPorEmisora,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
); );
// ── Alarmas (v2) ────────────────────────────────────────────────────── // ── Alarmas (v2) ──────────────────────────────────────────────────────
@@ -748,6 +794,7 @@ class EstadoRadio extends ChangeNotifier {
grabacion.dispose(); grabacion.dispose();
audio.dispose(); audio.dispose();
timer.dispose(); timer.dispose();
_dispositivoAudio?.dispose();
super.dispose(); super.dispose();
} }
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, one{محطة واحدة} two{محطتان} few{{count} محطات} other{{count} محطة}}", "stationCount": "{count, plural, one{محطة واحدة} two{محطتان} few{{count} محطات} other{{count} محطة}}",
"alarmIconLabel": "منبه موسيقي", "alarmIconLabel": "منبه موسيقي",
"vacationIconLabel": "وضع الإجازة", "vacationIconLabel": "وضع الإجازة",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "خيارات المعادلة الصوتية المتقدمة",
"advancedEqEnableToggle": "تفعيل EQ لكل جهاز",
"advancedEqEnableToggleSubtitle": "تطبيق إعداد EQ مختلف تلقائيًا عند تغيير مخرج الصوت.",
"advancedEqKnownDevicesTitle": "أجهزة الصوت المعروفة",
"advancedEqKnownDevicesEmpty": "لم يتم تسجيل أي جهاز صوتي بعد. قم بتوصيل جهاز للبدء.",
"advancedEqDevicePresetLabel": "الإعداد المسبق: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, =1{১টি স্টেশন} other{{count}টি স্টেশন}}", "stationCount": "{count, plural, =1{১টি স্টেশন} other{{count}টি স্টেশন}}",
"alarmIconLabel": "মিউজিক অ্যালার্ম", "alarmIconLabel": "মিউজিক অ্যালার্ম",
"vacationIconLabel": "ছুটির মোড", "vacationIconLabel": "ছুটির মোড",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "উন্নত EQ বিকল্পসমূহ",
"advancedEqEnableToggle": "প্রতিটি ডিভাইসের জন্য EQ সক্রিয় করুন",
"advancedEqEnableToggleSubtitle": "অডিও আউটপুট পরিবর্তিত হলে স্বয়ংক্রিয়ভাবে ভিন্ন EQ প্রিসেট প্রয়োগ করুন।",
"advancedEqKnownDevicesTitle": "পরিচিত অডিও ডিভাইস",
"advancedEqKnownDevicesEmpty": "এখনো কোনো অডিও ডিভাইস নথিভুক্ত হয়নি। শুরু করতে একটি ডিভাইস সংযুক্ত করুন।",
"advancedEqDevicePresetLabel": "প্রিসেট: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, =1{1 Sender} other{{count} Sender}}", "stationCount": "{count, plural, =1{1 Sender} other{{count} Sender}}",
"alarmIconLabel": "Musikwecker", "alarmIconLabel": "Musikwecker",
"vacationIconLabel": "Urlaubsmodus", "vacationIconLabel": "Urlaubsmodus",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "Erweiterte EQ-Optionen",
"advancedEqEnableToggle": "Geräteindividuellen EQ aktivieren",
"advancedEqEnableToggleSubtitle": "Wendet automatisch ein anderes EQ-Preset an, wenn du den Audioausgang wechselst.",
"advancedEqKnownDevicesTitle": "Bekannte Audiogeräte",
"advancedEqKnownDevicesEmpty": "Noch keine Audiogeräte gespeichert. Verbinde ein Gerät, um loszulegen.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}", "stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
"alarmIconLabel": "Musical alarm", "alarmIconLabel": "Musical alarm",
"vacationIconLabel": "Vacation mode", "vacationIconLabel": "Vacation mode",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "Advanced Equalization Options",
"advancedEqEnableToggle": "Enable per-device EQ",
"advancedEqEnableToggleSubtitle": "Automatically apply a different EQ preset when your audio output changes.",
"advancedEqKnownDevicesTitle": "Known audio devices",
"advancedEqKnownDevicesEmpty": "No audio devices recorded yet. Connect a device to get started.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+11
View File
@@ -579,5 +579,16 @@
"type": "int" "type": "int"
} }
} }
},
"advancedEqSectionTitle": "Opciones avanzadas de ecualización",
"advancedEqEnableToggle": "Activar EQ por dispositivo",
"advancedEqEnableToggleSubtitle": "Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.",
"advancedEqKnownDevicesTitle": "Dispositivos de audio conocidos",
"advancedEqKnownDevicesEmpty": "No hay dispositivos registrados. Conectá uno para empezar.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
} }
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}", "stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
"alarmIconLabel": "Alarme musicale", "alarmIconLabel": "Alarme musicale",
"vacationIconLabel": "Mode vacances", "vacationIconLabel": "Mode vacances",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "Options d'égalisation avancées",
"advancedEqEnableToggle": "Activer l'EQ par appareil",
"advancedEqEnableToggleSubtitle": "Appliquez automatiquement un preset EQ différent lorsque votre sortie audio change.",
"advancedEqKnownDevicesTitle": "Appareils audio connus",
"advancedEqKnownDevicesEmpty": "Aucun appareil audio enregistré pour l'instant. Connectez un appareil pour commencer.",
"advancedEqDevicePresetLabel": "Preset : {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, =1{1 स्टेशन} other{{count} स्टेशन}}", "stationCount": "{count, plural, =1{1 स्टेशन} other{{count} स्टेशन}}",
"alarmIconLabel": "संगीत अलार्म", "alarmIconLabel": "संगीत अलार्म",
"vacationIconLabel": "अवकाश मोड", "vacationIconLabel": "अवकाश मोड",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "उन्नत EQ विकल्प",
"advancedEqEnableToggle": "प्रति-डिवाइस EQ सक्षम करें",
"advancedEqEnableToggleSubtitle": "ऑडियो आउटपुट बदलने पर स्वचालित रूप से अलग EQ प्रीसेट लागू करें।",
"advancedEqKnownDevicesTitle": "ज्ञात ऑडियो डिवाइस",
"advancedEqKnownDevicesEmpty": "अभी तक कोई ऑडियो डिवाइस दर्ज नहीं हुई। शुरू करने के लिए कोई डिवाइस कनेक्ट करें।",
"advancedEqDevicePresetLabel": "प्रीसेट: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, other{{count} stasiun}}", "stationCount": "{count, plural, other{{count} stasiun}}",
"alarmIconLabel": "Alarm musik", "alarmIconLabel": "Alarm musik",
"vacationIconLabel": "Mode liburan", "vacationIconLabel": "Mode liburan",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "Opsi EQ Lanjutan",
"advancedEqEnableToggle": "Aktifkan EQ per perangkat",
"advancedEqEnableToggleSubtitle": "Terapkan preset EQ yang berbeda secara otomatis saat keluaran audio Anda berubah.",
"advancedEqKnownDevicesTitle": "Perangkat audio yang dikenal",
"advancedEqKnownDevicesEmpty": "Belum ada perangkat audio yang tercatat. Hubungkan perangkat untuk memulai.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, =1{1 stazione} other{{count} stazioni}}", "stationCount": "{count, plural, =1{1 stazione} other{{count} stazioni}}",
"alarmIconLabel": "Sveglia musicale", "alarmIconLabel": "Sveglia musicale",
"vacationIconLabel": "Modalità vacanza", "vacationIconLabel": "Modalità vacanza",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "Opzioni EQ avanzate",
"advancedEqEnableToggle": "Abilita EQ per dispositivo",
"advancedEqEnableToggleSubtitle": "Applica automaticamente un preset EQ diverso quando cambi l'uscita audio.",
"advancedEqKnownDevicesTitle": "Dispositivi audio conosciuti",
"advancedEqKnownDevicesEmpty": "Nessun dispositivo audio registrato. Collega un dispositivo per iniziare.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, other{{count}局}}", "stationCount": "{count, plural, other{{count}局}}",
"alarmIconLabel": "ミュージックアラーム", "alarmIconLabel": "ミュージックアラーム",
"vacationIconLabel": "休暇モード", "vacationIconLabel": "休暇モード",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "高度なEQオプション",
"advancedEqEnableToggle": "デバイスごとのEQを有効にする",
"advancedEqEnableToggleSubtitle": "オーディオ出力が変わると、自動的に別のEQプリセットを適用します。",
"advancedEqKnownDevicesTitle": "登録済みオーディオデバイス",
"advancedEqKnownDevicesEmpty": "まだオーディオデバイスが登録されていません。デバイスを接続して始めましょう。",
"advancedEqDevicePresetLabel": "プリセット: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, =1{1 estação} other{{count} estações}}", "stationCount": "{count, plural, =1{1 estação} other{{count} estações}}",
"alarmIconLabel": "Alarme musical", "alarmIconLabel": "Alarme musical",
"vacationIconLabel": "Modo férias", "vacationIconLabel": "Modo férias",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "Opções avançadas de equalização",
"advancedEqEnableToggle": "Ativar EQ por dispositivo",
"advancedEqEnableToggleSubtitle": "Aplica automaticamente um preset de EQ diferente quando a saída de áudio muda.",
"advancedEqKnownDevicesTitle": "Dispositivos de áudio conhecidos",
"advancedEqKnownDevicesEmpty": "Nenhum dispositivo de áudio registrado ainda. Conecte um dispositivo para começar.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, one{{count} станция} few{{count} станции} other{{count} станций}}", "stationCount": "{count, plural, one{{count} станция} few{{count} станции} other{{count} станций}}",
"alarmIconLabel": "Музыкальный будильник", "alarmIconLabel": "Музыкальный будильник",
"vacationIconLabel": "Режим отпуска", "vacationIconLabel": "Режим отпуска",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "Расширенные настройки EQ",
"advancedEqEnableToggle": "Включить EQ для каждого устройства",
"advancedEqEnableToggleSubtitle": "Автоматически применять другой пресет EQ при смене аудиовыхода.",
"advancedEqKnownDevicesTitle": "Известные аудиоустройства",
"advancedEqKnownDevicesEmpty": "Аудиоустройства пока не зарегистрированы. Подключите устройство, чтобы начать.",
"advancedEqDevicePresetLabel": "Пресет: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, other{{count} 个电台}}", "stationCount": "{count, plural, other{{count} 个电台}}",
"alarmIconLabel": "音乐闹钟", "alarmIconLabel": "音乐闹钟",
"vacationIconLabel": "假期模式", "vacationIconLabel": "假期模式",
"streamUrlHint": "https://stream.example.com:8000/radio" "streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "高级均衡器选项",
"advancedEqEnableToggle": "启用每设备 EQ",
"advancedEqEnableToggleSubtitle": "当音频输出更改时,自动应用不同的 EQ 预设。",
"advancedEqKnownDevicesTitle": "已知音频设备",
"advancedEqKnownDevicesEmpty": "尚未记录任何音频设备。连接设备以开始使用。",
"advancedEqDevicePresetLabel": "预设:{presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
} }
+36
View File
@@ -2269,6 +2269,42 @@ abstract class AppLocalizations {
/// In es, this message translates to: /// In es, this message translates to:
/// **'https://stream.example.com:8000/radio'** /// **'https://stream.example.com:8000/radio'**
String get streamUrlHint; String get streamUrlHint;
/// No description provided for @advancedEqSectionTitle.
///
/// In es, this message translates to:
/// **'Opciones avanzadas de ecualización'**
String get advancedEqSectionTitle;
/// No description provided for @advancedEqEnableToggle.
///
/// In es, this message translates to:
/// **'Activar EQ por dispositivo'**
String get advancedEqEnableToggle;
/// No description provided for @advancedEqEnableToggleSubtitle.
///
/// In es, this message translates to:
/// **'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'**
String get advancedEqEnableToggleSubtitle;
/// No description provided for @advancedEqKnownDevicesTitle.
///
/// In es, this message translates to:
/// **'Dispositivos de audio conocidos'**
String get advancedEqKnownDevicesTitle;
/// No description provided for @advancedEqKnownDevicesEmpty.
///
/// In es, this message translates to:
/// **'No hay dispositivos registrados. Conectá uno para empezar.'**
String get advancedEqKnownDevicesEmpty;
/// No description provided for @advancedEqDevicePresetLabel.
///
/// In es, this message translates to:
/// **'Preset: {presetName}'**
String advancedEqDevicePresetLabel(Object presetName);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+22
View File
@@ -1225,4 +1225,26 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'خيارات المعادلة الصوتية المتقدمة';
@override
String get advancedEqEnableToggle => 'تفعيل EQ لكل جهاز';
@override
String get advancedEqEnableToggleSubtitle =>
'تطبيق إعداد EQ مختلف تلقائيًا عند تغيير مخرج الصوت.';
@override
String get advancedEqKnownDevicesTitle => 'أجهزة الصوت المعروفة';
@override
String get advancedEqKnownDevicesEmpty =>
'لم يتم تسجيل أي جهاز صوتي بعد. قم بتوصيل جهاز للبدء.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'الإعداد المسبق: $presetName';
}
} }
+22
View File
@@ -1232,4 +1232,26 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'উন্নত EQ বিকল্পসমূহ';
@override
String get advancedEqEnableToggle => 'প্রতিটি ডিভাইসের জন্য EQ সক্রিয় করুন';
@override
String get advancedEqEnableToggleSubtitle =>
'অডিও আউটপুট পরিবর্তিত হলে স্বয়ংক্রিয়ভাবে ভিন্ন EQ প্রিসেট প্রয়োগ করুন।';
@override
String get advancedEqKnownDevicesTitle => 'পরিচিত অডিও ডিভাইস';
@override
String get advancedEqKnownDevicesEmpty =>
'এখনো কোনো অডিও ডিভাইস নথিভুক্ত হয়নি। শুরু করতে একটি ডিভাইস সংযুক্ত করুন।';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'প্রিসেট: $presetName';
}
} }
+22
View File
@@ -1242,4 +1242,26 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Erweiterte EQ-Optionen';
@override
String get advancedEqEnableToggle => 'Geräteindividuellen EQ aktivieren';
@override
String get advancedEqEnableToggleSubtitle =>
'Wendet automatisch ein anderes EQ-Preset an, wenn du den Audioausgang wechselst.';
@override
String get advancedEqKnownDevicesTitle => 'Bekannte Audiogeräte';
@override
String get advancedEqKnownDevicesEmpty =>
'Noch keine Audiogeräte gespeichert. Verbinde ein Gerät, um loszulegen.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
} }
+22
View File
@@ -1228,4 +1228,26 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Advanced Equalization Options';
@override
String get advancedEqEnableToggle => 'Enable per-device EQ';
@override
String get advancedEqEnableToggleSubtitle =>
'Automatically apply a different EQ preset when your audio output changes.';
@override
String get advancedEqKnownDevicesTitle => 'Known audio devices';
@override
String get advancedEqKnownDevicesEmpty =>
'No audio devices recorded yet. Connect a device to get started.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
} }
+22
View File
@@ -1237,4 +1237,26 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
} }
+22
View File
@@ -1247,4 +1247,26 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Options d\'égalisation avancées';
@override
String get advancedEqEnableToggle => 'Activer l\'EQ par appareil';
@override
String get advancedEqEnableToggleSubtitle =>
'Appliquez automatiquement un preset EQ différent lorsque votre sortie audio change.';
@override
String get advancedEqKnownDevicesTitle => 'Appareils audio connus';
@override
String get advancedEqKnownDevicesEmpty =>
'Aucun appareil audio enregistré pour l\'instant. Connectez un appareil pour commencer.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset : $presetName';
}
} }
+22
View File
@@ -1231,4 +1231,26 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'उन्नत EQ विकल्प';
@override
String get advancedEqEnableToggle => 'प्रति-डिवाइस EQ सक्षम करें';
@override
String get advancedEqEnableToggleSubtitle =>
'ऑडियो आउटपुट बदलने पर स्वचालित रूप से अलग EQ प्रीसेट लागू करें।';
@override
String get advancedEqKnownDevicesTitle => 'ज्ञात ऑडियो डिवाइस';
@override
String get advancedEqKnownDevicesEmpty =>
'अभी तक कोई ऑडियो डिवाइस दर्ज नहीं हुई। शुरू करने के लिए कोई डिवाइस कनेक्ट करें।';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'प्रीसेट: $presetName';
}
} }
+22
View File
@@ -1236,4 +1236,26 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opsi EQ Lanjutan';
@override
String get advancedEqEnableToggle => 'Aktifkan EQ per perangkat';
@override
String get advancedEqEnableToggleSubtitle =>
'Terapkan preset EQ yang berbeda secara otomatis saat keluaran audio Anda berubah.';
@override
String get advancedEqKnownDevicesTitle => 'Perangkat audio yang dikenal';
@override
String get advancedEqKnownDevicesEmpty =>
'Belum ada perangkat audio yang tercatat. Hubungkan perangkat untuk memulai.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
} }
+22
View File
@@ -1242,4 +1242,26 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opzioni EQ avanzate';
@override
String get advancedEqEnableToggle => 'Abilita EQ per dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Applica automaticamente un preset EQ diverso quando cambi l\'uscita audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivi audio conosciuti';
@override
String get advancedEqKnownDevicesEmpty =>
'Nessun dispositivo audio registrato. Collega un dispositivo per iniziare.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
} }
+22
View File
@@ -1192,4 +1192,26 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => '高度なEQオプション';
@override
String get advancedEqEnableToggle => 'デバイスごとのEQを有効にする';
@override
String get advancedEqEnableToggleSubtitle =>
'オーディオ出力が変わると、自動的に別のEQプリセットを適用します。';
@override
String get advancedEqKnownDevicesTitle => '登録済みオーディオデバイス';
@override
String get advancedEqKnownDevicesEmpty =>
'まだオーディオデバイスが登録されていません。デバイスを接続して始めましょう。';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'プリセット: $presetName';
}
} }
+22
View File
@@ -1234,4 +1234,26 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opções avançadas de equalização';
@override
String get advancedEqEnableToggle => 'Ativar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automaticamente um preset de EQ diferente quando a saída de áudio muda.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de áudio conhecidos';
@override
String get advancedEqKnownDevicesEmpty =>
'Nenhum dispositivo de áudio registrado ainda. Conecte um dispositivo para começar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
} }
+22
View File
@@ -1238,4 +1238,26 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Расширенные настройки EQ';
@override
String get advancedEqEnableToggle => 'Включить EQ для каждого устройства';
@override
String get advancedEqEnableToggleSubtitle =>
'Автоматически применять другой пресет EQ при смене аудиовыхода.';
@override
String get advancedEqKnownDevicesTitle => 'Известные аудиоустройства';
@override
String get advancedEqKnownDevicesEmpty =>
'Аудиоустройства пока не зарегистрированы. Подключите устройство, чтобы начать.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Пресет: $presetName';
}
} }
+20
View File
@@ -1187,4 +1187,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get streamUrlHint => 'https://stream.example.com:8000/radio'; String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => '高级均衡器选项';
@override
String get advancedEqEnableToggle => '启用每设备 EQ';
@override
String get advancedEqEnableToggleSubtitle => '当音频输出更改时,自动应用不同的 EQ 预设。';
@override
String get advancedEqKnownDevicesTitle => '已知音频设备';
@override
String get advancedEqKnownDevicesEmpty => '尚未记录任何音频设备。连接设备以开始使用。';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return '预设:$presetName';
}
} }
+47
View File
@@ -0,0 +1,47 @@
/// Audio device types detected via platform channel.
enum TipoDispositivo {
/// Built-in speaker — id: "builtin_speaker"
altavozInterno,
/// Wired headset or headphones — id: "wired_headset"
auricularesCable,
/// Bluetooth A2DP device — id: `"bt_a2dp:<MAC>"`
bluetoothA2dp,
/// USB audio device — id: `"usb_headset:<address>"`
usbAudio,
/// Unrecognized device type.
desconocido,
}
/// Represents an audio output device detected on the current platform.
///
/// Equality is based on [id] only so that two instances referring to the
/// same physical device compare as equal regardless of display name.
class DispositivoAudio {
final String id;
final TipoDispositivo tipo;
final String nombre;
const DispositivoAudio({
required this.id,
required this.tipo,
required this.nombre,
});
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DispositivoAudio) return false;
return id == other.id;
}
@override
int get hashCode => id.hashCode;
@override
String toString() =>
'DispositivoAudio(id: $id, tipo: $tipo, nombre: $nombre)';
}
+122
View File
@@ -60,6 +60,8 @@ class _AjustesContent extends StatelessWidget {
children: const [ children: const [
_SeccionEcualizador(), _SeccionEcualizador(),
SizedBox(height: 12), SizedBox(height: 12),
_SeccionEcualizadorAvanzado(),
SizedBox(height: 12),
_SeccionGrabaciones(), _SeccionGrabaciones(),
SizedBox(height: 12), SizedBox(height: 12),
_SeccionTimerSueno(), _SeccionTimerSueno(),
@@ -652,6 +654,126 @@ class _SeccionEcualizador extends StatelessWidget {
} }
} }
/// "Advanced Equalization Options" settings section (Phase 7, multi-device EQ).
///
/// Always shows the feature toggle so the user can discover it. When the
/// toggle is OFF, the device list is completely absent (not just invisible),
/// matching the spec scenario "Settings section is absent when toggle is off".
class _SeccionEcualizadorAvanzado extends StatelessWidget {
const _SeccionEcualizadorAvanzado();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final multiDeviceEnabled = eq.eqMultiDeviceEnabled;
final presetsDispositivo = eq.presetsDispositivo;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.devices_rounded),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.advancedEqSectionTitle,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
// The toggle uses GestureDetector + custom row instead of
// SwitchListTile to avoid Material ink assertion inside
// PluriGlassSurface's DecoratedBox. The visual result is identical
// to SwitchListTile.
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap:
() => eq.cambiarMultiDeviceEnabled(!multiDeviceEnabled),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.advancedEqEnableToggle,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
l10n.advancedEqEnableToggleSubtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
Switch.adaptive(
value: multiDeviceEnabled,
onChanged:
(habilitado) => eq.cambiarMultiDeviceEnabled(habilitado),
),
],
),
),
if (multiDeviceEnabled) ...[
const SizedBox(height: 8),
Text(
l10n.advancedEqKnownDevicesTitle,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
if (presetsDispositivo.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
l10n.advancedEqKnownDevicesEmpty,
style: Theme.of(context).textTheme.bodySmall,
),
)
else
for (final entry in presetsDispositivo.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
const Icon(Icons.headphones_rounded, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.key,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
l10n.advancedEqDevicePresetLabel(
entry.value.nombre,
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
],
],
),
);
}
}
class _SeccionOrdenListas extends StatelessWidget { class _SeccionOrdenListas extends StatelessWidget {
const _SeccionOrdenListas(); const _SeccionOrdenListas();
@@ -0,0 +1,112 @@
import 'dart:async';
import 'package:flutter/services.dart';
import '../modelos/dispositivo_audio.dart';
/// Abstract service for audio device detection.
///
/// Implementations:
/// - [ServicioDispositivoAudioReal] — platform channel (Android/iOS)
/// - `FakeServicioDispositivoAudio` (test/helpers/fakes.dart) — for unit tests
abstract class ServicioDispositivoAudio {
/// The last known active audio output device, or null if none detected yet.
DispositivoAudio? get dispositivoActual;
/// A broadcast stream that emits whenever the active audio output device
/// changes (connect or disconnect event from the platform).
Stream<DispositivoAudio> get onDispositivoCambiado;
/// Requests the current active device from the platform synchronously
/// (method channel round-trip). Returns the cached value if already known.
Future<DispositivoAudio> obtenerDispositivoActual();
/// Cancels the device-change subscription and releases resources.
Future<void> dispose();
}
/// Platform channel implementation of [ServicioDispositivoAudio].
///
/// Talks to `pluriwave/audio_devices` on Android (Kotlin) and iOS (Swift).
/// Type int constants (from platform channel protocol):
/// 2 → altavozInterno (builtin_speaker)
/// 3 → auricularesCable (wired_headset)
/// 8 → bluetoothA2dp (`bt_a2dp:<MAC>`)
/// 14 → usbAudio (`usb_headset:<address>`)
class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
static const _channelName = 'pluriwave/audio_devices';
final MethodChannel _methodChannel;
final EventChannel _eventChannel;
final _controller = StreamController<DispositivoAudio>.broadcast();
StreamSubscription<dynamic>? _eventSub;
DispositivoAudio? _dispositivoActual;
ServicioDispositivoAudioReal({
MethodChannel? methodChannel,
EventChannel? eventChannel,
}) : _methodChannel = methodChannel ?? const MethodChannel(_channelName),
_eventChannel = eventChannel ?? const EventChannel(_channelName) {
_subscribeToEvents();
}
void _subscribeToEvents() {
_eventSub = _eventChannel.receiveBroadcastStream().listen(
(dynamic event) {
if (event is Map) {
final device = _mapToDispositivo(Map<String, dynamic>.from(event));
_dispositivoActual = device;
_controller.add(device);
}
},
onError: (_) {
/* swallow platform errors; stream continues */
},
cancelOnError: false,
);
}
@override
DispositivoAudio? get dispositivoActual => _dispositivoActual;
@override
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
@override
Future<DispositivoAudio> obtenerDispositivoActual() async {
final raw = await _methodChannel.invokeMethod<Map>('getActiveDevice');
final map = Map<String, dynamic>.from(raw ?? {});
final device = _mapToDispositivo(map);
_dispositivoActual = device;
return device;
}
@override
Future<void> dispose() async {
await _eventSub?.cancel();
await _controller.close();
}
static DispositivoAudio _mapToDispositivo(Map<String, dynamic> map) {
final id = map['id'] as String? ?? 'builtin_speaker';
final type = map['type'] as int? ?? 2;
final name = map['name'] as String? ?? '';
return DispositivoAudio(id: id, tipo: _tipoDesdeInt(type), nombre: name);
}
static TipoDispositivo _tipoDesdeInt(int type) {
switch (type) {
case 2:
return TipoDispositivo.altavozInterno;
case 3:
return TipoDispositivo.auricularesCable;
case 8:
return TipoDispositivo.bluetoothA2dp;
case 14:
return TipoDispositivo.usbAudio;
default:
return TipoDispositivo.desconocido;
}
}
}
+100
View File
@@ -9,11 +9,23 @@ class ConfiguracionEcualizador {
required this.principal, required this.principal,
required this.porEmisora, required this.porEmisora,
this.activo = true, this.activo = true,
this.eqMultiDeviceEnabled = false,
this.presetsDispositivo = const {},
this.presetsMatriz = const {},
}); });
final PresetEcualizador principal; final PresetEcualizador principal;
final Map<String, PresetEcualizador> porEmisora; final Map<String, PresetEcualizador> porEmisora;
final bool activo; final bool activo;
/// Feature toggle: when false all multi-device logic is bypassed.
final bool eqMultiDeviceEnabled;
/// Per-device presets: deviceId → PresetEcualizador.
final Map<String, PresetEcualizador> presetsDispositivo;
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
final Map<String, PresetEcualizador> presetsMatriz;
} }
class ServicioEcualizador { class ServicioEcualizador {
@@ -22,6 +34,9 @@ class ServicioEcualizador {
static const _keyPresetPrincipal = 'eq_preset_principal_v1'; static const _keyPresetPrincipal = 'eq_preset_principal_v1';
static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1'; static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1';
static const _keyActivo = 'eq_activo_v1'; static const _keyActivo = 'eq_activo_v1';
static const _keyMultiDeviceEnabled = 'eq_multi_device_enabled_v1';
static const _keyPresetsPorDispositivo = 'eq_preset_por_dispositivo_v1';
static const _keyPresetsMatriz = 'eq_presets_matriz_v1';
final SharedPreferences? _prefs; final SharedPreferences? _prefs;
@@ -33,10 +48,15 @@ class ServicioEcualizador {
final prefs = await _resolverPrefs(); final prefs = await _resolverPrefs();
final principal = _leerPresetPrincipal(prefs); final principal = _leerPresetPrincipal(prefs);
final porEmisora = _leerPresetsPorEmisora(prefs); final porEmisora = _leerPresetsPorEmisora(prefs);
final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
return ConfiguracionEcualizador( return ConfiguracionEcualizador(
principal: principal, principal: principal,
porEmisora: porEmisora, porEmisora: porEmisora,
activo: prefs.getBool(_keyActivo) ?? true, activo: prefs.getBool(_keyActivo) ?? true,
eqMultiDeviceEnabled: prefs.getBool(_keyMultiDeviceEnabled) ?? false,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
); );
} }
@@ -72,6 +92,86 @@ class ServicioEcualizador {
); );
await _guardarPresetsPorEmisora(prefs, config.porEmisora); await _guardarPresetsPorEmisora(prefs, config.porEmisora);
await prefs.setBool(_keyActivo, config.activo); await prefs.setBool(_keyActivo, config.activo);
await prefs.setBool(_keyMultiDeviceEnabled, config.eqMultiDeviceEnabled);
await _guardarMapa(
prefs,
_keyPresetsPorDispositivo,
config.presetsDispositivo,
);
await _guardarMapa(prefs, _keyPresetsMatriz, config.presetsMatriz);
}
/// Persists the multi-device feature toggle.
Future<void> guardarToggleMultiDispositivo(bool habilitado) async {
final prefs = await _resolverPrefs();
await prefs.setBool(_keyMultiDeviceEnabled, habilitado);
}
/// Saves a per-device preset.
Future<void> guardarPresetDispositivo(
String deviceId,
PresetEcualizador preset,
) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo);
mapa[deviceId] = preset;
await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa);
}
/// Saves a matrix (stationUuid:deviceId) preset entry.
Future<void> guardarPresetMatriz(
String clave,
PresetEcualizador preset,
) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsMatriz);
mapa[clave] = preset;
await _guardarMapa(prefs, _keyPresetsMatriz, mapa);
}
/// Removes a per-device preset entry.
Future<void> eliminarPresetDispositivo(String deviceId) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo);
mapa.remove(deviceId);
await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa);
}
/// Removes a matrix preset entry.
Future<void> eliminarPresetMatriz(String clave) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsMatriz);
mapa.remove(clave);
await _guardarMapa(prefs, _keyPresetsMatriz, mapa);
}
/// Reads a `Map<String, PresetEcualizador>` from a SharedPreferences JSON key.
Map<String, PresetEcualizador> _leerMapa(
SharedPreferences prefs,
String key,
) {
final raw = prefs.getString(key);
if (raw == null || raw.isEmpty) return {};
try {
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
return data.map(
(k, v) => MapEntry(
k,
PresetEcualizador.desdeJson(Map<String, dynamic>.from(v as Map)),
),
);
} catch (_) {
return {};
}
}
Future<void> _guardarMapa(
SharedPreferences prefs,
String key,
Map<String, PresetEcualizador> mapa,
) async {
final serializado = mapa.map((k, v) => MapEntry(k, v.toJson()));
await prefs.setString(key, jsonEncode(serializado));
} }
PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) { PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) {
+44 -14
View File
@@ -6,21 +6,29 @@ import '../modelos/preset_ecualizador.dart';
/// Owns the backup (export/import) JSON serialization (S4-R4). /// Owns the backup (export/import) JSON serialization (S4-R4).
/// ///
/// The v2 envelope produced here is byte-compatible with the legacy format /// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and
/// previously assembled inline by `EstadoRadio.exportarConfig` and /// `eqMultiDeviceEnabled`. When those optional parameters are omitted the
/// pretty-printed by `pantalla_ajustes.dart`, so existing exports keep /// export stays at v2 for backward compat with the old app. State APPLICATION
/// round-tripping. State APPLICATION (writing favorites, EQ, alarms back /// (writing favorites, EQ, alarms back into the app) stays in
/// into the app) stays in `EstadoRadio.importarConfig` — this service only /// `EstadoRadio.importarConfig` — this service only owns serialization,
/// owns serialization, parsing and the envelope shape. /// parsing and the envelope shape.
class ServicioExportImport { class ServicioExportImport {
const ServicioExportImport(); const ServicioExportImport();
/// Current backup schema version (v2 — full portability). /// Current backup schema version (v3 — multi-device EQ).
static const int versionActual = 2; static const int versionActual = 3;
/// Builds the v2 export envelope. Key set and semantics must stay exactly /// Legacy v2 version constant kept for clarity.
/// as the legacy inline export so old backups remain importable: static const int versionV2 = 2;
/// the `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
/// Builds the export envelope.
///
/// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null),
/// [versionActual] (3) is written. When both are omitted the call behaves
/// identically to the original v2 format (version key stays 2) so old
/// backups keep round-tripping without version bumps.
///
/// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
/// and passes through untouched (no re-parsing here). /// and passes through untouched (no re-parsing here).
Map<String, dynamic> construirExportacion({ Map<String, dynamic> construirExportacion({
required List<GrupoFavoritos> gruposFavoritos, required List<GrupoFavoritos> gruposFavoritos,
@@ -33,9 +41,18 @@ class ServicioExportImport {
required String ordenListas, required String ordenListas,
required List<int> timerSuenoPresetsSegundos, required List<int> timerSuenoPresetsSegundos,
DateTime? exportadoEn, DateTime? exportadoEn,
// v3 extensions — omitting these produces a v2-compatible export.
Map<String, PresetEcualizador>? presetsPorDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
}) { }) {
return { final tieneExtensionesV3 =
'version': versionActual, presetsPorDispositivo != null ||
presetsMatriz != null ||
eqMultiDeviceEnabled != null;
final envelope = <String, dynamic>{
'version': tieneExtensionesV3 ? versionActual : versionV2,
'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(), 'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(),
// Favorites + groups (preserves grupo_id assignments per station). // Favorites + groups (preserves grupo_id assignments per station).
// The protected "sin asignar" group is implicit and never exported. // The protected "sin asignar" group is implicit and never exported.
@@ -47,7 +64,7 @@ class ServicioExportImport {
'favoritos': favoritos.map((e) => e.toMap()).toList(), 'favoritos': favoritos.map((e) => e.toMap()).toList(),
// Custom stations. // Custom stations.
'emisorasCustom': emisorasCustom.map((e) => e.toMap()).toList(), 'emisorasCustom': emisorasCustom.map((e) => e.toMap()).toList(),
// Equalizer. // Equalizer (base fields — present in all versions).
'presetPrincipalEcualizador': presetPrincipal.toJson(), 'presetPrincipalEcualizador': presetPrincipal.toJson(),
'presetsEcualizador': presetsPorEmisora.map( 'presetsEcualizador': presetsPorEmisora.map(
(uuid, preset) => MapEntry(uuid, preset.toJson()), (uuid, preset) => MapEntry(uuid, preset.toJson()),
@@ -59,6 +76,19 @@ class ServicioExportImport {
'ordenListas': ordenListas, 'ordenListas': ordenListas,
'timerSuenoPresetsSegundos': timerSuenoPresetsSegundos, 'timerSuenoPresetsSegundos': timerSuenoPresetsSegundos,
}; };
// v3 extensions: only written when explicitly provided.
if (tieneExtensionesV3) {
envelope['presetsPorDispositivo'] = (presetsPorDispositivo ?? {}).map(
(deviceId, preset) => MapEntry(deviceId, preset.toJson()),
);
envelope['presetsMatriz'] = (presetsMatriz ?? {}).map(
(clave, preset) => MapEntry(clave, preset.toJson()),
);
envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false;
}
return envelope;
} }
/// Serializes an export envelope to pretty-printed JSON (same formatting /// Serializes an export envelope to pretty-printed JSON (same formatting
+490
View File
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart';
import '../helpers/fakes.dart'; import '../helpers/fakes.dart';
@@ -48,4 +49,493 @@ void main() {
estado.dispose(); estado.dispose();
}); });
}); });
// ---------------------------------------------------------------------------
// Phase 5: 4-level resolution hierarchy
// ---------------------------------------------------------------------------
group('EstadoEcualizador — 4-level resolution (Phase 5)', () {
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Speaker',
);
const stationUuid = 'station-uuid-123';
EstadoEcualizador buildEq({
FakeServicioEcualizador? servicio,
FakeServicioDispositivoAudio? dispositivoAudio,
bool multiDeviceEnabled = true,
String? emisoraActualUuid,
}) {
return EstadoEcualizador(
audio: FakeServicioAudio(),
servicio:
servicio ??
FakeServicioEcualizador(eqMultiDeviceEnabled: multiDeviceEnabled),
dispositivoAudio: dispositivoAudio ?? FakeServicioDispositivoAudio(),
emisoraActualUuid: () => emisoraActualUuid,
);
}
// 5.1a — matrix entry wins over all others
test(
'5.1a matrix entry (stationUuid:deviceId) wins top priority',
() async {
final matrizPreset = PresetEcualizador.bassBoost;
final stationPreset = PresetEcualizador.rock;
final devicePreset = PresetEcualizador.jazz;
final globalPreset = PresetEcualizador.pop;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: globalPreset,
porEmisora: {stationUuid: stationPreset},
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset},
),
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(matrizPreset),
);
eq.dispose();
},
);
// 5.1b — falls back to station when no matrix entry
test('5.1b falls back to station preset when no matrix entry', () async {
final stationPreset = PresetEcualizador.rock;
final devicePreset = PresetEcualizador.jazz;
final globalPreset = PresetEcualizador.pop;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: globalPreset,
porEmisora: {stationUuid: stationPreset},
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {},
),
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(stationPreset),
);
eq.dispose();
});
// 5.1c — falls back to device when no station preset
test(
'5.1c falls back to device preset when no station or matrix entry',
() async {
final devicePreset = PresetEcualizador.jazz;
final globalPreset = PresetEcualizador.pop;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: globalPreset,
porEmisora: {},
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {},
),
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(devicePreset),
);
eq.dispose();
},
);
// 5.1d — falls back to global when nothing else matches
test(
'5.1d falls back to global preset when no matrix, station, or device',
() async {
final globalPreset = PresetEcualizador.pop;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: globalPreset,
porEmisora: {},
eqMultiDeviceEnabled: true,
presetsDispositivo: {},
presetsMatriz: {},
),
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(globalPreset),
);
eq.dispose();
},
);
// 5.1e — toggle off collapses to 2-level
test(
'5.1e toggle off: presetEfectivo uses only station → global',
() async {
final stationPreset = PresetEcualizador.rock;
final devicePreset = PresetEcualizador.jazz;
final matrizPreset = PresetEcualizador.bassBoost;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.flat,
porEmisora: {stationUuid: stationPreset},
eqMultiDeviceEnabled: false,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset},
),
multiDeviceEnabled: false,
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
// When toggle is off, device and matrix entries must be ignored
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(stationPreset),
);
eq.dispose();
},
);
// 5.1f — toggle off with no station falls back to global
test('5.1f toggle off with no station: falls back to global', () async {
final devicePreset = PresetEcualizador.jazz;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.flat,
porEmisora: {},
eqMultiDeviceEnabled: false,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {},
),
multiDeviceEnabled: false,
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(PresetEcualizador.flat),
);
eq.dispose();
});
});
// ---------------------------------------------------------------------------
// Phase 5.3: Device stream subscription triggers re-resolution
// ---------------------------------------------------------------------------
group('EstadoEcualizador — device stream subscription (Phase 5.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();
},
);
},
);
} }
+104
View File
@@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
import 'package:pluriwave/servicios/servicio_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_favoritos.dart'; import 'package:pluriwave/servicios/servicio_favoritos.dart';
import 'package:pluriwave/servicios/servicio_radio.dart'; import 'package:pluriwave/servicios/servicio_radio.dart';
@@ -282,10 +284,16 @@ class FakeServicioEcualizador extends ServicioEcualizador {
PresetEcualizador? principal, PresetEcualizador? principal,
Map<String, PresetEcualizador>? porEmisora, Map<String, PresetEcualizador>? porEmisora,
bool activo = true, bool activo = true,
bool eqMultiDeviceEnabled = false,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
}) : _config = ConfiguracionEcualizador( }) : _config = ConfiguracionEcualizador(
principal: principal ?? PresetEcualizador.flat, principal: principal ?? PresetEcualizador.flat,
porEmisora: porEmisora ?? {}, porEmisora: porEmisora ?? {},
activo: activo, activo: activo,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
presetsDispositivo: presetsDispositivo ?? {},
presetsMatriz: presetsMatriz ?? {},
); );
ConfiguracionEcualizador _config; ConfiguracionEcualizador _config;
@@ -300,6 +308,9 @@ class FakeServicioEcualizador extends ServicioEcualizador {
principal: preset, principal: preset,
porEmisora: _config.porEmisora, porEmisora: _config.porEmisora,
activo: _config.activo, activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
); );
} }
@@ -309,6 +320,9 @@ class FakeServicioEcualizador extends ServicioEcualizador {
principal: _config.principal, principal: _config.principal,
porEmisora: _config.porEmisora, porEmisora: _config.porEmisora,
activo: activo, activo: activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
); );
} }
@@ -320,6 +334,9 @@ class FakeServicioEcualizador extends ServicioEcualizador {
principal: _config.principal, principal: _config.principal,
porEmisora: mapa, porEmisora: mapa,
activo: _config.activo, activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
); );
} }
@@ -331,8 +348,95 @@ class FakeServicioEcualizador extends ServicioEcualizador {
principal: _config.principal, principal: _config.principal,
porEmisora: mapa, porEmisora: mapa,
activo: _config.activo, activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
); );
} }
@override
Future<void> guardarToggleMultiDispositivo(bool habilitado) async {
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: _config.activo,
eqMultiDeviceEnabled: habilitado,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
);
}
@override
Future<void> guardarPresetDispositivo(
String deviceId,
PresetEcualizador preset,
) async {
final mapa = Map<String, PresetEcualizador>.from(
_config.presetsDispositivo,
);
mapa[deviceId] = preset;
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: mapa,
presetsMatriz: _config.presetsMatriz,
);
}
@override
Future<void> guardarPresetMatriz(
String clave,
PresetEcualizador preset,
) async {
final mapa = Map<String, PresetEcualizador>.from(_config.presetsMatriz);
mapa[clave] = preset;
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: mapa,
);
}
}
/// Fake implementation of [ServicioDispositivoAudio] for unit tests.
///
/// Use [emitirDispositivo] to push device change events without needing
/// a real platform channel.
class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
final _controller = StreamController<DispositivoAudio>.broadcast();
DispositivoAudio? _dispositivoActual;
@override
DispositivoAudio? get dispositivoActual => _dispositivoActual;
@override
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
@override
Future<DispositivoAudio> obtenerDispositivoActual() async {
return _dispositivoActual ??
const DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Speaker',
);
}
/// Helper for tests: push a device change event.
void emitirDispositivo(DispositivoAudio dispositivo) {
_dispositivoActual = dispositivo;
_controller.add(dispositivo);
}
@override
Future<void> dispose() async {
await _controller.close();
}
} }
Emisora emisoraDemo({ Emisora emisoraDemo({
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
void main() {
group('TipoDispositivo', () {
test('has all required enum values', () {
expect(
TipoDispositivo.values,
containsAll([
TipoDispositivo.altavozInterno,
TipoDispositivo.auricularesCable,
TipoDispositivo.bluetoothA2dp,
TipoDispositivo.usbAudio,
TipoDispositivo.desconocido,
]),
);
});
});
group('DispositivoAudio', () {
const builtinSpeaker = DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Altavoz interno',
);
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'Auriculares BT',
);
test('equality: same id and tipo are equal', () {
const a = DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Altavoz',
);
const b = DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Different name',
);
expect(a, equals(b));
});
test('equality: different id are not equal', () {
expect(builtinSpeaker, isNot(equals(btDevice)));
});
test('hashCode is consistent with equality', () {
const a = DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'A',
);
const b = DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'B',
);
expect(a.hashCode, equals(b.hashCode));
});
test('holds all required fields', () {
expect(builtinSpeaker.id, 'builtin_speaker');
expect(builtinSpeaker.tipo, TipoDispositivo.altavozInterno);
expect(builtinSpeaker.nombre, 'Altavoz interno');
});
test('BT device id format matches spec', () {
expect(btDevice.id, 'bt_a2dp:AA:BB:CC:DD:EE:FF');
expect(btDevice.tipo, TipoDispositivo.bluetoothA2dp);
});
test('wired headset type', () {
const wired = DispositivoAudio(
id: 'wired_headset',
tipo: TipoDispositivo.auricularesCable,
nombre: 'Auriculares cable',
);
expect(wired.tipo, TipoDispositivo.auricularesCable);
});
test('USB audio type', () {
const usb = DispositivoAudio(
id: 'usb_headset:some_address',
tipo: TipoDispositivo.usbAudio,
nombre: 'USB Headset',
);
expect(usb.tipo, TipoDispositivo.usbAudio);
});
});
}
+218
View File
@@ -0,0 +1,218 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_idioma.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
// Pre-existing project constraint: PluriGlassSurface (a glassmorphism
// DecoratedBox) is used as the card-style container throughout PantallaAjustes.
// Flutter asserts that ListTile's ink is visible, but the assertion fires
// as a warning (not a correctness bug) — ink animations are simply not visible
// behind the backdrop-filter blur in production either. We suppress it here so
// functional tests can run against the existing UI structure.
void _suppressListTileInkAssertion() {
final original = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains(
'ListTile background color or ink splashes may be invisible',
)) {
return;
}
original?.call(details);
};
addTearDown(() => FlutterError.onError = original);
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
// ── Helpers ────────────────────────────────────────────────────────────────
Widget buildAjustes(EstadoRadio estado, {EstadoIdioma? idioma}) {
final estadoIdioma = idioma ?? EstadoIdioma();
return MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ChangeNotifierProvider<EstadoIdioma>.value(value: estadoIdioma),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAjustes()),
),
);
}
Future<EstadoRadio> crearEstado({
bool eqMultiDeviceEnabled = false,
Map<String, PresetEcualizador> presetsDispositivo = const {},
}) async {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
presetsDispositivo: presetsDispositivo,
),
servicioGrabacion: _FakeGrabacion(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.ecualizador.cargarPersistido();
return estado;
}
void setLargeSurface(WidgetTester tester) {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
}
Future<void> pumpStable(WidgetTester tester) async {
await tester.pump();
await tester.pumpAndSettle(const Duration(milliseconds: 100));
}
// ── Phase 7 tests ──────────────────────────────────────────────────────────
group('_SeccionEcualizadorAvanzado (Phase 7)', () {
testWidgets('7.1-A: toggle OFF — advanced EQ section is visible but device '
'list is not shown', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion(); // Must be before pumpWidget.
final estado = await crearEstado(eqMultiDeviceEnabled: false);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
// Scroll to find the advanced EQ section.
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// The section header must be present.
expect(find.text('Advanced Equalization Options'), findsOneWidget);
// Toggle switch title must be present.
expect(find.text('Enable per-device EQ'), findsOneWidget);
// When toggle is OFF, device list must NOT be rendered.
expect(find.text('Known audio devices'), findsNothing);
});
testWidgets(
'7.1-B: toggle ON with known devices — device list is visible',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado(
eqMultiDeviceEnabled: true,
presetsDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.rock,
},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Section header present.
expect(find.text('Advanced Equalization Options'), findsOneWidget);
// Toggle switch title present.
expect(find.text('Enable per-device EQ'), findsOneWidget);
// Known devices header should appear when toggle is on and there are
// known devices.
expect(find.text('Known audio devices'), findsOneWidget);
// The device ID should appear in the list.
expect(
find.textContaining('bt_a2dp:AA:BB:CC:DD:EE:FF'),
findsOneWidget,
);
},
);
testWidgets(
'7.1-C: toggle can be flipped — tapping it enables multi-device EQ',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado(eqMultiDeviceEnabled: false);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Initially OFF.
expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse);
// Tap the Switch widget to toggle on.
await tester.tap(find.byType(Switch).last);
await pumpStable(tester);
// After tap, toggle should be ON.
expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue);
},
);
});
}
// ── Infrastructure ──────────────────────────────────────────────────────────
class _FakeGrabacion extends ServicioGrabacionRadio {
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
@override
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
@override
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
@override
Future<void> inicializar() async {}
@override
Future<void> dispose() => _controller.close();
}
Future<File> _archivoCustomVacio() async =>
File('${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json');
@@ -0,0 +1,109 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
// Type int constants from the platform channel protocol (design doc):
// 2 = builtin_speaker, 3 = wired_headset, 8 = bt_a2dp, 14 = usb_headset
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ServicioDispositivoAudioReal', () {
const methodChannelName = 'pluriwave/audio_devices';
late ServicioDispositivoAudioReal servicio;
setUp(() {
servicio = ServicioDispositivoAudioReal();
});
tearDown(() async {
await servicio.dispose();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(methodChannelName),
null,
);
});
void stubGetActiveDevice(Map<String, dynamic> response) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(const MethodChannel(methodChannelName), (
call,
) async {
if (call.method == 'getActiveDevice') return response;
return null;
});
}
test('obtenerDispositivoActual maps builtin_speaker (type=2)', () async {
stubGetActiveDevice({
'id': 'builtin_speaker',
'type': 2,
'name': 'Speaker',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.id, 'builtin_speaker');
expect(device.tipo, TipoDispositivo.altavozInterno);
expect(device.nombre, 'Speaker');
});
test('obtenerDispositivoActual maps wired_headset (type=3)', () async {
stubGetActiveDevice({
'id': 'wired_headset',
'type': 3,
'name': 'Wired Headset',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.id, 'wired_headset');
expect(device.tipo, TipoDispositivo.auricularesCable);
});
test('obtenerDispositivoActual maps bt_a2dp (type=8)', () async {
stubGetActiveDevice({
'id': 'bt_a2dp:AA:BB:CC:DD:EE:FF',
'type': 8,
'name': 'My BT Device',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.id, 'bt_a2dp:AA:BB:CC:DD:EE:FF');
expect(device.tipo, TipoDispositivo.bluetoothA2dp);
});
test('obtenerDispositivoActual maps usb_headset (type=14)', () async {
stubGetActiveDevice({
'id': 'usb_headset:0001:0002',
'type': 14,
'name': 'USB Headset',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.id, 'usb_headset:0001:0002');
expect(device.tipo, TipoDispositivo.usbAudio);
});
test('obtenerDispositivoActual maps unknown type to desconocido', () async {
stubGetActiveDevice({
'id': 'unknown_device',
'type': 99,
'name': 'Unknown',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.tipo, TipoDispositivo.desconocido);
});
test('obtenerDispositivoActual updates dispositivoActual cache', () async {
stubGetActiveDevice({
'id': 'builtin_speaker',
'type': 2,
'name': 'Speaker',
});
await servicio.obtenerDispositivoActual();
expect(servicio.dispositivoActual?.id, 'builtin_speaker');
});
test('onDispositivoCambiado is a broadcast stream', () {
expect(servicio.onDispositivoCambiado.isBroadcast, isTrue);
});
});
}
@@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
import '../helpers/fakes.dart';
void main() {
group('ServicioDispositivoAudio (abstract contract)', () {
late FakeServicioDispositivoAudio fake;
setUp(() {
fake = FakeServicioDispositivoAudio();
});
tearDown(() async {
await fake.dispose();
});
test('dispositivoActual is null initially', () {
expect(fake.dispositivoActual, isNull);
});
test(
'obtenerDispositivoActual returns current device after emit',
() async {
const device = DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Speaker',
);
fake.emitirDispositivo(device);
final result = await fake.obtenerDispositivoActual();
expect(result, equals(device));
},
);
test('onDispositivoCambiado stream emits when device changes', () async {
const deviceA = DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Speaker',
);
const deviceB = DispositivoAudio(
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Headphones',
);
final received = <DispositivoAudio>[];
final sub = fake.onDispositivoCambiado.listen(received.add);
fake.emitirDispositivo(deviceA);
fake.emitirDispositivo(deviceB);
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(received, equals([deviceA, deviceB]));
});
test('dispositivoActual tracks last emitted device', () {
const device = DispositivoAudio(
id: 'wired_headset',
tipo: TipoDispositivo.auricularesCable,
nombre: 'Wired Headset',
);
fake.emitirDispositivo(device);
expect(fake.dispositivoActual, equals(device));
});
test(
'onDispositivoCambiado is a broadcast stream (multiple listeners)',
() {
expect(fake.onDispositivoCambiado.isBroadcast, isTrue);
},
);
test('dispose closes the stream without error', () async {
await expectLater(fake.dispose(), completes);
});
});
}
@@ -0,0 +1,73 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
import '../helpers/fakes.dart';
/// Null (toggle-off) implementation that returns empty data without errors.
/// Represents the behavior when `eqMultiDeviceEnabled` is false.
class NullServicioDispositivoAudio extends ServicioDispositivoAudio {
@override
DispositivoAudio? get dispositivoActual => null;
@override
Stream<DispositivoAudio> get onDispositivoCambiado => const Stream.empty();
@override
Future<DispositivoAudio> obtenerDispositivoActual() async {
return const DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Speaker',
);
}
@override
Future<void> dispose() async {}
}
void main() {
group('Toggle-off path: NullServicioDispositivoAudio', () {
late NullServicioDispositivoAudio servicio;
setUp(() {
servicio = NullServicioDispositivoAudio();
});
test('dispositivoActual is always null', () {
expect(servicio.dispositivoActual, isNull);
});
test('onDispositivoCambiado emits no events', () async {
final received = <DispositivoAudio>[];
final sub = servicio.onDispositivoCambiado.listen(received.add);
await Future<void>.delayed(const Duration(milliseconds: 10));
await sub.cancel();
expect(received, isEmpty);
});
test('obtenerDispositivoActual returns fallback builtin_speaker', () async {
final device = await servicio.obtenerDispositivoActual();
expect(device.id, 'builtin_speaker');
expect(device.tipo, TipoDispositivo.altavozInterno);
});
test('dispose completes without error', () async {
await expectLater(servicio.dispose(), completes);
});
test('no platform channel code is invoked (fake, not real)', () async {
// Verify that FakeServicioDispositivoAudio also produces no events
// when no emitirDispositivo is called — simulating toggle-off behavior.
final fake = FakeServicioDispositivoAudio();
final received = <DispositivoAudio>[];
final sub = fake.onDispositivoCambiado.listen(received.add);
await Future<void>.delayed(const Duration(milliseconds: 10));
await sub.cancel();
expect(received, isEmpty);
await fake.dispose();
});
});
}
@@ -0,0 +1,172 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('ServicioEcualizador — multi-device persistence (Phase 4)', () {
test('4.1a toggle defaults to false when no SP data', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(config.eqMultiDeviceEnabled, isFalse);
});
test(
'4.1b guardarToggleMultiDispositivo persists and cargar restores it',
() async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarToggleMultiDispositivo(true);
final config = await servicio.cargar();
expect(config.eqMultiDeviceEnabled, isTrue);
},
);
test('4.1c guardarPresetDispositivo stores a device preset', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
await servicio.guardarPresetDispositivo(deviceId, PresetEcualizador.jazz);
final config = await servicio.cargar();
expect(
config.presetsDispositivo[deviceId],
equals(PresetEcualizador.jazz),
);
});
test(
'4.1d obtenerPresetDispositivo returns null for unknown device',
() async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(config.presetsDispositivo['unknown_device'], isNull);
},
);
test('4.1e guardarPresetMatriz stores a matrix entry', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
const clave = 'station-uuid-123:bt_a2dp:AA:BB:CC:DD:EE:FF';
await servicio.guardarPresetMatriz(clave, PresetEcualizador.rock);
final config = await servicio.cargar();
expect(config.presetsMatriz[clave], equals(PresetEcualizador.rock));
});
test('4.1f multiple device presets persist independently', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(
'bt_a2dp:11:22:33:44:55:66',
PresetEcualizador.rock,
);
await servicio.guardarPresetDispositivo(
'wired_headset',
PresetEcualizador.jazz,
);
final config = await servicio.cargar();
expect(
config.presetsDispositivo['bt_a2dp:11:22:33:44:55:66'],
equals(PresetEcualizador.rock),
);
expect(
config.presetsDispositivo['wired_headset'],
equals(PresetEcualizador.jazz),
);
});
test('4.1g multiple matrix entries persist independently', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetMatriz(
'station-A:bt_a2dp:AA:BB:CC:DD:EE:FF',
PresetEcualizador.jazz,
);
await servicio.guardarPresetMatriz(
'station-B:wired_headset',
PresetEcualizador.bassBoost,
);
final config = await servicio.cargar();
expect(
config.presetsMatriz['station-A:bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(PresetEcualizador.jazz),
);
expect(
config.presetsMatriz['station-B:wired_headset'],
equals(PresetEcualizador.bassBoost),
);
});
test(
'4.1h existing principal/emisora fields unaffected by new fields',
() async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPrincipal(PresetEcualizador.pop);
await servicio.guardarPorEmisora('station-X', PresetEcualizador.voz);
await servicio.guardarToggleMultiDispositivo(true);
await servicio.guardarPresetDispositivo(
'builtin_speaker',
PresetEcualizador.jazz,
);
final config = await servicio.cargar();
expect(config.principal, equals(PresetEcualizador.pop));
expect(config.porEmisora['station-X'], equals(PresetEcualizador.voz));
expect(config.eqMultiDeviceEnabled, isTrue);
expect(
config.presetsDispositivo['builtin_speaker'],
equals(PresetEcualizador.jazz),
);
},
);
test('4.1i guardarConfiguracion with new fields round-trips', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = ConfiguracionEcualizador(
principal: PresetEcualizador.rock,
porEmisora: {'station-1': PresetEcualizador.jazz},
activo: true,
eqMultiDeviceEnabled: true,
presetsDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.voz,
},
presetsMatriz: {
'station-1:bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.bassBoost,
},
);
await servicio.guardarConfiguracion(config);
final restored = await servicio.cargar();
expect(restored.principal, equals(PresetEcualizador.rock));
expect(restored.porEmisora['station-1'], equals(PresetEcualizador.jazz));
expect(restored.eqMultiDeviceEnabled, isTrue);
expect(
restored.presetsDispositivo['bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(PresetEcualizador.voz),
);
expect(
restored.presetsMatriz['station-1:bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(PresetEcualizador.bassBoost),
);
});
});
}
+175 -23
View File
@@ -7,11 +7,8 @@ import 'package:pluriwave/servicios/servicio_export_import.dart';
void main() { void main() {
const servicio = ServicioExportImport(); const servicio = ServicioExportImport();
group('ServicioExportImport (S4-R4)', () { // Shared fixture data used across multiple tests.
test('v2 round-trip: exportar then importar yields a deep-equal config ' const gruposFavoritosFixture = [
'(S4-R4-A, S6-R2 test #4)', () {
final config = servicio.construirExportacion(
gruposFavoritos: const [
GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1), GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1),
GrupoFavoritos( GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId, id: GrupoFavoritos.sinAsignarId,
@@ -19,8 +16,8 @@ void main() {
orden: 0, orden: 0,
protegido: true, protegido: true,
), ),
], ];
favoritos: const [ const favoritosFixture = [
Emisora( Emisora(
uuid: 'fav-1', uuid: 'fav-1',
nombre: 'Radio Uno', nombre: 'Radio Uno',
@@ -28,32 +25,35 @@ void main() {
grupoFavoritosId: 'g1', grupoFavoritosId: 'g1',
bitrate: 192, bitrate: 192,
), ),
], ];
emisorasCustom: const [ const emisorasCustomFixture = [
Emisora( Emisora(
uuid: 'custom-1', uuid: 'custom-1',
nombre: 'Mi Radio', nombre: 'Mi Radio',
url: 'https://mia.example/stream', url: 'https://mia.example/stream',
), ),
], ];
presetPrincipal: PresetEcualizador.jazz, const alarmasFixture = {
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
// Raw alarm block exactly as ServicioAlarmas persists it (alarms +
// vacations + exceptions) — must pass through untouched.
alarmas: const {
'alarmas': [ 'alarmas': [
{ {'id': 'a1', 'hora': 7, 'minuto': 30, 'activa': true, 'snoozeMinutos': 5},
'id': 'a1',
'hora': 7,
'minuto': 30,
'activa': true,
'snoozeMinutos': 5,
},
], ],
'vacaciones': [ 'vacaciones': [
{'desde': '2026-07-01', 'hasta': '2026-07-15'}, {'desde': '2026-07-01', 'hasta': '2026-07-15'},
], ],
}, };
group('ServicioExportImport (S4-R4)', () {
test('v2 round-trip: exportar then importar yields a deep-equal config '
'(S4-R4-A, S6-R2 test #4)', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.jazz,
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
// Raw alarm block exactly as ServicioAlarmas persists it (alarms +
// vacations + exceptions) — must pass through untouched.
alarmas: alarmasFixture,
emisoraPreferidaUuid: 'fav-1', emisoraPreferidaUuid: 'fav-1',
ordenListas: 'calidad', ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300, 600, 1800], timerSuenoPresetsSegundos: const [300, 600, 1800],
@@ -80,4 +80,156 @@ void main() {
expect(servicio.importar('[1, 2, 3]'), isNull); expect(servicio.importar('[1, 2, 3]'), isNull);
}); });
}); });
group('ServicioExportImport v3 — multi-device EQ (Phase 6)', () {
test(
'6.1-A: v3 export includes schemaVersion 3 and device/matrix preset keys',
() {
final presetDispositivo = PresetEcualizador.rock;
final presetMatriz = PresetEcualizador.jazz;
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
presetsPorDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': presetDispositivo,
},
presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': presetMatriz},
eqMultiDeviceEnabled: true,
);
// v3 schema version.
expect(config['version'], 3);
// Device preset map present and correct.
expect(config['presetsPorDispositivo'], isA<Map>());
final dispositivoMap =
config['presetsPorDispositivo'] as Map<String, dynamic>;
expect(dispositivoMap, contains('bt_a2dp:AA:BB:CC:DD:EE:FF'));
// Matrix preset map present and correct.
expect(config['presetsMatriz'], isA<Map>());
final matrizMap = config['presetsMatriz'] as Map<String, dynamic>;
expect(matrizMap, contains('fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF'));
// Toggle exported.
expect(config['eqMultiDeviceEnabled'], isTrue);
},
);
test(
'6.1-B: v3 round-trip preserves device and matrix presets exactly',
() {
final dispositivoPreset = PresetEcualizador.rock;
final matrizPreset = PresetEcualizador.jazz;
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {'fav-1': PresetEcualizador.pop},
alarmas: alarmasFixture,
emisoraPreferidaUuid: 'fav-1',
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300, 1800],
presetsPorDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': dispositivoPreset,
'wired_headset': PresetEcualizador.flat,
},
presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': matrizPreset},
eqMultiDeviceEnabled: true,
);
final json = servicio.exportar(config);
final importado = servicio.importar(json);
expect(importado, isNotNull);
expect(importado!['version'], 3);
// Device presets survive the round-trip.
final deviceMap =
importado['presetsPorDispositivo'] as Map<String, dynamic>;
expect(
deviceMap['bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(dispositivoPreset.toJson()),
);
expect(
deviceMap['wired_headset'],
equals(PresetEcualizador.flat.toJson()),
);
// Matrix presets survive the round-trip.
final matrizMap = importado['presetsMatriz'] as Map<String, dynamic>;
expect(
matrizMap['fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(matrizPreset.toJson()),
);
// Toggle survives.
expect(importado['eqMultiDeviceEnabled'], isTrue);
},
);
test(
'6.1-C: v2 file (no device fields) imports cleanly — missing keys default to empty maps',
() {
// Simulate a v2 export file (no presetsPorDispositivo / presetsMatriz).
final v2Config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.jazz,
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
// No device fields — emulates a v2 export from the old app.
);
// Verify the export has no device keys (a v2 file would not).
expect(v2Config.containsKey('presetsPorDispositivo'), isFalse);
expect(v2Config.containsKey('presetsMatriz'), isFalse);
expect(v2Config.containsKey('eqMultiDeviceEnabled'), isFalse);
// Import it — parsing should succeed.
final json = servicio.exportar(v2Config);
final importado = servicio.importar(json);
expect(importado, isNotNull);
// Missing device keys should gracefully resolve to null (caller uses ?? {}).
expect(importado!['presetsPorDispositivo'], isNull);
expect(importado['presetsMatriz'], isNull);
expect(importado['eqMultiDeviceEnabled'], isNull);
},
);
test(
'6.1-D: unknown schemaVersion (v4-future) does not crash importar()',
() {
// Simulate a future v4 export (unknown version, extra fields).
final futurista = <String, dynamic>{
'version': 99,
'presetsPorDispositivo': <String, dynamic>{},
'presetsMatriz': <String, dynamic>{},
'unknownFutureField': 'some-value',
};
final json = servicio.exportar(futurista);
// importar() should parse without throwing — version checking is the
// caller's responsibility (EstadoRadio.importarConfig).
expect(() => servicio.importar(json), returnsNormally);
final importado = servicio.importar(json);
expect(importado, isNotNull);
expect(importado!['version'], 99);
},
);
});
} }