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
+52
View File
@@ -62,6 +62,8 @@ class _AjustesContent extends StatelessWidget {
SizedBox(height: 12),
_SeccionIdioma(),
SizedBox(height: 12),
_SeccionOrdenListas(),
SizedBox(height: 12),
_SeccionEmisoraPreferida(),
SizedBox(height: 12),
_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 {
const _SeccionEmisoraPreferida();
+56 -70
View File
@@ -47,6 +47,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
final _controller = TextEditingController();
String? _paisSeleccionado;
String? _idiomaSeleccionado;
int? _calidadMinima;
@override
void dispose() {
@@ -60,6 +61,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
nombre: q.isNotEmpty ? q : null,
pais: _paisSeleccionado,
idioma: _idiomaSeleccionado,
minBitrate: _calidadMinima,
);
}
@@ -108,7 +110,6 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
),
),
_seccionCercanas(estado),
_seccionFiltro(
'Pais',
_paises.map((p) => (p.$1, p.$2)).toList(),
@@ -127,80 +128,20 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
_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),
],
);
}
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(
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) {
if (estado.cargandoBusqueda) {
return const SizedBox(
+32 -7
View File
@@ -361,6 +361,7 @@ class _GrabacionWidget extends StatelessWidget {
final theme = Theme.of(context);
final grabacion = estado.estadoGrabacion;
final activa = grabacion.activa;
final hayUltimaGrabacion = estado.ultimaGrabacion != null;
return PluriGlassSurface(
borderRadius: BorderRadius.circular(24),
@@ -397,19 +398,43 @@ class _GrabacionWidget extends StatelessWidget {
),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
icon: Icon(activa ? Icons.stop_rounded : Icons.mic_rounded),
label: Text(activa ? 'Parar' : 'Grabar'),
onPressed:
activa
? estado.detenerGrabacion
: () => _mostrarDialogoGrabacion(context),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
FilledButton.tonalIcon(
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) {
showModalBottomSheet(
context: context,