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:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/pais_radio.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_buscar.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_paises.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_theme.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -541,6 +544,46 @@ void main() {
|
||||
expect(find.byType(PantallaPaises), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Item 24: tapping a country row in Paises pops back and filters '
|
||||
'Buscar by that ISO code',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(
|
||||
nombre: 'Kazakhstan',
|
||||
codigoIso: 'KZ',
|
||||
numeroEmisoras: 12,
|
||||
),
|
||||
],
|
||||
busqueda: [emisoraDemo(uuid: 'kz-1', nombre: 'Radio Kazajstan')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('explore-cell-paises')));
|
||||
await _pumpStableFrame(tester);
|
||||
expect(find.byType(PantallaPaises), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Kazakhstan'));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(
|
||||
find.byType(PantallaPaises),
|
||||
findsNothing,
|
||||
reason: 'selecting a country pops back to Buscar',
|
||||
);
|
||||
expect(find.text('Radio Kazajstan'), findsOneWidget);
|
||||
expect(estado.busqueda.resultados.map((e) => e.uuid), contains('kz-1'));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'tapping Generos opens a picker sheet; selecting a genre closes it '
|
||||
'and filters the discovery grid (same capability, relocated)',
|
||||
@@ -621,6 +664,177 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Item 23 / audit 6.5 + 6.6 (t4:302-306): flat, background-less search-
|
||||
// result rows with a square thumbnail, a favourite toggle, and a circular
|
||||
// play affordance -- replacing the full glass TarjetaEmisora card. Rows
|
||||
// sit back-to-back (no 10px separator), matching t4:299's bare column.
|
||||
group('Item 23 -- flat search-result rows (audit 6.5, 6.6)', () {
|
||||
testWidgets(
|
||||
'each result is a flat FilaEmisoraPlana with a "genre - country - '
|
||||
'kbps" meta line, not the full glass card',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [
|
||||
emisoraDemo(
|
||||
uuid: 'r-1',
|
||||
nombre: 'Radio Uno',
|
||||
).copyWith(tags: 'Rock', pais: 'Spain', bitrate: 128),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(find.byType(FilaEmisoraPlana), findsOneWidget);
|
||||
expect(
|
||||
find.byType(TarjetaEmisora),
|
||||
findsNothing,
|
||||
reason: 'audit 6.5 replaces the full glass card with a flat row',
|
||||
);
|
||||
expect(find.text('Rock · Spain · 128 kbps'), findsOneWidget);
|
||||
expect(find.byType(BotonFavoritoEmisora), findsOneWidget);
|
||||
expect(find.byType(BotonReproducirCircular), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('rows sit back-to-back, with no 10px separator (t4:299)', (
|
||||
tester,
|
||||
) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [
|
||||
emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno'),
|
||||
emisoraDemo(uuid: 'r-2', nombre: 'Radio Dos'),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final filas = find.byType(FilaEmisoraPlana);
|
||||
expect(filas, findsNWidgets(2));
|
||||
final primeraAbajo = tester.getBottomLeft(filas.at(0)).dy;
|
||||
final segundaArriba = tester.getTopLeft(filas.at(1)).dy;
|
||||
expect(
|
||||
segundaArriba - primeraAbajo,
|
||||
closeTo(0, 0.5),
|
||||
reason:
|
||||
't4:299 rows have no separator; each keeps only its own '
|
||||
'8px padding',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping the favourite toggle on a search result adds it to favorites',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(find.byIcon(Icons.favorite_outline_rounded), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(await estado.esFavorito('r-1'), isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Item 25 / audit 13.2 (t4:643-646): a reconnect card with a rotating
|
||||
// ring, the station name, and a stop affordance -- previously the ONLY
|
||||
// signal of a reconnect was a word in the mini player.
|
||||
//
|
||||
// Hazard: `pumpAndSettle()` never terminates while this card's rotating
|
||||
// ring animates -- every assertion below uses a bounded `pump()` once
|
||||
// `reconectando` is emitted, never `_pumpStableFrame`'s `pumpAndSettle`.
|
||||
group('Item 25 -- reconnect card (audit 13.2)', () {
|
||||
testWidgets(
|
||||
'shows the station name, "Reconectando...", and a stop button while '
|
||||
'reconnecting',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final audio = FakeServicioAudio();
|
||||
final estado = _crearEstado(audio: audio);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(
|
||||
find.byIcon(Icons.stop_circle_rounded),
|
||||
findsNothing,
|
||||
reason: 'not reconnecting yet -- the card must not show',
|
||||
);
|
||||
|
||||
audio.emitirEstado(EstadoReproduccion.reconectando);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Radio Uno'), findsOneWidget);
|
||||
final l10n = _l10nDe(tester);
|
||||
expect(find.text(l10n.playbackStatusReconnecting), findsOneWidget);
|
||||
expect(find.byIcon(Icons.stop_circle_rounded), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tapping stop calls EstadoRadio.detenerReproduccion', (
|
||||
tester,
|
||||
) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final audio = FakeServicioAudio();
|
||||
final estado = _crearEstado(audio: audio);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
audio.emitirEstado(EstadoReproduccion.reconectando);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.stop_circle_rounded));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// `EstadoRadio.emisoraActual` deliberately keeps showing the last
|
||||
// selected station even once stopped (an existing, unrelated
|
||||
// property of `_emisoraSeleccionada`'s tracking) -- the real,
|
||||
// user-visible effect of tapping stop is that this card disappears,
|
||||
// since it only renders while `reconectando`.
|
||||
expect(find.byKey(const ValueKey('tarjeta-reconectando')), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
EstadoRadio _crearEstado({
|
||||
|
||||
@@ -66,9 +66,12 @@ void main() {
|
||||
|
||||
// Full alphabetical list: all 3 fetched countries render.
|
||||
expect(find.text('Argentina'), findsOneWidget);
|
||||
expect(find.text('France'), findsOneWidget);
|
||||
// 'Spain' renders twice: "Tus idiomas" (locale es -> representative
|
||||
// country ES) AND the full list below.
|
||||
// 'France' and 'Spain' each render twice: once in "Tus idiomas" (FR
|
||||
// and ES both back a supported locale) and once in the full list
|
||||
// below. Item 24's row now shows the name as its OWN Text (not
|
||||
// concatenated with the count into one Chip label), so both are
|
||||
// independently findable in each section.
|
||||
expect(find.text('France'), findsWidgets);
|
||||
expect(find.text('Spain'), findsWidgets);
|
||||
|
||||
// Each entry's parsed `stationcount` renders via the existing
|
||||
@@ -77,10 +80,127 @@ void main() {
|
||||
// as a plain number here, never throwing a cast error.
|
||||
expect(find.text(l10n.stationsCount(482)), findsWidgets);
|
||||
expect(find.text(l10n.stationsCount(120)), findsOneWidget);
|
||||
expect(find.text(l10n.stationsCount(75)), findsOneWidget);
|
||||
// Also doubled, for the same reason as 'France' above.
|
||||
expect(find.text(l10n.stationsCount(75)), findsWidgets);
|
||||
},
|
||||
);
|
||||
|
||||
// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): tappable ISO rows, not
|
||||
// non-interactive chips/plain ListTiles.
|
||||
group('Item 24 -- tappable ISO rows', () {
|
||||
testWidgets('rows show the ISO code and no longer use Chip or ListTile', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoBusqueda(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
|
||||
PaisRadio(
|
||||
nombre: 'Argentina',
|
||||
codigoIso: 'AR',
|
||||
numeroEmisoras: 120,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpEstable(tester);
|
||||
|
||||
expect(
|
||||
find.byType(Chip),
|
||||
findsNothing,
|
||||
reason: 'audit 5.1 replaces the Wrap-of-Chip with tappable rows',
|
||||
);
|
||||
expect(
|
||||
find.byType(ListTile),
|
||||
findsNothing,
|
||||
reason:
|
||||
'audit 5.3 rows need an ISO-code column a plain ListTile '
|
||||
'has no slot for',
|
||||
);
|
||||
expect(find.text('AR'), findsOneWidget);
|
||||
// 'ES' renders in BOTH the "Tus idiomas" and "Todos" rows.
|
||||
expect(find.text('ES'), findsWidgets);
|
||||
expect(find.byIcon(Icons.chevron_right_rounded), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('tapping a row invokes onPaisSeleccionado with that country', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoBusqueda(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(
|
||||
nombre: 'Argentina',
|
||||
codigoIso: 'AR',
|
||||
numeroEmisoras: 120,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
PaisRadio? seleccionado;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<EstadoBusqueda>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaPaises(
|
||||
onPaisSeleccionado: (pais) => seleccionado = pais,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await pumpEstable(tester);
|
||||
|
||||
await tester.tap(find.text('Argentina'));
|
||||
await pumpEstable(tester);
|
||||
|
||||
expect(seleccionado?.codigoIso, 'AR');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the first "Tus idiomas" row is highlighted: teal-tinted background, '
|
||||
'bold name',
|
||||
(tester) async {
|
||||
final estado = EstadoBusqueda(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpEstable(tester);
|
||||
|
||||
final nombre = tester.widget<Text>(find.text('Spain').first);
|
||||
expect(nombre.style?.fontWeight, FontWeight.w800);
|
||||
|
||||
final decorado = tester.widget<DecoratedBox>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('Spain').first,
|
||||
matching: find.byType(DecoratedBox),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final decoration = decorado.decoration as BoxDecoration;
|
||||
expect(
|
||||
decoration.color,
|
||||
isNot(Colors.transparent),
|
||||
reason: 't4:256 the first row is teal-tinted, not transparent',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'cargarPaises no se re-dispara si ya hay datos en caché al reconstruir',
|
||||
(tester) async {
|
||||
|
||||
Reference in New Issue
Block a user