import 'dart:async'; 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/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../servicios/servicio_audio.dart'; import '../tema/pluri_animate.dart'; import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_tokens.dart'; import '../widgets/fila_emisora_plana.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 createState() => _PantallaBuscarState(); } class _PantallaBuscarState extends State { 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().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(); 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), // Audit 3.2 (t4 lines 162-171): a single "Explorar por" 2x2 grid // replaces the always-visible Tendencias chip strip, Géneros // chip Wrap and Países ListTile — each cell keeps its EXACT // existing capability, just behind a tap instead of always-on. _seccionExplorarPor(context, theme, l10n), if (context.select((e) => e.error) != null) _errorBanner( context, context.select((e) => e.error)!, theme, l10n, ), // Item 25 / audit 13.2 (t4:643-646): the reconnect card -- until // now the ONLY signal of a reconnect was a word in the mini // player. _TarjetaReconectando(estado: context.watch()), _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 = [ 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(); }), ]; // 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, 10, PluriLayout.horizontal, 0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap(spacing: 8, runSpacing: 8, children: [...pills, ...entryChips]), if (!estado.cargando && estado.resultados.isNotEmpty) ...[ const SizedBox(height: 10), Row( children: [ // Audit 6.4 (t4:299): eyebrow styling (11/w800/ls.06em) -- // was labelLarge (14/w800). Text( l10n.searchResultsCount(estado.resultados.length), style: context.pluriType.eyebrowLabel, ), const Spacer(), PopupMenuButton( icon: const Icon(Icons.swap_vert_rounded), tooltip: l10n.stationOrderTitle, onSelected: (criterio) => context .read() .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), ), ], ), ], ), ], ], ), ); } /// 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: 15), 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 _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. // Issue 3 (feedback-pruebas): row tier (12), not card tier (16) -- // these are background-less row placeholders, same tier as the real // results below; the top inset is the standard section gap rather // than the horizontal constant reused for a vertical axis. return Padding( padding: const EdgeInsets.fromLTRB( PluriLayout.rowHorizontal, PluriLayout.sectionGap, PluriLayout.rowHorizontal, PluriLayout.rowHorizontal, ), 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), // Audit 13.4 (t4:651): 4px between skeleton rows, not 10. if (i < 3) const SizedBox(height: 4), ], ], ), ); } final resultados = estado.resultados; if (resultados.isEmpty) { final sinFiltros = _controller.text.isEmpty && _filtrosActivosCount == 0; // 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(); // Issue 3 (feedback-pruebas): this card-tier state had NO top gap at // all against the filter row above it -- the standard section gap // now matches the other two mutually-exclusive results-area states // (loading, populated) above. return Padding( padding: const EdgeInsets.fromLTRB( PluriLayout.horizontal, PluriLayout.sectionGap, PluriLayout.horizontal, 0, ), 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". accionQuitarFiltros: _filtrosActivosCount > 0 ? ( etiqueta: l10n.searchClearFiltersAction( _filtrosActivosCount, ), onTap: _quitarTodosLosFiltros, ) : null, ), ); } final total = resultados.length + (estado.hayMas ? 1 : 0); // Item 23 / audit 6.6 (t4:299): rows sit back-to-back -- `ListView`, not // `.separated`, since there is no longer a 10px gap to insert between // them (the prototype's own results column is a bare `flex-direction: // column`, no `gap`). return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), // Issue 3 (feedback-pruebas): row tier (12), not card tier (16) -- // `FilaEmisoraPlana` rows are documented as "flat, background-less" // (audit 6.5) but this padding never got updated to match when Tier 1 // introduced the 3-tier scale. The top inset is the standard section // gap, not the horizontal constant reused for a vertical axis. padding: const EdgeInsets.fromLTRB( PluriLayout.rowHorizontal, PluriLayout.sectionGap, PluriLayout.rowHorizontal, PluriLayout.rowHorizontal, ), itemCount: total, itemBuilder: (context, i) { if (i >= resultados.length) { if (!estado.cargandoMas) { Future.microtask(estado.cargarMas); } return const Padding( padding: EdgeInsets.all(18), child: Center(child: CircularProgressIndicator()), ); } if (i >= resultados.length - 5 && estado.hayMas) { Future.microtask(estado.cargarMas); } final emisora = resultados[i]; // Item 23 / audit 6.5 (t4:302-306): a flat, background-less row -- // square art, name+meta, a favourite toggle, and a circular play // affordance -- replacing the full glass TarjetaEmisora card. return FilaEmisoraPlana( key: ValueKey(emisora.uuid), emisora: emisora, meta: _metaResultado(emisora), onTap: () => reproducirMinimizado(context, emisora), trailing: [ BotonFavoritoEmisora(emisora: emisora), BotonReproducirCircular( onPressed: () => reproducirMinimizado(context, emisora), ), ], ).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(); 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( (e) => e.cargandoPopulares, ); final tendencias = context.select>( (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().buscar(tag: g); } else { context.read().cargarPopulares(); } // Audit 3.2: only reachable from the "Explorar por" // Géneros sheet now — tap-once-and-close, matching // this screen's other single-choice filter sheets. if (Navigator.canPop(context)) { Navigator.of(context).pop(); } }, ); }).toList(), ), ], ), ), ); } /// Audit 3.2 (t4 lines 162-171): "Explorar por" 2x2 grid. Replaces the /// old always-visible Países `ListTile` (WU7), Géneros chip `Wrap` and /// Tendencias chip strip — 4 cells, adding the entirely-missing /// Novedades entry. Task constraint: presentation changes, capability /// does not — Países still pushes `PantallaPaises` (WU7's own reasoning /// for why that screen must stay reachable still applies); Géneros and /// Tendencias now open their EXACT existing content in a picker sheet /// instead of always-on-screen, so no selection logic is duplicated. Widget _seccionExplorarPor( BuildContext context, ThemeData theme, AppLocalizations l10n, ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB( PluriLayout.titleHorizontal, 8, PluriLayout.titleHorizontal, 0, ), child: Text( l10n.exploreByTitle, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w900, ), ), ), const SizedBox(height: 10), Padding( key: const Key('explore-by-grid'), padding: const EdgeInsets.symmetric( horizontal: PluriLayout.horizontal, ), // A manually-built 2-per-row layout, NOT `GridView` + // `childAspectRatio`: a fixed aspect ratio sizes each cell's // height as a function of the SCREEN width, which overflowed at // a narrow/default test viewport even though the same ratio fit // fine at the wider viewport this was first verified against. // `Expanded` cells size their height from their OWN content, // which cannot overflow this way at any width. child: Column( children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _CeldaExplorarPor( key: const Key('explore-cell-paises'), icon: Icons.public_rounded, color: PluriWaveTokens.brand, title: l10n.countriesScreenTitle, // No subtitle: a live count needs // EstadoBusqueda.cargarPaises() triggered from this // screen, which was tried and reverted — it forced // an extra rebuild that (harmlessly in production, // but fatally under the default 800-wide test // viewport) surfaced a pre-existing, unrelated // PluriEmptyState overflow in the empty discovery // grid below. Not worth the coupling for a cosmetic // badge. subtitle: null, onTap: () => PluriPushScaffold.push( context, // Item 24 / audit 5.2: tapping a country row // pops back to Búsqueda and filters results by // that ISO code -- the SAME `EstadoBusqueda. // buscar(pais: ...)` filter the picker sheet // already uses, just with an arbitrary code // instead of one of this screen's ~10 presets. (_) => PantallaPaises( onPaisSeleccionado: (pais) { Navigator.of(context).pop(); setState( () => _paisSeleccionado = pais.codigoIso, ); _buscar(); }, ), ), ), ), const SizedBox(width: 10), Expanded( child: _CeldaExplorarPor( key: const Key('explore-cell-generos'), icon: Icons.library_music_rounded, color: context.pluriTokens.liveGreen, title: l10n.genresTitle, subtitle: '${_generos.length}', onTap: () => _abrirGenerosSheet(theme, l10n), ), ), ], ), const SizedBox(height: 10), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _CeldaExplorarPor( key: const Key('explore-cell-tendencias'), icon: Icons.trending_up_rounded, color: context.pluriTokens.warmCoral, title: l10n.exploreTrendingTitle, subtitle: l10n.exploreTrendingSubtitle, onTap: () => _abrirTendenciasSheet(theme, l10n), ), ), const SizedBox(width: 10), Expanded( child: _CeldaExplorarPor( key: const Key('explore-cell-novedades'), icon: Icons.fiber_new_rounded, color: PluriWaveTokens.skyBlue, title: l10n.exploreNewTitle, subtitle: l10n.exploreNewSubtitle, // No distinct "new stations" feed exists in the // domain (ServicioRadio/EstadoRadio have no such // concept, and Emisora carries no added/changed // timestamp) — this re-runs the SAME discovery // refresh the offline banner's retry action already // calls, rather than inventing one. onTap: () => unawaited( context.read().cargarPopulares(), ), ), ), ], ), ], ), ), ], ); } /// Opens [_chipGeneros]'s EXACT existing content/selection logic behind /// a tap instead of always-on-screen — same capability, same state /// (`_generoSeleccionado`), same calls (`EstadoBusqueda.buscar(tag:)` / /// `EstadoRadio.cargarPopulares()`). Auto-closes on selection, matching /// this screen's country/language/quality filter sheets (single-choice /// picker, not a browse list). Future _abrirGenerosSheet(ThemeData theme, AppLocalizations l10n) { return showModalBottomSheet( context: context, showDragHandle: true, builder: (ctx) => SafeArea(child: _chipGeneros(ctx, theme, l10n)), ); } /// Opens [_seccionTendencias]'s EXACT existing content/tap-to-play logic /// behind a tap. Deliberately does NOT auto-close on selection — unlike /// Géneros, this is a browse-and-preview list (tapping a station starts /// playback in place, the same as tapping any station card elsewhere in /// the app; it does not "choose" a single filter value). Future _abrirTendenciasSheet(ThemeData theme, AppLocalizations l10n) { return showModalBottomSheet( context: context, showDragHandle: true, builder: (ctx) => SafeArea(child: _seccionTendencias(ctx, theme, l10n)), ); } // Audit 13.1 (t4 line 641): the prototype's offline banner is a // rgba(207,102,121,X) tinted card — this consolidates on the ONE // `offlineAccent` token (`#E8879A`) the proposal named for this exact // banner (`pluriwave_tokens.dart:77`), which existed unused until now, // rather than introducing a second near-identical raw hex for the tint. Widget _errorBanner( BuildContext context, String error, ThemeData theme, AppLocalizations l10n, ) { final tokens = context.pluriTokens; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: DecoratedBox( key: const ValueKey('offline-banner'), decoration: BoxDecoration( color: tokens.offlineAccent.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(16), border: Border.all( color: tokens.offlineAccent.withValues(alpha: 0.4), ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13), child: Row( children: [ Icon(Icons.wifi_off, size: 22, color: tokens.offlineAccent), const SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( l10n.offlineBannerTitle, style: const TextStyle( fontSize: 13.5, fontWeight: FontWeight.w800, ), ), Text( error, style: TextStyle( fontSize: 11.5, color: theme.colorScheme.onSurface.withValues( alpha: 0.65, ), ), ), ], ), ), const SizedBox(width: 8), TextButton( style: TextButton.styleFrom( backgroundColor: Colors.white.withValues(alpha: 0.1), foregroundColor: theme.colorScheme.onSurface, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 7, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w800, ), ), onPressed: () => context.read().cargarPopulares(), child: Text(l10n.retryAction), ), ], ), ), ), ); } Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) { final porGenero = _generoSeleccionado != null; final emisoras = porGenero ? context.select>((b) => b.resultados) : context.select>( (e) => e.emisorasInicio, ); final cargando = context.select((e) => e.cargandoPopulares) || (porGenero && context.select((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, ), ); } } /// Item 23 / audit 6.5 (t4:303): "genre - country - kbps" built ONLY from /// fields [Emisora] already carries -- mirrors the Escuchar hero's own /// `_metaEscuchar` (audit 1.6, `pantalla_inicio.dart`) exactly, duplicated /// rather than shared since each screen's meta line is free to evolve /// independently. Whatever a station lacks is omitted gracefully. String _metaResultado(Emisora emisora) { final partes = [ if (emisora.generos.isNotEmpty) emisora.generos.first, if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!, if (emisora.bitrate != null && emisora.bitrate! > 0) '${emisora.bitrate} kbps', ]; return partes.join(' · '); } 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), ), ), ); } } /// 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. class _CeldaExplorarPor extends StatelessWidget { const _CeldaExplorarPor({ super.key, required this.icon, required this.color, required this.title, required this.subtitle, required this.onTap, }); final IconData icon; final Color color; final String title; final String? subtitle; final VoidCallback onTap; static const _radio = 16.0; @override Widget build(BuildContext context) { final theme = Theme.of(context); return PluriGlassSurface( borderRadius: BorderRadius.circular(_radio), padding: const EdgeInsets.all(14), child: Material( type: MaterialType.transparency, child: InkWell( borderRadius: BorderRadius.circular(_radio), onTap: onTap, child: Row( children: [ Icon(icon, size: 24, color: color), const SizedBox(width: 11), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontSize: 13.5, fontWeight: FontWeight.w800, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (subtitle != null) Text( subtitle!, style: TextStyle( fontSize: 11, color: theme.colorScheme.onSurface.withValues( alpha: 0.55, ), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ), ), ); } } /// Item 25 / audit 13.2 (t4:643-646): the reconnect card. Renders nothing /// unless playback is actively reconnecting AND a station is known. /// /// NO attempt counter: the prototype shows "Reconectando · intento 2 de 5", /// backed by `ControladorReconexion.intentos`/`.maxReintentos` /// (`controlador_reconexion.dart:42`/`:33`). That controller instance lives /// as a PRIVATE field of `PluriWaveAudioHandler` inside /// `servicio_audio.dart` -- a file this task requires stay byte-identical /// to `main` -- and nothing else in the app already re-exposes it /// publicly. Reconstructing a count from the public `estadoStream`'s /// `reconectando` emissions would not be a faithful proxy (the stream can /// emit `reconectando` many times per actual backoff attempt), so it was /// deliberately not attempted: a fabricated number is worse than none. class _TarjetaReconectando extends StatelessWidget { const _TarjetaReconectando({required this.estado}); final EstadoRadio estado; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return StreamBuilder( stream: estado.estadoStream, builder: (context, snapshot) { final emisora = estado.emisoraActual; if (snapshot.data != EstadoReproduccion.reconectando || emisora == null) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(top: 16), child: _CuerpoTarjetaReconectando( nombreEstacion: localizedStationName(l10n, emisora.nombre), etiquetaReconectando: l10n.playbackStatusReconnecting, tooltipDetener: l10n.stopAction, onDetener: estado.detenerReproduccion, ), ); }, ); } } class _CuerpoTarjetaReconectando extends StatefulWidget { const _CuerpoTarjetaReconectando({ required this.nombreEstacion, required this.etiquetaReconectando, required this.tooltipDetener, required this.onDetener, }); final String nombreEstacion; final String etiquetaReconectando; final String tooltipDetener; final VoidCallback onDetener; @override State<_CuerpoTarjetaReconectando> createState() => _CuerpoTarjetaReconectandoState(); } class _CuerpoTarjetaReconectandoState extends State<_CuerpoTarjetaReconectando> with SingleTickerProviderStateMixin { // t4:644: `animation:pw-ring .9s linear infinite`. late final AnimationController _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), )..repeat(); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final onSurface = Theme.of(context).colorScheme.onSurface; return Container( key: const ValueKey('tarjeta-reconectando'), margin: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), border: Border.all(color: Colors.white.withValues(alpha: 0.2)), color: Colors.white.withValues(alpha: 0.09), ), child: Row( children: [ // t4:644: a 52x52 rotating ring around a radio icon. SizedBox( width: 52, height: 52, child: Stack( alignment: Alignment.center, children: [ RotationTransition( turns: _ctrl, child: CircularProgressIndicator( value: 0.25, strokeWidth: 3, backgroundColor: PluriWaveTokens.brand.withValues( alpha: 0.22, ), valueColor: AlwaysStoppedAnimation(PluriWaveTokens.brand), ), ), const Icon( Icons.radio_rounded, size: 22, color: PluriWaveTokens.brand, ), ], ), ), const SizedBox(width: 12), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // t4:645: 15px/w800. Text( widget.nombreEstacion, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 3), // t4:645: 12px/w700/warmCoral (amber). Text( widget.etiquetaReconectando, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: PluriWaveTokens.dark.warmCoral, ), ), ], ), ), // t4:646: stop_circle 24px, neutral (not brand-coloured) -- this // stops playback outright, unlike the mini player's retry icon. SizedBox( width: 44, height: 44, child: IconButton( padding: EdgeInsets.zero, tooltip: widget.tooltipDetener, icon: Icon( Icons.stop_circle_rounded, size: 24, color: onSurface.withValues(alpha: 0.6), ), onPressed: widget.onDetener, ), ), ], ), ); } }