fix(buscar): build the 2x2 "Explorar por" grid, add Novedades

Audit 3.2 (t4 lines 162-171): the prototype groups Países, Géneros,
Tendencias and the entirely-missing Novedades into one 2x2 entry-point
grid. The build had them as three unrelated always-visible widgets
(a Países ListTile, a Géneros FilterChip Wrap, a Tendencias ActionChip
strip) and no Novedades entry at all.

Replaces all three with a single grid section, capability-preserving:
Países still pushes PantallaPaises; Géneros and Tendencias now open
their exact existing content in a picker sheet instead of always-on-
screen (Géneros auto-closes on selection, matching this screen's other
single-choice filter sheets; Tendencias stays open, a browse list, not
a filter). Novedades re-triggers the existing discovery refresh, since
no distinct "new stations" feed exists anywhere in the domain.

New ARB key set (exploreByTitle/exploreTrendingTitle/
exploreTrendingSubtitle/exploreNewTitle/exploreNewSubtitle) translated
across all 13 locales with zero anti-copy allowlist entries needed.

Also fixes a pre-existing PluriEmptyState overflow this restructure
exposed (unrelated to the grid itself, confirmed via isolated repro):
wraps its Column in a SingleChildScrollView so a too-tall title/
subtitle scrolls instead of throwing a hard RenderFlex overflow.
This commit is contained in:
2026-07-30 00:43:27 +02:00
parent a49b2b6519
commit e5d461af53
31 changed files with 755 additions and 58 deletions
@@ -21,6 +21,17 @@ import '../helpers/fakes.dart';
/// provider from the start, not just `EstadoBusqueda`. A non-empty query is
/// entered first so the screen leaves the landing state and reaches the
/// search-results branch this test actually targets.
///
/// Bugfix (surfaced while landing audit item 18's "Explorar por" grid):
/// on the FIRST pump here (landing state, empty `EstadoRadio`, default
/// narrow test viewport, no `_setLargeSurfaceSize`), `_gridEmisoras`
/// renders its empty-state branch — `PluriEmptyState`, in a fixed
/// `SizedBox(height: 260)`. That widget's own Column overflowed by 10px
/// at this width with real ARB copy (`pluri_premium_widgets.dart`,
/// fixed by wrapping it in a `SingleChildScrollView` — a no-op whenever
/// content already fits, which is every real device width). This test IS
/// the end-to-end regression guard for that fix: it renders the exact
/// scenario that surfaced it.
class _BusquedaCargando extends EstadoBusqueda {
_BusquedaCargando() : super(radio: FakeServicioRadio());
+188 -1
View File
@@ -9,7 +9,9 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.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/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -43,8 +45,14 @@ void main() {
final l10n = _l10nDe(tester);
expect(find.text(l10n.nearYou), findsOneWidget);
expect(find.text(l10n.liveRadar), findsOneWidget);
// Audit 3.2 (t4 lines 164-170): the "Explorar por" 2x2 grid replaced
// the old always-visible Tendencias chip strip and Géneros chip Wrap
// (both now live behind this grid's own cells, task 18's scope).
expect(find.text(l10n.exploreByTitle), findsOneWidget);
expect(find.text(l10n.countriesScreenTitle), findsOneWidget);
expect(find.text(l10n.genresTitle), findsOneWidget);
expect(find.text(l10n.exploreTrendingTitle), findsOneWidget);
expect(find.text(l10n.exploreNewTitle), findsOneWidget);
expect(find.text('Populares Uno'), findsOneWidget);
// The search-results empty state must NOT show — this is the
// landing state, not "you searched and got nothing".
@@ -434,6 +442,185 @@ void main() {
},
);
});
group('Buscar — "Explorar por" 2x2 grid (audit 3.2)', () {
testWidgets('renders exactly 4 cells arranged 2 per row', (tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado();
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
expect(find.byKey(const Key('explore-cell-paises')), findsOneWidget);
expect(find.byKey(const Key('explore-cell-generos')), findsOneWidget);
expect(find.byKey(const Key('explore-cell-tendencias')), findsOneWidget);
expect(find.byKey(const Key('explore-cell-novedades')), findsOneWidget);
// Geometry, not GridView internals: prototype t4 line 166
// (grid-template-columns:1fr 1fr) means Países/Géneros share a row
// and Tendencias/Novedades share the NEXT row, 2 columns each.
final paises = tester.getTopLeft(
find.byKey(const Key('explore-cell-paises')),
);
final generos = tester.getTopLeft(
find.byKey(const Key('explore-cell-generos')),
);
final tendencias = tester.getTopLeft(
find.byKey(const Key('explore-cell-tendencias')),
);
final novedades = tester.getTopLeft(
find.byKey(const Key('explore-cell-novedades')),
);
expect(paises.dy, generos.dy, reason: 'Países/Géneros share a row');
expect(
tendencias.dy,
novedades.dy,
reason: 'Tendencias/Novedades share a row',
);
expect(
paises.dy,
lessThan(tendencias.dy),
reason: 'the first row sits above the second',
);
expect(paises.dx, lessThan(generos.dx), reason: 'Países is column 1');
expect(
tendencias.dx,
lessThan(novedades.dx),
reason: 'Tendencias is column 1',
);
});
testWidgets(
'each cell icon uses the prototype accent colour (t4 lines 167-170)',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado();
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
Color colorDe(String key) {
final icono = tester.widget<Icon>(
find.descendant(
of: find.byKey(Key(key)),
matching: find.byType(Icon),
),
);
return icono.color!;
}
final contexto = tester.element(
find.byKey(const Key('explore-cell-generos')),
);
expect(colorDe('explore-cell-paises'), PluriWaveTokens.brand);
expect(colorDe('explore-cell-generos'), contexto.pluriTokens.liveGreen);
expect(
colorDe('explore-cell-tendencias'),
contexto.pluriTokens.warmCoral,
);
expect(colorDe('explore-cell-novedades'), PluriWaveTokens.skyBlue);
},
);
testWidgets('tapping Paises pushes PantallaPaises', (tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado();
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);
});
testWidgets(
'tapping Generos opens a picker sheet; selecting a genre closes it '
'and filters the discovery grid (same capability, relocated)',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'rock-1', nombre: 'Rock Uno')],
),
);
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-generos')));
await tester.pumpAndSettle();
final l10n = _l10nDe(tester);
await tester.tap(find.text(l10n.genreRock));
await tester.pumpAndSettle();
// Tap-once picker (matches this file's country/language/quality
// filter sheets) — the sheet closes and the grid below now shows
// the genre-filtered results.
expect(find.byType(BottomSheet), findsNothing);
expect(find.text('Rock Uno'), findsOneWidget);
},
);
testWidgets('tapping Tendencias opens a sheet listing the trending strip; '
'tapping a station plays it via the existing flow', (tester) async {
_setLargeSurfaceSize(tester);
final audio = FakeServicioAudio();
final estado = _crearEstado(
audio: audio,
radio: FakeServicioRadio(
tendencias: [emisoraDemo(uuid: 'trend-1', nombre: 'Trend Uno')],
),
);
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-tendencias')));
await tester.pumpAndSettle();
await tester.tap(find.text('Trend Uno'));
await _pumpStableFrame(tester);
expect(
audio.emisorasReproducidas.map((e) => e.uuid),
contains('trend-1'),
);
});
testWidgets(
'tapping Novedades re-triggers the discovery refresh (no distinct '
'"new stations" feed exists in the domain)',
(tester) async {
_setLargeSurfaceSize(tester);
final radio = FakeServicioRadio();
final estado = _crearEstado(radio: radio);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
final llamadasAntes = radio.obtenerPopularesCalls;
await tester.tap(find.byKey(const Key('explore-cell-novedades')));
await _pumpStableFrame(tester);
expect(radio.obtenerPopularesCalls, greaterThan(llamadasAntes));
},
);
});
}
EstadoRadio _crearEstado({