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:
@@ -306,6 +306,11 @@
|
||||
"detectAction": "اكتشاف",
|
||||
"liveRadar": "رادار مباشر",
|
||||
"genresTitle": "الأنواع",
|
||||
"exploreByTitle": "استكشاف حسب",
|
||||
"exploreTrendingTitle": "الرائجة",
|
||||
"exploreTrendingSubtitle": "اليوم",
|
||||
"exploreNewTitle": "جديد",
|
||||
"exploreNewSubtitle": "هذا الأسبوع",
|
||||
"offlineBannerTitle": "لا يوجد اتصال",
|
||||
"retryAction": "إعادة المحاولة",
|
||||
"noStationsAvailable": "لا توجد محطات متاحة",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "সনাক্ত করুন",
|
||||
"liveRadar": "লাইভ রাডার",
|
||||
"genresTitle": "ধরন",
|
||||
"exploreByTitle": "যেভাবে অন্বেষণ করুন",
|
||||
"exploreTrendingTitle": "ট্রেন্ডিং",
|
||||
"exploreTrendingSubtitle": "আজ",
|
||||
"exploreNewTitle": "নতুন",
|
||||
"exploreNewSubtitle": "এই সপ্তাহে",
|
||||
"offlineBannerTitle": "সংযোগ নেই",
|
||||
"retryAction": "আবার চেষ্টা করুন",
|
||||
"noStationsAvailable": "কোনো স্টেশন উপলব্ধ নেই",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "Erkennen",
|
||||
"liveRadar": "Live-Radar",
|
||||
"genresTitle": "Genres",
|
||||
"exploreByTitle": "Entdecken nach",
|
||||
"exploreTrendingTitle": "Trends",
|
||||
"exploreTrendingSubtitle": "Heute",
|
||||
"exploreNewTitle": "Neuheiten",
|
||||
"exploreNewSubtitle": "Diese Woche",
|
||||
"offlineBannerTitle": "Keine Verbindung",
|
||||
"retryAction": "Erneut versuchen",
|
||||
"noStationsAvailable": "Keine Sender verfügbar",
|
||||
|
||||
@@ -369,6 +369,11 @@
|
||||
"detectAction": "Detect",
|
||||
"liveRadar": "Live radar",
|
||||
"genresTitle": "Genres",
|
||||
"exploreByTitle": "Browse by",
|
||||
"exploreTrendingTitle": "Trending",
|
||||
"exploreTrendingSubtitle": "Today",
|
||||
"exploreNewTitle": "New",
|
||||
"exploreNewSubtitle": "This week",
|
||||
"offlineBannerTitle": "No connection",
|
||||
"retryAction": "Retry",
|
||||
"noStationsAvailable": "No stations available",
|
||||
|
||||
@@ -369,6 +369,11 @@
|
||||
"detectAction": "Detectar",
|
||||
"liveRadar": "Radar en directo",
|
||||
"genresTitle": "Géneros",
|
||||
"exploreByTitle": "Explorar por",
|
||||
"exploreTrendingTitle": "Tendencias",
|
||||
"exploreTrendingSubtitle": "Hoy",
|
||||
"exploreNewTitle": "Novedades",
|
||||
"exploreNewSubtitle": "Esta semana",
|
||||
"offlineBannerTitle": "Sin conexión",
|
||||
"retryAction": "Reintentar",
|
||||
"noStationsAvailable": "No hay emisoras disponibles",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "Détecter",
|
||||
"liveRadar": "Radar en direct",
|
||||
"genresTitle": "Genres",
|
||||
"exploreByTitle": "Explorer par",
|
||||
"exploreTrendingTitle": "Tendances",
|
||||
"exploreTrendingSubtitle": "Aujourd'hui",
|
||||
"exploreNewTitle": "Nouveautés",
|
||||
"exploreNewSubtitle": "Cette semaine",
|
||||
"offlineBannerTitle": "Aucune connexion",
|
||||
"retryAction": "Réessayer",
|
||||
"noStationsAvailable": "Aucune station disponible",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "पहचानें",
|
||||
"liveRadar": "लाइव रडार",
|
||||
"genresTitle": "शैलियाँ",
|
||||
"exploreByTitle": "इस अनुसार खोजें",
|
||||
"exploreTrendingTitle": "ट्रेंडिंग",
|
||||
"exploreTrendingSubtitle": "आज",
|
||||
"exploreNewTitle": "नया",
|
||||
"exploreNewSubtitle": "इस सप्ताह",
|
||||
"offlineBannerTitle": "कोई कनेक्शन नहीं",
|
||||
"retryAction": "फिर कोशिश करें",
|
||||
"noStationsAvailable": "कोई स्टेशन उपलब्ध नहीं",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "Deteksi",
|
||||
"liveRadar": "Radar langsung",
|
||||
"genresTitle": "Genre",
|
||||
"exploreByTitle": "Jelajahi berdasarkan",
|
||||
"exploreTrendingTitle": "Tren",
|
||||
"exploreTrendingSubtitle": "Hari ini",
|
||||
"exploreNewTitle": "Baru",
|
||||
"exploreNewSubtitle": "Minggu ini",
|
||||
"offlineBannerTitle": "Tidak ada koneksi",
|
||||
"retryAction": "Coba lagi",
|
||||
"noStationsAvailable": "Tidak ada stasiun tersedia",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "Rileva",
|
||||
"liveRadar": "Radar live",
|
||||
"genresTitle": "Generi",
|
||||
"exploreByTitle": "Esplora per",
|
||||
"exploreTrendingTitle": "Tendenze",
|
||||
"exploreTrendingSubtitle": "Oggi",
|
||||
"exploreNewTitle": "Novità",
|
||||
"exploreNewSubtitle": "Questa settimana",
|
||||
"offlineBannerTitle": "Nessuna connessione",
|
||||
"retryAction": "Riprova",
|
||||
"noStationsAvailable": "Nessuna emittente disponibile",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "検出",
|
||||
"liveRadar": "ライブレーダー",
|
||||
"genresTitle": "ジャンル",
|
||||
"exploreByTitle": "カテゴリで探す",
|
||||
"exploreTrendingTitle": "トレンド",
|
||||
"exploreTrendingSubtitle": "今日",
|
||||
"exploreNewTitle": "新着",
|
||||
"exploreNewSubtitle": "今週",
|
||||
"offlineBannerTitle": "接続なし",
|
||||
"retryAction": "再試行",
|
||||
"noStationsAvailable": "利用できる局がありません",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "Detectar",
|
||||
"liveRadar": "Radar ao vivo",
|
||||
"genresTitle": "Gêneros",
|
||||
"exploreByTitle": "Navegar por",
|
||||
"exploreTrendingTitle": "Tendências",
|
||||
"exploreTrendingSubtitle": "Hoje",
|
||||
"exploreNewTitle": "Novidades",
|
||||
"exploreNewSubtitle": "Nesta semana",
|
||||
"offlineBannerTitle": "Sem conexão",
|
||||
"retryAction": "Tentar novamente",
|
||||
"noStationsAvailable": "Não há estações disponíveis",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "Определить",
|
||||
"liveRadar": "Радар в эфире",
|
||||
"genresTitle": "Жанры",
|
||||
"exploreByTitle": "Обзор по",
|
||||
"exploreTrendingTitle": "В тренде",
|
||||
"exploreTrendingSubtitle": "Сегодня",
|
||||
"exploreNewTitle": "Новинки",
|
||||
"exploreNewSubtitle": "На этой неделе",
|
||||
"offlineBannerTitle": "Нет подключения",
|
||||
"retryAction": "Повторить",
|
||||
"noStationsAvailable": "Нет доступных станций",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"detectAction": "检测",
|
||||
"liveRadar": "直播雷达",
|
||||
"genresTitle": "类型",
|
||||
"exploreByTitle": "浏览分类",
|
||||
"exploreTrendingTitle": "热门",
|
||||
"exploreTrendingSubtitle": "今天",
|
||||
"exploreNewTitle": "最新",
|
||||
"exploreNewSubtitle": "本周",
|
||||
"offlineBannerTitle": "无连接",
|
||||
"retryAction": "重试",
|
||||
"noStationsAvailable": "没有可用电台",
|
||||
|
||||
@@ -1438,6 +1438,36 @@ abstract class AppLocalizations {
|
||||
/// **'Géneros'**
|
||||
String get genresTitle;
|
||||
|
||||
/// No description provided for @exploreByTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Explorar por'**
|
||||
String get exploreByTitle;
|
||||
|
||||
/// No description provided for @exploreTrendingTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Tendencias'**
|
||||
String get exploreTrendingTitle;
|
||||
|
||||
/// No description provided for @exploreTrendingSubtitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Hoy'**
|
||||
String get exploreTrendingSubtitle;
|
||||
|
||||
/// No description provided for @exploreNewTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Novedades'**
|
||||
String get exploreNewTitle;
|
||||
|
||||
/// No description provided for @exploreNewSubtitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Esta semana'**
|
||||
String get exploreNewSubtitle;
|
||||
|
||||
/// No description provided for @offlineBannerTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
|
||||
@@ -766,6 +766,21 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'الأنواع';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'استكشاف حسب';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'الرائجة';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'اليوم';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'جديد';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'هذا الأسبوع';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'لا يوجد اتصال';
|
||||
|
||||
|
||||
@@ -769,6 +769,21 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'ধরন';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'যেভাবে অন্বেষণ করুন';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'ট্রেন্ডিং';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'আজ';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'নতুন';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'এই সপ্তাহে';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'সংযোগ নেই';
|
||||
|
||||
|
||||
@@ -772,6 +772,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'Genres';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'Entdecken nach';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'Trends';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'Heute';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'Neuheiten';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Diese Woche';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Keine Verbindung';
|
||||
|
||||
|
||||
@@ -765,6 +765,21 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'Genres';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'Browse by';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'Trending';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'Today';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'New';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'This week';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'No connection';
|
||||
|
||||
|
||||
@@ -770,6 +770,21 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'Géneros';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'Explorar por';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'Tendencias';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'Hoy';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'Novedades';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Esta semana';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Sin conexión';
|
||||
|
||||
|
||||
@@ -774,6 +774,21 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'Genres';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'Explorer par';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'Tendances';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'Aujourd\'hui';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'Nouveautés';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Cette semaine';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Aucune connexion';
|
||||
|
||||
|
||||
@@ -766,6 +766,21 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'शैलियाँ';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'इस अनुसार खोजें';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'ट्रेंडिंग';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'आज';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'नया';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'इस सप्ताह';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'कोई कनेक्शन नहीं';
|
||||
|
||||
|
||||
@@ -766,6 +766,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'Genre';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'Jelajahi berdasarkan';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'Tren';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'Hari ini';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'Baru';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Minggu ini';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Tidak ada koneksi';
|
||||
|
||||
|
||||
@@ -772,6 +772,21 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'Generi';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'Esplora per';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'Tendenze';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'Oggi';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'Novità';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Questa settimana';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Nessuna connessione';
|
||||
|
||||
|
||||
@@ -740,6 +740,21 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'ジャンル';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'カテゴリで探す';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'トレンド';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => '今日';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => '新着';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => '今週';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => '接続なし';
|
||||
|
||||
|
||||
@@ -769,6 +769,21 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'Gêneros';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'Navegar por';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'Tendências';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'Hoje';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'Novidades';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Nesta semana';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Sem conexão';
|
||||
|
||||
|
||||
@@ -770,6 +770,21 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => 'Жанры';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => 'Обзор по';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => 'В тренде';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => 'Сегодня';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => 'Новинки';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => 'На этой неделе';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Нет подключения';
|
||||
|
||||
|
||||
@@ -737,6 +737,21 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get genresTitle => '类型';
|
||||
|
||||
@override
|
||||
String get exploreByTitle => '浏览分类';
|
||||
|
||||
@override
|
||||
String get exploreTrendingTitle => '热门';
|
||||
|
||||
@override
|
||||
String get exploreTrendingSubtitle => '今天';
|
||||
|
||||
@override
|
||||
String get exploreNewTitle => '最新';
|
||||
|
||||
@override
|
||||
String get exploreNewSubtitle => '本周';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => '无连接';
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,31 +74,42 @@ class PluriEmptyState extends StatelessWidget {
|
||||
child: PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(t.radiusLg + 10),
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PluriIcon(
|
||||
glyph: glyph,
|
||||
variant: PluriIconVariant.activeGlow,
|
||||
size: 58,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
// Bugfix: every call site constrains this widget to a fixed
|
||||
// height (`SizedBox(height: 260)`). At a narrow width with
|
||||
// realistic two-line title/subtitle copy, the icon + gaps +
|
||||
// wrapped text can exceed what's actually left after this
|
||||
// surface's own padding — a hard RenderFlex overflow rather
|
||||
// than a graceful clip. `SingleChildScrollView` is a no-op
|
||||
// visually whenever the content already fits (the normal
|
||||
// case, on any real device width this was designed for) and
|
||||
// only engages scrolling in the rare case it does not.
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PluriIcon(
|
||||
glyph: glyph,
|
||||
variant: PluriIconVariant.activeGlow,
|
||||
size: 58,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user