Files
pluriwave/lib/pantallas/pantalla_buscar.dart
T
FreeTLab 97e38becfe fix(chrome): retire PluriScreenHeader, it has no equivalent in the prototype
PluriScreenHeader was a 38-radius glass hero: an aurora banner at 24%
opacity, a black-to-transparent scrim, two radial orbs, a 120px app-mark
watermark and a 56px tri-gradient glyph badge. None of it is in the
prototype (t4) -- every root's title is plain text in its own 56px
row, which PluriRootHeader (S1) already provides. Delete the class
(and its now-orphaned _Orb helper) and its four call sites (Buscar,
Favoritos x2, Alarmas, Ajustes landed with S8).

Two of those call sites carried the hero's only functional bit besides
the title, so PluriRootHeader gains an `actions` slot (rendered before
the shared bedtime button) to keep them reachable:
  - Alarmas' create-alarm button (FilledButton.tonalIcon, unchanged
    shape, just relocated)
  - Buscar's filters entry point (the same tappable PluriStatusPill,
    just relocated)
Every other retired trailing pill (Ajustes' "Secure" status,
Favoritos' collection-count badges, Alarmas' alarm-count badge) was
purely decorative and matches the prototype by simply disappearing.

S2, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 21:29:49 +02:00

984 lines
32 KiB
Dart

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_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';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'pantalla_paises.dart';
import 'reproducir_minimizado.dart';
const _paises = [
('countrySpain', 'ES'),
('countryUsa', 'US'),
('countryMexico', 'MX'),
('countryArgentina', 'AR'),
('countryUk', 'GB'),
('countryFrance', 'FR'),
('countryGermany', 'DE'),
('countryItaly', 'IT'),
('countryBrazil', 'BR'),
('countryJapan', 'JP'),
];
const _idiomas = [
('spanish', 'languageNameSpanish'),
('english', 'languageNameEnglish'),
('french', 'languageNameFrench'),
('german', 'languageNameGerman'),
('portuguese', 'languageNamePortuguese'),
('italian', 'languageNameItalian'),
('japanese', 'languageNameJapanese'),
('arabic', 'languageNameArabic'),
('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});
@override
State<PantallaBuscar> createState() => _PantallaBuscarState();
}
class _PantallaBuscarState extends State<PantallaBuscar> {
final _controller = TextEditingController();
String? _paisSeleccionado;
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();
super.dispose();
}
void _buscar() {
final q = _controller.text.trim();
context.read<EstadoBusqueda>().buscar(
nombre: q.isNotEmpty ? q : null,
pais: _paisSeleccionado,
idioma: _idiomaSeleccionado,
minBitrate: _calidadMinima,
);
}
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
// the dedicated notifier — playback events no longer rebuild it.
final estado = context.watch<EstadoBusqueda>();
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
return ListView(
padding: PluriLayout.pageListPadding,
children: [
// S1/S2 (Tier 1 visual fidelity): the prototype has no global
// AppBar and no PluriScreenHeader (the glass hero this used to be
// is retired everywhere) — this root draws its own 56px title row
// instead, carrying the filters entry point that used to live on
// the hero's `trailing` slot.
PluriRootHeader(
title: l10n.searchScreenTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
actions: [
GestureDetector(
onTap: _abrirFiltros,
child: PluriStatusPill(
icon: Icons.tune_rounded,
label: l10n.searchFiltersLabel,
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
10,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(10),
borderRadius: BorderRadius.circular(999),
child: SearchBar(
controller: _controller,
hintText: l10n.searchHint,
leading: const PluriIcon(
glyph: PluriIconGlyph.search,
variant: PluriIconVariant.filled,
),
trailing: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
setState(() {});
_buscar();
},
),
],
onSubmitted: (_) => _buscar(),
onChanged: (_) => setState(() {}),
),
),
),
if (_hayBusquedaActiva) ...[
_barraFiltrosActivos(context, estado, theme),
_resultados(estado, theme),
] else ...[
_seccionCercanas(context, theme, l10n),
_seccionTendencias(context, theme, l10n),
_chipGeneros(context, theme, l10n),
_seccionPaises(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,
String? seleccionado,
void Function(String?) 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),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (label, value) in opciones)
FilterChip(
label: Text(label),
selected: seleccionado == value,
visualDensity: VisualDensity.compact,
onSelected:
(_) => onChanged(seleccionado == value ? null : value),
),
],
),
],
),
),
);
}
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),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (label, value) in opciones)
FilterChip(
label: Text(label),
selected: seleccionado == value,
visualDensity: VisualDensity.compact,
onSelected:
(_) => onChanged(seleccionado == value ? null : value),
),
],
),
],
),
),
);
}
Widget _resultados(EstadoBusqueda estado, ThemeData theme) {
final l10n = AppLocalizations.of(context);
if (estado.cargando) {
// S5-R6: shimmer placeholders instead of a bare spinner, consistent
// with the loading pattern used by the home grid.
return Padding(
padding: const EdgeInsets.all(PluriLayout.horizontal),
child: Column(
children: [
for (var i = 0; i < 4; i++) ...[
const TarjetaEmisoraShimmer(esCompacta: true),
if (i < 3) const SizedBox(height: 10),
],
],
),
);
}
final resultados = estado.resultados;
if (resultados.isEmpty) {
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),
),
),
),
],
);
}
final total = resultados.length + (estado.hayMas ? 1 : 0);
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(PluriLayout.horizontal),
itemCount: total,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) {
if (i >= resultados.length) {
if (!estado.cargandoMas) {
Future<void>.microtask(estado.cargarMas);
}
return const Padding(
padding: EdgeInsets.all(18),
child: Center(child: CircularProgressIndicator()),
);
}
if (i >= resultados.length - 5 && estado.hayMas) {
Future<void>.microtask(estado.cargarMas);
}
return TarjetaEmisora(
emisora: resultados[i],
esCompacta: true,
onTap: () => reproducirMinimizado(context, resultados[i]),
).pluriFadeSlideIn(
context,
delay: Duration(milliseconds: i.clamp(0, 12) * 20),
beginY: 0.08,
);
},
);
}
String _countryLabel(AppLocalizations l10n, String key) => switch (key) {
'countrySpain' => l10n.countrySpain,
'countryUsa' => l10n.countryUsa,
'countryMexico' => l10n.countryMexico,
'countryArgentina' => l10n.countryArgentina,
'countryUk' => l10n.countryUk,
'countryFrance' => l10n.countryFrance,
'countryGermany' => l10n.countryGermany,
'countryItaly' => l10n.countryItaly,
'countryBrazil' => l10n.countryBrazil,
'countryJapan' => l10n.countryJapan,
_ => key,
};
String _languageLabel(AppLocalizations l10n, String key) => switch (key) {
'languageNameSpanish' => l10n.languageNameSpanish,
'languageNameEnglish' => l10n.languageNameEnglish,
'languageNameFrench' => l10n.languageNameFrench,
'languageNameGerman' => l10n.languageNameGerman,
'languageNamePortuguese' => l10n.languageNamePortuguese,
'languageNameItalian' => l10n.languageNameItalian,
'languageNameJapanese' => l10n.languageNameJapanese,
'languageNameArabic' => l10n.languageNameArabic,
'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(),
),
],
),
),
);
}
/// WU7, `station-discovery-browse` spec: the "Países entry point" the
/// spec's landing-state scenario lists. Deferred from WU6 (this class's
/// own doc comment: `PantallaPaises` did not exist yet); added here now
/// that it does, alongside the screen it targets — an unreachable screen
/// would repeat the WU15/WU15b lesson (a fully-tested screen shipped with
/// no navigation path to it).
Widget _seccionPaises(
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
8,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface(
padding: EdgeInsets.zero,
// `PluriGlassSurface` paints via `DecoratedBox`, not `Material` — a
// tappable `ListTile` needs its own `Material` ancestor or its ink
// splash silently fails to paint (Flutter's own debug assertion).
child: Material(
type: MaterialType.transparency,
child: ListTile(
leading: const Icon(Icons.public_rounded),
title: Text(l10n.countriesScreenTitle),
trailing: const Icon(Icons.chevron_right_rounded),
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaPaises(),
),
),
),
),
);
}
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),
),
),
);
}
}