diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index e005462..39b4176 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -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 { theme, l10n, ), + // Item 25 / audit 13.2 (t4:643-646): the reconnect card -- until + // now the ONLY signal of a reconnect was a word in the mini + // player. + _TarjetaReconectando(estado: context.watch()), _gridEmisoras(context, l10n), ], ], @@ -562,12 +569,15 @@ class _PantallaBuscarState extends State { } 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 { if (i >= resultados.length - 5 && estado.hayMas) { Future.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 { 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 { } } +/// Item 23 / audit 6.5 (t4:303): "genre - country - kbps" built ONLY from +/// fields [Emisora] already carries -- mirrors the Escuchar hero's own +/// `_metaEscuchar` (audit 1.6, `pantalla_inicio.dart`) exactly, duplicated +/// rather than shared since each screen's meta line is free to evolve +/// independently. Whatever a station lacks is omitted gracefully. +String _metaResultado(Emisora emisora) { + final partes = [ + if (emisora.generos.isNotEmpty) emisora.generos.first, + if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!, + if (emisora.bitrate != null && emisora.bitrate! > 0) + '${emisora.bitrate} kbps', + ]; + return partes.join(' · '); +} + String _genreName(AppLocalizations l10n, String tag) => switch (tag) { 'pop' => l10n.genrePop, 'rock' => l10n.genreRock, @@ -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( + 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pantallas/pantalla_paises.dart b/lib/pantallas/pantalla_paises.dart index 9069d7f..31d7671 100644 --- a/lib/pantallas/pantalla_paises.dart +++ b/lib/pantallas/pantalla_paises.dart @@ -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? onPaisSeleccionado; @override State createState() => _PantallaPaisesState(); @@ -78,6 +86,10 @@ class _PantallaPaisesState extends State { ); } + void _seleccionar(PaisRadio pais) { + widget.onPaisSeleccionado?.call(pais); + } + Widget _seccionTusIdiomas( BuildContext context, List paises, @@ -105,18 +117,16 @@ class _PantallaPaisesState extends State { ), ), 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 { ), ), 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), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/test/pantallas/pantalla_buscar_test.dart b/test/pantallas/pantalla_buscar_test.dart index d1d8694..e30adad 100644 --- a/test/pantallas/pantalla_buscar_test.dart +++ b/test/pantallas/pantalla_buscar_test.dart @@ -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({ diff --git a/test/pantallas/pantalla_paises_test.dart b/test/pantallas/pantalla_paises_test.dart index d6c64fb..ba5a04c 100644 --- a/test/pantallas/pantalla_paises_test.dart +++ b/test/pantallas/pantalla_paises_test.dart @@ -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.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(find.text('Spain').first); + expect(nombre.style?.fontWeight, FontWeight.w800); + + final decorado = tester.widget( + 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 {