feat(buscar): add discovery landing state, filter pills, counter, and sort

This commit is contained in:
2026-07-29 08:45:36 +02:00
parent 3a803ce2bf
commit 9bd828139f
29 changed files with 1789 additions and 655 deletions
+660 -82
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_busqueda.dart';
import '../tema/pluri_animate.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluri_animate.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -37,6 +40,28 @@ const _idiomas = [
('russian', 'languageNameRussian'),
];
const _calidades = [
('64 kbps', 64),
('96 kbps', 96),
('128 kbps', 128),
('192 kbps', 192),
('320 kbps', 320),
];
/// WU6, `station-discovery-browse` spec: Buscar now owns BOTH the discovery
/// landing state (relocated from `PantallaInicio`, task 6.5 — `_seccionCercanas`,
/// `_seccionTendencias`, `_chipGeneros`, `_errorBanner` and the browse grid
/// all moved here verbatim and were deleted from `pantalla_inicio.dart`) and
/// the free-text/filtered search results view. [_hayBusquedaActiva] is the
/// single switch between the two: empty query AND no active filter shows
/// discovery content; any of the three flips to the results view.
///
/// Design correction, documented like WU3a's own task 3a.1: the spec's
/// landing-state scenario also lists a "Países entry point", but no such
/// affordance existed anywhere in `pantalla_inicio.dart` to relocate —
/// `PantallaPaises` itself doesn't exist until WU7. That entry point is
/// added in WU7 alongside the screen it targets (design's own component
/// inventory: "Países (WU7)"), not invented here pointing nowhere.
class PantallaBuscar extends StatefulWidget {
const PantallaBuscar({super.key});
@@ -50,6 +75,33 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
String? _idiomaSeleccionado;
int? _calidadMinima;
// Relocated from PantallaInicio (task 6.5) — genre-chip selection only
// drives the LANDING grid, same as it always did on Inicio; it is
// orthogonal to the free-text/filter search flow below.
static const _generos = [
'pop',
'rock',
'jazz',
'classical',
'electronic',
'news',
'talk',
'hip-hop',
'country',
'metal',
'reggae',
'latin',
];
String? _generoSeleccionado;
int get _filtrosActivosCount =>
(_paisSeleccionado != null ? 1 : 0) +
(_idiomaSeleccionado != null ? 1 : 0) +
(_calidadMinima != null ? 1 : 0);
bool get _hayBusquedaActiva =>
_controller.text.trim().isNotEmpty || _filtrosActivosCount > 0;
@override
void dispose() {
_controller.dispose();
@@ -66,6 +118,15 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
);
}
void _quitarTodosLosFiltros() {
setState(() {
_paisSeleccionado = null;
_idiomaSeleccionado = null;
_calidadMinima = null;
});
_buscar();
}
@override
Widget build(BuildContext context) {
// S4-R3/S4-R5: this screen depends only on search state, so it watches
@@ -81,9 +142,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
title: l10n.searchScreenTitle,
subtitle: l10n.searchScreenSubtitle,
glyph: PluriIconGlyph.search,
trailing: PluriStatusPill(
icon: Icons.tune_rounded,
label: l10n.searchFiltersLabel,
trailing: GestureDetector(
onTap: _abrirFiltros,
child: PluriStatusPill(
icon: Icons.tune_rounded,
label: l10n.searchFiltersLabel,
),
),
),
Padding(
@@ -119,44 +183,225 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
),
),
_seccionFiltro(
l10n.searchCountryFilterLabel,
_paises.map((p) => (_countryLabel(l10n, p.$1), p.$2)).toList(),
_paisSeleccionado,
(v) {
setState(() => _paisSeleccionado = v);
_buscar();
},
),
_seccionFiltro(
l10n.searchLanguageFilterLabel,
_idiomas.map((i) => (_languageLabel(l10n, i.$2), i.$1)).toList(),
_idiomaSeleccionado,
(v) {
setState(() => _idiomaSeleccionado = v);
_buscar();
},
),
_seccionFiltroInt(
l10n.searchMinQualityFilterLabel,
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),
if (_hayBusquedaActiva) ...[
_barraFiltrosActivos(context, estado, theme),
_resultados(estado, theme),
] else ...[
_seccionCercanas(context, theme, l10n),
_seccionTendencias(context, theme, l10n),
_chipGeneros(context, theme, l10n),
if (context.select<EstadoRadio, String?>((e) => e.error) != null)
_errorBanner(
context,
context.select<EstadoRadio, String?>((e) => e.error)!,
theme,
l10n,
),
_gridEmisoras(context, l10n),
],
],
);
}
// ── Active-filter pills, results counter, sort (task 6.6/6.7) ──────────
Widget _barraFiltrosActivos(
BuildContext context,
EstadoBusqueda estado,
ThemeData theme,
) {
final l10n = AppLocalizations.of(context);
final pills = <Widget>[
if (_paisSeleccionado != null)
_pillFiltro(
_paisLabelSeleccionado(l10n) ?? _paisSeleccionado!,
() {
setState(() => _paisSeleccionado = null);
_buscar();
},
),
if (_idiomaSeleccionado != null)
_pillFiltro(
_idiomaLabelSeleccionado(l10n) ?? _idiomaSeleccionado!,
() {
setState(() => _idiomaSeleccionado = null);
_buscar();
},
),
if (_calidadMinima != null)
_pillFiltro('$_calidadMinima kbps', () {
setState(() => _calidadMinima = null);
_buscar();
}),
];
if (pills.isEmpty && estado.resultados.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
10,
PluriLayout.horizontal,
0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (pills.isNotEmpty) Wrap(spacing: 8, runSpacing: 8, children: pills),
if (pills.isNotEmpty && estado.resultados.isNotEmpty)
const SizedBox(height: 10),
if (!estado.cargando && estado.resultados.isNotEmpty)
Row(
children: [
Text(
l10n.searchResultsCount(estado.resultados.length),
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const Spacer(),
PopupMenuButton<OrdenEmisoras>(
icon: const Icon(Icons.swap_vert_rounded),
tooltip: l10n.stationOrderTitle,
onSelected:
(criterio) => context
.read<EstadoRadio>()
.cambiarOrdenListas(criterio),
itemBuilder:
(context) => [
PopupMenuItem(
value: OrdenEmisoras.nombre,
child: Text(l10n.stationOrderByName),
),
PopupMenuItem(
value: OrdenEmisoras.calidad,
child: Text(l10n.stationOrderByQuality),
),
PopupMenuItem(
value: OrdenEmisoras.popularidad,
child: Text(l10n.stationOrderByPopularity),
),
],
),
],
),
],
),
);
}
Widget _pillFiltro(String label, VoidCallback onDeleted) {
return Chip(
label: Text(label),
onDeleted: onDeleted,
deleteIcon: const Icon(Icons.close, size: 18),
visualDensity: VisualDensity.compact,
);
}
String? _paisLabelSeleccionado(AppLocalizations l10n) {
for (final p in _paises) {
if (p.$2 == _paisSeleccionado) return _countryLabel(l10n, p.$1);
}
return null;
}
String? _idiomaLabelSeleccionado(AppLocalizations l10n) {
for (final i in _idiomas) {
if (i.$1 == _idiomaSeleccionado) return _languageLabel(l10n, i.$2);
}
return null;
}
/// Opens the filter picker as a bottom sheet (task 6.6 — "collapse the 3
/// always-visible FilterChip rows into ... bottom-sheet pickers"). Each
/// chip selection applies immediately and closes the sheet, mirroring the
/// established "tap once, sheet closes" precedent already used by
/// `pantalla_favoritos.dart`'s `_FilaFavorito._asignar` (WU4) — this
/// avoids needing a `StatefulBuilder` to keep the sheet's own chip
/// selection visually live while it stays open.
Future<void> _abrirFiltros() async {
final l10n = AppLocalizations.of(context);
await showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder:
(ctx) => SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
4,
PluriLayout.horizontal,
0,
),
child: Text(
l10n.searchFiltersLabel,
style: Theme.of(ctx).textTheme.titleLarge,
),
),
_seccionFiltro(
l10n.searchCountryFilterLabel,
_paises
.map((p) => (_countryLabel(l10n, p.$1), p.$2))
.toList(),
_paisSeleccionado,
(v) {
setState(() => _paisSeleccionado = v);
_buscar();
Navigator.of(ctx).pop();
},
),
_seccionFiltro(
l10n.searchLanguageFilterLabel,
_idiomas
.map((i) => (_languageLabel(l10n, i.$2), i.$1))
.toList(),
_idiomaSeleccionado,
(v) {
setState(() => _idiomaSeleccionado = v);
_buscar();
Navigator.of(ctx).pop();
},
),
_seccionFiltroInt(
l10n.searchMinQualityFilterLabel,
_calidades,
_calidadMinima,
(v) {
setState(() => _calidadMinima = v);
_buscar();
Navigator.of(ctx).pop();
},
),
],
),
),
),
),
);
}
/// Renders every option as a `Wrap` of `FilterChip`s — deliberately NOT a
/// horizontal `ListView` (the shape this used to have when these rows were
/// always-visible inline strips, before task 6.6 moved them into this
/// bottom sheet). Reason found at apply time: a horizontal `ListView` is
/// lazily built by viewport, and nested inside the sheet's
/// `SingleChildScrollView` its viewport-based build only ever realised the
/// first few chips per row (confirmed empirically — country's 10 options
/// built, but quality's 5th option, "320 kbps", silently never did). A
/// `Wrap` lays out every child eagerly, has no lazy-build boundary, and
/// suits a bottom sheet (vertical room to spare) better than a horizontal
/// scroll strip anyway.
Widget _seccionFiltro(
String titulo,
List<(String, String)> opciones,
@@ -183,23 +428,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
),
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(
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (label, value) in opciones)
FilterChip(
label: Text(label),
selected: sel,
selected: seleccionado == value,
visualDensity: VisualDensity.compact,
onSelected: (_) => onChanged(sel ? null : value),
);
},
),
onSelected:
(_) => onChanged(
seleccionado == value ? null : value,
),
),
],
),
],
),
@@ -233,23 +476,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
),
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(
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (label, value) in opciones)
FilterChip(
label: Text(label),
selected: sel,
selected: seleccionado == value,
visualDensity: VisualDensity.compact,
onSelected: (_) => onChanged(sel ? null : value),
);
},
),
onSelected:
(_) => onChanged(
seleccionado == value ? null : value,
),
),
],
),
],
),
@@ -278,20 +519,35 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
final resultados = estado.resultados;
if (resultados.isEmpty) {
final sinFiltros =
_controller.text.isEmpty &&
_paisSeleccionado == null &&
_idiomaSeleccionado == null;
return SizedBox(
height: 260,
child: PluriEmptyState(
glyph: PluriIconGlyph.search,
title: sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
subtitle:
sinFiltros
? l10n.searchEmptySubtitle
: l10n.searchNoResultsSubtitle,
),
final sinFiltros = _controller.text.isEmpty && _filtrosActivosCount == 0;
return Column(
children: [
SizedBox(
height: 260,
child: PluriEmptyState(
glyph: PluriIconGlyph.search,
title:
sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
subtitle:
sinFiltros
? l10n.searchEmptySubtitle
: l10n.searchNoResultsSubtitle,
),
),
// task 6.6 / spec "One-Tap Clear-All-Filters on Empty Results":
// only offered once 1+ pill-filters are active AND the search
// came back empty — not merely "no query typed yet".
if (_filtrosActivosCount > 0)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: OutlinedButton(
onPressed: _quitarTodosLosFiltros,
child: Text(
l10n.searchClearFiltersAction(_filtrosActivosCount),
),
),
),
],
);
}
@@ -354,4 +610,326 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
'languageNameRussian' => l10n.languageNameRussian,
_ => key,
};
// ── Discovery landing state (relocated from PantallaInicio, task 6.5) ──
Widget _seccionCercanas(
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
// Nearby stations live in EstadoBusqueda (S4-R3).
final busqueda = context.watch<EstadoBusqueda>();
final pais = busqueda.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 ? l10n.nearYou : l10n.nearYouInCountry(pais),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
TextButton.icon(
onPressed:
busqueda.cargandoCercanas
? null
: busqueda.cargarEmisorasCercanas,
icon:
busqueda.cargandoCercanas
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.my_location_rounded, size: 18),
label: Text(l10n.detectAction),
),
],
),
if (busqueda.errorCercanas != null)
Text(
busqueda.errorCercanas!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
if (busqueda.cercanas.isNotEmpty) ...[
const SizedBox(height: 8),
SizedBox(
height: 76,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: busqueda.cercanas.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final emisora = busqueda.cercanas[i];
return SizedBox(
width: 260,
child: TarjetaEmisora(
emisora: emisora,
esCompacta: true,
onTap: () => reproducirMinimizado(context, emisora),
),
);
},
),
),
],
],
),
),
);
}
Widget _seccionTendencias(
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
final cargando = context.select<EstadoRadio, bool>(
(e) => e.cargandoPopulares,
);
final tendencias = context.select<EstadoRadio, List<Emisora>>(
(e) => e.tendencias,
);
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: [
Text(l10n.liveRadar, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 56,
child:
cargando
? ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 5,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, __) => _ChipShimmer(theme: theme),
)
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: tendencias.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final e = tendencias[i];
return ActionChip(
avatar: const Icon(
Icons.graphic_eq_rounded,
size: 18,
),
label: Text(e.nombre, maxLines: 1),
onPressed: () => reproducirMinimizado(context, e),
).pluriFadeIn(
context,
delay: Duration(milliseconds: i * 50),
);
},
),
),
],
),
),
);
}
Widget _chipGeneros(
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
16,
PluriLayout.horizontal,
8,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.genresTitle, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children:
_generos.map((g) {
final seleccionado = _generoSeleccionado == g;
return FilterChip(
label: Text(_genreName(l10n, g)),
selected: seleccionado,
onSelected: (_) {
setState(() {
_generoSeleccionado = seleccionado ? null : g;
});
if (!seleccionado) {
context.read<EstadoBusqueda>().buscar(tag: g);
} else {
context.read<EstadoRadio>().cargarPopulares();
}
},
);
}).toList(),
),
],
),
),
);
}
Widget _errorBanner(
BuildContext context,
String error,
ThemeData theme,
AppLocalizations l10n,
) {
return Padding(
padding: const EdgeInsets.all(16),
child: PluriGlassSurface(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.wifi_off, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(child: Text(error)),
TextButton(
onPressed: () => context.read<EstadoRadio>().cargarPopulares(),
child: Text(l10n.retryAction),
),
],
),
),
);
}
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
final porGenero = _generoSeleccionado != null;
final emisoras =
porGenero
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
: context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasInicio,
);
final cargando =
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
(porGenero && context.select<EstadoBusqueda, bool>((b) => b.cargando));
if (cargando) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
0,
PluriLayout.horizontal,
PluriLayout.compactGap,
),
itemCount: 12,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.78,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemBuilder: (_, __) => const TarjetaEmisoraShimmer(),
);
}
if (emisoras.isEmpty) {
return SizedBox(
height: 260,
child: PluriEmptyState(
glyph: PluriIconGlyph.home,
title: l10n.noStationsAvailable,
subtitle: l10n.noStationsAvailableSubtitle,
),
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
0,
PluriLayout.horizontal,
PluriLayout.compactGap,
),
itemCount: emisoras.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.78,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemBuilder:
(context, i) => TarjetaEmisora(
emisora: emisoras[i],
onTap: () => reproducirMinimizado(context, emisoras[i]),
).pluriFadeSlideIn(context, delay: Duration(milliseconds: i * 30), beginY: 0.1),
);
}
}
String _genreName(AppLocalizations l10n, String tag) => switch (tag) {
'pop' => l10n.genrePop,
'rock' => l10n.genreRock,
'jazz' => l10n.genreJazz,
'classical' => l10n.genreClassical,
'electronic' => l10n.genreElectronic,
'news' => l10n.genreNews,
'talk' => l10n.genreTalk,
'hip-hop' => l10n.genreHipHop,
'country' => l10n.genreCountry,
'metal' => l10n.genreMetal,
'reggae' => l10n.genreReggae,
'latin' => l10n.genreLatin,
_ => tag,
};
class _ChipShimmer extends StatelessWidget {
final ThemeData theme;
const _ChipShimmer({required this.theme});
@override
Widget build(BuildContext context) {
return shimmer.Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(
width: 120,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
),
);
}
}
+21 -362
View File
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_busqueda.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_navegacion.dart';
import '../estado/estado_radio.dart';
@@ -11,10 +10,8 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/visualizador_audio.dart';
@@ -32,22 +29,6 @@ class PantallaInicio extends StatefulWidget {
}
class _PantallaInicioState extends State<PantallaInicio> {
static const _generos = [
'pop',
'rock',
'jazz',
'classical',
'electronic',
'news',
'talk',
'hip-hop',
'country',
'metal',
'reggae',
'latin',
];
String? _generoSeleccionado;
@override
Widget build(BuildContext context) {
// S4-R5: no root watch on EstadoRadio. Every field is consumed through
@@ -55,41 +36,28 @@ class _PantallaInicioState extends State<PantallaInicio> {
// (which notify EstadoRadio) no longer rebuild this screen.
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final error = context.select<EstadoRadio, String?>((e) => e.error);
return RefreshIndicator(
onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
child: CustomScrollView(
slivers: [
// WU5: replaces the old PluriScreenHeader hero. The discovery
// sections below (_seccionCercanas onward) are deliberately left
// in place — WU6 relocates them to Buscar and deletes them from
// here; removing them now would leave that content nowhere until
// WU6 lands.
const SliverToBoxAdapter(child: _EscucharHero()),
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
if (error != null)
SliverToBoxAdapter(
child: _errorBanner(context, error, theme, l10n),
),
SliverPadding(
// ADR-7(b): the mini player is hidden on this whole screen
// (app.dart), so its content needs less bottom padding than
// every other root — escucharBottomChromeInset, not the plain
// bottomChromeInset every other root/scrollable uses.
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
0,
PluriLayout.horizontal,
PluriLayout.escucharBottomChromeInset,
),
sliver: _gridEmisoras(context, l10n),
),
],
),
return CustomScrollView(
slivers: [
// WU5 built the hero; WU6 relocated the discovery sections that
// used to follow it (_seccionCercanas, _seccionTendencias,
// _chipGeneros, _errorBanner, the browse grid) into
// PantallaBuscar's landing state and DELETED them here (this WU's
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
// content is now just the hero and the favorites preview below;
// pull-to-refresh was dropped along with the grid it refreshed —
// the retry button that used to live in the (now relocated) error
// banner already covers manual recovery on Buscar.
const SliverToBoxAdapter(child: _EscucharHero()),
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
// ADR-7(b): the mini player is hidden on this whole screen
// (app.dart), so its content needs less bottom padding than every
// other root — escucharBottomChromeInset, not the plain
// bottomChromeInset every other root/scrollable uses.
const SliverToBoxAdapter(
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
),
],
);
}
@@ -175,315 +143,6 @@ class _PantallaInicioState extends State<PantallaInicio> {
);
}
Widget _seccionCercanas(
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
// Nearby stations live in EstadoBusqueda (S4-R3).
final busqueda = context.watch<EstadoBusqueda>();
final pais = busqueda.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 ? l10n.nearYou : l10n.nearYouInCountry(pais),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
TextButton.icon(
onPressed:
busqueda.cargandoCercanas
? null
: busqueda.cargarEmisorasCercanas,
icon:
busqueda.cargandoCercanas
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.my_location_rounded, size: 18),
label: Text(l10n.detectAction),
),
],
),
if (busqueda.errorCercanas != null)
Text(
busqueda.errorCercanas!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
if (busqueda.cercanas.isNotEmpty) ...[
const SizedBox(height: 8),
SizedBox(
height: 76,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: busqueda.cercanas.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final emisora = busqueda.cercanas[i];
return SizedBox(
width: 260,
child: TarjetaEmisora(
emisora: emisora,
esCompacta: true,
onTap: () => reproducirMinimizado(context, emisora),
),
);
},
),
),
],
],
),
),
);
}
Widget _seccionTendencias(
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
final cargando = context.select<EstadoRadio, bool>(
(e) => e.cargandoPopulares,
);
final tendencias = context.select<EstadoRadio, List<Emisora>>(
(e) => e.tendencias,
);
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: [
Text(l10n.liveRadar, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 56,
child:
cargando
? ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 5,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, __) => _ChipShimmer(theme: theme),
)
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: tendencias.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final e = tendencias[i];
return ActionChip(
avatar: const Icon(
Icons.graphic_eq_rounded,
size: 18,
),
label: Text(e.nombre, maxLines: 1),
onPressed: () => reproducirMinimizado(context, e),
).pluriFadeIn(
context,
delay: Duration(milliseconds: i * 50),
);
},
),
),
],
),
),
);
}
Widget _chipGeneros(
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
16,
PluriLayout.horizontal,
8,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.genresTitle, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children:
_generos.map((g) {
final seleccionado = _generoSeleccionado == g;
return FilterChip(
label: Text(_genreName(l10n, g)),
selected: seleccionado,
onSelected: (_) {
setState(() {
_generoSeleccionado = seleccionado ? null : g;
});
if (!seleccionado) {
context.read<EstadoBusqueda>().buscar(tag: g);
} else {
context.read<EstadoRadio>().cargarPopulares();
}
},
);
}).toList(),
),
],
),
),
);
}
Widget _errorBanner(
BuildContext context,
String error,
ThemeData theme,
AppLocalizations l10n,
) {
return Padding(
padding: const EdgeInsets.all(16),
child: PluriGlassSurface(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.wifi_off, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(child: Text(error)),
TextButton(
onPressed: () => context.read<EstadoRadio>().cargarPopulares(),
child: Text(l10n.retryAction),
),
],
),
),
);
}
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
final porGenero = _generoSeleccionado != null;
final emisoras =
porGenero
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
: context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasInicio,
);
final cargando =
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
(porGenero && context.select<EstadoBusqueda, bool>((b) => b.cargando));
if (cargando) {
return SliverGrid(
delegate: SliverChildBuilderDelegate(
(_, __) => const TarjetaEmisoraShimmer(),
childCount: 12,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.78,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
);
}
if (emisoras.isEmpty) {
return SliverFillRemaining(
child: PluriEmptyState(
glyph: PluriIconGlyph.home,
title: l10n.noStationsAvailable,
subtitle: l10n.noStationsAvailableSubtitle,
),
);
}
return SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, i) => TarjetaEmisora(
emisora: emisoras[i],
onTap: () => reproducirMinimizado(context, emisoras[i]),
).pluriFadeSlideIn(
context,
delay: Duration(milliseconds: i * 30),
beginY: 0.1,
),
childCount: emisoras.length,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.78,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
);
}
}
String _genreName(AppLocalizations l10n, String tag) => switch (tag) {
'pop' => l10n.genrePop,
'rock' => l10n.genreRock,
'jazz' => l10n.genreJazz,
'classical' => l10n.genreClassical,
'electronic' => l10n.genreElectronic,
'news' => l10n.genreNews,
'talk' => l10n.genreTalk,
'hip-hop' => l10n.genreHipHop,
'country' => l10n.genreCountry,
'metal' => l10n.genreMetal,
'reggae' => l10n.genreReggae,
'latin' => l10n.genreLatin,
_ => tag,
};
class _ChipShimmer extends StatelessWidget {
final ThemeData theme;
const _ChipShimmer({required this.theme});
@override
Widget build(BuildContext context) {
return shimmer.Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(
width: 120,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
),
);
}
}
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the