diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index 39b4176..940ab34 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -253,10 +253,25 @@ class _PantallaBuscarState extends State { }), ]; - 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 = [ + 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 { 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( @@ -306,16 +319,28 @@ class _PantallaBuscarState extends State { ), ], ), + ], ], ), ); } + /// 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 { 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 { 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. diff --git a/test/pantallas/pantalla_buscar_shimmer_test.dart b/test/pantallas/pantalla_buscar_shimmer_test.dart index e9c24ca..7790d68 100644 --- a/test/pantallas/pantalla_buscar_shimmer_test.dart +++ b/test/pantallas/pantalla_buscar_shimmer_test.dart @@ -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.value(value: estado), + ListenableProvider.value( + value: estado.ecualizador, + ), + ListenableProvider.value(value: estado.grabacion), + ListenableProvider.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); + }); } diff --git a/test/pantallas/pantalla_buscar_test.dart b/test/pantallas/pantalla_buscar_test.dart index e30adad..5d418be 100644 --- a/test/pantallas/pantalla_buscar_test.dart +++ b/test/pantallas/pantalla_buscar_test.dart @@ -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( + 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( + 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 '