Restructure pantalla_reproductor.dart onto PluriPushScaffold (design ADR-2 - this screen is the documented single consumer of titleOverride, a centered live/not-playing status pill, and the non-default keyboard_arrow_down leadingIcon). Square art replaces the old circular hero, favorite moves from the AppBar into the transport row (the redundant live-indicator dot is dropped - the AppBar pill already covers that signal), the old separate info chips collapse into a single subtitle line, and a new quality row surfaces codec/bitrate with a "Cambiar" action that reconnects the current stream (this app has no per-station alternate-quality capability to invoke, so this reuses the same reproducir() call the existing error-state retry button already uses, rather than a dead button or an invented picker). The always-expanded recording panel and the standalone sleep-timer button both become tool-tray tiles (EQ propio / Grabar / sleep timer / Compartir), each opening its own bottom sheet. "EQ propio" opens a sheet hosting EcualizadorWidget - the exact same component WU13 restyled for Settings, bound via the existing presetParaEmisora/guardarPresetPorEmisora per-station persistence path. No second editor was created; the multi-device-eq resolution hierarchy is untouched. pantalla_reproductor.dart had zero test coverage before this commit (907 lines) - writing it first surfaced two pre-existing bugs blocking any coverage at all, both fixed: initState called estado.reproducir() directly, which notifies listeners synchronously before its first await and threw "setState() during build" the instant the screen mounted against a fresh Provider tree (fixed via addPostFrameCallback); and the body Column had no scrollable ancestor and overflowed even a generously tall viewport (fixed by wrapping it in a SingleChildScrollView, a real UX improvement and not just a test workaround). The three protected EQ test files (servicio_ecualizador_test.dart, estado_ecualizador_test.dart, servicio_audio_eq_reapply_test.dart) stay unmodified. Full suite: 730/730 green (2 skipped, unchanged), up from 713. size:exception - realized 1,410 changed lines (25 files including this docs update) against the 450-600 forecast: the restructured screen file alone is 658 lines (a near-total rewrite of a 907-line file, not a patch), its new test file (first-ever coverage) is 519 lines, and a new test fake plus a togglePlay() override account for the rest. Not splittable: the restructure, the tool tray, and the EQ-sheet wiring are one cohesive change to one screen.
713 lines
22 KiB
Dart
713 lines
22 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
|
import 'package:pluriwave/modelos/dispositivo_audio.dart';
|
|
import 'package:pluriwave/modelos/emisora.dart';
|
|
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
|
import 'package:pluriwave/modelos/pais_radio.dart';
|
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
|
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
|
|
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
|
import 'package:pluriwave/servicios/servicio_favoritos.dart';
|
|
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
|
import 'package:pluriwave/servicios/servicio_presets_personalizados.dart';
|
|
import 'package:pluriwave/servicios/servicio_radio.dart';
|
|
|
|
class FakeServicioAudio extends ServicioAudio {
|
|
FakeServicioAudio({this.ecualizadorActivo = true}) {
|
|
_estadoController.add(EstadoReproduccion.detenido);
|
|
}
|
|
|
|
final bool ecualizadorActivo;
|
|
final _estadoController = StreamController<EstadoReproduccion>.broadcast();
|
|
final List<PresetEcualizador> presetsAplicados = [];
|
|
final List<Emisora> emisorasReproducidas = [];
|
|
final List<bool> cambiosEcualizadorActivo = [];
|
|
final List<double> volumenesAplicados = [];
|
|
int pausas = 0;
|
|
int configuracionesL10n = 0;
|
|
Emisora? _emisoraActual;
|
|
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
|
|
|
|
@override
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
// No global handler in tests; just record the call.
|
|
configuracionesL10n++;
|
|
}
|
|
|
|
@override
|
|
Emisora? get emisoraActual => _emisoraActual;
|
|
|
|
@override
|
|
bool get ecualizadorDisponible => ecualizadorActivo;
|
|
|
|
@override
|
|
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
|
|
|
|
// WU5: the real getter needs registrarHandler() (main.dart, production
|
|
// only) — reading it against a bare FakeServicioAudio throws
|
|
// "registrarHandler() no fue llamado en main.dart". No test exercised
|
|
// this getter before PantallaInicio started wiring VisualizadorAudio to
|
|
// it (WU5's Escuchar hero, mirroring pantalla_reproductor.dart's own
|
|
// usage, which has no test file at all). An empty stream matches
|
|
// VisualizadorAudio's own documented no-native-session fallback.
|
|
@override
|
|
Stream<int?> get androidAudioSessionIdStream => const Stream<int?>.empty();
|
|
|
|
@override
|
|
bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo;
|
|
|
|
@override
|
|
Future<void> reproducir(Emisora emisora) async {
|
|
_emisoraActual = emisora;
|
|
emisorasReproducidas.add(emisora);
|
|
emitirEstado(EstadoReproduccion.reproduciendo);
|
|
}
|
|
|
|
@override
|
|
Future<void> detener() async {
|
|
_emisoraActual = null;
|
|
emitirEstado(EstadoReproduccion.detenido);
|
|
}
|
|
|
|
@override
|
|
Future<void> pausar() async {
|
|
pausas++;
|
|
emitirEstado(EstadoReproduccion.pausado);
|
|
}
|
|
|
|
// WU14: the real ServicioAudio.togglePlay() reads `_handler.playbackState`
|
|
// (a real just_audio-backed handler that requires registrarHandler(), same
|
|
// gap already documented for androidAudioSessionIdStream above) — unsafe
|
|
// against a bare FakeServicioAudio. Overridden here using only this Fake's
|
|
// own state machinery so `pantalla_reproductor.dart`'s play/pause control
|
|
// (previously untested) can be exercised safely.
|
|
@override
|
|
Future<void> togglePlay() async {
|
|
if (_estadoActual == EstadoReproduccion.reproduciendo) {
|
|
await pausar();
|
|
} else {
|
|
emitirEstado(EstadoReproduccion.reproduciendo);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> setVolumen(double vol) async {
|
|
volumenesAplicados.add(vol);
|
|
}
|
|
|
|
void emitirEstado(EstadoReproduccion estado) {
|
|
_estadoActual = estado;
|
|
_estadoController.add(estado);
|
|
}
|
|
|
|
@override
|
|
Future<void> aplicarPreset(PresetEcualizador preset) async {
|
|
presetsAplicados.add(preset);
|
|
}
|
|
|
|
@override
|
|
Future<void> setBanda(int index, double db) async {}
|
|
|
|
@override
|
|
Future<void> setEcualizadorActivo(bool activo) async {
|
|
cambiosEcualizadorActivo.add(activo);
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
await _estadoController.close();
|
|
}
|
|
}
|
|
|
|
class FakeServicioFavoritos extends ServicioFavoritos {
|
|
final List<Emisora> _favoritos = [];
|
|
final List<GrupoFavoritos> _grupos = [
|
|
const GrupoFavoritos(
|
|
id: GrupoFavoritos.sinAsignarId,
|
|
nombre: 'Sin asignar',
|
|
orden: 0,
|
|
protegido: true,
|
|
),
|
|
];
|
|
int toggleCalls = 0;
|
|
|
|
@override
|
|
Future<List<Emisora>> obtenerTodos() async =>
|
|
_favoritos.map((e) => e.copyWith()).toList();
|
|
|
|
@override
|
|
Future<void> agregar(Emisora emisora) async {
|
|
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
|
|
_favoritos.add(emisora.copyWith(orden: _favoritos.length));
|
|
}
|
|
|
|
@override
|
|
Future<void> eliminar(String uuid) async {
|
|
_favoritos.removeWhere((e) => e.uuid == uuid);
|
|
for (var i = 0; i < _favoritos.length; i++) {
|
|
_favoritos[i] = _favoritos[i].copyWith(orden: i);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> esFavorito(String uuid) async =>
|
|
_favoritos.any((e) => e.uuid == uuid);
|
|
|
|
@override
|
|
Future<bool> toggleFavorito(Emisora emisora) async {
|
|
toggleCalls += 1;
|
|
if (_favoritos.any((e) => e.uuid == emisora.uuid)) {
|
|
await eliminar(emisora.uuid);
|
|
return false;
|
|
}
|
|
await agregar(emisora);
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future<List<GrupoFavoritos>> obtenerGrupos() async =>
|
|
List.unmodifiable(_grupos);
|
|
|
|
@override
|
|
Future<GrupoFavoritos> crearGrupo(String nombre) async {
|
|
final grupo = GrupoFavoritos(
|
|
id: 'grupo_${_grupos.length}',
|
|
nombre: nombre.trim(),
|
|
orden: _grupos.length,
|
|
);
|
|
_grupos.add(grupo);
|
|
return grupo;
|
|
}
|
|
|
|
@override
|
|
Future<void> renombrarGrupo(String id, String nombre) async {
|
|
final index = _grupos.indexWhere((g) => g.id == id && !g.protegido);
|
|
if (index == -1) return;
|
|
_grupos[index] = _grupos[index].copyWith(nombre: nombre.trim());
|
|
}
|
|
|
|
@override
|
|
Future<void> eliminarGrupo(String id) async {
|
|
if (id == GrupoFavoritos.sinAsignarId) return;
|
|
_grupos.removeWhere((g) => g.id == id && !g.protegido);
|
|
for (var i = 0; i < _favoritos.length; i++) {
|
|
if (_favoritos[i].grupoFavoritosId == id) {
|
|
_favoritos[i] = _favoritos[i].copyWith(
|
|
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> asignarGrupo(String uuid, String grupoId) async {
|
|
final destino =
|
|
_grupos.any((g) => g.id == grupoId)
|
|
? grupoId
|
|
: GrupoFavoritos.sinAsignarId;
|
|
final index = _favoritos.indexWhere((e) => e.uuid == uuid);
|
|
if (index != -1) {
|
|
_favoritos[index] = _favoritos[index].copyWith(grupoFavoritosId: destino);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> reordenar(String uuid, int nuevoOrden) async {
|
|
final oldIndex = _favoritos.indexWhere((e) => e.uuid == uuid);
|
|
if (oldIndex == -1 || _favoritos.isEmpty) return;
|
|
final targetIndex = nuevoOrden.clamp(0, _favoritos.length - 1);
|
|
final moved = _favoritos.removeAt(oldIndex);
|
|
_favoritos.insert(targetIndex, moved);
|
|
for (var i = 0; i < _favoritos.length; i++) {
|
|
_favoritos[i] = _favoritos[i].copyWith(orden: i);
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeServicioRadio extends ServicioRadio {
|
|
FakeServicioRadio({
|
|
List<Emisora>? populares,
|
|
List<Emisora>? tendencias,
|
|
List<Emisora>? busqueda,
|
|
List<List<Emisora>>? popularesPorLlamada,
|
|
List<List<Emisora>>? tendenciasPorLlamada,
|
|
List<Object>? erroresPopularesPorLlamada,
|
|
List<Object>? erroresTendenciasPorLlamada,
|
|
List<PaisRadio>? paises,
|
|
Object? errorPaises,
|
|
}) : _populares = populares ?? [],
|
|
_tendencias = tendencias ?? [],
|
|
_busqueda = busqueda ?? [],
|
|
_popularesPorLlamada = popularesPorLlamada ?? const [],
|
|
_tendenciasPorLlamada = tendenciasPorLlamada ?? const [],
|
|
_erroresPopularesPorLlamada = erroresPopularesPorLlamada ?? const [],
|
|
_erroresTendenciasPorLlamada = erroresTendenciasPorLlamada ?? const [],
|
|
_paises = paises ?? [],
|
|
_errorPaises = errorPaises;
|
|
|
|
final List<Emisora> _populares;
|
|
final List<Emisora> _tendencias;
|
|
final List<Emisora> _busqueda;
|
|
final List<List<Emisora>> _popularesPorLlamada;
|
|
final List<List<Emisora>> _tendenciasPorLlamada;
|
|
final List<Object> _erroresPopularesPorLlamada;
|
|
final List<Object> _erroresTendenciasPorLlamada;
|
|
final List<PaisRadio> _paises;
|
|
final Object? _errorPaises;
|
|
|
|
int obtenerPopularesCalls = 0;
|
|
int obtenerTendenciasCalls = 0;
|
|
int registrarClickCalls = 0;
|
|
int buscarCalls = 0;
|
|
int obtenerPaisesCalls = 0;
|
|
String? ultimoUuidClick;
|
|
|
|
Exception _normalizarError(Object error) =>
|
|
error is Exception ? error : Exception(error.toString());
|
|
|
|
@override
|
|
Future<List<Emisora>> obtenerPopulares({
|
|
int limit = 30,
|
|
int offset = 0,
|
|
}) async {
|
|
final llamada = obtenerPopularesCalls++;
|
|
if (llamada < _erroresPopularesPorLlamada.length) {
|
|
throw _normalizarError(_erroresPopularesPorLlamada[llamada]);
|
|
}
|
|
final data =
|
|
llamada < _popularesPorLlamada.length
|
|
? _popularesPorLlamada[llamada]
|
|
: _populares;
|
|
return data.take(limit).toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<Emisora>> obtenerTendencias({int limit = 20}) async {
|
|
final llamada = obtenerTendenciasCalls++;
|
|
if (llamada < _erroresTendenciasPorLlamada.length) {
|
|
throw _normalizarError(_erroresTendenciasPorLlamada[llamada]);
|
|
}
|
|
final data =
|
|
llamada < _tendenciasPorLlamada.length
|
|
? _tendenciasPorLlamada[llamada]
|
|
: _tendencias;
|
|
return data.take(limit).toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<Emisora>> buscar({
|
|
String? nombre,
|
|
String? pais,
|
|
String? idioma,
|
|
String? tag,
|
|
int limit = 30,
|
|
int offset = 0,
|
|
}) async {
|
|
// WU6: counts calls so tests can assert a client-side re-sort does NOT
|
|
// trigger a new network request (`station-discovery-browse` spec,
|
|
// "Client-Side Search Sort Only").
|
|
buscarCalls++;
|
|
return _busqueda.skip(offset).take(limit).toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> registrarClick(String uuid) async {
|
|
registrarClickCalls += 1;
|
|
ultimoUuidClick = uuid;
|
|
}
|
|
|
|
@override
|
|
Future<List<PaisRadio>> obtenerPaises() async {
|
|
obtenerPaisesCalls++;
|
|
final error = _errorPaises;
|
|
if (error != null) throw _normalizarError(error);
|
|
return _paises;
|
|
}
|
|
}
|
|
|
|
class FakeServicioEcualizador extends ServicioEcualizador {
|
|
FakeServicioEcualizador({
|
|
PresetEcualizador? principal,
|
|
Map<String, PresetEcualizador>? porEmisora,
|
|
bool activo = true,
|
|
bool eqMultiDeviceEnabled = false,
|
|
Map<String, PresetEcualizador>? presetsDispositivo,
|
|
Map<String, PresetEcualizador>? presetsMatriz,
|
|
Map<String, String>? nombresDispositivos,
|
|
}) : _config = ConfiguracionEcualizador(
|
|
principal: principal ?? PresetEcualizador.flat,
|
|
porEmisora: porEmisora ?? {},
|
|
activo: activo,
|
|
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
|
|
presetsDispositivo: presetsDispositivo ?? {},
|
|
presetsMatriz: presetsMatriz ?? {},
|
|
nombresDispositivos: nombresDispositivos ?? {},
|
|
);
|
|
|
|
ConfiguracionEcualizador _config;
|
|
ConfiguracionEcualizador get config => _config;
|
|
|
|
@override
|
|
Future<ConfiguracionEcualizador> cargar() async => _config;
|
|
|
|
@override
|
|
Future<void> guardarPrincipal(PresetEcualizador preset) async {
|
|
_config = ConfiguracionEcualizador(
|
|
principal: preset,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
nombresDispositivos: _config.nombresDispositivos,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarActivo(bool activo) async {
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
nombresDispositivos: _config.nombresDispositivos,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
|
|
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
|
|
mapa[uuid] = preset;
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: mapa,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> eliminarPorEmisora(String uuid) async {
|
|
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
|
|
mapa.remove(uuid);
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: mapa,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarToggleMultiDispositivo(bool habilitado) async {
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: habilitado,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarPresetDispositivo(
|
|
String deviceId,
|
|
PresetEcualizador preset,
|
|
) async {
|
|
final mapa = Map<String, PresetEcualizador>.from(
|
|
_config.presetsDispositivo,
|
|
);
|
|
mapa[deviceId] = preset;
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: mapa,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarPresetMatriz(
|
|
String clave,
|
|
PresetEcualizador preset,
|
|
) async {
|
|
final mapa = Map<String, PresetEcualizador>.from(_config.presetsMatriz);
|
|
mapa[clave] = preset;
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: mapa,
|
|
nombresDispositivos: _config.nombresDispositivos,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarNombresDispositivos(Map<String, String> nombres) async {
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
nombresDispositivos: nombres,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> eliminarDispositivo(String deviceId) async {
|
|
final presets = Map<String, PresetEcualizador>.from(
|
|
_config.presetsDispositivo,
|
|
)..remove(deviceId);
|
|
final nombres = Map<String, String>.from(_config.nombresDispositivos)
|
|
..remove(deviceId);
|
|
final matriz = Map<String, PresetEcualizador>.from(_config.presetsMatriz)
|
|
..removeWhere((clave, _) {
|
|
final separador = clave.indexOf(':');
|
|
if (separador == -1) return false;
|
|
return clave.substring(separador + 1) == deviceId;
|
|
});
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: presets,
|
|
presetsMatriz: matriz,
|
|
nombresDispositivos: nombres,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// In-memory fake for [ServicioPresetsPersonalizados] (WU13). Avoids any
|
|
/// real SharedPreferences I/O in state-layer tests, matching every other
|
|
/// `Fake*` service in this file.
|
|
class FakeServicioPresetsPersonalizados extends ServicioPresetsPersonalizados {
|
|
final List<PresetEcualizador> _presets = [];
|
|
|
|
@override
|
|
Future<List<PresetEcualizador>> listar() async => List.from(_presets);
|
|
|
|
@override
|
|
Future<void> guardar(PresetEcualizador preset) async {
|
|
_presets.removeWhere((p) => p.nombre == preset.nombre);
|
|
_presets.add(preset);
|
|
}
|
|
|
|
@override
|
|
Future<void> eliminar(String nombre) async {
|
|
_presets.removeWhere((p) => p.nombre == nombre);
|
|
}
|
|
}
|
|
|
|
/// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual].
|
|
///
|
|
/// Used to test the graceful-failure path in [EstadoEcualizador.cargarPersistido].
|
|
class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
|
|
final _controller = StreamController<DispositivoAudio>.broadcast();
|
|
|
|
@override
|
|
DispositivoAudio? get dispositivoActual => null;
|
|
|
|
@override
|
|
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
|
|
|
|
/// Number of times [resubscribir] was called.
|
|
int resubscribirLlamadas = 0;
|
|
|
|
@override
|
|
Future<DispositivoAudio> obtenerDispositivoActual() async {
|
|
throw Exception('Platform channel error: device unavailable');
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, String>> obtenerNombresEmparejados() async {
|
|
throw Exception('Platform channel error: bonded devices unavailable');
|
|
}
|
|
|
|
@override
|
|
Future<void> resubscribir() async {
|
|
resubscribirLlamadas++;
|
|
throw Exception('Platform channel error: resubscribe unavailable');
|
|
}
|
|
|
|
@override
|
|
Future<bool> solicitarPermisoBluetooth() async => true;
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
await _controller.close();
|
|
}
|
|
}
|
|
|
|
/// Fake implementation of [ServicioDispositivoAudio] for unit tests.
|
|
///
|
|
/// Use [emitirDispositivo] to push device change events without needing
|
|
/// a real platform channel.
|
|
class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
|
FakeServicioDispositivoAudio({
|
|
this.permisoBluetoothConcedido = true,
|
|
this.nombresEmparejados = const {},
|
|
});
|
|
|
|
/// MAC → name entries the system would report for PAIRED devices.
|
|
final Map<String, String> nombresEmparejados;
|
|
|
|
@override
|
|
Future<Map<String, String>> obtenerNombresEmparejados() async =>
|
|
nombresEmparejados;
|
|
|
|
/// Value returned by [solicitarPermisoBluetooth] (bt-device-identity
|
|
/// Phase 2/4 testing strategy — defaults to granted so existing tests
|
|
/// that don't care about the permission flow are unaffected).
|
|
final bool permisoBluetoothConcedido;
|
|
|
|
/// Number of times [solicitarPermisoBluetooth] was called.
|
|
int solicitarPermisoBluetoothCalls = 0;
|
|
|
|
/// Number of times [resubscribir] was called.
|
|
int resubscribirLlamadas = 0;
|
|
|
|
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',
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> resubscribir() async {
|
|
resubscribirLlamadas++;
|
|
}
|
|
|
|
@override
|
|
Future<bool> solicitarPermisoBluetooth() async {
|
|
solicitarPermisoBluetoothCalls++;
|
|
return permisoBluetoothConcedido;
|
|
}
|
|
|
|
/// Helper for tests: push a device change event.
|
|
void emitirDispositivo(DispositivoAudio dispositivo) {
|
|
_dispositivoActual = dispositivo;
|
|
_controller.add(dispositivo);
|
|
}
|
|
|
|
/// Helper for tests: change the cached device WITHOUT emitting a stream
|
|
/// event — simulates the stale-subscription bug (activity recreation),
|
|
/// where the native device changed but the event never reached Dart and
|
|
/// only a fresh query ([obtenerDispositivoActual]) sees the new value.
|
|
void establecerDispositivoSinEvento(DispositivoAudio dispositivo) {
|
|
_dispositivoActual = dispositivo;
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
await _controller.close();
|
|
}
|
|
}
|
|
|
|
/// WU6: promoted from a local class in `pantalla_inicio_test.dart` to this
|
|
/// shared helpers file, since `pantalla_buscar_test.dart` now needs the same
|
|
/// inert stand-in — any widget test that constructs a full `EstadoRadio`
|
|
/// must supply a `servicioGrabacion`, or the default REAL
|
|
/// `ServicioGrabacionRadio` touches native platform channels and hangs
|
|
/// inside `testWidgets()`.
|
|
class FakeServicioGrabacionRadio 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();
|
|
}
|
|
|
|
/// WU14: a recording fake that actually responds to `iniciar`/`detener`
|
|
/// in-memory, never touching real files or platform channels (`iniciar` on
|
|
/// the real `ServicioGrabacionRadio` opens an HTTP stream to the station's
|
|
/// URL and writes to disk — unsafe inside a widget test). Records every
|
|
/// call for assertions.
|
|
class FakeServicioGrabacionRadioActivable extends ServicioGrabacionRadio {
|
|
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
|
|
EstadoGrabacionRadio _estadoActual = const EstadoGrabacionRadio.inactiva();
|
|
final List<Duration?> duracionesIniciadas = [];
|
|
Emisora? ultimaEmisoraIniciada;
|
|
int detenerCalls = 0;
|
|
|
|
@override
|
|
EstadoGrabacionRadio get estado => _estadoActual;
|
|
|
|
@override
|
|
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
|
|
|
@override
|
|
Future<void> inicializar() async {}
|
|
|
|
@override
|
|
Future<void> iniciar(
|
|
Emisora emisora, {
|
|
Duration? duracion,
|
|
String? directorio,
|
|
}) async {
|
|
ultimaEmisoraIniciada = emisora;
|
|
duracionesIniciadas.add(duracion);
|
|
_estadoActual = EstadoGrabacionRadio(
|
|
tipo: EstadoGrabacionRadioTipo.grabando,
|
|
emisora: emisora,
|
|
inicio: DateTime.now(),
|
|
duracionObjetivo: duracion,
|
|
);
|
|
_controller.add(_estadoActual);
|
|
}
|
|
|
|
@override
|
|
Future<void> detener() async {
|
|
detenerCalls++;
|
|
_estadoActual = const EstadoGrabacionRadio.inactiva();
|
|
_controller.add(_estadoActual);
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() => _controller.close();
|
|
}
|
|
|
|
Emisora emisoraDemo({
|
|
required String uuid,
|
|
required String nombre,
|
|
String url = 'https://stream.demo/radio',
|
|
}) {
|
|
return Emisora(uuid: uuid, nombre: nombre, url: url);
|
|
}
|