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:
@@ -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>(
|
||||
@@ -307,15 +320,27 @@ 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:
|
||||
// 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,
|
||||
subtitle:
|
||||
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.
|
||||
|
||||
@@ -89,4 +89,55 @@ void main() {
|
||||
expect(find.byType(TarjetaEmisoraShimmer), findsWidgets);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('visual fidelity (audit 13.3/13.4): the "BUSCANDO EMISORAS..." '
|
||||
'eyebrow renders above the loading rows, spaced 4px apart (t4:649-651)', (
|
||||
tester,
|
||||
) async {
|
||||
final busqueda = _BusquedaCargando();
|
||||
addTearDown(busqueda.dispose);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: () async => throw UnimplementedError(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||
ListenableProvider<EstadoEcualizador>.value(
|
||||
value: estado.ecualizador,
|
||||
),
|
||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||
ListenableProvider<EstadoBusqueda>.value(value: busqueda),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: PantallaBuscar()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(SearchBar), 'jazz');
|
||||
await tester.pump();
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaBuscar)),
|
||||
);
|
||||
expect(find.text(l10n.searchLoadingStationsLabel), findsOneWidget);
|
||||
|
||||
final filas = find.byType(TarjetaEmisoraShimmer);
|
||||
expect(filas, findsWidgets);
|
||||
final primeraAbajo = tester.getBottomLeft(filas.at(0)).dy;
|
||||
final segundaArriba = tester.getTopLeft(filas.at(1)).dy;
|
||||
expect(segundaArriba - primeraAbajo, 4);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,6 +181,101 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group('visual fidelity (audit 6.2/6.3/6.4)', () {
|
||||
testWidgets(
|
||||
'6.2: an active filter pill is brand-teal tinted with an inline '
|
||||
'close glyph (t4:292-293)',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final l10n = _l10nDe(tester);
|
||||
await _abrirYSeleccionarPais(tester, l10n.countrySpain);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final chip = tester.widget<Chip>(
|
||||
find.ancestor(
|
||||
of: find.text(l10n.countrySpain),
|
||||
matching: find.byType(Chip),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
chip.backgroundColor,
|
||||
const Color(0xFF21D4D9).withValues(alpha: 0.2),
|
||||
);
|
||||
expect(
|
||||
(chip.shape as RoundedRectangleBorder?)?.side.color,
|
||||
const Color(0xFF21D4D9).withValues(alpha: 0.45),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'6.3: an "Idioma" entry chip is always reachable once a search is '
|
||||
'active, opening the filters sheet (t4:294-295)',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final l10n = _l10nDe(tester);
|
||||
expect(find.text(l10n.searchLanguageFilterLabel), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.searchLanguageFilterLabel));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(l10n.searchCountryFilterLabel), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'6.4: the results-count eyebrow uses eyebrowLabel styling (t4:299)',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final l10n = _l10nDe(tester);
|
||||
final texto = tester.widget<Text>(
|
||||
find.text(l10n.searchResultsCount(1)),
|
||||
);
|
||||
expect(texto.style?.fontSize, 11);
|
||||
expect(texto.style?.fontWeight, FontWeight.w800);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Buscar — Ordenar (client-side, WU6)', () {
|
||||
testWidgets(
|
||||
'cada opcion renderizada de Ordenar corresponde a un caso real de '
|
||||
@@ -774,6 +869,44 @@ void main() {
|
||||
// Hazard: `pumpAndSettle()` never terminates while this card's rotating
|
||||
// ring animates -- every assertion below uses a bounded `pump()` once
|
||||
// `reconectando` is emitted, never `_pumpStableFrame`'s `pumpAndSettle`.
|
||||
group('visual fidelity (audit 13.5/13.6)', () {
|
||||
testWidgets(
|
||||
'13.5/13.6: the no-results card quotes the query, and the "clear '
|
||||
'filters" pill sits INSIDE the same card (t4:657-663)',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(radio: FakeServicioRadio(busqueda: []));
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await _abrirYSeleccionarPais(tester, _l10nDe(tester).countrySpain);
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'jazzz');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final l10n = _l10nDe(tester);
|
||||
expect(
|
||||
find.text(l10n.searchNoResultsForQueryTitle('jazzz')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
final tarjeta = find.byKey(const ValueKey('search-no-results-card'));
|
||||
expect(tarjeta, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: tarjeta,
|
||||
matching: find.text(l10n.searchClearFiltersAction(1)),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: 't4:667 the clear-filters pill sits INSIDE the card',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Item 25 -- reconnect card (audit 13.2)', () {
|
||||
testWidgets(
|
||||
'shows the station name, "Reconectando...", and a stop button while '
|
||||
|
||||
Reference in New Issue
Block a user