feat(stations): add quality filters and list ordering
Build & Deploy Pluriwave / Análisis de código (push) Successful in 26s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m42s

This commit is contained in:
2026-05-22 15:54:39 +02:00
parent 0114e4805e
commit f667277e35
9 changed files with 306 additions and 101 deletions
+11
View File
@@ -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
View File
@@ -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();
+52
View File
@@ -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();
+56 -70
View File
@@ -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(
+32 -7
View File
@@ -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,
+40 -7
View File
@@ -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);
// v1v2: 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
View File
@@ -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();
}); });