Issue 3 (partial): the results area had no top gap against the filter row in one state and reused the horizontal constant for a vertical axis in another. Applies the 3-tier scale properly -- row tier for background-less placeholders, card tier for card states. The rest of the app's spacing review is still outstanding.
1627 lines
58 KiB
Dart
1627 lines
58 KiB
Dart
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<PantallaBuscar> createState() => _PantallaBuscarState();
|
|
}
|
|
|
|
class _PantallaBuscarState extends State<PantallaBuscar> {
|
|
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<EstadoBusqueda>().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<EstadoBusqueda>();
|
|
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<EstadoRadio, String?>((e) => e.error) != null)
|
|
_errorBanner(
|
|
context,
|
|
context.select<EstadoRadio, String?>((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<EstadoRadio>()),
|
|
_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 = <Widget>[
|
|
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 = <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,
|
|
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<OrdenEmisoras>(
|
|
icon: const Icon(Icons.swap_vert_rounded),
|
|
tooltip: l10n.stationOrderTitle,
|
|
onSelected:
|
|
(criterio) => context
|
|
.read<EstadoRadio>()
|
|
.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<void> _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<void>.microtask(estado.cargarMas);
|
|
}
|
|
return const Padding(
|
|
padding: EdgeInsets.all(18),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
if (i >= resultados.length - 5 && estado.hayMas) {
|
|
Future<void>.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<EstadoBusqueda>();
|
|
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<EstadoRadio, bool>(
|
|
(e) => e.cargandoPopulares,
|
|
);
|
|
final tendencias = context.select<EstadoRadio, List<Emisora>>(
|
|
(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<EstadoBusqueda>().buscar(tag: g);
|
|
} else {
|
|
context.read<EstadoRadio>().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<EstadoRadio>().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<void> _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<void> _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<EstadoRadio>().cargarPopulares(),
|
|
child: Text(l10n.retryAction),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
|
|
final porGenero = _generoSeleccionado != null;
|
|
final emisoras =
|
|
porGenero
|
|
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
|
|
: context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.emisorasInicio,
|
|
);
|
|
final cargando =
|
|
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
|
|
(porGenero && context.select<EstadoBusqueda, bool>((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 = <String>[
|
|
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<EstadoReproduccion>(
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|