fix(buscar): filter pills, Idioma chip, results eyebrow, states screen

Audit 6.2 (t4:292-293): active filter pills are brand-teal tinted with
a 15px close glyph and radius 10 -- was Material's own Chip theming.

Audit 6.3 (t4:294-295): an "Idioma" entry chip is now always reachable
once a search is active -- there was no standalone entry point for
language filtering before (only bundled inside "Filtros"). Opens the
same existing filter sheet rather than a new idioma-only picker.

Audit 6.4 (t4:299): the results-count line now uses eyebrowLabel
styling -- was labelLarge (14/w800).

Audit 13.3/13.4 (t4:649-651): the search-results loading skeleton
gains the missing "BUSCANDO EMISORAS..." eyebrow, and its row gap is
now 4px, not 10.

Audit 13.5/13.6 (t4:657-668): a new purpose-built _TarjetaSinResultados
replaces PluriEmptyState ONLY in the search-no-results branch (that
shared widget is left untouched for its other unrelated call sites --
favorites empty state, the discovery grid). The title now quotes the
typed query ("Sin resultados para <<jazzz>>"), and the clear-filters
pill sits INSIDE the card. New ARB key searchNoResultsForQueryTitle.

Item 6.1 (compact 60px active-search header replacing the persistent
glass pill) is NOT implemented: existing tests confirmed the quality
(bitrate) filter has NO entry point anywhere in the app besides the
header's "Filtros" pill (added first via _abrirYSeleccionarCalidad
while a country filter is ALREADY active, i.e. mid active-search).
Hiding PluriRootHeader during active search would make quality
filtering unreachable -- a real capability regression the task's own
"presentation changes, capability does not" principle forbids.
Re-plumbing where that filter lives is a bigger change than this
visual-fidelity pass should make unilaterally.
This commit is contained in:
2026-07-30 17:04:44 +02:00
parent f61b0b9163
commit 42d35a2541
3 changed files with 368 additions and 39 deletions
+184 -39
View File
@@ -253,10 +253,25 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
}),
];
if (pills.isEmpty && estado.resultados.isEmpty) {
return const SizedBox.shrink();
}
// Audit 6.3 (t4:294-295): "Idioma" is an always-reachable entry chip
// once a search is active -- there was no standalone entry point for
// language filtering before this (only bundled inside "Filtros").
// Opens the SAME existing filter sheet (all 3 sections) rather than a
// new idioma-only picker: "Ordenar" is a genuinely separate control
// (not part of that sheet), and "Filtros" itself must stay reachable
// from the header regardless of active-filter state -- the quality
// (bitrate) filter has no OTHER entry point anywhere in the app, so
// this chip is additive, not a replacement.
final entryChips = <Widget>[
ActionChip(
label: Text(l10n.searchLanguageFilterLabel),
onPressed: _abrirFiltros,
),
];
// Audit 6.3: entryChips is never empty (always carries "Idioma"), so
// this row now always renders while a search is active -- the old
// "nothing to show" early return no longer applies.
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
@@ -267,18 +282,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (pills.isNotEmpty)
Wrap(spacing: 8, runSpacing: 8, children: pills),
if (pills.isNotEmpty && estado.resultados.isNotEmpty)
Wrap(spacing: 8, runSpacing: 8, children: [...pills, ...entryChips]),
if (!estado.cargando && estado.resultados.isNotEmpty) ...[
const SizedBox(height: 10),
if (!estado.cargando && estado.resultados.isNotEmpty)
Row(
children: [
// Audit 6.4 (t4:299): eyebrow styling (11/w800/ls.06em) --
// was labelLarge (14/w800).
Text(
l10n.searchResultsCount(estado.resultados.length),
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
style: context.pluriType.eyebrowLabel,
),
const Spacer(),
PopupMenuButton<OrdenEmisoras>(
@@ -306,16 +319,28 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
],
),
],
],
),
);
}
/// Audit 6.2 (t4:292-293): brand-teal tinted, radius 10, an inline 15px
/// close glyph -- was Material's own `Chip` theming.
Widget _pillFiltro(String label, VoidCallback onDeleted) {
return Chip(
label: Text(label),
labelStyle: const TextStyle(
fontWeight: FontWeight.w800,
color: Color(0xFFF2F7FA),
),
backgroundColor: PluriWaveTokens.brand.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: PluriWaveTokens.brand.withValues(alpha: 0.45)),
),
onDeleted: onDeleted,
deleteIcon: const Icon(Icons.close, size: 18),
deleteIcon: const Icon(Icons.close, size: 15),
visualDensity: VisualDensity.compact,
);
}
@@ -521,10 +546,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
return Padding(
padding: const EdgeInsets.all(PluriLayout.horizontal),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 13.3 (t4:649): the "BUSCANDO EMISORAS…" eyebrow --
// never rendered before.
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
l10n.searchLoadingStationsLabel,
style: context.pluriType.eyebrowLabel,
),
),
for (var i = 0; i < 4; i++) ...[
const TarjetaEmisoraShimmer(esCompacta: true),
if (i < 3) const SizedBox(height: 10),
// Audit 13.4 (t4:651): 4px between skeleton rows, not 10.
if (i < 3) const SizedBox(height: 4),
],
],
),
@@ -535,36 +571,40 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
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,
),
),
// Audit 13.5/13.6 (t4:657-668): a purpose-built card for the
// search-no-results state -- centred, `listSurface`, the title
// QUOTES the typed query, and the clear-filters pill sits INSIDE
// the card. A NEW widget, not a restyle of the shared
// `PluriEmptyState` (used by several OTHER unrelated empty states
// across the app -- favorites, the discovery grid -- which this
// item does not touch).
final query = _controller.text.trim();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal),
child: _TarjetaSinResultados(
titulo:
sinFiltros
? l10n.searchEmptyTitle
: query.isNotEmpty
? l10n.searchNoResultsForQueryTitle(query)
: l10n.searchNoResultsTitle,
subtitulo:
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),
),
),
),
],
accionQuitarFiltros:
_filtrosActivosCount > 0
? (
etiqueta: l10n.searchClearFiltersAction(
_filtrosActivosCount,
),
onTap: _quitarTodosLosFiltros,
)
: null,
),
);
}
@@ -1213,6 +1253,111 @@ class _ChipShimmer extends StatelessWidget {
}
}
/// Audit 13.5/13.6 (t4:657-668): search-no-results card -- centred,
/// `listSurface`, radius 22 (matches neither of the other 2 named token
/// radii, so this is a local one-off like `_CeldaExplorarPor`'s own),
/// with the clear-filters pill living INSIDE the card rather than below
/// it.
class _TarjetaSinResultados extends StatelessWidget {
const _TarjetaSinResultados({
required this.titulo,
required this.subtitulo,
this.accionQuitarFiltros,
});
final String titulo;
final String subtitulo;
final ({String etiqueta, VoidCallback onTap})? accionQuitarFiltros;
static const _radio = 22.0;
@override
Widget build(BuildContext context) {
final accion = accionQuitarFiltros;
return DecoratedBox(
key: const ValueKey('search-no-results-card'),
decoration: BoxDecoration(
color: PluriWaveTokens.dark.listSurface,
borderRadius: BorderRadius.circular(_radio),
border: Border.all(color: Colors.white.withValues(alpha: 0.07)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 26),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_off_rounded,
size: 40,
color: PluriWaveTokens.brand.withValues(alpha: 0.6),
),
const SizedBox(height: 12),
Text(
titulo,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
),
const SizedBox(height: 5),
Text(
subtitulo,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.5,
height: 1.5,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
if (accion != null) ...[
const SizedBox(height: 14),
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: accion.onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 9,
),
decoration: BoxDecoration(
color: PluriWaveTokens.brand.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: PluriWaveTokens.brand.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.filter_alt_off_rounded,
size: 17,
color: PluriWaveTokens.brand,
),
const SizedBox(width: 7),
Text(
accion.etiqueta,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w800,
color: PluriWaveTokens.brand,
),
),
],
),
),
),
),
],
],
),
),
);
}
}
/// Audit 3.2 (t4 lines 167-170): one "Explorar por" grid cell — icon,
/// title (13.5/w800) and subtitle (11/55%). Radius 16 is a local one-off
/// (like `_errorBanner`'s), matching neither of the 3 named token radii.