fix(paises,buscar): tappable ISO rows and a reconnect card

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.
This commit is contained in:
2026-07-30 11:48:25 +02:00
parent 955682271c
commit 3303bd387c
4 changed files with 676 additions and 38 deletions
+223 -7
View File
@@ -6,11 +6,14 @@ 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';
@@ -214,6 +217,10 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
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),
],
],
@@ -562,12 +569,15 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
}
final total = resultados.length + (estado.hayMas ? 1 : 0);
return ListView.separated(
// 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,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) {
if (i >= resultados.length) {
if (!estado.cargandoMas) {
@@ -581,10 +591,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
if (i >= resultados.length - 5 && estado.hayMas) {
Future<void>.microtask(estado.cargarMas);
}
return TarjetaEmisora(
emisora: resultados[i],
esCompacta: true,
onTap: () => reproducirMinimizado(context, resultados[i]),
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),
@@ -886,7 +907,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaPaises(),
// 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();
},
),
),
),
),
@@ -1126,6 +1161,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
}
}
/// 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,
@@ -1235,3 +1285,169 @@ class _CeldaExplorarPor extends StatelessWidget {
);
}
}
/// 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,
),
),
],
),
);
}
}
+115 -27
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../estado/estado_busqueda.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/pais_radio.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
@@ -15,7 +16,14 @@ import '../widgets/pluri_push_scaffold.dart';
/// live station counts already parsed from the API's `stationcount`
/// **string** field (`PaisRadio.fromApi`, Engram id 2500).
class PantallaPaises extends StatefulWidget {
const PantallaPaises({super.key});
const PantallaPaises({super.key, this.onPaisSeleccionado});
/// Item 24 / audit 5.2 (t4:256-269): every row is tappable (the prototype
/// draws a `chevron_right` on all of them). Optional so this screen stays
/// usable stand-alone; the one production call site
/// (`pantalla_buscar.dart`'s "Explorar por" grid) wires this to filter
/// search results by the tapped country and pop back.
final ValueChanged<PaisRadio>? onPaisSeleccionado;
@override
State<PantallaPaises> createState() => _PantallaPaisesState();
@@ -78,6 +86,10 @@ class _PantallaPaisesState extends State<PantallaPaises> {
);
}
void _seleccionar(PaisRadio pais) {
widget.onPaisSeleccionado?.call(pais);
}
Widget _seccionTusIdiomas(
BuildContext context,
List<PaisRadio> paises,
@@ -105,18 +117,16 @@ class _PantallaPaisesState extends State<PantallaPaises> {
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final pais in destacados)
Chip(
label: Text(
'${pais.nombre} · ${l10n.stationsCount(pais.numeroEmisoras)}',
),
),
],
),
// Item 24 / audit 5.1 (t4:255-258): a column of tappable ISO
// rows, not a Wrap of non-interactive Chips.
for (final pais in destacados)
_FilaPais(
pais: pais,
l10n: l10n,
// Item 24 / audit 5.5 (t4:256): the first row is highlighted.
destacado: pais == destacados.first,
onTap: () => _seleccionar(pais),
),
],
),
);
@@ -140,22 +150,100 @@ class _PantallaPaisesState extends State<PantallaPaises> {
),
),
const SizedBox(height: 4),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: paises.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final pais = paises[i];
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(pais.nombre),
trailing: Text(l10n.stationsCount(pais.numeroEmisoras)),
);
},
),
// Item 24 / audit 5.1-5.3 (t4:262-269): the same tappable ISO row
// as "Tus idiomas" -- not the previous ListTile, which had no ISO
// column and no onTap.
for (final pais in paises)
_FilaPais(pais: pais, l10n: l10n, onTap: () => _seleccionar(pais)),
],
),
);
}
}
/// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): a tappable row -- ISO code
/// column, name, station count, and a chevron -- shared by "Tus idiomas"
/// and "Todos". [destacado] applies the t4:256 highlight (teal-tinted
/// background, bold name) reserved for the very first "Tus idiomas" row.
class _FilaPais extends StatelessWidget {
const _FilaPais({
required this.pais,
required this.l10n,
this.destacado = false,
this.onTap,
});
final PaisRadio pais;
final AppLocalizations l10n;
final bool destacado;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
return DecoratedBox(
decoration: BoxDecoration(
color:
destacado
? PluriWaveTokens.brand.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// t4:256-258: 26px/lh1, centred in a 34-wide column.
SizedBox(
width: 34,
child: Text(
pais.codigoIso,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 26, height: 1),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pais.nombre,
style: TextStyle(
fontSize: 15.5,
fontWeight:
destacado ? FontWeight.w800 : FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
l10n.stationsCount(pais.numeroEmisoras),
style: TextStyle(
fontSize: 12,
color: onSurface.withValues(alpha: 0.55),
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
size: 20,
color: onSurface.withValues(alpha: 0.4),
),
],
),
),
),
),
);
}
}