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
+232 -34
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer;
@@ -8,6 +10,7 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -199,9 +202,11 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
_resultados(estado, theme),
] else ...[
_seccionCercanas(context, theme, l10n),
_seccionTendencias(context, theme, l10n),
_chipGeneros(context, theme, l10n),
_seccionPaises(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,
@@ -798,6 +803,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
} 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(),
@@ -808,43 +819,157 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
);
}
/// WU7, `station-discovery-browse` spec: the "Países entry point" the
/// spec's landing-state scenario lists. Deferred from WU6 (this class's
/// own doc comment: `PantallaPaises` did not exist yet); added here now
/// that it does, alongside the screen it targets — an unreachable screen
/// would repeat the WU15/WU15b lesson (a fully-tested screen shipped with
/// no navigation path to it).
Widget _seccionPaises(
/// 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 Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
8,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface(
padding: EdgeInsets.zero,
// `PluriGlassSurface` paints via `DecoratedBox`, not `Material` — a
// tappable `ListTile` needs its own `Material` ancestor or its ink
// splash silently fails to paint (Flutter's own debug assertion).
child: Material(
type: MaterialType.transparency,
child: ListTile(
leading: const Icon(Icons.public_rounded),
title: Text(l10n.countriesScreenTitle),
trailing: const Icon(Icons.chevron_right_rounded),
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaPaises(),
),
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,
(_) => const PantallaPaises(),
),
),
),
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)),
);
}
@@ -1037,3 +1162,76 @@ class _ChipShimmer extends StatelessWidget {
);
}
}
/// 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,
),
],
),
),
],
),
),
),
);
}
}