feat(stations): add quality filters and list ordering
This commit is contained in:
@@ -34,3 +34,14 @@
|
|||||||
## Búsqueda de emisoras
|
## Búsqueda de emisoras
|
||||||
|
|
||||||
- [ ] Añadir filtro de calidad mínima de reproducción en kbps en el buscador de emisoras.
|
- [ ] Añadir filtro de calidad mínima de reproducción en kbps en el buscador de emisoras.
|
||||||
|
## Favoritos
|
||||||
|
|
||||||
|
- [ ] Revisar el sistema de guardado de favoritos en instalaciones nuevas y migradas: inicializaci?n de SQLite, creaci?n de ruta/base de datos, migraciones de columnas y refresco de estado tras guardar. Reporte: en un m?vil no se est?n guardando favoritos.
|
||||||
|
- [ ] A?adir tests de regresi?n para favoritos en base de datos real/migrada, incluyendo esquemas antiguos y primera instalaci?n limpia.
|
||||||
|
## Agrupaciones de favoritos
|
||||||
|
|
||||||
|
- [ ] Permitir crear listas de favoritos con nombre corto configurable por el usuario desde Ajustes.
|
||||||
|
- [ ] Mantener siempre un grupo interno por defecto traducible llamado "Sin asignar", no editable y no borrable.
|
||||||
|
- [ ] Gestionar desde la vista Favoritos qu? emisoras pertenecen a cada agrupaci?n/lista.
|
||||||
|
- [ ] Dise?ar migraci?n SQLite para asociar favoritos existentes al grupo "Sin asignar" sin perder datos.
|
||||||
|
|
||||||
|
|||||||
+100
-17
@@ -18,6 +18,8 @@ import '../servicios/servicio_grabacion_radio.dart';
|
|||||||
import '../servicios/servicio_radio.dart';
|
import '../servicios/servicio_radio.dart';
|
||||||
import '../servicios/servicio_timer.dart';
|
import '../servicios/servicio_timer.dart';
|
||||||
|
|
||||||
|
enum OrdenEmisoras { nombre, calidad }
|
||||||
|
|
||||||
/// Estado global de la app con ChangeNotifier (Provider).
|
/// Estado global de la app con ChangeNotifier (Provider).
|
||||||
class EstadoRadio extends ChangeNotifier {
|
class EstadoRadio extends ChangeNotifier {
|
||||||
EstadoRadio({
|
EstadoRadio({
|
||||||
@@ -86,8 +88,10 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
String? _ultimoPaisBusqueda;
|
String? _ultimoPaisBusqueda;
|
||||||
String? _ultimoIdiomaBusqueda;
|
String? _ultimoIdiomaBusqueda;
|
||||||
String? _ultimoTagBusqueda;
|
String? _ultimoTagBusqueda;
|
||||||
|
int? _ultimoMinBitrateBusqueda;
|
||||||
String? _errorCarga;
|
String? _errorCarga;
|
||||||
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
|
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
|
||||||
|
static const _keyOrdenListas = 'orden_listas_emisoras_v1';
|
||||||
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
|
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
|
||||||
static const _timerSuenoPresetsDefecto = <int>[
|
static const _timerSuenoPresetsDefecto = <int>[
|
||||||
180,
|
180,
|
||||||
@@ -103,13 +107,14 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
List<int> _timerSuenoPresetsSegundos = List<int>.from(
|
List<int> _timerSuenoPresetsSegundos = List<int>.from(
|
||||||
_timerSuenoPresetsDefecto,
|
_timerSuenoPresetsDefecto,
|
||||||
);
|
);
|
||||||
|
OrdenEmisoras _ordenListas = OrdenEmisoras.calidad;
|
||||||
|
|
||||||
List<Emisora> get populares => _populares;
|
List<Emisora> get populares => _ordenarEmisoras(_populares);
|
||||||
List<Emisora> get tendencias => _tendencias;
|
List<Emisora> get tendencias => _ordenarEmisoras(_tendencias);
|
||||||
List<Emisora> get resultadosBusqueda => _resultadosBusqueda;
|
List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda);
|
||||||
List<Emisora> get emisorasCercanas => _emisorasCercanas;
|
List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas);
|
||||||
List<Emisora> get listaFavoritos => _listaFavoritos;
|
List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos);
|
||||||
List<Emisora> get emisorasCustom => _emisorasCustom;
|
List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom);
|
||||||
bool get cargandoPopulares => _cargandoPopulares;
|
bool get cargandoPopulares => _cargandoPopulares;
|
||||||
bool get cargandoBusqueda => _cargandoBusqueda;
|
bool get cargandoBusqueda => _cargandoBusqueda;
|
||||||
bool get cargandoMasBusqueda => _cargandoMasBusqueda;
|
bool get cargandoMasBusqueda => _cargandoMasBusqueda;
|
||||||
@@ -126,6 +131,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal;
|
PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal;
|
||||||
bool get ecualizadorActivo => _ecualizadorActivo;
|
bool get ecualizadorActivo => _ecualizadorActivo;
|
||||||
bool get ecualizadorDisponible => audio.ecualizadorDisponible;
|
bool get ecualizadorDisponible => audio.ecualizadorDisponible;
|
||||||
|
OrdenEmisoras get ordenListas => _ordenListas;
|
||||||
List<int> get timerSuenoPresetsSegundos =>
|
List<int> get timerSuenoPresetsSegundos =>
|
||||||
List<int>.unmodifiable(_timerSuenoPresetsSegundos);
|
List<int>.unmodifiable(_timerSuenoPresetsSegundos);
|
||||||
|
|
||||||
@@ -145,6 +151,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
bool get grabacionActiva => grabacion.estado.activa;
|
bool get grabacionActiva => grabacion.estado.activa;
|
||||||
String? get directorioGrabacion => grabacion.directorioConfigurado;
|
String? get directorioGrabacion => grabacion.directorioConfigurado;
|
||||||
int get maxBytesGrabacion => grabacion.maxBytes;
|
int get maxBytesGrabacion => grabacion.maxBytes;
|
||||||
|
File? get ultimaGrabacion => grabacion.ultimoArchivo;
|
||||||
|
|
||||||
/// Lista principal (home): custom + populares, sin duplicados.
|
/// Lista principal (home): custom + populares, sin duplicados.
|
||||||
List<Emisora> get emisorasInicio {
|
List<Emisora> get emisorasInicio {
|
||||||
@@ -189,6 +196,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
await grabacion.inicializar();
|
await grabacion.inicializar();
|
||||||
await _cargarEcualizadorPersistido();
|
await _cargarEcualizadorPersistido();
|
||||||
|
await _cargarOrdenListas();
|
||||||
await _cargarEmisoraPreferida();
|
await _cargarEmisoraPreferida();
|
||||||
await _cargarTimerSuenoPresets();
|
await _cargarTimerSuenoPresets();
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
@@ -314,6 +322,23 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
_emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida);
|
_emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _cargarOrdenListas() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_keyOrdenListas);
|
||||||
|
_ordenListas = switch (raw) {
|
||||||
|
'nombre' => OrdenEmisoras.nombre,
|
||||||
|
'calidad' => OrdenEmisoras.calidad,
|
||||||
|
_ => OrdenEmisoras.calidad,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cambiarOrdenListas(OrdenEmisoras orden) async {
|
||||||
|
_ordenListas = orden;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyOrdenListas, orden.name);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _normalizarEmisoraPreferida() async {
|
Future<void> _normalizarEmisoraPreferida() async {
|
||||||
final preferida = _resolverEmisoraPreferida();
|
final preferida = _resolverEmisoraPreferida();
|
||||||
if (preferida?.uuid == _emisoraPreferidaUuid) return;
|
if (preferida?.uuid == _emisoraPreferidaUuid) return;
|
||||||
@@ -351,28 +376,27 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
String? pais,
|
String? pais,
|
||||||
String? idioma,
|
String? idioma,
|
||||||
String? tag,
|
String? tag,
|
||||||
|
int? minBitrate,
|
||||||
}) async {
|
}) async {
|
||||||
_ultimoNombreBusqueda = nombre;
|
_ultimoNombreBusqueda = nombre;
|
||||||
_ultimoPaisBusqueda = pais;
|
_ultimoPaisBusqueda = pais;
|
||||||
_ultimoIdiomaBusqueda = idioma;
|
_ultimoIdiomaBusqueda = idioma;
|
||||||
_ultimoTagBusqueda = tag;
|
_ultimoTagBusqueda = tag;
|
||||||
|
_ultimoMinBitrateBusqueda = minBitrate;
|
||||||
_offsetBusqueda = 0;
|
_offsetBusqueda = 0;
|
||||||
_hayMasBusqueda = true;
|
_hayMasBusqueda = true;
|
||||||
_cargandoBusqueda = true;
|
_cargandoBusqueda = true;
|
||||||
_resultadosBusqueda = [];
|
_resultadosBusqueda = [];
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final pagina = await radio.buscar(
|
final pagina = await _buscarPaginaFiltrada(
|
||||||
nombre: nombre,
|
nombre: nombre,
|
||||||
pais: pais,
|
pais: pais,
|
||||||
idioma: idioma,
|
idioma: idioma,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
limit: _tamanoPaginaBusqueda,
|
minBitrate: minBitrate,
|
||||||
offset: _offsetBusqueda,
|
|
||||||
);
|
);
|
||||||
_resultadosBusqueda = pagina;
|
_resultadosBusqueda = pagina;
|
||||||
_offsetBusqueda = pagina.length;
|
|
||||||
_hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda;
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_errorController.add('Error en la busqueda. Comprueba tu conexion.');
|
_errorController.add('Error en la busqueda. Comprueba tu conexion.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -386,13 +410,12 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
_cargandoMasBusqueda = true;
|
_cargandoMasBusqueda = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final pagina = await radio.buscar(
|
final pagina = await _buscarPaginaFiltrada(
|
||||||
nombre: _ultimoNombreBusqueda,
|
nombre: _ultimoNombreBusqueda,
|
||||||
pais: _ultimoPaisBusqueda,
|
pais: _ultimoPaisBusqueda,
|
||||||
idioma: _ultimoIdiomaBusqueda,
|
idioma: _ultimoIdiomaBusqueda,
|
||||||
tag: _ultimoTagBusqueda,
|
tag: _ultimoTagBusqueda,
|
||||||
limit: _tamanoPaginaBusqueda,
|
minBitrate: _ultimoMinBitrateBusqueda,
|
||||||
offset: _offsetBusqueda,
|
|
||||||
);
|
);
|
||||||
final porUuid = <String, Emisora>{
|
final porUuid = <String, Emisora>{
|
||||||
for (final emisora in _resultadosBusqueda) emisora.uuid: emisora,
|
for (final emisora in _resultadosBusqueda) emisora.uuid: emisora,
|
||||||
@@ -407,8 +430,8 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
_resultadosBusqueda = nuevaLista;
|
_resultadosBusqueda = nuevaLista;
|
||||||
_offsetBusqueda += pagina.length;
|
// _buscarPaginaFiltrada actualiza offset/hayMas usando p?ginas crudas.
|
||||||
_hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda;
|
_hayMasBusqueda = _hayMasBusqueda && pagina.isNotEmpty;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_errorController.add('No se pudieron cargar mas emisoras.');
|
_errorController.add('No se pudieron cargar mas emisoras.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -417,6 +440,54 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Emisora>> _buscarPaginaFiltrada({
|
||||||
|
String? nombre,
|
||||||
|
String? pais,
|
||||||
|
String? idioma,
|
||||||
|
String? tag,
|
||||||
|
int? minBitrate,
|
||||||
|
}) async {
|
||||||
|
final acumuladas = <Emisora>[];
|
||||||
|
var intentos = 0;
|
||||||
|
while (intentos < 4 && acumuladas.isEmpty && _hayMasBusqueda) {
|
||||||
|
final pagina = await radio.buscar(
|
||||||
|
nombre: nombre,
|
||||||
|
pais: pais,
|
||||||
|
idioma: idioma,
|
||||||
|
tag: tag,
|
||||||
|
limit: _tamanoPaginaBusqueda,
|
||||||
|
offset: _offsetBusqueda,
|
||||||
|
);
|
||||||
|
_offsetBusqueda += pagina.length;
|
||||||
|
_hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda;
|
||||||
|
acumuladas.addAll(_filtrarMinBitrate(pagina, minBitrate));
|
||||||
|
intentos++;
|
||||||
|
}
|
||||||
|
return acumuladas;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Emisora> _filtrarMinBitrate(List<Emisora> emisoras, int? minBitrate) {
|
||||||
|
if (minBitrate == null || minBitrate <= 0) return emisoras;
|
||||||
|
return emisoras.where((e) => (e.bitrate ?? 0) >= minBitrate).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Emisora> _ordenarEmisoras(List<Emisora> emisoras) {
|
||||||
|
final ordenadas = List<Emisora>.from(emisoras);
|
||||||
|
switch (_ordenListas) {
|
||||||
|
case OrdenEmisoras.nombre:
|
||||||
|
ordenadas.sort(
|
||||||
|
(a, b) => a.nombre.toLowerCase().compareTo(b.nombre.toLowerCase()),
|
||||||
|
);
|
||||||
|
case OrdenEmisoras.calidad:
|
||||||
|
ordenadas.sort((a, b) {
|
||||||
|
final porBitrate = (b.bitrate ?? 0).compareTo(a.bitrate ?? 0);
|
||||||
|
if (porBitrate != 0) return porBitrate;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ordenadas;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> cargarEmisorasCercanas() async {
|
Future<void> cargarEmisorasCercanas() async {
|
||||||
_cargandoCercanas = true;
|
_cargandoCercanas = true;
|
||||||
_errorCercanas = null;
|
_errorCercanas = null;
|
||||||
@@ -451,7 +522,10 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
throw Exception('No se pudo detectar tu region');
|
throw Exception('No se pudo detectar tu region');
|
||||||
}
|
}
|
||||||
_paisCercanoDetectado = pais;
|
_paisCercanoDetectado = pais;
|
||||||
_emisorasCercanas = await radio.buscar(pais: pais, limit: 30);
|
_emisorasCercanas = _filtrarMinBitrate(
|
||||||
|
await radio.buscar(pais: pais, limit: 30),
|
||||||
|
_ultimoMinBitrateBusqueda,
|
||||||
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_errorCercanas =
|
_errorCercanas =
|
||||||
'No pudimos detectar emisoras cercanas. Usa filtros por pais.';
|
'No pudimos detectar emisoras cercanas. Usa filtros por pais.';
|
||||||
@@ -527,6 +601,15 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> abrirUltimaGrabacion() async {
|
||||||
|
final archivo = ultimaGrabacion;
|
||||||
|
if (archivo == null || !await archivo.exists()) return false;
|
||||||
|
return launchUrl(
|
||||||
|
Uri.file(archivo.path),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> cambiarDirectorioGrabacion(String path) async {
|
Future<void> cambiarDirectorioGrabacion(String path) async {
|
||||||
await grabacion.guardarDirectorio(path);
|
await grabacion.guardarDirectorio(path);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ class _AjustesContent extends StatelessWidget {
|
|||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
_SeccionIdioma(),
|
_SeccionIdioma(),
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
|
_SeccionOrdenListas(),
|
||||||
|
SizedBox(height: 12),
|
||||||
_SeccionEmisoraPreferida(),
|
_SeccionEmisoraPreferida(),
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
_SeccionEmisoras(),
|
_SeccionEmisoras(),
|
||||||
@@ -641,6 +643,56 @@ class _SeccionEcualizador extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SeccionOrdenListas extends StatelessWidget {
|
||||||
|
const _SeccionOrdenListas();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final estado = context.watch<EstadoRadio>();
|
||||||
|
return PluriGlassSurface(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.sort_rounded),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Orden de emisoras',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<OrdenEmisoras>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: OrdenEmisoras.nombre,
|
||||||
|
icon: Icon(Icons.sort_by_alpha_rounded),
|
||||||
|
label: Text('Por nombre'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: OrdenEmisoras.calidad,
|
||||||
|
icon: Icon(Icons.hd_rounded),
|
||||||
|
label: Text('Por calidad'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {estado.ordenListas},
|
||||||
|
onSelectionChanged: (value) {
|
||||||
|
estado.cambiarOrdenListas(value.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Se aplica a favoritos, b?squedas, emisoras cercanas y listados r?pidos.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SeccionEmisoraPreferida extends StatelessWidget {
|
class _SeccionEmisoraPreferida extends StatelessWidget {
|
||||||
const _SeccionEmisoraPreferida();
|
const _SeccionEmisoraPreferida();
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
String? _paisSeleccionado;
|
String? _paisSeleccionado;
|
||||||
String? _idiomaSeleccionado;
|
String? _idiomaSeleccionado;
|
||||||
|
int? _calidadMinima;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -60,6 +61,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
nombre: q.isNotEmpty ? q : null,
|
nombre: q.isNotEmpty ? q : null,
|
||||||
pais: _paisSeleccionado,
|
pais: _paisSeleccionado,
|
||||||
idioma: _idiomaSeleccionado,
|
idioma: _idiomaSeleccionado,
|
||||||
|
minBitrate: _calidadMinima,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +110,6 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_seccionCercanas(estado),
|
|
||||||
_seccionFiltro(
|
_seccionFiltro(
|
||||||
'Pais',
|
'Pais',
|
||||||
_paises.map((p) => (p.$1, p.$2)).toList(),
|
_paises.map((p) => (p.$1, p.$2)).toList(),
|
||||||
@@ -127,80 +128,20 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
_buscar();
|
_buscar();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_seccionFiltroInt(
|
||||||
|
'Calidad m?nima',
|
||||||
|
const [('64 kbps', 64), ('96 kbps', 96), ('128 kbps', 128), ('192 kbps', 192), ('320 kbps', 320)],
|
||||||
|
_calidadMinima,
|
||||||
|
(v) {
|
||||||
|
setState(() => _calidadMinima = v);
|
||||||
|
_buscar();
|
||||||
|
},
|
||||||
|
),
|
||||||
_resultados(estado, theme),
|
_resultados(estado, theme),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _seccionCercanas(EstadoRadio estado) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final pais = estado.paisCercanoDetectado;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
|
|
||||||
child: PluriGlassSurface(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
pais == null ? 'Emisoras cercanas' : 'Emisoras cercanas - $pais',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: estado.cargandoCercanas
|
|
||||||
? null
|
|
||||||
: estado.cargarEmisorasCercanas,
|
|
||||||
icon: estado.cargandoCercanas
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.my_location_rounded, size: 18),
|
|
||||||
label: const Text('Buscar cerca'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (estado.errorCercanas != null)
|
|
||||||
Text(
|
|
||||||
estado.errorCercanas!,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (estado.emisorasCercanas.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SizedBox(
|
|
||||||
height: 76,
|
|
||||||
child: ListView.separated(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: estado.emisorasCercanas.length,
|
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final emisora = estado.emisorasCercanas[i];
|
|
||||||
return SizedBox(
|
|
||||||
width: 260,
|
|
||||||
child: TarjetaEmisora(
|
|
||||||
emisora: emisora,
|
|
||||||
esCompacta: true,
|
|
||||||
onTap: () => reproducirMinimizado(context, emisora),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _seccionFiltro(
|
Widget _seccionFiltro(
|
||||||
String titulo,
|
String titulo,
|
||||||
@@ -247,6 +188,51 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _seccionFiltroInt(
|
||||||
|
String titulo,
|
||||||
|
List<(String, int)> opciones,
|
||||||
|
int? seleccionado,
|
||||||
|
void Function(int?) onChanged,
|
||||||
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
|
||||||
|
child: PluriGlassSurface(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
titulo,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: opciones.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final (label, value) = opciones[i];
|
||||||
|
final sel = seleccionado == value;
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: sel,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onSelected: (_) => onChanged(sel ? null : value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _resultados(EstadoRadio estado, ThemeData theme) {
|
Widget _resultados(EstadoRadio estado, ThemeData theme) {
|
||||||
if (estado.cargandoBusqueda) {
|
if (estado.cargandoBusqueda) {
|
||||||
return const SizedBox(
|
return const SizedBox(
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ class _GrabacionWidget extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final grabacion = estado.estadoGrabacion;
|
final grabacion = estado.estadoGrabacion;
|
||||||
final activa = grabacion.activa;
|
final activa = grabacion.activa;
|
||||||
|
final hayUltimaGrabacion = estado.ultimaGrabacion != null;
|
||||||
|
|
||||||
return PluriGlassSurface(
|
return PluriGlassSurface(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
@@ -397,19 +398,43 @@ class _GrabacionWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton.tonalIcon(
|
Wrap(
|
||||||
icon: Icon(activa ? Icons.stop_rounded : Icons.mic_rounded),
|
spacing: 8,
|
||||||
label: Text(activa ? 'Parar' : 'Grabar'),
|
runSpacing: 8,
|
||||||
onPressed:
|
alignment: WrapAlignment.end,
|
||||||
activa
|
children: [
|
||||||
? estado.detenerGrabacion
|
FilledButton.tonalIcon(
|
||||||
: () => _mostrarDialogoGrabacion(context),
|
icon: Icon(activa ? Icons.stop_rounded : Icons.mic_rounded),
|
||||||
|
label: Text(activa ? 'Parar' : 'Grabar'),
|
||||||
|
onPressed:
|
||||||
|
activa
|
||||||
|
? estado.detenerGrabacion
|
||||||
|
: () => _mostrarDialogoGrabacion(context),
|
||||||
|
),
|
||||||
|
if (!activa && hayUltimaGrabacion)
|
||||||
|
IconButton.filledTonal(
|
||||||
|
tooltip: 'Abrir ?ltima grabaci?n',
|
||||||
|
icon: const Icon(Icons.audio_file_rounded),
|
||||||
|
onPressed: () => _abrirUltimaGrabacion(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _abrirUltimaGrabacion(BuildContext context) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final abierto = await estado.abrirUltimaGrabacion();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (!abierto) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('No se pudo abrir la ?ltima grabaci?n')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _mostrarDialogoGrabacion(BuildContext context) {
|
void _mostrarDialogoGrabacion(BuildContext context) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
@@ -19,12 +21,14 @@ class ServicioFavoritos {
|
|||||||
|
|
||||||
Future<Database> _initDb() async {
|
Future<Database> _initDb() async {
|
||||||
final dbPath = await getDatabasesPath();
|
final dbPath = await getDatabasesPath();
|
||||||
|
await Directory(dbPath).create(recursive: true);
|
||||||
final path = join(dbPath, _dbName);
|
final path = join(dbPath, _dbName);
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
path,
|
path,
|
||||||
version: _dbVersion,
|
version: _dbVersion,
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
|
onOpen: _asegurarEsquema,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,14 +54,43 @@ class ServicioFavoritos {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
if (oldVersion < 2) {
|
await _asegurarEsquema(db);
|
||||||
// v1→v2: añadir columnas de la Radio Browser API
|
}
|
||||||
await db.execute('ALTER TABLE favoritos ADD COLUMN codigo_pais TEXT');
|
|
||||||
await db.execute('ALTER TABLE favoritos ADD COLUMN codec TEXT');
|
Future<void> _asegurarEsquema(Database db) async {
|
||||||
await db.execute('ALTER TABLE favoritos ADD COLUMN bitrate INTEGER');
|
final tablas = await db.rawQuery(
|
||||||
await db.execute('ALTER TABLE favoritos ADD COLUMN votes INTEGER NOT NULL DEFAULT 0');
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'favoritos'",
|
||||||
await db.execute('ALTER TABLE favoritos ADD COLUMN clickcount INTEGER NOT NULL DEFAULT 0');
|
);
|
||||||
|
if (tablas.isEmpty) {
|
||||||
|
await _onCreate(db, _dbVersion);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final columnas = await _columnas(db, 'favoritos');
|
||||||
|
Future<void> addColumn(String nombre, String sql) async {
|
||||||
|
if (!columnas.contains(nombre)) {
|
||||||
|
await db.execute('ALTER TABLE favoritos ADD COLUMN $nombre $sql');
|
||||||
|
columnas.add(nombre);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migración defensiva: algunas instalaciones antiguas pueden venir de
|
||||||
|
// esquemas intermedios. No asumimos qué columna existe: la verificamos.
|
||||||
|
await addColumn('favicon', 'TEXT');
|
||||||
|
await addColumn('pais', 'TEXT');
|
||||||
|
await addColumn('codigo_pais', 'TEXT');
|
||||||
|
await addColumn('idioma', 'TEXT');
|
||||||
|
await addColumn('tags', 'TEXT');
|
||||||
|
await addColumn('codec', 'TEXT');
|
||||||
|
await addColumn('bitrate', 'INTEGER');
|
||||||
|
await addColumn('votes', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
await addColumn('clickcount', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
await addColumn('orden', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Set<String>> _columnas(Database db, String tabla) async {
|
||||||
|
final info = await db.rawQuery('PRAGMA table_info($tabla)');
|
||||||
|
return info.map((row) => row['name'] as String).toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Devuelve todas las emisoras favoritas ordenadas por [orden].
|
/// Devuelve todas las emisoras favoritas ordenadas por [orden].
|
||||||
|
|||||||
@@ -100,11 +100,13 @@ class ServicioGrabacionRadio {
|
|||||||
Timer? _timerAutoStop;
|
Timer? _timerAutoStop;
|
||||||
String? _directorioConfigurado;
|
String? _directorioConfigurado;
|
||||||
int _maxBytes = maxBytesPorDefecto;
|
int _maxBytes = maxBytesPorDefecto;
|
||||||
|
File? _ultimoArchivo;
|
||||||
|
|
||||||
EstadoGrabacionRadio get estado => _estado;
|
EstadoGrabacionRadio get estado => _estado;
|
||||||
Stream<EstadoGrabacionRadio> get estadoStream => _estadoController.stream;
|
Stream<EstadoGrabacionRadio> get estadoStream => _estadoController.stream;
|
||||||
String? get directorioConfigurado => _directorioConfigurado;
|
String? get directorioConfigurado => _directorioConfigurado;
|
||||||
int get maxBytes => _maxBytes;
|
int get maxBytes => _maxBytes;
|
||||||
|
File? get ultimoArchivo => _ultimoArchivo;
|
||||||
|
|
||||||
Future<void> inicializar() async {
|
Future<void> inicializar() async {
|
||||||
try {
|
try {
|
||||||
@@ -244,6 +246,7 @@ class ServicioGrabacionRadio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _finalizar() async {
|
Future<void> _finalizar() async {
|
||||||
|
final archivoFinalizado = _estado.archivo;
|
||||||
_timerAutoStop?.cancel();
|
_timerAutoStop?.cancel();
|
||||||
_timerAutoStop = null;
|
_timerAutoStop = null;
|
||||||
await _subscripcionStream?.cancel();
|
await _subscripcionStream?.cancel();
|
||||||
@@ -255,6 +258,9 @@ class ServicioGrabacionRadio {
|
|||||||
_clienteActivo?.close();
|
_clienteActivo?.close();
|
||||||
}
|
}
|
||||||
_clienteActivo = null;
|
_clienteActivo = null;
|
||||||
|
if (archivoFinalizado != null) {
|
||||||
|
_ultimoArchivo = archivoFinalizado;
|
||||||
|
}
|
||||||
_emitir(const EstadoGrabacionRadio.inactiva());
|
_emitir(const EstadoGrabacionRadio.inactiva());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ import 'package:pluriwave/estado/estado_radio.dart';
|
|||||||
import 'package:pluriwave/modelos/emisora.dart';
|
import 'package:pluriwave/modelos/emisora.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:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../helpers/fakes.dart';
|
import '../helpers/fakes.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
group('EstadoRadio integración de custom + EQ persistente', () {
|
group('EstadoRadio integración de custom + EQ persistente', () {
|
||||||
test('incluye emisoras custom en el listado principal de inicio', () async {
|
test('incluye emisoras custom en el listado principal de inicio', () async {
|
||||||
final archivo = await _crearArchivoCustom([
|
final archivo = await _crearArchivoCustom([
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ void main() {
|
|||||||
expect(archivos.single.path, endsWith('.mp3'));
|
expect(archivos.single.path, endsWith('.mp3'));
|
||||||
expect(await File(archivos.single.path).readAsBytes(), [1, 2, 3, 4, 5]);
|
expect(await File(archivos.single.path).readAsBytes(), [1, 2, 3, 4, 5]);
|
||||||
expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.inactiva);
|
expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.inactiva);
|
||||||
|
expect(servicio.ultimoArchivo?.path, archivos.single.path);
|
||||||
|
|
||||||
await servicio.dispose();
|
await servicio.dispose();
|
||||||
},
|
},
|
||||||
@@ -74,6 +75,7 @@ void main() {
|
|||||||
await servicio.detener();
|
await servicio.detener();
|
||||||
|
|
||||||
expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.inactiva);
|
expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.inactiva);
|
||||||
|
expect(servicio.ultimoArchivo, isNotNull);
|
||||||
await controller.close();
|
await controller.close();
|
||||||
await servicio.dispose();
|
await servicio.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user