Item 24 / audit 5.1-5.3, 5.5 (t4:255-269): Paises' "Tus idiomas" was a Wrap of non-interactive Chips, and the full list was a plain ListTile with no ISO column and no onTap. Both now share one tappable row (ISO code, name, station count, chevron); the first "Tus idiomas" row gets the prototype's teal-tinted highlight. The one production call site wires the tap to filter Buscar by that country's code and pop back -- EstadoBusqueda.buscar(pais: ...) already accepts any ISO alpha-2 code, not just the ~10 presets in the filter sheet. Item 25 / audit 13.2 (t4:643-646): a reconnect card (rotating ring, station name, "Reconectando...", a stop affordance) replaces the complete absence of any reconnect signal outside a word in the mini player. Ships WITHOUT the prototype's attempt counter: the only live ControladorReconexion instance is a private field of PluriWaveAudioHandler inside servicio_audio.dart, a file this task requires stay byte-identical to main, and nothing else re-exposes it. Reconstructing a count from estadoStream's reconectando emissions would not be faithful (the stream can emit it many times per actual backoff attempt), so that was deliberately not attempted.
1454 lines
50 KiB
Dart
1454 lines
50 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();
|
|
}),
|
|
];
|
|
|
|
if (pills.isEmpty && estado.resultados.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
10,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (pills.isNotEmpty)
|
|
Wrap(spacing: 8, runSpacing: 8, children: pills),
|
|
if (pills.isNotEmpty && estado.resultados.isNotEmpty)
|
|
const SizedBox(height: 10),
|
|
if (!estado.cargando && estado.resultados.isNotEmpty)
|
|
Row(
|
|
children: [
|
|
Text(
|
|
l10n.searchResultsCount(estado.resultados.length),
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _pillFiltro(String label, VoidCallback onDeleted) {
|
|
return Chip(
|
|
label: Text(label),
|
|
onDeleted: onDeleted,
|
|
deleteIcon: const Icon(Icons.close, size: 18),
|
|
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.
|
|
return Padding(
|
|
padding: const EdgeInsets.all(PluriLayout.horizontal),
|
|
child: Column(
|
|
children: [
|
|
for (var i = 0; i < 4; i++) ...[
|
|
const TarjetaEmisoraShimmer(esCompacta: true),
|
|
if (i < 3) const SizedBox(height: 10),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final resultados = estado.resultados;
|
|
|
|
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,
|
|
),
|
|
),
|
|
// 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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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(),
|
|
padding: const EdgeInsets.all(PluriLayout.horizontal),
|
|
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 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|