diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index e0acc0f..5d8a57f 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -458,6 +458,7 @@ class EstadoRadio extends ChangeNotifier { _ordenListas = switch (raw) { 'nombre' => OrdenEmisoras.nombre, 'calidad' => OrdenEmisoras.calidad, + 'popularidad' => OrdenEmisoras.popularidad, _ => OrdenEmisoras.calidad, }; } @@ -874,6 +875,7 @@ class EstadoRadio extends ChangeNotifier { _ordenListas = switch (ordenRaw) { 'nombre' => OrdenEmisoras.nombre, 'calidad' => OrdenEmisoras.calidad, + 'popularidad' => OrdenEmisoras.popularidad, _ => OrdenEmisoras.calidad, }; await prefs.setString(_keyOrdenListas, _ordenListas.name); diff --git a/lib/estado/orden_emisoras.dart b/lib/estado/orden_emisoras.dart index 9064dc3..a03570c 100644 --- a/lib/estado/orden_emisoras.dart +++ b/lib/estado/orden_emisoras.dart @@ -1,7 +1,11 @@ import '../modelos/emisora.dart'; /// User-selectable ordering for every station list in the app. -enum OrdenEmisoras { nombre, calidad } +/// +/// WU6 adds [popularidad] to the Buscar "Ordenar" control (design ADR-4), +/// backed by fields the model already carries (`votes`, `clickcount`) — +/// no new API surface, no server-side `order` parameter. +enum OrdenEmisoras { nombre, calidad, popularidad } /// Returns a sorted COPY of [emisoras] according to [orden]. List ordenarEmisoras(List emisoras, OrdenEmisoras orden) { @@ -17,6 +21,12 @@ List ordenarEmisoras(List emisoras, OrdenEmisoras orden) { if (porBitrate != 0) return porBitrate; return 0; }); + case OrdenEmisoras.popularidad: + ordenadas.sort((a, b) { + final porVotos = b.votes.compareTo(a.votes); + if (porVotos != 0) return porVotos; + return b.clickcount.compareTo(a.clickcount); + }); } return ordenadas; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4d13df8..137378f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -235,6 +235,7 @@ "stationOrderTitle": "Station order", "stationOrderByName": "By name", "stationOrderByQuality": "By quality", + "stationOrderByPopularity": "By popularity", "stationOrderScopeDescription": "Applies to favorites, searches, nearby stations and quick lists.", "favoriteGroupsTitle": "Favorite lists", "favoriteGroupsDescription": "Create short lists to organize your saved stations.", @@ -306,6 +307,8 @@ "searchNoResultsTitle": "No results", "searchEmptySubtitle": "Use the top bar or chips to discover stations from around the world.", "searchNoResultsSubtitle": "Try removing filters or typing another name to find an active station.", + "searchResultsCount": "{count, plural, =1{1 result} other{{count} results}}", + "searchClearFiltersAction": "{count, plural, =1{Clear filter} other{Clear {count} filters}}", "countrySpain": "Spain", "countryUsa": "USA", "countryMexico": "Mexico", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 85cf09b..870911e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -235,6 +235,7 @@ "stationOrderTitle": "Orden de emisoras", "stationOrderByName": "Por nombre", "stationOrderByQuality": "Por calidad", + "stationOrderByPopularity": "Por popularidad", "stationOrderScopeDescription": "Se aplica a favoritos, búsquedas, emisoras cercanas y listados rápidos.", "favoriteGroupsTitle": "Listas de favoritos", "favoriteGroupsDescription": "Creá listas cortas para organizar tus emisoras guardadas.", @@ -306,6 +307,8 @@ "searchNoResultsTitle": "Sin resultados", "searchEmptySubtitle": "Usá la barra superior o los chips para descubrir señales de todo el mundo.", "searchNoResultsSubtitle": "Probá quitar filtros o escribir otro nombre para encontrar una señal activa.", + "searchResultsCount": "{count, plural, =1{1 resultado} other{{count} resultados}}", + "searchClearFiltersAction": "{count, plural, =1{Quitar el filtro} other{Quitar los {count} filtros}}", "countrySpain": "España", "countryUsa": "EE. UU.", "countryMexico": "México", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 3ccc568..9ade1d3 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -892,6 +892,12 @@ abstract class AppLocalizations { /// **'Por calidad'** String get stationOrderByQuality; + /// No description provided for @stationOrderByPopularity. + /// + /// In es, this message translates to: + /// **'Por popularidad'** + String get stationOrderByPopularity; + /// No description provided for @stationOrderScopeDescription. /// /// In es, this message translates to: @@ -1132,6 +1138,18 @@ abstract class AppLocalizations { /// **'Probá quitar filtros o escribir otro nombre para encontrar una señal activa.'** String get searchNoResultsSubtitle; + /// No description provided for @searchResultsCount. + /// + /// In es, this message translates to: + /// **'{count, plural, =1{1 resultado} other{{count} resultados}}'** + String searchResultsCount(num count); + + /// No description provided for @searchClearFiltersAction. + /// + /// In es, this message translates to: + /// **'{count, plural, =1{Quitar el filtro} other{Quitar los {count} filtros}}'** + String searchClearFiltersAction(num count); + /// No description provided for @countrySpain. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index b49d5a0..ddc0ddf 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -447,6 +447,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get stationOrderByQuality => 'حسب الجودة'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'ينطبق على المفضلات وعمليات البحث والمحطات القريبة والقوائم السريعة.'; @@ -586,6 +589,28 @@ class AppLocalizationsAr extends AppLocalizations { String get searchNoResultsSubtitle => 'جرّب إزالة الفلاتر أو كتابة اسم آخر للعثور على إشارة نشطة.'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'إسبانيا'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 808b10c..7dca4e1 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -450,6 +450,9 @@ class AppLocalizationsBn extends AppLocalizations { @override String get stationOrderByQuality => 'গুণমান অনুযায়ী'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'প্রিয়, অনুসন্ধান, কাছাকাছি স্টেশন এবং দ্রুত তালিকায় প্রযোজ্য।'; @@ -590,6 +593,28 @@ class AppLocalizationsBn extends AppLocalizations { String get searchNoResultsSubtitle => 'সক্রিয় সিগন্যাল পেতে ফিল্টার সরিয়ে বা অন্য নাম লিখে দেখুন।'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'স্পেন'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index ce9e947..78272fc 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -453,6 +453,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get stationOrderByQuality => 'Nach Qualität'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'Gilt für Favoriten, Suchen, nahegelegene Sender und Schnelllisten.'; @@ -593,6 +596,28 @@ class AppLocalizationsDe extends AppLocalizations { String get searchNoResultsSubtitle => 'Versuche, Filter zu entfernen oder einen anderen Namen einzugeben, um einen aktiven Sender zu finden.'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'Spanien'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 076e5dc..0aec379 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -447,6 +447,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get stationOrderByQuality => 'By quality'; + @override + String get stationOrderByPopularity => 'By popularity'; + @override String get stationOrderScopeDescription => 'Applies to favorites, searches, nearby stations and quick lists.'; @@ -587,6 +590,28 @@ class AppLocalizationsEn extends AppLocalizations { String get searchNoResultsSubtitle => 'Try removing filters or typing another name to find an active station.'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count results', + one: '1 result', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Clear $count filters', + one: 'Clear filter', + ); + return '$_temp0'; + } + @override String get countrySpain => 'Spain'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 741b7ac..57690fd 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -451,6 +451,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get stationOrderByQuality => 'Por calidad'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'Se aplica a favoritos, búsquedas, emisoras cercanas y listados rápidos.'; @@ -591,6 +594,28 @@ class AppLocalizationsEs extends AppLocalizations { String get searchNoResultsSubtitle => 'Probá quitar filtros o escribir otro nombre para encontrar una señal activa.'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'España'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 7a4c35c..ca203e5 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -455,6 +455,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get stationOrderByQuality => 'Par qualité'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'S’applique aux favoris, recherches, stations proches et listes rapides.'; @@ -595,6 +598,28 @@ class AppLocalizationsFr extends AppLocalizations { String get searchNoResultsSubtitle => 'Essayez de retirer des filtres ou de saisir un autre nom pour trouver une station active.'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'Espagne'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 7d2e258..1262ca3 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -448,6 +448,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get stationOrderByQuality => 'गुणवत्ता से'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'पसंदीदा, खोज, पास के स्टेशन और त्वरित सूचियों पर लागू होता है।'; @@ -588,6 +591,28 @@ class AppLocalizationsHi extends AppLocalizations { String get searchNoResultsSubtitle => 'सक्रिय सिग्नल पाने के लिए फ़िल्टर हटाएँ या कोई दूसरा नाम लिखें।'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'स्पेन'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 7ce46e6..08e172f 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -449,6 +449,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get stationOrderByQuality => 'Berdasarkan kualitas'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'Berlaku untuk favorit, pencarian, stasiun terdekat, dan daftar cepat.'; @@ -589,6 +592,28 @@ class AppLocalizationsId extends AppLocalizations { String get searchNoResultsSubtitle => 'Coba hapus filter atau tulis nama lain untuk menemukan sinyal aktif.'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'Spanyol'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 158133e..c2ba32a 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -451,6 +451,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get stationOrderByQuality => 'Per qualità'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'Si applica a preferiti, ricerche, emittenti vicine ed elenchi rapidi.'; @@ -591,6 +594,28 @@ class AppLocalizationsIt extends AppLocalizations { String get searchNoResultsSubtitle => 'Prova a rimuovere i filtri o a digitare un altro nome per trovare un\'emittente attiva.'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'Spagna'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 1fa7080..51f3f71 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -438,6 +438,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get stationOrderByQuality => '品質順'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'お気に入り、検索、近くの局、クイック一覧に適用されます。'; @@ -568,6 +571,28 @@ class AppLocalizationsJa extends AppLocalizations { @override String get searchNoResultsSubtitle => '有効な電波を見つけるには、フィルターを外すか別の名前を入力してください。'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'スペイン'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index c336261..9175673 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -450,6 +450,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get stationOrderByQuality => 'Por qualidade'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'Aplica-se a favoritos, buscas, estações próximas e listas rápidas.'; @@ -590,6 +593,28 @@ class AppLocalizationsPt extends AppLocalizations { String get searchNoResultsSubtitle => 'Tente remover filtros ou digitar outro nome para encontrar uma estação ativa.'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'Espanha'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 57e0a8d..8cbd720 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -451,6 +451,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get stationOrderByQuality => 'По качеству'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => 'Применяется к избранному, поиску, ближайшим станциям и быстрым спискам.'; @@ -591,6 +594,28 @@ class AppLocalizationsRu extends AppLocalizations { String get searchNoResultsSubtitle => 'Попробуйте убрать фильтры или ввести другое название, чтобы найти активный сигнал.'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => 'Испания'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 33438f3..f304586 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -436,6 +436,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get stationOrderByQuality => '按质量'; + @override + String get stationOrderByPopularity => 'Por popularidad'; + @override String get stationOrderScopeDescription => '适用于收藏、搜索、附近电台和快捷列表。'; @@ -566,6 +569,28 @@ class AppLocalizationsZh extends AppLocalizations { @override String get searchNoResultsSubtitle => '尝试减少筛选条件,或换个名称搜索,找到正在播出的电台。'; + @override + String searchResultsCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count resultados', + one: '1 resultado', + ); + return '$_temp0'; + } + + @override + String searchClearFiltersAction(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quitar los $count filtros', + one: 'Quitar el filtro', + ); + return '$_temp0'; + } + @override String get countrySpain => '西班牙'; diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index 320af47..21dcd76 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:shimmer/shimmer.dart' as shimmer; import '../estado/estado_busqueda.dart'; -import '../tema/pluri_animate.dart'; +import '../estado/estado_radio.dart'; import '../l10n/gen/app_localizations.dart'; +import '../modelos/emisora.dart'; +import '../tema/pluri_animate.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; @@ -37,6 +40,28 @@ const _idiomas = [ ('russian', 'languageNameRussian'), ]; +const _calidades = [ + ('64 kbps', 64), + ('96 kbps', 96), + ('128 kbps', 128), + ('192 kbps', 192), + ('320 kbps', 320), +]; + +/// WU6, `station-discovery-browse` spec: Buscar now owns BOTH the discovery +/// landing state (relocated from `PantallaInicio`, task 6.5 — `_seccionCercanas`, +/// `_seccionTendencias`, `_chipGeneros`, `_errorBanner` and the browse grid +/// all moved here verbatim and were deleted from `pantalla_inicio.dart`) and +/// the free-text/filtered search results view. [_hayBusquedaActiva] is the +/// single switch between the two: empty query AND no active filter shows +/// discovery content; any of the three flips to the results view. +/// +/// Design correction, documented like WU3a's own task 3a.1: the spec's +/// landing-state scenario also lists a "Países entry point", but no such +/// affordance existed anywhere in `pantalla_inicio.dart` to relocate — +/// `PantallaPaises` itself doesn't exist until WU7. That entry point is +/// added in WU7 alongside the screen it targets (design's own component +/// inventory: "Países (WU7)"), not invented here pointing nowhere. class PantallaBuscar extends StatefulWidget { const PantallaBuscar({super.key}); @@ -50,6 +75,33 @@ class _PantallaBuscarState extends State { String? _idiomaSeleccionado; int? _calidadMinima; + // Relocated from PantallaInicio (task 6.5) — genre-chip selection only + // drives the LANDING grid, same as it always did on Inicio; it is + // orthogonal to the free-text/filter search flow below. + static const _generos = [ + 'pop', + 'rock', + 'jazz', + 'classical', + 'electronic', + 'news', + 'talk', + 'hip-hop', + 'country', + 'metal', + 'reggae', + 'latin', + ]; + String? _generoSeleccionado; + + int get _filtrosActivosCount => + (_paisSeleccionado != null ? 1 : 0) + + (_idiomaSeleccionado != null ? 1 : 0) + + (_calidadMinima != null ? 1 : 0); + + bool get _hayBusquedaActiva => + _controller.text.trim().isNotEmpty || _filtrosActivosCount > 0; + @override void dispose() { _controller.dispose(); @@ -66,6 +118,15 @@ class _PantallaBuscarState extends State { ); } + void _quitarTodosLosFiltros() { + setState(() { + _paisSeleccionado = null; + _idiomaSeleccionado = null; + _calidadMinima = null; + }); + _buscar(); + } + @override Widget build(BuildContext context) { // S4-R3/S4-R5: this screen depends only on search state, so it watches @@ -81,9 +142,12 @@ class _PantallaBuscarState extends State { title: l10n.searchScreenTitle, subtitle: l10n.searchScreenSubtitle, glyph: PluriIconGlyph.search, - trailing: PluriStatusPill( - icon: Icons.tune_rounded, - label: l10n.searchFiltersLabel, + trailing: GestureDetector( + onTap: _abrirFiltros, + child: PluriStatusPill( + icon: Icons.tune_rounded, + label: l10n.searchFiltersLabel, + ), ), ), Padding( @@ -119,44 +183,225 @@ class _PantallaBuscarState extends State { ), ), ), - _seccionFiltro( - l10n.searchCountryFilterLabel, - _paises.map((p) => (_countryLabel(l10n, p.$1), p.$2)).toList(), - _paisSeleccionado, - (v) { - setState(() => _paisSeleccionado = v); - _buscar(); - }, - ), - _seccionFiltro( - l10n.searchLanguageFilterLabel, - _idiomas.map((i) => (_languageLabel(l10n, i.$2), i.$1)).toList(), - _idiomaSeleccionado, - (v) { - setState(() => _idiomaSeleccionado = v); - _buscar(); - }, - ), - _seccionFiltroInt( - l10n.searchMinQualityFilterLabel, - const [ - ('64 kbps', 64), - ('96 kbps', 96), - ('128 kbps', 128), - ('192 kbps', 192), - ('320 kbps', 320), - ], - _calidadMinima, - (v) { - setState(() => _calidadMinima = v); - _buscar(); - }, - ), - _resultados(estado, theme), + if (_hayBusquedaActiva) ...[ + _barraFiltrosActivos(context, estado, theme), + _resultados(estado, theme), + ] else ...[ + _seccionCercanas(context, theme, l10n), + _seccionTendencias(context, theme, l10n), + _chipGeneros(context, theme, l10n), + if (context.select((e) => e.error) != null) + _errorBanner( + context, + context.select((e) => e.error)!, + theme, + l10n, + ), + _gridEmisoras(context, l10n), + ], ], ); } + // ── Active-filter pills, results counter, sort (task 6.6/6.7) ────────── + + Widget _barraFiltrosActivos( + BuildContext context, + EstadoBusqueda estado, + ThemeData theme, + ) { + final l10n = AppLocalizations.of(context); + final pills = [ + if (_paisSeleccionado != null) + _pillFiltro( + _paisLabelSeleccionado(l10n) ?? _paisSeleccionado!, + () { + setState(() => _paisSeleccionado = null); + _buscar(); + }, + ), + if (_idiomaSeleccionado != null) + _pillFiltro( + _idiomaLabelSeleccionado(l10n) ?? _idiomaSeleccionado!, + () { + setState(() => _idiomaSeleccionado = null); + _buscar(); + }, + ), + if (_calidadMinima != null) + _pillFiltro('≥$_calidadMinima kbps', () { + setState(() => _calidadMinima = null); + _buscar(); + }), + ]; + + if (pills.isEmpty && estado.resultados.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 10, + PluriLayout.horizontal, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (pills.isNotEmpty) Wrap(spacing: 8, runSpacing: 8, children: pills), + if (pills.isNotEmpty && estado.resultados.isNotEmpty) + const SizedBox(height: 10), + if (!estado.cargando && estado.resultados.isNotEmpty) + Row( + children: [ + Text( + l10n.searchResultsCount(estado.resultados.length), + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + PopupMenuButton( + icon: const Icon(Icons.swap_vert_rounded), + tooltip: l10n.stationOrderTitle, + onSelected: + (criterio) => context + .read() + .cambiarOrdenListas(criterio), + itemBuilder: + (context) => [ + PopupMenuItem( + value: OrdenEmisoras.nombre, + child: Text(l10n.stationOrderByName), + ), + PopupMenuItem( + value: OrdenEmisoras.calidad, + child: Text(l10n.stationOrderByQuality), + ), + PopupMenuItem( + value: OrdenEmisoras.popularidad, + child: Text(l10n.stationOrderByPopularity), + ), + ], + ), + ], + ), + ], + ), + ); + } + + Widget _pillFiltro(String label, VoidCallback onDeleted) { + return Chip( + label: Text(label), + onDeleted: onDeleted, + deleteIcon: const Icon(Icons.close, size: 18), + visualDensity: VisualDensity.compact, + ); + } + + String? _paisLabelSeleccionado(AppLocalizations l10n) { + for (final p in _paises) { + if (p.$2 == _paisSeleccionado) return _countryLabel(l10n, p.$1); + } + return null; + } + + String? _idiomaLabelSeleccionado(AppLocalizations l10n) { + for (final i in _idiomas) { + if (i.$1 == _idiomaSeleccionado) return _languageLabel(l10n, i.$2); + } + return null; + } + + /// Opens the filter picker as a bottom sheet (task 6.6 — "collapse the 3 + /// always-visible FilterChip rows into ... bottom-sheet pickers"). Each + /// chip selection applies immediately and closes the sheet, mirroring the + /// established "tap once, sheet closes" precedent already used by + /// `pantalla_favoritos.dart`'s `_FilaFavorito._asignar` (WU4) — this + /// avoids needing a `StatefulBuilder` to keep the sheet's own chip + /// selection visually live while it stays open. + Future _abrirFiltros() async { + final l10n = AppLocalizations.of(context); + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: + (ctx) => SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 4, + PluriLayout.horizontal, + 0, + ), + child: Text( + l10n.searchFiltersLabel, + style: Theme.of(ctx).textTheme.titleLarge, + ), + ), + _seccionFiltro( + l10n.searchCountryFilterLabel, + _paises + .map((p) => (_countryLabel(l10n, p.$1), p.$2)) + .toList(), + _paisSeleccionado, + (v) { + setState(() => _paisSeleccionado = v); + _buscar(); + Navigator.of(ctx).pop(); + }, + ), + _seccionFiltro( + l10n.searchLanguageFilterLabel, + _idiomas + .map((i) => (_languageLabel(l10n, i.$2), i.$1)) + .toList(), + _idiomaSeleccionado, + (v) { + setState(() => _idiomaSeleccionado = v); + _buscar(); + Navigator.of(ctx).pop(); + }, + ), + _seccionFiltroInt( + l10n.searchMinQualityFilterLabel, + _calidades, + _calidadMinima, + (v) { + setState(() => _calidadMinima = v); + _buscar(); + Navigator.of(ctx).pop(); + }, + ), + ], + ), + ), + ), + ), + ); + } + + /// Renders every option as a `Wrap` of `FilterChip`s — deliberately NOT a + /// horizontal `ListView` (the shape this used to have when these rows were + /// always-visible inline strips, before task 6.6 moved them into this + /// bottom sheet). Reason found at apply time: a horizontal `ListView` is + /// lazily built by viewport, and nested inside the sheet's + /// `SingleChildScrollView` its viewport-based build only ever realised the + /// first few chips per row (confirmed empirically — country's 10 options + /// built, but quality's 5th option, "320 kbps", silently never did). A + /// `Wrap` lays out every child eagerly, has no lazy-build boundary, and + /// suits a bottom sheet (vertical room to spare) better than a horizontal + /// scroll strip anyway. Widget _seccionFiltro( String titulo, List<(String, String)> opciones, @@ -183,23 +428,21 @@ class _PantallaBuscarState extends State { ), ), const SizedBox(height: 6), - SizedBox( - height: 40, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: opciones.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (_, i) { - final (label, value) = opciones[i]; - final sel = seleccionado == value; - return FilterChip( + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final (label, value) in opciones) + FilterChip( label: Text(label), - selected: sel, + selected: seleccionado == value, visualDensity: VisualDensity.compact, - onSelected: (_) => onChanged(sel ? null : value), - ); - }, - ), + onSelected: + (_) => onChanged( + seleccionado == value ? null : value, + ), + ), + ], ), ], ), @@ -233,23 +476,21 @@ class _PantallaBuscarState extends State { ), ), const SizedBox(height: 6), - SizedBox( - height: 40, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: opciones.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (_, i) { - final (label, value) = opciones[i]; - final sel = seleccionado == value; - return FilterChip( + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final (label, value) in opciones) + FilterChip( label: Text(label), - selected: sel, + selected: seleccionado == value, visualDensity: VisualDensity.compact, - onSelected: (_) => onChanged(sel ? null : value), - ); - }, - ), + onSelected: + (_) => onChanged( + seleccionado == value ? null : value, + ), + ), + ], ), ], ), @@ -278,20 +519,35 @@ class _PantallaBuscarState extends State { final resultados = estado.resultados; if (resultados.isEmpty) { - final sinFiltros = - _controller.text.isEmpty && - _paisSeleccionado == null && - _idiomaSeleccionado == null; - return SizedBox( - height: 260, - child: PluriEmptyState( - glyph: PluriIconGlyph.search, - title: sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle, - subtitle: - sinFiltros - ? l10n.searchEmptySubtitle - : l10n.searchNoResultsSubtitle, - ), + final sinFiltros = _controller.text.isEmpty && _filtrosActivosCount == 0; + return Column( + children: [ + SizedBox( + height: 260, + child: PluriEmptyState( + glyph: PluriIconGlyph.search, + title: + sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle, + subtitle: + sinFiltros + ? l10n.searchEmptySubtitle + : l10n.searchNoResultsSubtitle, + ), + ), + // task 6.6 / spec "One-Tap Clear-All-Filters on Empty Results": + // only offered once 1+ pill-filters are active AND the search + // came back empty — not merely "no query typed yet". + if (_filtrosActivosCount > 0) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: OutlinedButton( + onPressed: _quitarTodosLosFiltros, + child: Text( + l10n.searchClearFiltersAction(_filtrosActivosCount), + ), + ), + ), + ], ); } @@ -354,4 +610,326 @@ class _PantallaBuscarState extends State { 'languageNameRussian' => l10n.languageNameRussian, _ => key, }; + + // ── Discovery landing state (relocated from PantallaInicio, task 6.5) ── + + Widget _seccionCercanas( + BuildContext context, + ThemeData theme, + AppLocalizations l10n, + ) { + // Nearby stations live in EstadoBusqueda (S4-R3). + final busqueda = context.watch(); + final pais = busqueda.paisCercanoDetectado; + return Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 8, + PluriLayout.horizontal, + 0, + ), + child: PluriGlassSurface( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + pais == null ? l10n.nearYou : l10n.nearYouInCountry(pais), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + ), + TextButton.icon( + onPressed: + busqueda.cargandoCercanas + ? null + : busqueda.cargarEmisorasCercanas, + icon: + busqueda.cargandoCercanas + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location_rounded, size: 18), + label: Text(l10n.detectAction), + ), + ], + ), + if (busqueda.errorCercanas != null) + Text( + busqueda.errorCercanas!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + if (busqueda.cercanas.isNotEmpty) ...[ + const SizedBox(height: 8), + SizedBox( + height: 76, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: busqueda.cercanas.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final emisora = busqueda.cercanas[i]; + return SizedBox( + width: 260, + child: TarjetaEmisora( + emisora: emisora, + esCompacta: true, + onTap: () => reproducirMinimizado(context, emisora), + ), + ); + }, + ), + ), + ], + ], + ), + ), + ); + } + + Widget _seccionTendencias( + BuildContext context, + ThemeData theme, + AppLocalizations l10n, + ) { + final cargando = context.select( + (e) => e.cargandoPopulares, + ); + final tendencias = context.select>( + (e) => e.tendencias, + ); + return Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 8, + PluriLayout.horizontal, + 0, + ), + child: PluriGlassSurface( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.liveRadar, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + SizedBox( + height: 56, + child: + cargando + ? ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: 5, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, __) => _ChipShimmer(theme: theme), + ) + : ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: tendencias.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final e = tendencias[i]; + return ActionChip( + avatar: const Icon( + Icons.graphic_eq_rounded, + size: 18, + ), + label: Text(e.nombre, maxLines: 1), + onPressed: () => reproducirMinimizado(context, e), + ).pluriFadeIn( + context, + delay: Duration(milliseconds: i * 50), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _chipGeneros( + BuildContext context, + ThemeData theme, + AppLocalizations l10n, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 16, + PluriLayout.horizontal, + 8, + ), + child: PluriGlassSurface( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.genresTitle, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: + _generos.map((g) { + final seleccionado = _generoSeleccionado == g; + return FilterChip( + label: Text(_genreName(l10n, g)), + selected: seleccionado, + onSelected: (_) { + setState(() { + _generoSeleccionado = seleccionado ? null : g; + }); + if (!seleccionado) { + context.read().buscar(tag: g); + } else { + context.read().cargarPopulares(); + } + }, + ); + }).toList(), + ), + ], + ), + ), + ); + } + + Widget _errorBanner( + BuildContext context, + String error, + ThemeData theme, + AppLocalizations l10n, + ) { + return Padding( + padding: const EdgeInsets.all(16), + child: PluriGlassSurface( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.wifi_off, color: theme.colorScheme.error), + const SizedBox(width: 8), + Expanded(child: Text(error)), + TextButton( + onPressed: () => context.read().cargarPopulares(), + child: Text(l10n.retryAction), + ), + ], + ), + ), + ); + } + + Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) { + final porGenero = _generoSeleccionado != null; + final emisoras = + porGenero + ? context.select>((b) => b.resultados) + : context.select>( + (e) => e.emisorasInicio, + ); + final cargando = + context.select((e) => e.cargandoPopulares) || + (porGenero && context.select((b) => b.cargando)); + + if (cargando) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 0, + PluriLayout.horizontal, + PluriLayout.compactGap, + ), + itemCount: 12, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.78, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemBuilder: (_, __) => const TarjetaEmisoraShimmer(), + ); + } + + if (emisoras.isEmpty) { + return SizedBox( + height: 260, + child: PluriEmptyState( + glyph: PluriIconGlyph.home, + title: l10n.noStationsAvailable, + subtitle: l10n.noStationsAvailableSubtitle, + ), + ); + } + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 0, + PluriLayout.horizontal, + PluriLayout.compactGap, + ), + itemCount: emisoras.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.78, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemBuilder: + (context, i) => TarjetaEmisora( + emisora: emisoras[i], + onTap: () => reproducirMinimizado(context, emisoras[i]), + ).pluriFadeSlideIn(context, delay: Duration(milliseconds: i * 30), beginY: 0.1), + ); + } +} + +String _genreName(AppLocalizations l10n, String tag) => switch (tag) { + 'pop' => l10n.genrePop, + 'rock' => l10n.genreRock, + 'jazz' => l10n.genreJazz, + 'classical' => l10n.genreClassical, + 'electronic' => l10n.genreElectronic, + 'news' => l10n.genreNews, + 'talk' => l10n.genreTalk, + 'hip-hop' => l10n.genreHipHop, + 'country' => l10n.genreCountry, + 'metal' => l10n.genreMetal, + 'reggae' => l10n.genreReggae, + 'latin' => l10n.genreLatin, + _ => tag, +}; + +class _ChipShimmer extends StatelessWidget { + final ThemeData theme; + const _ChipShimmer({required this.theme}); + + @override + Widget build(BuildContext context) { + return shimmer.Shimmer.fromColors( + baseColor: theme.colorScheme.surfaceContainerHighest, + highlightColor: theme.colorScheme.surface, + child: Container( + width: 120, + height: 56, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + ), + ); + } } diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index 44d1f6e..64fa210 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart' as shimmer; -import '../estado/estado_busqueda.dart'; import '../estado/estado_ecualizador.dart'; import '../estado/estado_navegacion.dart'; import '../estado/estado_radio.dart'; @@ -11,10 +10,8 @@ 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 '../widgets/pluri_glass_surface.dart'; -import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/visualizador_audio.dart'; @@ -32,22 +29,6 @@ class PantallaInicio extends StatefulWidget { } class _PantallaInicioState extends State { - static const _generos = [ - 'pop', - 'rock', - 'jazz', - 'classical', - 'electronic', - 'news', - 'talk', - 'hip-hop', - 'country', - 'metal', - 'reggae', - 'latin', - ]; - String? _generoSeleccionado; - @override Widget build(BuildContext context) { // S4-R5: no root watch on EstadoRadio. Every field is consumed through @@ -55,41 +36,28 @@ class _PantallaInicioState extends State { // (which notify EstadoRadio) no longer rebuild this screen. final theme = Theme.of(context); final l10n = AppLocalizations.of(context); - final error = context.select((e) => e.error); - return RefreshIndicator( - onRefresh: () => context.read().cargarPopulares(), - child: CustomScrollView( - slivers: [ - // WU5: replaces the old PluriScreenHeader hero. The discovery - // sections below (_seccionCercanas onward) are deliberately left - // in place — WU6 relocates them to Buscar and deletes them from - // here; removing them now would leave that content nowhere until - // WU6 lands. - const SliverToBoxAdapter(child: _EscucharHero()), - SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)), - SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)), - SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)), - SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)), - if (error != null) - SliverToBoxAdapter( - child: _errorBanner(context, error, theme, l10n), - ), - SliverPadding( - // ADR-7(b): the mini player is hidden on this whole screen - // (app.dart), so its content needs less bottom padding than - // every other root — escucharBottomChromeInset, not the plain - // bottomChromeInset every other root/scrollable uses. - padding: const EdgeInsets.fromLTRB( - PluriLayout.horizontal, - 0, - PluriLayout.horizontal, - PluriLayout.escucharBottomChromeInset, - ), - sliver: _gridEmisoras(context, l10n), - ), - ], - ), + return CustomScrollView( + slivers: [ + // WU5 built the hero; WU6 relocated the discovery sections that + // used to follow it (_seccionCercanas, _seccionTendencias, + // _chipGeneros, _errorBanner, the browse grid) into + // PantallaBuscar's landing state and DELETED them here (this WU's + // own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's + // content is now just the hero and the favorites preview below; + // pull-to-refresh was dropped along with the grid it refreshed — + // the retry button that used to live in the (now relocated) error + // banner already covers manual recovery on Buscar. + const SliverToBoxAdapter(child: _EscucharHero()), + SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)), + // ADR-7(b): the mini player is hidden on this whole screen + // (app.dart), so its content needs less bottom padding than every + // other root — escucharBottomChromeInset, not the plain + // bottomChromeInset every other root/scrollable uses. + const SliverToBoxAdapter( + child: SizedBox(height: PluriLayout.escucharBottomChromeInset), + ), + ], ); } @@ -175,315 +143,6 @@ class _PantallaInicioState extends State { ); } - Widget _seccionCercanas( - BuildContext context, - ThemeData theme, - AppLocalizations l10n, - ) { - // Nearby stations live in EstadoBusqueda (S4-R3). - final busqueda = context.watch(); - final pais = busqueda.paisCercanoDetectado; - return Padding( - padding: const EdgeInsets.fromLTRB( - PluriLayout.horizontal, - 8, - PluriLayout.horizontal, - 0, - ), - child: PluriGlassSurface( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - pais == null ? l10n.nearYou : l10n.nearYouInCountry(pais), - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - ), - ), - ), - TextButton.icon( - onPressed: - busqueda.cargandoCercanas - ? null - : busqueda.cargarEmisorasCercanas, - icon: - busqueda.cargandoCercanas - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.my_location_rounded, size: 18), - label: Text(l10n.detectAction), - ), - ], - ), - if (busqueda.errorCercanas != null) - Text( - busqueda.errorCercanas!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - if (busqueda.cercanas.isNotEmpty) ...[ - const SizedBox(height: 8), - SizedBox( - height: 76, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: busqueda.cercanas.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, i) { - final emisora = busqueda.cercanas[i]; - return SizedBox( - width: 260, - child: TarjetaEmisora( - emisora: emisora, - esCompacta: true, - onTap: () => reproducirMinimizado(context, emisora), - ), - ); - }, - ), - ), - ], - ], - ), - ), - ); - } - - Widget _seccionTendencias( - BuildContext context, - ThemeData theme, - AppLocalizations l10n, - ) { - final cargando = context.select( - (e) => e.cargandoPopulares, - ); - final tendencias = context.select>( - (e) => e.tendencias, - ); - return Padding( - padding: const EdgeInsets.fromLTRB( - PluriLayout.horizontal, - 8, - PluriLayout.horizontal, - 0, - ), - child: PluriGlassSurface( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.liveRadar, style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - SizedBox( - height: 56, - child: - cargando - ? ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: 5, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (_, __) => _ChipShimmer(theme: theme), - ) - : ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: tendencias.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, i) { - final e = tendencias[i]; - return ActionChip( - avatar: const Icon( - Icons.graphic_eq_rounded, - size: 18, - ), - label: Text(e.nombre, maxLines: 1), - onPressed: () => reproducirMinimizado(context, e), - ).pluriFadeIn( - context, - delay: Duration(milliseconds: i * 50), - ); - }, - ), - ), - ], - ), - ), - ); - } - - Widget _chipGeneros( - BuildContext context, - ThemeData theme, - AppLocalizations l10n, - ) { - return Padding( - padding: const EdgeInsets.fromLTRB( - PluriLayout.horizontal, - 16, - PluriLayout.horizontal, - 8, - ), - child: PluriGlassSurface( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.genresTitle, style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 4, - children: - _generos.map((g) { - final seleccionado = _generoSeleccionado == g; - return FilterChip( - label: Text(_genreName(l10n, g)), - selected: seleccionado, - onSelected: (_) { - setState(() { - _generoSeleccionado = seleccionado ? null : g; - }); - if (!seleccionado) { - context.read().buscar(tag: g); - } else { - context.read().cargarPopulares(); - } - }, - ); - }).toList(), - ), - ], - ), - ), - ); - } - - Widget _errorBanner( - BuildContext context, - String error, - ThemeData theme, - AppLocalizations l10n, - ) { - return Padding( - padding: const EdgeInsets.all(16), - child: PluriGlassSurface( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon(Icons.wifi_off, color: theme.colorScheme.error), - const SizedBox(width: 8), - Expanded(child: Text(error)), - TextButton( - onPressed: () => context.read().cargarPopulares(), - child: Text(l10n.retryAction), - ), - ], - ), - ), - ); - } - - Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) { - final porGenero = _generoSeleccionado != null; - final emisoras = - porGenero - ? context.select>((b) => b.resultados) - : context.select>( - (e) => e.emisorasInicio, - ); - final cargando = - context.select((e) => e.cargandoPopulares) || - (porGenero && context.select((b) => b.cargando)); - - if (cargando) { - return SliverGrid( - delegate: SliverChildBuilderDelegate( - (_, __) => const TarjetaEmisoraShimmer(), - childCount: 12, - ), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.78, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), - ); - } - - if (emisoras.isEmpty) { - return SliverFillRemaining( - child: PluriEmptyState( - glyph: PluriIconGlyph.home, - title: l10n.noStationsAvailable, - subtitle: l10n.noStationsAvailableSubtitle, - ), - ); - } - - return SliverGrid( - delegate: SliverChildBuilderDelegate( - (context, i) => TarjetaEmisora( - emisora: emisoras[i], - onTap: () => reproducirMinimizado(context, emisoras[i]), - ).pluriFadeSlideIn( - context, - delay: Duration(milliseconds: i * 30), - beginY: 0.1, - ), - childCount: emisoras.length, - ), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.78, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), - ); - } -} - -String _genreName(AppLocalizations l10n, String tag) => switch (tag) { - 'pop' => l10n.genrePop, - 'rock' => l10n.genreRock, - 'jazz' => l10n.genreJazz, - 'classical' => l10n.genreClassical, - 'electronic' => l10n.genreElectronic, - 'news' => l10n.genreNews, - 'talk' => l10n.genreTalk, - 'hip-hop' => l10n.genreHipHop, - 'country' => l10n.genreCountry, - 'metal' => l10n.genreMetal, - 'reggae' => l10n.genreReggae, - 'latin' => l10n.genreLatin, - _ => tag, -}; - -class _ChipShimmer extends StatelessWidget { - final ThemeData theme; - const _ChipShimmer({required this.theme}); - - @override - Widget build(BuildContext context) { - return shimmer.Shimmer.fromColors( - baseColor: theme.colorScheme.surfaceContainerHighest, - highlightColor: theme.colorScheme.surface, - child: Container( - width: 120, - height: 56, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - ), - ); - } } /// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index 6ce49f3..f445bab 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -434,24 +434,24 @@ Results Counter, One-Tap Clear-All-Filters on Empty Results, Client-Side Search **Verify**: `flutter test test/pantallas/pantalla_buscar_shimmer_test.dart test/estado/estado_busqueda_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')` **Modified tests**: `pantalla_buscar_shimmer_test.dart`, `estado_busqueda_test.dart` (non-sort paths unmodified) -- [ ] 6.1 No spike needed here — the sort ruling is already verified (Engram 2505): server-side `order`/`reverse` +- [x] 6.1 No spike needed here — the sort ruling is already verified (Engram 2505): server-side `order`/`reverse` exist but are rejected; `OrdenEmisoras` is the only extension point. Do not re-derive. -- [ ] 6.2 RED — empty query renders discovery content (near-you / genres / trending / Países entry); non-empty query +- [x] 6.2 RED — empty query renders discovery content (near-you / genres / trending / Países entry); non-empty query replaces it with the results view. -- [ ] 6.3 RED — active-filter pills (country/language/quality) with a close affordance; results counter text; the +- [x] 6.3 RED — active-filter pills (country/language/quality) with a close affordance; results counter text; the one-tap "Quitar los 2 filtros" action on a 2-filter/0-result fixture. -- [ ] 6.4 RED — regression guard: every rendered "Ordenar" option maps to a real `OrdenEmisoras` case with its own +- [x] 6.4 RED — regression guard: every rendered "Ordenar" option maps to a real `OrdenEmisoras` case with its own test. -- [ ] 6.5 GREEN — move the discovery content (near-you/genres/trending, Países entry point) from +- [x] 6.5 GREEN — move the discovery content (near-you/genres/trending, Países entry point) from `pantalla_inicio.dart`'s old sections into `pantalla_buscar.dart`'s empty-query landing state; **delete** the moved sections from `pantalla_inicio.dart` now (completes WU5's deferred cleanup, task 5.9). -- [ ] 6.6 GREEN — collapse the 3 always-visible `FilterChip` rows into an active-pills bar with bottom-sheet +- [x] 6.6 GREEN — collapse the 3 always-visible `FilterChip` rows into an active-pills bar with bottom-sheet pickers; add the results counter and clear-all-filters action. -- [ ] 6.7 GREEN — extend `enum OrdenEmisoras` (`orden_emisoras.dart:4`) only with criteria backed by existing +- [x] 6.7 GREEN — extend `enum OrdenEmisoras` (`orden_emisoras.dart:4`) only with criteria backed by existing `Emisora` fields (`bitrate`, `votes`, `clickcount`); wire "Ordenar" client-side only — no `order`/`reverse` param added to any request. -- [ ] 6.8 REFACTOR — confirm `estado_busqueda_test.dart`'s non-sort paths still pass unmodified. -- [ ] 6.9 Verify — no test in this WU asserts an `order` param on any outgoing request; sort-option regression guard +- [x] 6.8 REFACTOR — confirm `estado_busqueda_test.dart`'s non-sort paths still pass unmodified. +- [x] 6.9 Verify — no test in this WU asserts an `order` param on any outgoing request; sort-option regression guard green. ## WU7 — Países browser + `_get` transport extraction diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index f91351f..96ffa72 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -373,6 +373,45 @@ void main() { }); }); + group('EstadoRadio orden de listas — persistencia (WU6)', () { + // Correction applied at apply time: cambiarOrdenListas'/_cargarOrdenListas + // round-trip had zero test coverage before WU6 added OrdenEmisoras. + // popularidad — a latent gap similar to WU5's androidAudioSessionIdStream + // discovery. Adding a new enum case without covering the persistence + // switch would have shipped a silent revert-to-calidad-on-restart bug. + test( + 'popularidad persiste y sobrevive a una nueva instancia (reinicio)', + () async { + final estadoUno = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estadoUno.inicializar(); + + await estadoUno.cambiarOrdenListas(OrdenEmisoras.popularidad); + expect(estadoUno.ordenListas, OrdenEmisoras.popularidad); + + // Fresh instance, same (mocked) SharedPreferences-backed store — + // simulates an app restart re-reading the persisted preference. + final estadoDos = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estadoDos.inicializar(); + + expect(estadoDos.ordenListas, OrdenEmisoras.popularidad); + }, + ); + }); + group( 'EstadoRadio — emisoras custom: lectura tolerante y guardia de ' 'degradacion (persistence-resilience)', diff --git a/test/estado/orden_emisoras_test.dart b/test/estado/orden_emisoras_test.dart new file mode 100644 index 0000000..a44e9fb --- /dev/null +++ b/test/estado/orden_emisoras_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/orden_emisoras.dart'; +import 'package:pluriwave/modelos/emisora.dart'; + +/// WU6: every criterion the Buscar "Ordenar" control can render must map to +/// a real, tested [OrdenEmisoras] case (`station-discovery-browse` spec, +/// "Client-Side Search Sort Only" — "the system MUST NOT render an option +/// that does not actually sort"). +void main() { + Emisora conMetricas({ + required String uuid, + required String nombre, + int? bitrate, + int votes = 0, + int clickcount = 0, + }) => Emisora( + uuid: uuid, + nombre: nombre, + url: 'https://stream.demo/$uuid', + bitrate: bitrate, + votes: votes, + clickcount: clickcount, + ); + + group('OrdenEmisoras.nombre', () { + test('ordena alfabéticamente sin distinguir mayúsculas', () { + final emisoras = [ + conMetricas(uuid: 'z', nombre: 'zeta fm'), + conMetricas(uuid: 'a', nombre: 'Alfa FM'), + conMetricas(uuid: 'm', nombre: 'medio fm'), + ]; + + final resultado = ordenarEmisoras(emisoras, OrdenEmisoras.nombre); + + expect(resultado.map((e) => e.uuid), ['a', 'm', 'z']); + }); + }); + + group('OrdenEmisoras.calidad', () { + test('ordena por bitrate descendente', () { + final emisoras = [ + conMetricas(uuid: 'baja', nombre: 'Baja', bitrate: 64), + conMetricas(uuid: 'alta', nombre: 'Alta', bitrate: 320), + conMetricas(uuid: 'media', nombre: 'Media', bitrate: 128), + ]; + + final resultado = ordenarEmisoras(emisoras, OrdenEmisoras.calidad); + + expect(resultado.map((e) => e.uuid), ['alta', 'media', 'baja']); + }); + }); + + group('OrdenEmisoras.popularidad', () { + test('ordena por votos descendente', () { + final emisoras = [ + conMetricas(uuid: 'pocos', nombre: 'Pocos', votes: 3), + conMetricas(uuid: 'muchos', nombre: 'Muchos', votes: 900), + conMetricas(uuid: 'medio', nombre: 'Medio', votes: 40), + ]; + + final resultado = ordenarEmisoras(emisoras, OrdenEmisoras.popularidad); + + expect(resultado.map((e) => e.uuid), ['muchos', 'medio', 'pocos']); + }); + + test('en empate de votos, desempata por clickcount descendente', () { + final emisoras = [ + conMetricas( + uuid: 'menos-clicks', + nombre: 'Menos clicks', + votes: 10, + clickcount: 5, + ), + conMetricas( + uuid: 'mas-clicks', + nombre: 'Mas clicks', + votes: 10, + clickcount: 500, + ), + ]; + + final resultado = ordenarEmisoras(emisoras, OrdenEmisoras.popularidad); + + expect(resultado.map((e) => e.uuid), ['mas-clicks', 'menos-clicks']); + }); + }); + + test( + 'todos los valores del enum tienen un caso de ordenamiento implementado ' + '(regresión: nunca ofrecer una opción decorativa que no ordene)', + () { + for (final criterio in OrdenEmisoras.values) { + // Must not throw and must return a same-length list for every case. + final resultado = ordenarEmisoras([ + conMetricas(uuid: 'x', nombre: 'X'), + ], criterio); + expect(resultado, hasLength(1)); + } + }, + ); +} diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 01c382d..15d5fa6 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -9,6 +9,7 @@ import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart'; import 'package:pluriwave/servicios/servicio_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_favoritos.dart'; +import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; import 'package:pluriwave/servicios/servicio_radio.dart'; class FakeServicioAudio extends ServicioAudio { @@ -236,6 +237,7 @@ class FakeServicioRadio extends ServicioRadio { int obtenerPopularesCalls = 0; int obtenerTendenciasCalls = 0; int registrarClickCalls = 0; + int buscarCalls = 0; String? ultimoUuidClick; Exception _normalizarError(Object error) => @@ -279,6 +281,10 @@ class FakeServicioRadio extends ServicioRadio { int limit = 30, int offset = 0, }) async { + // WU6: counts calls so tests can assert a client-side re-sort does NOT + // trigger a new network request (`station-discovery-browse` spec, + // "Client-Side Search Sort Only"). + buscarCalls++; return _busqueda.skip(offset).take(limit).toList(); } @@ -573,6 +579,28 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio { } } +/// WU6: promoted from a local class in `pantalla_inicio_test.dart` to this +/// shared helpers file, since `pantalla_buscar_test.dart` now needs the same +/// inert stand-in — any widget test that constructs a full `EstadoRadio` +/// must supply a `servicioGrabacion`, or the default REAL +/// `ServicioGrabacionRadio` touches native platform channels and hangs +/// inside `testWidgets()`. +class FakeServicioGrabacionRadio extends ServicioGrabacionRadio { + final _controller = StreamController.broadcast(); + + @override + EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva(); + + @override + Stream get estadoStream => _controller.stream; + + @override + Future inicializar() async {} + + @override + Future dispose() => _controller.close(); +} + Emisora emisoraDemo({ required String uuid, required String nombre, diff --git a/test/pantallas/pantalla_buscar_shimmer_test.dart b/test/pantallas/pantalla_buscar_shimmer_test.dart index a1438de..b798c8b 100644 --- a/test/pantallas/pantalla_buscar_shimmer_test.dart +++ b/test/pantallas/pantalla_buscar_shimmer_test.dart @@ -1,15 +1,26 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_busqueda.dart'; +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/pantallas/pantalla_buscar.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes.dart'; /// S5-R6: the search loading state uses shimmer placeholders, not a bare /// spinner, to stay consistent with the rest of the app. +/// +/// WU6 correction: `PantallaBuscar` now also renders a discovery LANDING +/// state (task 6.5) that reads `EstadoRadio` directly, so any widget test +/// mounting it — this one included — must provide a full `EstadoRadio` +/// 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. class _BusquedaCargando extends EstadoBusqueda { _BusquedaCargando() : super(radio: FakeServicioRadio()); @@ -18,13 +29,36 @@ class _BusquedaCargando extends EstadoBusqueda { } void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + testWidgets('PantallaBuscar muestra shimmer mientras carga', (tester) async { final busqueda = _BusquedaCargando(); addTearDown(busqueda.dispose); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadio(), + resolverArchivoCustom: () async => throw UnimplementedError(), + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); await tester.pumpWidget( - ListenableProvider.value( - value: busqueda, + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + ListenableProvider.value( + value: estado.ecualizador, + ), + ListenableProvider.value(value: estado.grabacion), + // Overrides EstadoRadio's own (unused here) EstadoBusqueda with a + // fake pinned to cargando: true. + ListenableProvider.value(value: busqueda), + ], child: MaterialApp( locale: const Locale('es'), localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -35,6 +69,12 @@ void main() { ); await tester.pump(); + // Still the landing state — no query entered yet. + expect(find.byType(TarjetaEmisoraShimmer), findsNothing); + + await tester.enterText(find.byType(SearchBar), 'jazz'); + await tester.pump(); + expect(find.byType(TarjetaEmisoraShimmer), findsWidgets); expect(find.byType(CircularProgressIndicator), findsNothing); }); diff --git a/test/pantallas/pantalla_buscar_test.dart b/test/pantallas/pantalla_buscar_test.dart new file mode 100644 index 0000000..6d71906 --- /dev/null +++ b/test/pantallas/pantalla_buscar_test.dart @@ -0,0 +1,480 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_busqueda.dart'; +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/pantallas/pantalla_buscar.dart'; +import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; +import 'package:pluriwave/widgets/tarjeta_emisora.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; + +/// WU6, `station-discovery-browse` spec: Buscar's landing state (relocated +/// discovery content from PantallaInicio, task 6.5), active-filter pills + +/// results counter + clear-all-filters (task 6.6), and the client-side +/// "Ordenar" control (task 6.7). +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('Buscar — landing state (empty query shows discovery content)', () { + testWidgets( + 'consulta vacia muestra cerca de vos, generos y tendencias; ' + 'el grid de resultados de busqueda no aparece', + (tester) async { + _setLargeSurfaceSize(tester); + final estado = _crearEstado( + radio: FakeServicioRadio( + populares: [emisoraDemo(uuid: 'pop-1', nombre: 'Populares Uno')], + tendencias: [emisoraDemo(uuid: 'tr-1', nombre: 'Tendencia Uno')], + ), + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + + final l10n = _l10nDe(tester); + expect(find.text(l10n.nearYou), findsOneWidget); + expect(find.text(l10n.liveRadar), findsOneWidget); + expect(find.text(l10n.genresTitle), 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". + expect(find.text(l10n.searchEmptyTitle), findsNothing); + }, + ); + + testWidgets( + 'escribir una consulta reemplaza el contenido de descubrimiento por ' + 'la vista de resultados', + (tester) async { + _setLargeSurfaceSize(tester); + final estado = _crearEstado( + radio: FakeServicioRadio( + populares: [emisoraDemo(uuid: 'pop-1', nombre: 'Populares Uno')], + busqueda: [emisoraDemo(uuid: 'res-1', nombre: 'Resultado Uno')], + ), + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + + final l10n = _l10nDe(tester); + expect(find.text(l10n.nearYou), findsOneWidget); + + await tester.enterText(find.byType(SearchBar), 'resultado'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await _pumpStableFrame(tester); + + expect(find.text(l10n.nearYou), findsNothing); + expect(find.text('Resultado Uno'), findsOneWidget); + }, + ); + }); + + group('Buscar — active-filter pills y contador de resultados', () { + testWidgets( + 'aplicar un filtro de pais muestra un pill removible y el contador ' + 'refleja el conteo filtrado', + (tester) async { + _setLargeSurfaceSize(tester); + final estado = _crearEstado( + radio: FakeServicioRadio( + busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')], + ), + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + + final l10n = _l10nDe(tester); + await _abrirYSeleccionarPais(tester, l10n.countrySpain); + await _pumpStableFrame(tester); + + expect(find.text(l10n.countrySpain), findsOneWidget); + expect(find.byIcon(Icons.close), findsWidgets); + expect(find.text(l10n.searchResultsCount(1)), findsOneWidget); + }, + ); + + testWidgets( + 'quitar un pill vuelve a buscar sin ese filtro', + (tester) async { + _setLargeSurfaceSize(tester); + final estado = _crearEstado( + radio: FakeServicioRadio( + busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')], + ), + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + + final l10n = _l10nDe(tester); + await _abrirYSeleccionarPais(tester, l10n.countrySpain); + await _pumpStableFrame(tester); + expect(find.text(l10n.countrySpain), findsOneWidget); + + final pillPais = find.ancestor( + of: find.text(l10n.countrySpain), + matching: find.byType(Chip), + ); + await tester.tap( + find.descendant(of: pillPais, matching: find.byIcon(Icons.close)), + ); + await _pumpStableFrame(tester); + + expect(find.text(l10n.countrySpain), findsNothing); + }, + ); + + testWidgets( + 'dos filtros activos y cero resultados ofrecen quitar ambos de una ' + 'sola vez', + (tester) async { + _setLargeSurfaceSize(tester); + final estado = _crearEstado(radio: FakeServicioRadio(busqueda: [])); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + + final l10n = _l10nDe(tester); + await _abrirYSeleccionarPais(tester, l10n.countrySpain); + await _pumpStableFrame(tester); + await _abrirYSeleccionarCalidad(tester, '320 kbps'); + await _pumpStableFrame(tester); + + expect(find.text(l10n.countrySpain), findsOneWidget); + final accionQuitar = find.text(l10n.searchClearFiltersAction(2)); + expect(accionQuitar, findsOneWidget); + + await tester.tap(accionQuitar); + await _pumpStableFrame(tester); + + expect(find.text(l10n.countrySpain), findsNothing); + }, + ); + }); + + group('Buscar — Ordenar (client-side, WU6)', () { + testWidgets( + 'cada opcion renderizada de Ordenar corresponde a un caso real de ' + 'OrdenEmisoras (regresion: nunca una opcion decorativa)', + (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); + + await tester.tap(find.byIcon(Icons.swap_vert_rounded)); + await tester.pumpAndSettle(); + + final l10n = _l10nDe(tester); + expect( + find.byType(PopupMenuItem), + findsNWidgets(OrdenEmisoras.values.length), + ); + expect(find.text(l10n.stationOrderByName), findsOneWidget); + expect(find.text(l10n.stationOrderByQuality), findsOneWidget); + expect(find.text(l10n.stationOrderByPopularity), findsOneWidget); + }, + ); + + testWidgets( + 'elegir un criterio reordena la pagina actual sin una nueva ' + 'solicitud de red con parametro order', + (tester) async { + _setLargeSurfaceSize(tester); + final radio = FakeServicioRadio( + busqueda: [ + emisoraDemo(uuid: 'alta', nombre: 'Estacion Alta') + .copyWith(bitrate: 320, votes: 1), + emisoraDemo(uuid: 'popular', nombre: 'Estacion Popular') + .copyWith(bitrate: 64, votes: 900), + ], + ); + final estado = _crearEstado(radio: radio); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + await tester.enterText(find.byType(SearchBar), 'estacion'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await _pumpStableFrame(tester); + + // Default global ordering is OrdenEmisoras.calidad (bitrate desc): + // "Estacion Alta" (320 kbps) must render above "Estacion Popular". + final yAltaAntes = tester.getTopLeft(find.text('Estacion Alta')).dy; + final yPopularAntes = tester.getTopLeft( + find.text('Estacion Popular'), + ).dy; + expect(yAltaAntes, lessThan(yPopularAntes)); + + final llamadasAntes = radio.buscarCalls; + + final l10n = _l10nDe(tester); + await tester.tap(find.byIcon(Icons.swap_vert_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.stationOrderByPopularity)); + await _pumpStableFrame(tester); + + expect( + radio.buscarCalls, + llamadasAntes, + reason: 'sorting must not trigger a new /json request', + ); + + final yAltaDespues = tester.getTopLeft(find.text('Estacion Alta')).dy; + final yPopularDespues = tester.getTopLeft( + find.text('Estacion Popular'), + ).dy; + expect(yPopularDespues, lessThan(yAltaDespues)); + }, + ); + }); + + group('Buscar — contenido relocalizado desde PantallaInicio (WU6 6.5)', () { + testWidgets( + 'el grid de descubrimiento muestra custom + populares; tocar ' + 'reproduce via EstadoRadio y el boton de favorito usa el flujo ' + 'existente', + (tester) async { + _setLargeSurfaceSize(tester); + final audio = FakeServicioAudio(); + final favoritos = FakeServicioFavoritos(); + final radio = FakeServicioRadio(); + final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); + final archivo = await _crearArchivoCustom([custom]); + final estado = _crearEstado( + audio: audio, + favoritos: favoritos, + radio: radio, + resolverArchivoCustom: () async => archivo, + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + + expect(find.text('Custom Uno'), findsOneWidget); + + await tester.tap(find.text('Custom Uno')); + await _pumpStableFrame(tester); + expect( + audio.emisorasReproducidas.map((e) => e.uuid), + contains('custom-1'), + ); + expect(radio.ultimoUuidClick, 'custom-1'); + + final tarjetaCustom = find.ancestor( + of: find.text('Custom Uno'), + matching: find.byType(TarjetaEmisora), + ); + final botonFavorito = + find + .descendant(of: tarjetaCustom, matching: find.byType(InkWell)) + .last; + expect(botonFavorito, findsOneWidget); + + await tester.ensureVisible(botonFavorito); + await _pumpStableFrame(tester); + await tester.tap(botonFavorito); + await _pumpStableFrame(tester); + + expect(favoritos.toggleCalls, 1); + 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( + 'PantallaFavoritos muestra el custom marcado como favorito desde ' + 'Buscar tras recargar', + (tester) async { + _setLargeSurfaceSize(tester); + final favoritos = FakeServicioFavoritos(); + final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); + final archivo = await _crearArchivoCustom([custom]); + final estado = _crearEstado( + favoritos: favoritos, + radio: FakeServicioRadio( + populares: [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')], + ), + resolverArchivoCustom: () async => archivo, + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + + final tarjetaCustom = find.ancestor( + of: find.text('Custom Uno'), + matching: find.byType(TarjetaEmisora), + ); + final botonFavorito = + find + .descendant(of: tarjetaCustom, matching: find.byType(InkWell)) + .last; + await tester.ensureVisible(botonFavorito); + await _pumpStableFrame(tester); + await tester.tap(botonFavorito); + await _pumpStableFrame(tester); + + await tester.pumpWidget( + _conProviders(estado, _testApp(child: const PantallaFavoritos())), + ); + await _pumpStableFrame(tester); + + expect(await favoritos.esFavorito(custom.uuid), isTrue); + expect(find.text('Custom Uno'), findsOneWidget); + }, + ); + }); +} + +EstadoRadio _crearEstado({ + FakeServicioAudio? audio, + FakeServicioFavoritos? favoritos, + FakeServicioRadio? radio, + Future Function()? resolverArchivoCustom, +}) { + return EstadoRadio( + audio: audio ?? FakeServicioAudio(), + favoritos: favoritos ?? FakeServicioFavoritos(), + radio: radio ?? FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadio(), + resolverArchivoCustom: resolverArchivoCustom ?? _archivoCustomVacio, + iniciarAutomaticamente: false, + ); +} + +Widget _conProviders(EstadoRadio estado, Widget child) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + ListenableProvider.value(value: estado.ecualizador), + ListenableProvider.value(value: estado.grabacion), + ListenableProvider.value(value: estado.busqueda), + ], + child: child, + ); +} + +Widget _testApp({Widget child = const PantallaBuscar()}) { + return MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); +} + +AppLocalizations _l10nDe(WidgetTester tester) { + return AppLocalizations.of(tester.element(find.byType(PantallaBuscar))); +} + +Future _abrirYSeleccionarPais(WidgetTester tester, String label) async { + final l10n = _l10nDe(tester); + await tester.tap(find.text(l10n.searchFiltersLabel).first); + await tester.pumpAndSettle(); + await tester.tap(find.text(label).last); + await tester.pumpAndSettle(); +} + +Future _abrirYSeleccionarCalidad( + WidgetTester tester, + String label, +) async { + final l10n = _l10nDe(tester); + await tester.tap(find.text(l10n.searchFiltersLabel).first); + await tester.pumpAndSettle(); + await tester.tap(find.text(label).last); + await tester.pumpAndSettle(); +} + +Future _pumpStableFrame(WidgetTester tester) async { + await tester.pump(); + await tester.pumpAndSettle(const Duration(milliseconds: 100)); +} + +void _setLargeSurfaceSize(WidgetTester tester) { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} + +Future _crearArchivoCustom(List emisoras) async { + final nombre = + emisoras.isEmpty + ? 'emisoras_custom_vacio.json' + : 'emisoras_custom_uno.json'; + return File('${Directory.current.path}/test/fixtures/$nombre'); +} + +Future _archivoCustomVacio() async => _crearArchivoCustom(const []); diff --git a/test/pantallas/pantalla_inicio_rebuild_test.dart b/test/pantallas/pantalla_inicio_rebuild_test.dart index e1877eb..0c0c16a 100644 --- a/test/pantallas/pantalla_inicio_rebuild_test.dart +++ b/test/pantallas/pantalla_inicio_rebuild_test.dart @@ -89,8 +89,19 @@ void main() { expect(registro.any((linea) => linea.contains('PantallaInicio')), isFalse); // Probe control: a real data change DOES rebuild the screen. + // + // WU6 correction: this used to be `estado.cargarPopulares()`, which + // notifies `emisorasInicio`/`cargandoPopulares` — fields the removed + // discovery grid (`_gridEmisoras`) used to read. Task 6.5 relocated + // that grid to `PantallaBuscar` and deleted it here, so + // `PantallaInicio` no longer selects either field; `cargarPopulares()` + // would silently turn this probe into a false negative instead of a + // real control. Toggling a favorite is the correct replacement — the + // remaining "Tus emisoras" section (`_seccionTusEmisoras`) selects + // `listaFavoritos` directly. registro.clear(); - await tester.runAsync(estado.cargarPopulares); + final emisoraProbe = emisoraDemo(uuid: 'probe-1', nombre: 'Probe Uno'); + await tester.runAsync(() => estado.toggleFavorito(emisoraProbe)); await tester.pump(); expect(registro.any((linea) => linea.contains('PantallaInicio')), isTrue); debugPrintRebuildDirtyWidgets = false; diff --git a/test/pantallas/pantalla_inicio_test.dart b/test/pantallas/pantalla_inicio_test.dart index 62fe2f7..2eb0558 100644 --- a/test/pantallas/pantalla_inicio_test.dart +++ b/test/pantallas/pantalla_inicio_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -9,10 +8,7 @@ import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_navegacion.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; -import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; import 'package:pluriwave/pantallas/pantalla_inicio.dart'; -import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; -import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -23,162 +19,16 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - testWidgets( - 'PantallaInicio muestra custom, reproducir usa EstadoRadio y favorito usa flujo existente', - (tester) async { - _setLargeSurfaceSize(tester); - final audio = FakeServicioAudio(); - final favoritos = FakeServicioFavoritos(); - final radio = FakeServicioRadio(); - final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); - final archivo = await _crearArchivoCustom([custom]); - final estado = EstadoRadio( - audio: audio, - favoritos: favoritos, - radio: radio, - servicioEcualizador: FakeServicioEcualizador(), - servicioGrabacion: FakeServicioGrabacionRadio(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - await tester.runAsync(estado.inicializar); - - await tester.pumpWidget( - _conProviders(estado, _testApp(const PantallaInicio())), - ); - await _pumpStableFrame(tester); - - await _scrollUntilText(tester, 'Custom Uno'); - expect(find.text('Custom Uno'), findsOneWidget); - - await tester.ensureVisible(find.text('Custom Uno')); - await _pumpStableFrame(tester); - await tester.tap(find.text('Custom Uno')); - await _pumpStableFrame(tester); - expect( - audio.emisorasReproducidas.map((e) => e.uuid), - contains('custom-1'), - ); - expect(radio.ultimoUuidClick, 'custom-1'); - - final tarjetaCustom = find.ancestor( - of: find.text('Custom Uno'), - matching: find.byType(TarjetaEmisora), - ); - final botonFavorito = - find - .descendant(of: tarjetaCustom, matching: find.byType(InkWell)) - .last; - expect(botonFavorito, findsOneWidget); - - await tester.ensureVisible(botonFavorito); - await _pumpStableFrame(tester); - await tester.tap(botonFavorito); - await _pumpStableFrame(tester); - - expect(favoritos.toggleCalls, 1); - expect(await favoritos.esFavorito(custom.uuid), isTrue); - }, - ); - - testWidgets( - 'PantallaInicio 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 = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: radio, - servicioEcualizador: FakeServicioEcualizador(), - servicioGrabacion: FakeServicioGrabacionRadio(), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - await tester.runAsync(estado.inicializar); - - await tester.pumpWidget( - _conProviders(estado, _testApp(const PantallaInicio())), - ); - await _pumpStableFrame(tester); - - await _scrollUntilText(tester, 'Sin conexión a la API de radio'); - expect(find.text('Sin conexión a la API de radio'), findsOneWidget); - expect(find.text('Reintentar'), findsOneWidget); - - await tester.ensureVisible(find.text('Reintentar')); - await _pumpStableFrame(tester); - await tester.tap(find.text('Reintentar')); - 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('PantallaFavoritos muestra custom favorito tras recarga', ( - tester, - ) async { - _setLargeSurfaceSize(tester); - final favoritos = FakeServicioFavoritos(); - final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); - final archivo = await _crearArchivoCustom([custom]); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: favoritos, - radio: FakeServicioRadio( - populares: [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')], - ), - servicioEcualizador: FakeServicioEcualizador(), - servicioGrabacion: FakeServicioGrabacionRadio(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - await tester.runAsync(estado.inicializar); - - await tester.pumpWidget( - _conProviders(estado, _testApp(const PantallaInicio())), - ); - await _pumpStableFrame(tester); - - await _scrollUntilText(tester, 'Custom Uno'); - await _pumpStableFrame(tester); - final tarjetaCustom = find.ancestor( - of: find.text('Custom Uno'), - matching: find.byType(TarjetaEmisora), - ); - final botonFavorito = - find.descendant(of: tarjetaCustom, matching: find.byType(InkWell)).last; - expect(botonFavorito, findsOneWidget); - - await tester.ensureVisible(botonFavorito); - await _pumpStableFrame(tester); - await tester.tap(botonFavorito); - await _pumpStableFrame(tester); - - await tester.pumpWidget( - _conProviders(estado, _testApp(const PantallaFavoritos())), - ); - await _pumpStableFrame(tester); - - expect(await favoritos.esFavorito(custom.uuid), isTrue); - expect(find.text('Custom Uno'), findsOneWidget); - }); - + // WU6 correction: the 3 scenarios that used to live here ("muestra custom + // ... favorito usa flujo existente", "permite reintentar manualmente", + // "PantallaFavoritos muestra custom favorito tras recarga") all depended + // on `_gridEmisoras`/`_errorBanner`, which task 6.5 relocates to + // `PantallaBuscar` and DELETES from here (WU5's task 5.9 deferred this + // cleanup explicitly). Their coverage moved, adapted, to + // `pantalla_buscar_test.dart` — PantallaInicio no longer renders any + // station list of its own (only the hero + the "Tus emisoras" favorites + // preview), so there is nothing left on this screen for those scenarios + // to exercise. testWidgets( 'WU5 ADR-7 anti-cache: the Escuchar hero reflects a station changed ' 'from OUTSIDE the widget tree (e.g. Android Auto / a notification ' @@ -317,22 +167,6 @@ class _RecordingNavigatorObserver extends NavigatorObserver { } } -class FakeServicioGrabacionRadio extends ServicioGrabacionRadio { - final _controller = StreamController.broadcast(); - - @override - EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva(); - - @override - Stream get estadoStream => _controller.stream; - - @override - Future inicializar() async {} - - @override - Future dispose() => _controller.close(); -} - Future _pumpStableFrame(WidgetTester tester) async { await tester.pump(); await tester.pumpAndSettle(const Duration(milliseconds: 100)); @@ -354,21 +188,5 @@ void _setLargeSurfaceSize(WidgetTester tester) { addTearDown(tester.view.resetDevicePixelRatio); } -Future _scrollUntilText(WidgetTester tester, String text) async { - await tester.scrollUntilVisible( - find.text(text), - 300, - scrollable: find.byType(Scrollable).first, - ); - await _pumpStableFrame(tester); -} - -Future _crearArchivoCustom(List emisoras) async { - final nombre = - emisoras.isEmpty - ? 'emisoras_custom_vacio.json' - : 'emisoras_custom_uno.json'; - return File('${Directory.current.path}/test/fixtures/$nombre'); -} - -Future _archivoCustomVacio() async => _crearArchivoCustom(const []); +Future _archivoCustomVacio() async => + File('${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json'); diff --git a/test/widgets/pluri_push_scaffold_test.dart b/test/widgets/pluri_push_scaffold_test.dart index fac7f22..d345e30 100644 --- a/test/widgets/pluri_push_scaffold_test.dart +++ b/test/widgets/pluri_push_scaffold_test.dart @@ -109,12 +109,31 @@ void main() { testWidgets('PantallaBuscar', (tester) async { setLargeSurface(tester); - final busqueda = EstadoBusqueda(radio: FakeServicioRadio()); - addTearDown(busqueda.dispose); + // WU6 correction: PantallaBuscar now also owns the discovery landing + // state relocated from PantallaInicio (task 6.5), which reads + // EstadoRadio directly (near-you/trending/browse-grid selectors) — + // a bare EstadoBusqueda provider is no longer sufficient. + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: archivoCustomVacio, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); await tester.pumpWidget( - ListenableProvider.value( - value: busqueda, + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + ListenableProvider.value( + value: estado.ecualizador, + ), + ListenableProvider.value(value: estado.grabacion), + ListenableProvider.value(value: estado.busqueda), + ], child: testApp(const PantallaBuscar()), ), );