feat(buscar): add discovery landing state, filter pills, counter, and sort
This commit is contained in:
@@ -458,6 +458,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
_ordenListas = switch (raw) {
|
_ordenListas = switch (raw) {
|
||||||
'nombre' => OrdenEmisoras.nombre,
|
'nombre' => OrdenEmisoras.nombre,
|
||||||
'calidad' => OrdenEmisoras.calidad,
|
'calidad' => OrdenEmisoras.calidad,
|
||||||
|
'popularidad' => OrdenEmisoras.popularidad,
|
||||||
_ => OrdenEmisoras.calidad,
|
_ => OrdenEmisoras.calidad,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -874,6 +875,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
_ordenListas = switch (ordenRaw) {
|
_ordenListas = switch (ordenRaw) {
|
||||||
'nombre' => OrdenEmisoras.nombre,
|
'nombre' => OrdenEmisoras.nombre,
|
||||||
'calidad' => OrdenEmisoras.calidad,
|
'calidad' => OrdenEmisoras.calidad,
|
||||||
|
'popularidad' => OrdenEmisoras.popularidad,
|
||||||
_ => OrdenEmisoras.calidad,
|
_ => OrdenEmisoras.calidad,
|
||||||
};
|
};
|
||||||
await prefs.setString(_keyOrdenListas, _ordenListas.name);
|
await prefs.setString(_keyOrdenListas, _ordenListas.name);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
|
|
||||||
/// User-selectable ordering for every station list in the app.
|
/// 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].
|
/// Returns a sorted COPY of [emisoras] according to [orden].
|
||||||
List<Emisora> ordenarEmisoras(List<Emisora> emisoras, OrdenEmisoras orden) {
|
List<Emisora> ordenarEmisoras(List<Emisora> emisoras, OrdenEmisoras orden) {
|
||||||
@@ -17,6 +21,12 @@ List<Emisora> ordenarEmisoras(List<Emisora> emisoras, OrdenEmisoras orden) {
|
|||||||
if (porBitrate != 0) return porBitrate;
|
if (porBitrate != 0) return porBitrate;
|
||||||
return 0;
|
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;
|
return ordenadas;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,7 @@
|
|||||||
"stationOrderTitle": "Station order",
|
"stationOrderTitle": "Station order",
|
||||||
"stationOrderByName": "By name",
|
"stationOrderByName": "By name",
|
||||||
"stationOrderByQuality": "By quality",
|
"stationOrderByQuality": "By quality",
|
||||||
|
"stationOrderByPopularity": "By popularity",
|
||||||
"stationOrderScopeDescription": "Applies to favorites, searches, nearby stations and quick lists.",
|
"stationOrderScopeDescription": "Applies to favorites, searches, nearby stations and quick lists.",
|
||||||
"favoriteGroupsTitle": "Favorite lists",
|
"favoriteGroupsTitle": "Favorite lists",
|
||||||
"favoriteGroupsDescription": "Create short lists to organize your saved stations.",
|
"favoriteGroupsDescription": "Create short lists to organize your saved stations.",
|
||||||
@@ -306,6 +307,8 @@
|
|||||||
"searchNoResultsTitle": "No results",
|
"searchNoResultsTitle": "No results",
|
||||||
"searchEmptySubtitle": "Use the top bar or chips to discover stations from around the world.",
|
"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.",
|
"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",
|
"countrySpain": "Spain",
|
||||||
"countryUsa": "USA",
|
"countryUsa": "USA",
|
||||||
"countryMexico": "Mexico",
|
"countryMexico": "Mexico",
|
||||||
|
|||||||
@@ -235,6 +235,7 @@
|
|||||||
"stationOrderTitle": "Orden de emisoras",
|
"stationOrderTitle": "Orden de emisoras",
|
||||||
"stationOrderByName": "Por nombre",
|
"stationOrderByName": "Por nombre",
|
||||||
"stationOrderByQuality": "Por calidad",
|
"stationOrderByQuality": "Por calidad",
|
||||||
|
"stationOrderByPopularity": "Por popularidad",
|
||||||
"stationOrderScopeDescription": "Se aplica a favoritos, búsquedas, emisoras cercanas y listados rápidos.",
|
"stationOrderScopeDescription": "Se aplica a favoritos, búsquedas, emisoras cercanas y listados rápidos.",
|
||||||
"favoriteGroupsTitle": "Listas de favoritos",
|
"favoriteGroupsTitle": "Listas de favoritos",
|
||||||
"favoriteGroupsDescription": "Creá listas cortas para organizar tus emisoras guardadas.",
|
"favoriteGroupsDescription": "Creá listas cortas para organizar tus emisoras guardadas.",
|
||||||
@@ -306,6 +307,8 @@
|
|||||||
"searchNoResultsTitle": "Sin resultados",
|
"searchNoResultsTitle": "Sin resultados",
|
||||||
"searchEmptySubtitle": "Usá la barra superior o los chips para descubrir señales de todo el mundo.",
|
"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.",
|
"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",
|
"countrySpain": "España",
|
||||||
"countryUsa": "EE. UU.",
|
"countryUsa": "EE. UU.",
|
||||||
"countryMexico": "México",
|
"countryMexico": "México",
|
||||||
|
|||||||
@@ -892,6 +892,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Por calidad'**
|
/// **'Por calidad'**
|
||||||
String get stationOrderByQuality;
|
String get stationOrderByQuality;
|
||||||
|
|
||||||
|
/// No description provided for @stationOrderByPopularity.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Por popularidad'**
|
||||||
|
String get stationOrderByPopularity;
|
||||||
|
|
||||||
/// No description provided for @stationOrderScopeDescription.
|
/// No description provided for @stationOrderScopeDescription.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// 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.'**
|
/// **'Probá quitar filtros o escribir otro nombre para encontrar una señal activa.'**
|
||||||
String get searchNoResultsSubtitle;
|
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.
|
/// No description provided for @countrySpain.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
|
|||||||
@@ -447,6 +447,9 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'حسب الجودة';
|
String get stationOrderByQuality => 'حسب الجودة';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'ينطبق على المفضلات وعمليات البحث والمحطات القريبة والقوائم السريعة.';
|
'ينطبق على المفضلات وعمليات البحث والمحطات القريبة والقوائم السريعة.';
|
||||||
@@ -586,6 +589,28 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
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
|
@override
|
||||||
String get countrySpain => 'إسبانيا';
|
String get countrySpain => 'إسبانيا';
|
||||||
|
|
||||||
|
|||||||
@@ -450,6 +450,9 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'গুণমান অনুযায়ী';
|
String get stationOrderByQuality => 'গুণমান অনুযায়ী';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'প্রিয়, অনুসন্ধান, কাছাকাছি স্টেশন এবং দ্রুত তালিকায় প্রযোজ্য।';
|
'প্রিয়, অনুসন্ধান, কাছাকাছি স্টেশন এবং দ্রুত তালিকায় প্রযোজ্য।';
|
||||||
@@ -590,6 +593,28 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
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
|
@override
|
||||||
String get countrySpain => 'স্পেন';
|
String get countrySpain => 'স্পেন';
|
||||||
|
|
||||||
|
|||||||
@@ -453,6 +453,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'Nach Qualität';
|
String get stationOrderByQuality => 'Nach Qualität';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'Gilt für Favoriten, Suchen, nahegelegene Sender und Schnelllisten.';
|
'Gilt für Favoriten, Suchen, nahegelegene Sender und Schnelllisten.';
|
||||||
@@ -593,6 +596,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
String get searchNoResultsSubtitle =>
|
||||||
'Versuche, Filter zu entfernen oder einen anderen Namen einzugeben, um einen aktiven Sender zu finden.';
|
'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
|
@override
|
||||||
String get countrySpain => 'Spanien';
|
String get countrySpain => 'Spanien';
|
||||||
|
|
||||||
|
|||||||
@@ -447,6 +447,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'By quality';
|
String get stationOrderByQuality => 'By quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'By popularity';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'Applies to favorites, searches, nearby stations and quick lists.';
|
'Applies to favorites, searches, nearby stations and quick lists.';
|
||||||
@@ -587,6 +590,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
String get searchNoResultsSubtitle =>
|
||||||
'Try removing filters or typing another name to find an active station.';
|
'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
|
@override
|
||||||
String get countrySpain => 'Spain';
|
String get countrySpain => 'Spain';
|
||||||
|
|
||||||
|
|||||||
@@ -451,6 +451,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'Por calidad';
|
String get stationOrderByQuality => 'Por calidad';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'Se aplica a favoritos, búsquedas, emisoras cercanas y listados rápidos.';
|
'Se aplica a favoritos, búsquedas, emisoras cercanas y listados rápidos.';
|
||||||
@@ -591,6 +594,28 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
String get searchNoResultsSubtitle =>
|
||||||
'Probá quitar filtros o escribir otro nombre para encontrar una señal activa.';
|
'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
|
@override
|
||||||
String get countrySpain => 'España';
|
String get countrySpain => 'España';
|
||||||
|
|
||||||
|
|||||||
@@ -455,6 +455,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'Par qualité';
|
String get stationOrderByQuality => 'Par qualité';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'S’applique aux favoris, recherches, stations proches et listes rapides.';
|
'S’applique aux favoris, recherches, stations proches et listes rapides.';
|
||||||
@@ -595,6 +598,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
String get searchNoResultsSubtitle =>
|
||||||
'Essayez de retirer des filtres ou de saisir un autre nom pour trouver une station active.';
|
'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
|
@override
|
||||||
String get countrySpain => 'Espagne';
|
String get countrySpain => 'Espagne';
|
||||||
|
|
||||||
|
|||||||
@@ -448,6 +448,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'गुणवत्ता से';
|
String get stationOrderByQuality => 'गुणवत्ता से';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'पसंदीदा, खोज, पास के स्टेशन और त्वरित सूचियों पर लागू होता है।';
|
'पसंदीदा, खोज, पास के स्टेशन और त्वरित सूचियों पर लागू होता है।';
|
||||||
@@ -588,6 +591,28 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
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
|
@override
|
||||||
String get countrySpain => 'स्पेन';
|
String get countrySpain => 'स्पेन';
|
||||||
|
|
||||||
|
|||||||
@@ -449,6 +449,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'Berdasarkan kualitas';
|
String get stationOrderByQuality => 'Berdasarkan kualitas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'Berlaku untuk favorit, pencarian, stasiun terdekat, dan daftar cepat.';
|
'Berlaku untuk favorit, pencarian, stasiun terdekat, dan daftar cepat.';
|
||||||
@@ -589,6 +592,28 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
String get searchNoResultsSubtitle =>
|
||||||
'Coba hapus filter atau tulis nama lain untuk menemukan sinyal aktif.';
|
'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
|
@override
|
||||||
String get countrySpain => 'Spanyol';
|
String get countrySpain => 'Spanyol';
|
||||||
|
|
||||||
|
|||||||
@@ -451,6 +451,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'Per qualità';
|
String get stationOrderByQuality => 'Per qualità';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'Si applica a preferiti, ricerche, emittenti vicine ed elenchi rapidi.';
|
'Si applica a preferiti, ricerche, emittenti vicine ed elenchi rapidi.';
|
||||||
@@ -591,6 +594,28 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
String get searchNoResultsSubtitle =>
|
||||||
'Prova a rimuovere i filtri o a digitare un altro nome per trovare un\'emittente attiva.';
|
'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
|
@override
|
||||||
String get countrySpain => 'Spagna';
|
String get countrySpain => 'Spagna';
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => '品質順';
|
String get stationOrderByQuality => '品質順';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription => 'お気に入り、検索、近くの局、クイック一覧に適用されます。';
|
String get stationOrderScopeDescription => 'お気に入り、検索、近くの局、クイック一覧に適用されます。';
|
||||||
|
|
||||||
@@ -568,6 +571,28 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchNoResultsSubtitle => '有効な電波を見つけるには、フィルターを外すか別の名前を入力してください。';
|
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
|
@override
|
||||||
String get countrySpain => 'スペイン';
|
String get countrySpain => 'スペイン';
|
||||||
|
|
||||||
|
|||||||
@@ -450,6 +450,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'Por qualidade';
|
String get stationOrderByQuality => 'Por qualidade';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'Aplica-se a favoritos, buscas, estações próximas e listas rápidas.';
|
'Aplica-se a favoritos, buscas, estações próximas e listas rápidas.';
|
||||||
@@ -590,6 +593,28 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
String get searchNoResultsSubtitle =>
|
||||||
'Tente remover filtros ou digitar outro nome para encontrar uma estação ativa.';
|
'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
|
@override
|
||||||
String get countrySpain => 'Espanha';
|
String get countrySpain => 'Espanha';
|
||||||
|
|
||||||
|
|||||||
@@ -451,6 +451,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => 'По качеству';
|
String get stationOrderByQuality => 'По качеству';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription =>
|
String get stationOrderScopeDescription =>
|
||||||
'Применяется к избранному, поиску, ближайшим станциям и быстрым спискам.';
|
'Применяется к избранному, поиску, ближайшим станциям и быстрым спискам.';
|
||||||
@@ -591,6 +594,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get searchNoResultsSubtitle =>
|
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
|
@override
|
||||||
String get countrySpain => 'Испания';
|
String get countrySpain => 'Испания';
|
||||||
|
|
||||||
|
|||||||
@@ -436,6 +436,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get stationOrderByQuality => '按质量';
|
String get stationOrderByQuality => '按质量';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stationOrderByPopularity => 'Por popularidad';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderScopeDescription => '适用于收藏、搜索、附近电台和快捷列表。';
|
String get stationOrderScopeDescription => '适用于收藏、搜索、附近电台和快捷列表。';
|
||||||
|
|
||||||
@@ -566,6 +569,28 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchNoResultsSubtitle => '尝试减少筛选条件,或换个名称搜索,找到正在播出的电台。';
|
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
|
@override
|
||||||
String get countrySpain => '西班牙';
|
String get countrySpain => '西班牙';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shimmer/shimmer.dart' as shimmer;
|
||||||
|
|
||||||
import '../estado/estado_busqueda.dart';
|
import '../estado/estado_busqueda.dart';
|
||||||
import '../tema/pluri_animate.dart';
|
import '../estado/estado_radio.dart';
|
||||||
import '../l10n/gen/app_localizations.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_glass_surface.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
@@ -37,6 +40,28 @@ const _idiomas = [
|
|||||||
('russian', 'languageNameRussian'),
|
('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 {
|
class PantallaBuscar extends StatefulWidget {
|
||||||
const PantallaBuscar({super.key});
|
const PantallaBuscar({super.key});
|
||||||
|
|
||||||
@@ -50,6 +75,33 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
String? _idiomaSeleccionado;
|
String? _idiomaSeleccionado;
|
||||||
int? _calidadMinima;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
@@ -66,6 +118,15 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _quitarTodosLosFiltros() {
|
||||||
|
setState(() {
|
||||||
|
_paisSeleccionado = null;
|
||||||
|
_idiomaSeleccionado = null;
|
||||||
|
_calidadMinima = null;
|
||||||
|
});
|
||||||
|
_buscar();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// S4-R3/S4-R5: this screen depends only on search state, so it watches
|
// S4-R3/S4-R5: this screen depends only on search state, so it watches
|
||||||
@@ -81,9 +142,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
title: l10n.searchScreenTitle,
|
title: l10n.searchScreenTitle,
|
||||||
subtitle: l10n.searchScreenSubtitle,
|
subtitle: l10n.searchScreenSubtitle,
|
||||||
glyph: PluriIconGlyph.search,
|
glyph: PluriIconGlyph.search,
|
||||||
trailing: PluriStatusPill(
|
trailing: GestureDetector(
|
||||||
icon: Icons.tune_rounded,
|
onTap: _abrirFiltros,
|
||||||
label: l10n.searchFiltersLabel,
|
child: PluriStatusPill(
|
||||||
|
icon: Icons.tune_rounded,
|
||||||
|
label: l10n.searchFiltersLabel,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -119,44 +183,225 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_seccionFiltro(
|
if (_hayBusquedaActiva) ...[
|
||||||
l10n.searchCountryFilterLabel,
|
_barraFiltrosActivos(context, estado, theme),
|
||||||
_paises.map((p) => (_countryLabel(l10n, p.$1), p.$2)).toList(),
|
_resultados(estado, theme),
|
||||||
_paisSeleccionado,
|
] else ...[
|
||||||
(v) {
|
_seccionCercanas(context, theme, l10n),
|
||||||
setState(() => _paisSeleccionado = v);
|
_seccionTendencias(context, theme, l10n),
|
||||||
_buscar();
|
_chipGeneros(context, theme, l10n),
|
||||||
},
|
if (context.select<EstadoRadio, String?>((e) => e.error) != null)
|
||||||
),
|
_errorBanner(
|
||||||
_seccionFiltro(
|
context,
|
||||||
l10n.searchLanguageFilterLabel,
|
context.select<EstadoRadio, String?>((e) => e.error)!,
|
||||||
_idiomas.map((i) => (_languageLabel(l10n, i.$2), i.$1)).toList(),
|
theme,
|
||||||
_idiomaSeleccionado,
|
l10n,
|
||||||
(v) {
|
),
|
||||||
setState(() => _idiomaSeleccionado = v);
|
_gridEmisoras(context, l10n),
|
||||||
_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),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 = <Widget>[
|
||||||
|
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<OrdenEmisoras>(
|
||||||
|
icon: const Icon(Icons.swap_vert_rounded),
|
||||||
|
tooltip: l10n.stationOrderTitle,
|
||||||
|
onSelected:
|
||||||
|
(criterio) => context
|
||||||
|
.read<EstadoRadio>()
|
||||||
|
.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<void> _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(
|
Widget _seccionFiltro(
|
||||||
String titulo,
|
String titulo,
|
||||||
List<(String, String)> opciones,
|
List<(String, String)> opciones,
|
||||||
@@ -183,23 +428,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
SizedBox(
|
Wrap(
|
||||||
height: 40,
|
spacing: 8,
|
||||||
child: ListView.separated(
|
runSpacing: 8,
|
||||||
scrollDirection: Axis.horizontal,
|
children: [
|
||||||
itemCount: opciones.length,
|
for (final (label, value) in opciones)
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
FilterChip(
|
||||||
itemBuilder: (_, i) {
|
|
||||||
final (label, value) = opciones[i];
|
|
||||||
final sel = seleccionado == value;
|
|
||||||
return FilterChip(
|
|
||||||
label: Text(label),
|
label: Text(label),
|
||||||
selected: sel,
|
selected: seleccionado == value,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
onSelected: (_) => onChanged(sel ? null : value),
|
onSelected:
|
||||||
);
|
(_) => onChanged(
|
||||||
},
|
seleccionado == value ? null : value,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -233,23 +476,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
SizedBox(
|
Wrap(
|
||||||
height: 40,
|
spacing: 8,
|
||||||
child: ListView.separated(
|
runSpacing: 8,
|
||||||
scrollDirection: Axis.horizontal,
|
children: [
|
||||||
itemCount: opciones.length,
|
for (final (label, value) in opciones)
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
FilterChip(
|
||||||
itemBuilder: (_, i) {
|
|
||||||
final (label, value) = opciones[i];
|
|
||||||
final sel = seleccionado == value;
|
|
||||||
return FilterChip(
|
|
||||||
label: Text(label),
|
label: Text(label),
|
||||||
selected: sel,
|
selected: seleccionado == value,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
onSelected: (_) => onChanged(sel ? null : value),
|
onSelected:
|
||||||
);
|
(_) => onChanged(
|
||||||
},
|
seleccionado == value ? null : value,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -278,20 +519,35 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
final resultados = estado.resultados;
|
final resultados = estado.resultados;
|
||||||
|
|
||||||
if (resultados.isEmpty) {
|
if (resultados.isEmpty) {
|
||||||
final sinFiltros =
|
final sinFiltros = _controller.text.isEmpty && _filtrosActivosCount == 0;
|
||||||
_controller.text.isEmpty &&
|
return Column(
|
||||||
_paisSeleccionado == null &&
|
children: [
|
||||||
_idiomaSeleccionado == null;
|
SizedBox(
|
||||||
return SizedBox(
|
height: 260,
|
||||||
height: 260,
|
child: PluriEmptyState(
|
||||||
child: PluriEmptyState(
|
glyph: PluriIconGlyph.search,
|
||||||
glyph: PluriIconGlyph.search,
|
title:
|
||||||
title: sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
|
sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
|
||||||
subtitle:
|
subtitle:
|
||||||
sinFiltros
|
sinFiltros
|
||||||
? l10n.searchEmptySubtitle
|
? l10n.searchEmptySubtitle
|
||||||
: l10n.searchNoResultsSubtitle,
|
: 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<PantallaBuscar> {
|
|||||||
'languageNameRussian' => l10n.languageNameRussian,
|
'languageNameRussian' => l10n.languageNameRussian,
|
||||||
_ => key,
|
_ => 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<EstadoBusqueda>();
|
||||||
|
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<EstadoRadio, bool>(
|
||||||
|
(e) => e.cargandoPopulares,
|
||||||
|
);
|
||||||
|
final tendencias = context.select<EstadoRadio, List<Emisora>>(
|
||||||
|
(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<EstadoBusqueda>().buscar(tag: g);
|
||||||
|
} else {
|
||||||
|
context.read<EstadoRadio>().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<EstadoRadio>().cargarPopulares(),
|
||||||
|
child: Text(l10n.retryAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
|
||||||
|
final porGenero = _generoSeleccionado != null;
|
||||||
|
final emisoras =
|
||||||
|
porGenero
|
||||||
|
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
|
||||||
|
: context.select<EstadoRadio, List<Emisora>>(
|
||||||
|
(e) => e.emisorasInicio,
|
||||||
|
);
|
||||||
|
final cargando =
|
||||||
|
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
|
||||||
|
(porGenero && context.select<EstadoBusqueda, bool>((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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shimmer/shimmer.dart' as shimmer;
|
import 'package:shimmer/shimmer.dart' as shimmer;
|
||||||
|
|
||||||
import '../estado/estado_busqueda.dart';
|
|
||||||
import '../estado/estado_ecualizador.dart';
|
import '../estado/estado_ecualizador.dart';
|
||||||
import '../estado/estado_navegacion.dart';
|
import '../estado/estado_navegacion.dart';
|
||||||
import '../estado/estado_radio.dart';
|
import '../estado/estado_radio.dart';
|
||||||
@@ -11,10 +10,8 @@ import '../l10n/display_names.dart';
|
|||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../servicios/servicio_audio.dart';
|
import '../servicios/servicio_audio.dart';
|
||||||
import '../tema/pluri_animate.dart';
|
|
||||||
import '../tema/pluriwave_theme.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_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
import '../widgets/pluri_premium_widgets.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
import '../widgets/visualizador_audio.dart';
|
import '../widgets/visualizador_audio.dart';
|
||||||
@@ -32,22 +29,6 @@ class PantallaInicio extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PantallaInicioState extends State<PantallaInicio> {
|
class _PantallaInicioState extends State<PantallaInicio> {
|
||||||
static const _generos = [
|
|
||||||
'pop',
|
|
||||||
'rock',
|
|
||||||
'jazz',
|
|
||||||
'classical',
|
|
||||||
'electronic',
|
|
||||||
'news',
|
|
||||||
'talk',
|
|
||||||
'hip-hop',
|
|
||||||
'country',
|
|
||||||
'metal',
|
|
||||||
'reggae',
|
|
||||||
'latin',
|
|
||||||
];
|
|
||||||
String? _generoSeleccionado;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// S4-R5: no root watch on EstadoRadio. Every field is consumed through
|
// S4-R5: no root watch on EstadoRadio. Every field is consumed through
|
||||||
@@ -55,41 +36,28 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
|||||||
// (which notify EstadoRadio) no longer rebuild this screen.
|
// (which notify EstadoRadio) no longer rebuild this screen.
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final error = context.select<EstadoRadio, String?>((e) => e.error);
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
return CustomScrollView(
|
||||||
onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
|
slivers: [
|
||||||
child: CustomScrollView(
|
// WU5 built the hero; WU6 relocated the discovery sections that
|
||||||
slivers: [
|
// used to follow it (_seccionCercanas, _seccionTendencias,
|
||||||
// WU5: replaces the old PluriScreenHeader hero. The discovery
|
// _chipGeneros, _errorBanner, the browse grid) into
|
||||||
// sections below (_seccionCercanas onward) are deliberately left
|
// PantallaBuscar's landing state and DELETED them here (this WU's
|
||||||
// in place — WU6 relocates them to Buscar and deletes them from
|
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
|
||||||
// here; removing them now would leave that content nowhere until
|
// content is now just the hero and the favorites preview below;
|
||||||
// WU6 lands.
|
// pull-to-refresh was dropped along with the grid it refreshed —
|
||||||
const SliverToBoxAdapter(child: _EscucharHero()),
|
// the retry button that used to live in the (now relocated) error
|
||||||
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
|
// banner already covers manual recovery on Buscar.
|
||||||
SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
|
const SliverToBoxAdapter(child: _EscucharHero()),
|
||||||
SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
|
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
|
||||||
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
|
// ADR-7(b): the mini player is hidden on this whole screen
|
||||||
if (error != null)
|
// (app.dart), so its content needs less bottom padding than every
|
||||||
SliverToBoxAdapter(
|
// other root — escucharBottomChromeInset, not the plain
|
||||||
child: _errorBanner(context, error, theme, l10n),
|
// bottomChromeInset every other root/scrollable uses.
|
||||||
),
|
const SliverToBoxAdapter(
|
||||||
SliverPadding(
|
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
|
||||||
// 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,315 +143,6 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _seccionCercanas(
|
|
||||||
BuildContext context,
|
|
||||||
ThemeData theme,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
) {
|
|
||||||
// Nearby stations live in EstadoBusqueda (S4-R3).
|
|
||||||
final busqueda = context.watch<EstadoBusqueda>();
|
|
||||||
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<EstadoRadio, bool>(
|
|
||||||
(e) => e.cargandoPopulares,
|
|
||||||
);
|
|
||||||
final tendencias = context.select<EstadoRadio, List<Emisora>>(
|
|
||||||
(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<EstadoBusqueda>().buscar(tag: g);
|
|
||||||
} else {
|
|
||||||
context.read<EstadoRadio>().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<EstadoRadio>().cargarPopulares(),
|
|
||||||
child: Text(l10n.retryAction),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
|
|
||||||
final porGenero = _generoSeleccionado != null;
|
|
||||||
final emisoras =
|
|
||||||
porGenero
|
|
||||||
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
|
|
||||||
: context.select<EstadoRadio, List<Emisora>>(
|
|
||||||
(e) => e.emisorasInicio,
|
|
||||||
);
|
|
||||||
final cargando =
|
|
||||||
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
|
|
||||||
(porGenero && context.select<EstadoBusqueda, bool>((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
|
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
|
||||||
|
|||||||
@@ -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')`
|
**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)
|
**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.
|
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.
|
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.
|
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.
|
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
|
`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).
|
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.
|
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`
|
`Emisora` fields (`bitrate`, `votes`, `clickcount`); wire "Ordenar" client-side only — no `order`/`reverse`
|
||||||
param added to any request.
|
param added to any request.
|
||||||
- [ ] 6.8 REFACTOR — confirm `estado_busqueda_test.dart`'s non-sort paths still pass unmodified.
|
- [x] 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.9 Verify — no test in this WU asserts an `order` param on any outgoing request; sort-option regression guard
|
||||||
green.
|
green.
|
||||||
|
|
||||||
## WU7 — Países browser + `_get` transport extraction
|
## WU7 — Países browser + `_get` transport extraction
|
||||||
|
|||||||
@@ -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(
|
group(
|
||||||
'EstadoRadio — emisoras custom: lectura tolerante y guardia de '
|
'EstadoRadio — emisoras custom: lectura tolerante y guardia de '
|
||||||
'degradacion (persistence-resilience)',
|
'degradacion (persistence-resilience)',
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
|
|||||||
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
|
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_favoritos.dart';
|
import 'package:pluriwave/servicios/servicio_favoritos.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_radio.dart';
|
import 'package:pluriwave/servicios/servicio_radio.dart';
|
||||||
|
|
||||||
class FakeServicioAudio extends ServicioAudio {
|
class FakeServicioAudio extends ServicioAudio {
|
||||||
@@ -236,6 +237,7 @@ class FakeServicioRadio extends ServicioRadio {
|
|||||||
int obtenerPopularesCalls = 0;
|
int obtenerPopularesCalls = 0;
|
||||||
int obtenerTendenciasCalls = 0;
|
int obtenerTendenciasCalls = 0;
|
||||||
int registrarClickCalls = 0;
|
int registrarClickCalls = 0;
|
||||||
|
int buscarCalls = 0;
|
||||||
String? ultimoUuidClick;
|
String? ultimoUuidClick;
|
||||||
|
|
||||||
Exception _normalizarError(Object error) =>
|
Exception _normalizarError(Object error) =>
|
||||||
@@ -279,6 +281,10 @@ class FakeServicioRadio extends ServicioRadio {
|
|||||||
int limit = 30,
|
int limit = 30,
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
}) async {
|
}) 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();
|
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<EstadoGrabacionRadio>.broadcast();
|
||||||
|
|
||||||
|
@override
|
||||||
|
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> inicializar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() => _controller.close();
|
||||||
|
}
|
||||||
|
|
||||||
Emisora emisoraDemo({
|
Emisora emisoraDemo({
|
||||||
required String uuid,
|
required String uuid,
|
||||||
required String nombre,
|
required String nombre,
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pluriwave/estado/estado_busqueda.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/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_buscar.dart';
|
import 'package:pluriwave/pantallas/pantalla_buscar.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 '../helpers/fakes.dart';
|
import '../helpers/fakes.dart';
|
||||||
|
|
||||||
/// S5-R6: the search loading state uses shimmer placeholders, not a bare
|
/// S5-R6: the search loading state uses shimmer placeholders, not a bare
|
||||||
/// spinner, to stay consistent with the rest of the app.
|
/// 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 {
|
class _BusquedaCargando extends EstadoBusqueda {
|
||||||
_BusquedaCargando() : super(radio: FakeServicioRadio());
|
_BusquedaCargando() : super(radio: FakeServicioRadio());
|
||||||
|
|
||||||
@@ -18,13 +29,36 @@ class _BusquedaCargando extends EstadoBusqueda {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('PantallaBuscar muestra shimmer mientras carga', (tester) async {
|
testWidgets('PantallaBuscar muestra shimmer mientras carga', (tester) async {
|
||||||
final busqueda = _BusquedaCargando();
|
final busqueda = _BusquedaCargando();
|
||||||
addTearDown(busqueda.dispose);
|
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(
|
await tester.pumpWidget(
|
||||||
ListenableProvider<EstadoBusqueda>.value(
|
MultiProvider(
|
||||||
value: busqueda,
|
providers: [
|
||||||
|
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||||
|
ListenableProvider<EstadoEcualizador>.value(
|
||||||
|
value: estado.ecualizador,
|
||||||
|
),
|
||||||
|
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||||
|
// Overrides EstadoRadio's own (unused here) EstadoBusqueda with a
|
||||||
|
// fake pinned to cargando: true.
|
||||||
|
ListenableProvider<EstadoBusqueda>.value(value: busqueda),
|
||||||
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
locale: const Locale('es'),
|
locale: const Locale('es'),
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
@@ -35,6 +69,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pump();
|
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(TarjetaEmisoraShimmer), findsWidgets);
|
||||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<OrdenEmisoras>),
|
||||||
|
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<File> 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<EstadoRadio>.value(value: estado),
|
||||||
|
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||||
|
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||||
|
ListenableProvider<EstadoBusqueda>.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<void> _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<void> _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<void> _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<File> _crearArchivoCustom(List<dynamic> emisoras) async {
|
||||||
|
final nombre =
|
||||||
|
emisoras.isEmpty
|
||||||
|
? 'emisoras_custom_vacio.json'
|
||||||
|
: 'emisoras_custom_uno.json';
|
||||||
|
return File('${Directory.current.path}/test/fixtures/$nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []);
|
||||||
@@ -89,8 +89,19 @@ void main() {
|
|||||||
expect(registro.any((linea) => linea.contains('PantallaInicio')), isFalse);
|
expect(registro.any((linea) => linea.contains('PantallaInicio')), isFalse);
|
||||||
|
|
||||||
// Probe control: a real data change DOES rebuild the screen.
|
// 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();
|
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();
|
await tester.pump();
|
||||||
expect(registro.any((linea) => linea.contains('PantallaInicio')), isTrue);
|
expect(registro.any((linea) => linea.contains('PantallaInicio')), isTrue);
|
||||||
debugPrintRebuildDirtyWidgets = false;
|
debugPrintRebuildDirtyWidgets = false;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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_navegacion.dart';
|
||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
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_favoritos.dart';
|
|
||||||
import 'package:pluriwave/pantallas/pantalla_inicio.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:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -23,162 +19,16 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
// WU6 correction: the 3 scenarios that used to live here ("muestra custom
|
||||||
'PantallaInicio muestra custom, reproducir usa EstadoRadio y favorito usa flujo existente',
|
// ... favorito usa flujo existente", "permite reintentar manualmente",
|
||||||
(tester) async {
|
// "PantallaFavoritos muestra custom favorito tras recarga") all depended
|
||||||
_setLargeSurfaceSize(tester);
|
// on `_gridEmisoras`/`_errorBanner`, which task 6.5 relocates to
|
||||||
final audio = FakeServicioAudio();
|
// `PantallaBuscar` and DELETES from here (WU5's task 5.9 deferred this
|
||||||
final favoritos = FakeServicioFavoritos();
|
// cleanup explicitly). Their coverage moved, adapted, to
|
||||||
final radio = FakeServicioRadio();
|
// `pantalla_buscar_test.dart` — PantallaInicio no longer renders any
|
||||||
final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno');
|
// station list of its own (only the hero + the "Tus emisoras" favorites
|
||||||
final archivo = await _crearArchivoCustom([custom]);
|
// preview), so there is nothing left on this screen for those scenarios
|
||||||
final estado = EstadoRadio(
|
// to exercise.
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'WU5 ADR-7 anti-cache: the Escuchar hero reflects a station changed '
|
'WU5 ADR-7 anti-cache: the Escuchar hero reflects a station changed '
|
||||||
'from OUTSIDE the widget tree (e.g. Android Auto / a notification '
|
'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<EstadoGrabacionRadio>.broadcast();
|
|
||||||
|
|
||||||
@override
|
|
||||||
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> inicializar() async {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> dispose() => _controller.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pumpStableFrame(WidgetTester tester) async {
|
Future<void> _pumpStableFrame(WidgetTester tester) async {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pumpAndSettle(const Duration(milliseconds: 100));
|
await tester.pumpAndSettle(const Duration(milliseconds: 100));
|
||||||
@@ -354,21 +188,5 @@ void _setLargeSurfaceSize(WidgetTester tester) {
|
|||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _scrollUntilText(WidgetTester tester, String text) async {
|
Future<File> _archivoCustomVacio() async =>
|
||||||
await tester.scrollUntilVisible(
|
File('${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json');
|
||||||
find.text(text),
|
|
||||||
300,
|
|
||||||
scrollable: find.byType(Scrollable).first,
|
|
||||||
);
|
|
||||||
await _pumpStableFrame(tester);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<File> _crearArchivoCustom(List<dynamic> emisoras) async {
|
|
||||||
final nombre =
|
|
||||||
emisoras.isEmpty
|
|
||||||
? 'emisoras_custom_vacio.json'
|
|
||||||
: 'emisoras_custom_uno.json';
|
|
||||||
return File('${Directory.current.path}/test/fixtures/$nombre');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []);
|
|
||||||
|
|||||||
@@ -109,12 +109,31 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('PantallaBuscar', (tester) async {
|
testWidgets('PantallaBuscar', (tester) async {
|
||||||
setLargeSurface(tester);
|
setLargeSurface(tester);
|
||||||
final busqueda = EstadoBusqueda(radio: FakeServicioRadio());
|
// WU6 correction: PantallaBuscar now also owns the discovery landing
|
||||||
addTearDown(busqueda.dispose);
|
// 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(
|
await tester.pumpWidget(
|
||||||
ListenableProvider<EstadoBusqueda>.value(
|
MultiProvider(
|
||||||
value: busqueda,
|
providers: [
|
||||||
|
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||||
|
ListenableProvider<EstadoEcualizador>.value(
|
||||||
|
value: estado.ecualizador,
|
||||||
|
),
|
||||||
|
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||||
|
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
|
||||||
|
],
|
||||||
child: testApp(const PantallaBuscar()),
|
child: testApp(const PantallaBuscar()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user