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
@@ -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);
});
}
+133
View File
@@ -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 '