fix(buscar): rebuild the offline banner with offlineAccent

Audit 13.1 (t4 line 641): the banner rendered wifi_off in
colorScheme.error (red) inside a generic glass card. Rebuilt using
offlineAccent (#E8879A) — a token that existed since Tier 1 with no
consumer — for the tint, border and icon, plus the prototype's title/
detail layout and a restyled Retry chip. New offlineBannerTitle key
translated to all 13 locales.
This commit is contained in:
2026-07-29 23:40:38 +02:00
parent e1505d7aaa
commit 684e8e8ff9
29 changed files with 329 additions and 175 deletions
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "اكتشاف", "detectAction": "اكتشاف",
"liveRadar": "رادار مباشر", "liveRadar": "رادار مباشر",
"genresTitle": "الأنواع", "genresTitle": "الأنواع",
"offlineBannerTitle": "لا يوجد اتصال",
"retryAction": "إعادة المحاولة", "retryAction": "إعادة المحاولة",
"noStationsAvailable": "لا توجد محطات متاحة", "noStationsAvailable": "لا توجد محطات متاحة",
"noStationsAvailableSubtitle": "جرّب التحديث أو اختيار نوع آخر لالتقاط الإشارة من جديد.", "noStationsAvailableSubtitle": "جرّب التحديث أو اختيار نوع آخر لالتقاط الإشارة من جديد.",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "সনাক্ত করুন", "detectAction": "সনাক্ত করুন",
"liveRadar": "লাইভ রাডার", "liveRadar": "লাইভ রাডার",
"genresTitle": "ধরন", "genresTitle": "ধরন",
"offlineBannerTitle": "সংযোগ নেই",
"retryAction": "আবার চেষ্টা করুন", "retryAction": "আবার চেষ্টা করুন",
"noStationsAvailable": "কোনো স্টেশন উপলব্ধ নেই", "noStationsAvailable": "কোনো স্টেশন উপলব্ধ নেই",
"noStationsAvailableSubtitle": "সিগন্যাল আবার ধরতে রিফ্রেশ করুন বা অন্য ধরন বেছে নিন।", "noStationsAvailableSubtitle": "সিগন্যাল আবার ধরতে রিফ্রেশ করুন বা অন্য ধরন বেছে নিন।",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "Erkennen", "detectAction": "Erkennen",
"liveRadar": "Live-Radar", "liveRadar": "Live-Radar",
"genresTitle": "Genres", "genresTitle": "Genres",
"offlineBannerTitle": "Keine Verbindung",
"retryAction": "Erneut versuchen", "retryAction": "Erneut versuchen",
"noStationsAvailable": "Keine Sender verfügbar", "noStationsAvailable": "Keine Sender verfügbar",
"noStationsAvailableSubtitle": "Versuche zu aktualisieren oder ein anderes Genre auszuwählen, um wieder ein Signal zu empfangen.", "noStationsAvailableSubtitle": "Versuche zu aktualisieren oder ein anderes Genre auszuwählen, um wieder ein Signal zu empfangen.",
+1
View File
@@ -369,6 +369,7 @@
"detectAction": "Detect", "detectAction": "Detect",
"liveRadar": "Live radar", "liveRadar": "Live radar",
"genresTitle": "Genres", "genresTitle": "Genres",
"offlineBannerTitle": "No connection",
"retryAction": "Retry", "retryAction": "Retry",
"noStationsAvailable": "No stations available", "noStationsAvailable": "No stations available",
"noStationsAvailableSubtitle": "Try refreshing or choosing another genre to capture a signal again.", "noStationsAvailableSubtitle": "Try refreshing or choosing another genre to capture a signal again.",
+1
View File
@@ -369,6 +369,7 @@
"detectAction": "Detectar", "detectAction": "Detectar",
"liveRadar": "Radar en directo", "liveRadar": "Radar en directo",
"genresTitle": "Géneros", "genresTitle": "Géneros",
"offlineBannerTitle": "Sin conexión",
"retryAction": "Reintentar", "retryAction": "Reintentar",
"noStationsAvailable": "No hay emisoras disponibles", "noStationsAvailable": "No hay emisoras disponibles",
"noStationsAvailableSubtitle": "Probá refrescar o elegir otro género para volver a capturar señal.", "noStationsAvailableSubtitle": "Probá refrescar o elegir otro género para volver a capturar señal.",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "Détecter", "detectAction": "Détecter",
"liveRadar": "Radar en direct", "liveRadar": "Radar en direct",
"genresTitle": "Genres", "genresTitle": "Genres",
"offlineBannerTitle": "Aucune connexion",
"retryAction": "Réessayer", "retryAction": "Réessayer",
"noStationsAvailable": "Aucune station disponible", "noStationsAvailable": "Aucune station disponible",
"noStationsAvailableSubtitle": "Essayez dactualiser ou de choisir un autre genre pour capter à nouveau un signal.", "noStationsAvailableSubtitle": "Essayez dactualiser ou de choisir un autre genre pour capter à nouveau un signal.",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "पहचानें", "detectAction": "पहचानें",
"liveRadar": "लाइव रडार", "liveRadar": "लाइव रडार",
"genresTitle": "शैलियाँ", "genresTitle": "शैलियाँ",
"offlineBannerTitle": "कोई कनेक्शन नहीं",
"retryAction": "फिर कोशिश करें", "retryAction": "फिर कोशिश करें",
"noStationsAvailable": "कोई स्टेशन उपलब्ध नहीं", "noStationsAvailable": "कोई स्टेशन उपलब्ध नहीं",
"noStationsAvailableSubtitle": "सिग्नल फिर पकड़ने के लिए रीफ़्रेश करें या दूसरी शैली चुनें।", "noStationsAvailableSubtitle": "सिग्नल फिर पकड़ने के लिए रीफ़्रेश करें या दूसरी शैली चुनें।",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "Deteksi", "detectAction": "Deteksi",
"liveRadar": "Radar langsung", "liveRadar": "Radar langsung",
"genresTitle": "Genre", "genresTitle": "Genre",
"offlineBannerTitle": "Tidak ada koneksi",
"retryAction": "Coba lagi", "retryAction": "Coba lagi",
"noStationsAvailable": "Tidak ada stasiun tersedia", "noStationsAvailable": "Tidak ada stasiun tersedia",
"noStationsAvailableSubtitle": "Coba segarkan atau pilih genre lain untuk menangkap sinyal kembali.", "noStationsAvailableSubtitle": "Coba segarkan atau pilih genre lain untuk menangkap sinyal kembali.",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "Rileva", "detectAction": "Rileva",
"liveRadar": "Radar live", "liveRadar": "Radar live",
"genresTitle": "Generi", "genresTitle": "Generi",
"offlineBannerTitle": "Nessuna connessione",
"retryAction": "Riprova", "retryAction": "Riprova",
"noStationsAvailable": "Nessuna emittente disponibile", "noStationsAvailable": "Nessuna emittente disponibile",
"noStationsAvailableSubtitle": "Prova ad aggiornare o a scegliere un altro genere per captare di nuovo un segnale.", "noStationsAvailableSubtitle": "Prova ad aggiornare o a scegliere un altro genere per captare di nuovo un segnale.",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "検出", "detectAction": "検出",
"liveRadar": "ライブレーダー", "liveRadar": "ライブレーダー",
"genresTitle": "ジャンル", "genresTitle": "ジャンル",
"offlineBannerTitle": "接続なし",
"retryAction": "再試行", "retryAction": "再試行",
"noStationsAvailable": "利用できる局がありません", "noStationsAvailable": "利用できる局がありません",
"noStationsAvailableSubtitle": "再読み込みするか別のジャンルを選んで、もう一度電波をつかまえてください。", "noStationsAvailableSubtitle": "再読み込みするか別のジャンルを選んで、もう一度電波をつかまえてください。",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "Detectar", "detectAction": "Detectar",
"liveRadar": "Radar ao vivo", "liveRadar": "Radar ao vivo",
"genresTitle": "Gêneros", "genresTitle": "Gêneros",
"offlineBannerTitle": "Sem conexão",
"retryAction": "Tentar novamente", "retryAction": "Tentar novamente",
"noStationsAvailable": "Não há estações disponíveis", "noStationsAvailable": "Não há estações disponíveis",
"noStationsAvailableSubtitle": "Tente atualizar ou escolher outro gênero para captar sinal novamente.", "noStationsAvailableSubtitle": "Tente atualizar ou escolher outro gênero para captar sinal novamente.",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "Определить", "detectAction": "Определить",
"liveRadar": "Радар в эфире", "liveRadar": "Радар в эфире",
"genresTitle": "Жанры", "genresTitle": "Жанры",
"offlineBannerTitle": "Нет подключения",
"retryAction": "Повторить", "retryAction": "Повторить",
"noStationsAvailable": "Нет доступных станций", "noStationsAvailable": "Нет доступных станций",
"noStationsAvailableSubtitle": "Попробуйте обновить или выбрать другой жанр, чтобы снова поймать сигнал.", "noStationsAvailableSubtitle": "Попробуйте обновить или выбрать другой жанр, чтобы снова поймать сигнал.",
+1
View File
@@ -306,6 +306,7 @@
"detectAction": "检测", "detectAction": "检测",
"liveRadar": "直播雷达", "liveRadar": "直播雷达",
"genresTitle": "类型", "genresTitle": "类型",
"offlineBannerTitle": "无连接",
"retryAction": "重试", "retryAction": "重试",
"noStationsAvailable": "没有可用电台", "noStationsAvailable": "没有可用电台",
"noStationsAvailableSubtitle": "尝试刷新或选择其他类型,重新捕捉信号。", "noStationsAvailableSubtitle": "尝试刷新或选择其他类型,重新捕捉信号。",
+6
View File
@@ -1438,6 +1438,12 @@ abstract class AppLocalizations {
/// **'Géneros'** /// **'Géneros'**
String get genresTitle; String get genresTitle;
/// No description provided for @offlineBannerTitle.
///
/// In es, this message translates to:
/// **'Sin conexión'**
String get offlineBannerTitle;
/// No description provided for @retryAction. /// No description provided for @retryAction.
/// ///
/// In es, this message translates to: /// In es, this message translates to:
+3
View File
@@ -766,6 +766,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get genresTitle => 'الأنواع'; String get genresTitle => 'الأنواع';
@override
String get offlineBannerTitle => 'لا يوجد اتصال';
@override @override
String get retryAction => 'إعادة المحاولة'; String get retryAction => 'إعادة المحاولة';
+3
View File
@@ -769,6 +769,9 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get genresTitle => 'ধরন'; String get genresTitle => 'ধরন';
@override
String get offlineBannerTitle => 'সংযোগ নেই';
@override @override
String get retryAction => 'আবার চেষ্টা করুন'; String get retryAction => 'আবার চেষ্টা করুন';
+3
View File
@@ -772,6 +772,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get genresTitle => 'Genres'; String get genresTitle => 'Genres';
@override
String get offlineBannerTitle => 'Keine Verbindung';
@override @override
String get retryAction => 'Erneut versuchen'; String get retryAction => 'Erneut versuchen';
+3
View File
@@ -765,6 +765,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get genresTitle => 'Genres'; String get genresTitle => 'Genres';
@override
String get offlineBannerTitle => 'No connection';
@override @override
String get retryAction => 'Retry'; String get retryAction => 'Retry';
+3
View File
@@ -770,6 +770,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get genresTitle => 'Géneros'; String get genresTitle => 'Géneros';
@override
String get offlineBannerTitle => 'Sin conexión';
@override @override
String get retryAction => 'Reintentar'; String get retryAction => 'Reintentar';
+3
View File
@@ -774,6 +774,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get genresTitle => 'Genres'; String get genresTitle => 'Genres';
@override
String get offlineBannerTitle => 'Aucune connexion';
@override @override
String get retryAction => 'Réessayer'; String get retryAction => 'Réessayer';
+3
View File
@@ -766,6 +766,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get genresTitle => 'शैलियाँ'; String get genresTitle => 'शैलियाँ';
@override
String get offlineBannerTitle => 'कोई कनेक्शन नहीं';
@override @override
String get retryAction => 'फिर कोशिश करें'; String get retryAction => 'फिर कोशिश करें';
+3
View File
@@ -766,6 +766,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get genresTitle => 'Genre'; String get genresTitle => 'Genre';
@override
String get offlineBannerTitle => 'Tidak ada koneksi';
@override @override
String get retryAction => 'Coba lagi'; String get retryAction => 'Coba lagi';
+3
View File
@@ -772,6 +772,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get genresTitle => 'Generi'; String get genresTitle => 'Generi';
@override
String get offlineBannerTitle => 'Nessuna connessione';
@override @override
String get retryAction => 'Riprova'; String get retryAction => 'Riprova';
+3
View File
@@ -740,6 +740,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get genresTitle => 'ジャンル'; String get genresTitle => 'ジャンル';
@override
String get offlineBannerTitle => '接続なし';
@override @override
String get retryAction => '再試行'; String get retryAction => '再試行';
+3
View File
@@ -769,6 +769,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get genresTitle => 'Gêneros'; String get genresTitle => 'Gêneros';
@override
String get offlineBannerTitle => 'Sem conexão';
@override @override
String get retryAction => 'Tentar novamente'; String get retryAction => 'Tentar novamente';
+3
View File
@@ -770,6 +770,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get genresTitle => 'Жанры'; String get genresTitle => 'Жанры';
@override
String get offlineBannerTitle => 'Нет подключения';
@override @override
String get retryAction => 'Повторить'; String get retryAction => 'Повторить';
+3
View File
@@ -737,6 +737,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get genresTitle => '类型'; String get genresTitle => '类型';
@override
String get offlineBannerTitle => '无连接';
@override @override
String get retryAction => '重试'; String get retryAction => '重试';
+69 -13
View File
@@ -7,6 +7,7 @@ import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import '../tema/pluri_animate.dart'; import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart'; import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart'; import '../widgets/pluri_layout.dart';
@@ -847,26 +848,81 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
); );
} }
// Audit 13.1 (t4 line 641): the prototype's offline banner is a
// rgba(207,102,121,X) tinted card — this consolidates on the ONE
// `offlineAccent` token (`#E8879A`) the proposal named for this exact
// banner (`pluriwave_tokens.dart:77`), which existed unused until now,
// rather than introducing a second near-identical raw hex for the tint.
Widget _errorBanner( Widget _errorBanner(
BuildContext context, BuildContext context,
String error, String error,
ThemeData theme, ThemeData theme,
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
final tokens = context.pluriTokens;
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: PluriGlassSurface( child: DecoratedBox(
padding: const EdgeInsets.all(12), key: const ValueKey('offline-banner'),
child: Row( decoration: BoxDecoration(
children: [ color: tokens.offlineAccent.withValues(alpha: 0.14),
Icon(Icons.wifi_off, color: theme.colorScheme.error), borderRadius: BorderRadius.circular(16),
const SizedBox(width: 8), border: Border.all(
Expanded(child: Text(error)), color: tokens.offlineAccent.withValues(alpha: 0.4),
TextButton( ),
onPressed: () => context.read<EstadoRadio>().cargarPopulares(), ),
child: Text(l10n.retryAction), child: Padding(
), padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
], child: Row(
children: [
Icon(Icons.wifi_off, size: 22, color: tokens.offlineAccent),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.offlineBannerTitle,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
),
),
Text(
error,
style: TextStyle(
fontSize: 11.5,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.65,
),
),
),
],
),
),
const SizedBox(width: 8),
TextButton(
style: TextButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.1),
foregroundColor: theme.colorScheme.onSurface,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
onPressed: () => context.read<EstadoRadio>().cargarPopulares(),
child: Text(l10n.retryAction),
),
],
),
), ),
), ),
); );
+202 -162
View File
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_buscar.dart'; import 'package:pluriwave/pantallas/pantalla_buscar.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -25,33 +26,30 @@ void main() {
}); });
group('Buscar — landing state (empty query shows discovery content)', () { group('Buscar — landing state (empty query shows discovery content)', () {
testWidgets( testWidgets('consulta vacia muestra cerca de vos, generos y tendencias; '
'consulta vacia muestra cerca de vos, generos y tendencias; ' 'el grid de resultados de busqueda no aparece', (tester) async {
'el grid de resultados de busqueda no aparece', _setLargeSurfaceSize(tester);
(tester) async { final estado = _crearEstado(
_setLargeSurfaceSize(tester); radio: FakeServicioRadio(
final estado = _crearEstado( populares: [emisoraDemo(uuid: 'pop-1', nombre: 'Populares Uno')],
radio: FakeServicioRadio( tendencias: [emisoraDemo(uuid: 'tr-1', nombre: 'Tendencia Uno')],
populares: [emisoraDemo(uuid: 'pop-1', nombre: 'Populares Uno')], ),
tendencias: [emisoraDemo(uuid: 'tr-1', nombre: 'Tendencia Uno')], );
), addTearDown(estado.dispose);
); await tester.runAsync(estado.inicializar);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp())); await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
final l10n = _l10nDe(tester); final l10n = _l10nDe(tester);
expect(find.text(l10n.nearYou), findsOneWidget); expect(find.text(l10n.nearYou), findsOneWidget);
expect(find.text(l10n.liveRadar), findsOneWidget); expect(find.text(l10n.liveRadar), findsOneWidget);
expect(find.text(l10n.genresTitle), findsOneWidget); expect(find.text(l10n.genresTitle), findsOneWidget);
expect(find.text('Populares Uno'), findsOneWidget); expect(find.text('Populares Uno'), findsOneWidget);
// The search-results empty state must NOT show — this is the // The search-results empty state must NOT show — this is the
// landing state, not "you searched and got nothing". // landing state, not "you searched and got nothing".
expect(find.text(l10n.searchEmptyTitle), findsNothing); expect(find.text(l10n.searchEmptyTitle), findsNothing);
}, });
);
testWidgets( testWidgets(
'escribir una consulta reemplaza el contenido de descubrimiento por ' 'escribir una consulta reemplaza el contenido de descubrimiento por '
@@ -110,38 +108,37 @@ void main() {
}, },
); );
testWidgets( testWidgets('quitar un pill vuelve a buscar sin ese filtro', (
'quitar un pill vuelve a buscar sin ese filtro', tester,
(tester) async { ) async {
_setLargeSurfaceSize(tester); _setLargeSurfaceSize(tester);
final estado = _crearEstado( final estado = _crearEstado(
radio: FakeServicioRadio( radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')], busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')],
), ),
); );
addTearDown(estado.dispose); addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar); await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp())); await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
final l10n = _l10nDe(tester); final l10n = _l10nDe(tester);
await _abrirYSeleccionarPais(tester, l10n.countrySpain); await _abrirYSeleccionarPais(tester, l10n.countrySpain);
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
expect(find.text(l10n.countrySpain), findsOneWidget); expect(find.text(l10n.countrySpain), findsOneWidget);
final pillPais = find.ancestor( final pillPais = find.ancestor(
of: find.text(l10n.countrySpain), of: find.text(l10n.countrySpain),
matching: find.byType(Chip), matching: find.byType(Chip),
); );
await tester.tap( await tester.tap(
find.descendant(of: pillPais, matching: find.byIcon(Icons.close)), find.descendant(of: pillPais, matching: find.byIcon(Icons.close)),
); );
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
expect(find.text(l10n.countrySpain), findsNothing); expect(find.text(l10n.countrySpain), findsNothing);
}, });
);
testWidgets( testWidgets(
'dos filtros activos y cero resultados ofrecen quitar ambos de una ' 'dos filtros activos y cero resultados ofrecen quitar ambos de una '
@@ -207,128 +204,150 @@ void main() {
}, },
); );
testWidgets( testWidgets('elegir un criterio reordena la pagina actual sin una nueva '
'elegir un criterio reordena la pagina actual sin una nueva ' 'solicitud de red con parametro order', (tester) async {
'solicitud de red con parametro order', _setLargeSurfaceSize(tester);
(tester) async { final radio = FakeServicioRadio(
_setLargeSurfaceSize(tester); busqueda: [
final radio = FakeServicioRadio( emisoraDemo(
busqueda: [ uuid: 'alta',
emisoraDemo(uuid: 'alta', nombre: 'Estacion Alta') nombre: 'Estacion Alta',
.copyWith(bitrate: 320, votes: 1), ).copyWith(bitrate: 320, votes: 1),
emisoraDemo(uuid: 'popular', nombre: 'Estacion Popular') emisoraDemo(
.copyWith(bitrate: 64, votes: 900), uuid: 'popular',
], nombre: 'Estacion Popular',
); ).copyWith(bitrate: 64, votes: 900),
final estado = _crearEstado(radio: radio); ],
addTearDown(estado.dispose); );
await tester.runAsync(estado.inicializar); final estado = _crearEstado(radio: radio);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp())); await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'estacion'); await tester.enterText(find.byType(SearchBar), 'estacion');
await tester.testTextInput.receiveAction(TextInputAction.done); await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
// Default global ordering is OrdenEmisoras.calidad (bitrate desc): // Default global ordering is OrdenEmisoras.calidad (bitrate desc):
// "Estacion Alta" (320 kbps) must render above "Estacion Popular". // "Estacion Alta" (320 kbps) must render above "Estacion Popular".
final yAltaAntes = tester.getTopLeft(find.text('Estacion Alta')).dy; final yAltaAntes = tester.getTopLeft(find.text('Estacion Alta')).dy;
final yPopularAntes = tester.getTopLeft( final yPopularAntes = tester.getTopLeft(find.text('Estacion Popular')).dy;
find.text('Estacion Popular'), expect(yAltaAntes, lessThan(yPopularAntes));
).dy;
expect(yAltaAntes, lessThan(yPopularAntes));
final llamadasAntes = radio.buscarCalls; final llamadasAntes = radio.buscarCalls;
final l10n = _l10nDe(tester); final l10n = _l10nDe(tester);
await tester.tap(find.byIcon(Icons.swap_vert_rounded)); await tester.tap(find.byIcon(Icons.swap_vert_rounded));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text(l10n.stationOrderByPopularity)); await tester.tap(find.text(l10n.stationOrderByPopularity));
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
expect( expect(
radio.buscarCalls, radio.buscarCalls,
llamadasAntes, llamadasAntes,
reason: 'sorting must not trigger a new /json request', reason: 'sorting must not trigger a new /json request',
); );
final yAltaDespues = tester.getTopLeft(find.text('Estacion Alta')).dy; final yAltaDespues = tester.getTopLeft(find.text('Estacion Alta')).dy;
final yPopularDespues = tester.getTopLeft( final yPopularDespues =
find.text('Estacion Popular'), tester.getTopLeft(find.text('Estacion Popular')).dy;
).dy; expect(yPopularDespues, lessThan(yAltaDespues));
expect(yPopularDespues, lessThan(yAltaDespues)); });
},
);
}); });
group('Buscar — contenido relocalizado desde PantallaInicio (WU6 6.5)', () { group('Buscar — contenido relocalizado desde PantallaInicio (WU6 6.5)', () {
testWidgets( testWidgets('el grid de descubrimiento muestra custom + populares; tocar '
'el grid de descubrimiento muestra custom + populares; tocar ' 'reproduce via EstadoRadio y el boton de favorito usa el flujo '
'reproduce via EstadoRadio y el boton de favorito usa el flujo ' 'existente', (tester) async {
'existente', _setLargeSurfaceSize(tester);
(tester) async { final audio = FakeServicioAudio();
_setLargeSurfaceSize(tester); final favoritos = FakeServicioFavoritos();
final audio = FakeServicioAudio(); final radio = FakeServicioRadio();
final favoritos = FakeServicioFavoritos(); final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno');
final radio = FakeServicioRadio(); final archivo = await _crearArchivoCustom([custom]);
final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); final estado = _crearEstado(
final archivo = await _crearArchivoCustom([custom]); audio: audio,
final estado = _crearEstado( favoritos: favoritos,
audio: audio, radio: radio,
favoritos: favoritos, resolverArchivoCustom: () async => archivo,
radio: radio, );
resolverArchivoCustom: () async => archivo, addTearDown(estado.dispose);
); await tester.runAsync(estado.inicializar);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp())); await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
expect(find.text('Custom Uno'), findsOneWidget); expect(find.text('Custom Uno'), findsOneWidget);
await tester.tap(find.text('Custom Uno')); await tester.tap(find.text('Custom Uno'));
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
expect( expect(
audio.emisorasReproducidas.map((e) => e.uuid), audio.emisorasReproducidas.map((e) => e.uuid),
contains('custom-1'), contains('custom-1'),
); );
expect(radio.ultimoUuidClick, 'custom-1'); expect(radio.ultimoUuidClick, 'custom-1');
final tarjetaCustom = find.ancestor( final tarjetaCustom = find.ancestor(
of: find.text('Custom Uno'), of: find.text('Custom Uno'),
matching: find.byType(TarjetaEmisora), matching: find.byType(TarjetaEmisora),
); );
final botonFavorito = final botonFavorito =
find find
.descendant(of: tarjetaCustom, matching: find.byType(InkWell)) .descendant(of: tarjetaCustom, matching: find.byType(InkWell))
.last; .last;
expect(botonFavorito, findsOneWidget); expect(botonFavorito, findsOneWidget);
await tester.ensureVisible(botonFavorito); await tester.ensureVisible(botonFavorito);
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
await tester.tap(botonFavorito); await tester.tap(botonFavorito);
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
expect(favoritos.toggleCalls, 1); expect(favoritos.toggleCalls, 1);
expect(await favoritos.esFavorito(custom.uuid), isTrue); expect(await favoritos.esFavorito(custom.uuid), isTrue);
}, });
);
testWidgets('permite reintentar manualmente tras fallo inicial agotado', (
tester,
) async {
_setLargeSurfaceSize(tester);
final radio = FakeServicioRadio(
erroresPopularesPorLlamada: [Exception('sin red')],
popularesPorLlamada: [
const [],
[emisoraDemo(uuid: 'api-1', nombre: 'API Uno')],
],
tendenciasPorLlamada: [
const [],
[emisoraDemo(uuid: 'trend-1', nombre: 'Trend Uno')],
],
);
final estado = _crearEstado(radio: radio);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
expect(find.text('Sin conexión a la API de radio'), findsOneWidget);
final l10n = _l10nDe(tester);
expect(find.text(l10n.retryAction), findsOneWidget);
await tester.tap(find.text(l10n.retryAction));
await _pumpStableFrame(tester);
expect(radio.obtenerPopularesCalls, 2);
expect(find.text('Sin conexión a la API de radio'), findsNothing);
expect(find.text('API Uno'), findsOneWidget);
});
testWidgets( testWidgets(
'permite reintentar manualmente tras fallo inicial agotado', 'visual fidelity (audit 13.1, proto t4 line 641): banner sin conexion '
'usa offlineAccent, no colorScheme.error',
(tester) async { (tester) async {
_setLargeSurfaceSize(tester); _setLargeSurfaceSize(tester);
final radio = FakeServicioRadio( final radio = FakeServicioRadio(
erroresPopularesPorLlamada: [Exception('sin red')], erroresPopularesPorLlamada: [Exception('sin red')],
popularesPorLlamada: [
const [],
[emisoraDemo(uuid: 'api-1', nombre: 'API Uno')],
],
tendenciasPorLlamada: [
const [],
[emisoraDemo(uuid: 'trend-1', nombre: 'Trend Uno')],
],
); );
final estado = _crearEstado(radio: radio); final estado = _crearEstado(radio: radio);
addTearDown(estado.dispose); addTearDown(estado.dispose);
@@ -337,16 +356,37 @@ void main() {
await tester.pumpWidget(_conProviders(estado, _testApp())); await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
expect(find.text('Sin conexión a la API de radio'), findsOneWidget); final banner = find.byKey(const ValueKey('offline-banner'));
expect(banner, findsOneWidget);
final contexto = tester.element(banner);
final tokens = contexto.pluriTokens;
final errorColor = Theme.of(contexto).colorScheme.error;
final caja = tester.widget<DecoratedBox>(banner);
final decoracion = caja.decoration as BoxDecoration;
expect(
decoracion.color,
tokens.offlineAccent.withValues(alpha: 0.14),
reason:
'prototype t4 line 641: rgba(207,102,121,.14) — this app '
'consolidates on the offlineAccent token (#E8879A)',
);
expect(decoracion.color, isNot(errorColor));
expect(
decoracion.borderRadius,
BorderRadius.circular(16),
reason: 'prototype t4 line 641: border-radius:16px',
);
final icono = tester.widget<Icon>(
find.descendant(of: banner, matching: find.byIcon(Icons.wifi_off)),
);
expect(icono.color, tokens.offlineAccent);
expect(icono.color, isNot(errorColor));
final l10n = _l10nDe(tester); final l10n = _l10nDe(tester);
expect(find.text(l10n.offlineBannerTitle), findsOneWidget);
expect(find.text(l10n.retryAction), findsOneWidget); expect(find.text(l10n.retryAction), findsOneWidget);
await tester.tap(find.text(l10n.retryAction));
await _pumpStableFrame(tester);
expect(radio.obtenerPopularesCalls, 2);
expect(find.text('Sin conexión a la API de radio'), findsNothing);
expect(find.text('API Uno'), findsOneWidget);
}, },
); );