feat(favoritos): replace stacked group panels with chip-filtered reorderable list
Replaces the stacked per-group panel layout with a single
chip-filtered flat list. Chips read "{name} · {count}" (new ARB keys
favoriteGroupsChipLabel/favoritesFilterAllLabel), one per group plus
an "All" chip. Rows drag-reorder via a leading handle
(ReorderableDragStartListener, buildDefaultDragHandles: false) using
the modern onReorderItem callback rather than the now-@Deprecated
onReorder (Flutter 3.44 marks it obsolete).
EstadoRadio additions: listaFavoritosManual (a new memoized getter
returning the stored order untouched by the global ordenListas
setting - listaFavoritos itself always re-sorts by
name/quality on every read, which would silently discard any
drag-to-reorder), reordenarFavorito (thin wrapper over the
already-existing ServicioFavoritos.reordenar, previously unused
outside its own service test), and ordenarFavoritos (applies an
existing OrdenEmisoras criterion via ordenarEmisoras() and persists
the result as the new manual order, so the swap_vert sort action's
result also survives a restart). listaFavoritos itself is untouched,
so Android Auto's tree and the future Escuchar grid (WU5) are
unaffected by Favoritos' own manual order.
Group management: an "Manage lists" action chip pushes the existing
PantallaAjustesGruposFavoritos screen (Settings' own screen, reused
rather than duplicated) - a second entry point to the same screen.
Custom-station CTA: a new dashed-bordered card opens the add-station
form directly; that form was renamed from private _FormularioEmisora
to public FormularioEmisoraPersonalizada in
pantalla_ajustes_emisoras_personalizadas.dart so both screens share
one implementation. New ARB keys: favoriteGroupsManage,
customStationsAddCta.
Tests: pantalla_favoritos_plural_test.dart (the file tasks.md named)
never imported PantallaFavoritos - it only covers stationCount's ARB
plural formatting, unrelated to this screen. Left it untouched and
added test/pantallas/pantalla_favoritos_test.dart instead: 3
state-layer tests for the new EstadoRadio surface plus 6 widget
scenarios (empty-state CTA, chip filter, drag-reorder persistence,
sort action, group management + chip reactivity, custom-station
CTA). 604 -> 614 tests (2 skipped, unchanged). flutter analyze
unchanged at 1 pre-existing info.
Recorded in tasks.md with the test-file correction and the
design decisions this WU had to make on its own (no ADR covers
Favoritos' manual-order persistence).
This commit is contained in:
@@ -164,6 +164,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
final _memoPopulares = MemoLista<Emisora>();
|
final _memoPopulares = MemoLista<Emisora>();
|
||||||
final _memoTendencias = MemoLista<Emisora>();
|
final _memoTendencias = MemoLista<Emisora>();
|
||||||
final _memoFavoritos = MemoLista<Emisora>();
|
final _memoFavoritos = MemoLista<Emisora>();
|
||||||
|
final _memoFavoritosManual = MemoLista<Emisora>();
|
||||||
final _memoGrupos = MemoLista<GrupoFavoritos>();
|
final _memoGrupos = MemoLista<GrupoFavoritos>();
|
||||||
final _memoCustom = MemoLista<Emisora>();
|
final _memoCustom = MemoLista<Emisora>();
|
||||||
final _memoInicio = MemoLista<Emisora>();
|
final _memoInicio = MemoLista<Emisora>();
|
||||||
@@ -200,6 +201,17 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
_listaFavoritos,
|
_listaFavoritos,
|
||||||
_ordenListas,
|
_ordenListas,
|
||||||
], () => ordenarEmisoras(_listaFavoritos, _ordenListas));
|
], () => ordenarEmisoras(_listaFavoritos, _ordenListas));
|
||||||
|
|
||||||
|
/// WU4, `favorites-organization` spec: Favoritos' own manual order —
|
||||||
|
/// unlike [listaFavoritos], this is NOT re-sorted by the global
|
||||||
|
/// [ordenListas] setting on every read. It reflects exactly the order
|
||||||
|
/// stored via the `orden` column (`ServicioFavoritos.obtenerTodos()`),
|
||||||
|
/// which [reordenarFavorito] and [ordenarFavoritos] mutate. Other
|
||||||
|
/// consumers of `listaFavoritos` (Android Auto, the Escuchar grid) are
|
||||||
|
/// unaffected by drag-reordering Favoritos.
|
||||||
|
List<Emisora> get listaFavoritosManual => _memoFavoritosManual.obtener([
|
||||||
|
_listaFavoritos,
|
||||||
|
], () => List<Emisora>.unmodifiable(_listaFavoritos));
|
||||||
List<GrupoFavoritos> get gruposFavoritos => _memoGrupos.obtener([
|
List<GrupoFavoritos> get gruposFavoritos => _memoGrupos.obtener([
|
||||||
_gruposFavoritos,
|
_gruposFavoritos,
|
||||||
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
|
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
|
||||||
@@ -371,6 +383,30 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
await cargarFavoritos();
|
await cargarFavoritos();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WU4: persists a single drag-to-reorder move within [listaFavoritosManual].
|
||||||
|
/// [nuevoIndice] is the absolute target position among ALL favorites (not
|
||||||
|
/// scoped to any chip filter) — the screen translates a filtered-list drag
|
||||||
|
/// into this global index before calling this method.
|
||||||
|
Future<void> reordenarFavorito(String uuid, int nuevoIndice) async {
|
||||||
|
await favoritos.reordenar(uuid, nuevoIndice);
|
||||||
|
await cargarFavoritos();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WU4: the Favoritos `swap_vert` sort action. Reuses the existing
|
||||||
|
/// [OrdenEmisoras] criteria and [ordenarEmisoras] function — "It MUST NOT
|
||||||
|
/// introduce a sort criterion with no backing implementation"
|
||||||
|
/// (favorites-organization spec). Unlike [ordenListas] (a GLOBAL setting
|
||||||
|
/// affecting favorites/searches/nearby/quick-lists), this sorts ONLY the
|
||||||
|
/// favorites list once and PERSISTS the result as the new manual order —
|
||||||
|
/// consistent with [reordenarFavorito]'s "the order MUST persist" contract.
|
||||||
|
Future<void> ordenarFavoritos(OrdenEmisoras criterio) async {
|
||||||
|
final ordenados = ordenarEmisoras(_listaFavoritos, criterio);
|
||||||
|
for (var i = 0; i < ordenados.length; i++) {
|
||||||
|
await favoritos.reordenar(ordenados[i].uuid, i);
|
||||||
|
}
|
||||||
|
await cargarFavoritos();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> cambiarEmisoraPreferida(Emisora? emisora) async {
|
Future<void> cambiarEmisoraPreferida(Emisora? emisora) async {
|
||||||
_emisoraPreferidaUuid = emisora?.uuid;
|
_emisoraPreferidaUuid = emisora?.uuid;
|
||||||
final prefs = await _resolverPrefs();
|
final prefs = await _resolverPrefs();
|
||||||
|
|||||||
@@ -282,6 +282,18 @@
|
|||||||
"stationName": {}
|
"stationName": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"favoritesFilterAllLabel": "All",
|
||||||
|
"favoriteGroupsChipLabel": "{groupName} · {count}",
|
||||||
|
"@favoriteGroupsChipLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"groupName": {},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"favoriteGroupsManage": "Manage lists",
|
||||||
|
"customStationsAddCta": "Add custom station",
|
||||||
"alarmPostponedCurrentExecution": "Alarm postponed for this occurrence.",
|
"alarmPostponedCurrentExecution": "Alarm postponed for this occurrence.",
|
||||||
"searchScreenTitle": "Search signal",
|
"searchScreenTitle": "Search signal",
|
||||||
"searchScreenSubtitle": "Find stations by name, country, or language with fast filters and high contrast.",
|
"searchScreenSubtitle": "Find stations by name, country, or language with fast filters and high contrast.",
|
||||||
|
|||||||
@@ -282,6 +282,18 @@
|
|||||||
"stationName": {}
|
"stationName": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"favoritesFilterAllLabel": "Todas",
|
||||||
|
"favoriteGroupsChipLabel": "{groupName} · {count}",
|
||||||
|
"@favoriteGroupsChipLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"groupName": {},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"favoriteGroupsManage": "Gestionar listas",
|
||||||
|
"customStationsAddCta": "Añadir emisora personalizada",
|
||||||
"alarmPostponedCurrentExecution": "Alarma pospuesta para esta ejecución.",
|
"alarmPostponedCurrentExecution": "Alarma pospuesta para esta ejecución.",
|
||||||
"searchScreenTitle": "Buscar señal",
|
"searchScreenTitle": "Buscar señal",
|
||||||
"searchScreenSubtitle": "Encontrá radios por nombre, país o idioma con filtros rápidos y alto contraste.",
|
"searchScreenSubtitle": "Encontrá radios por nombre, país o idioma con filtros rápidos y alto contraste.",
|
||||||
|
|||||||
@@ -1036,6 +1036,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'{stationName} eliminada de favoritos'**
|
/// **'{stationName} eliminada de favoritos'**
|
||||||
String favoritesRemovedMessage(Object stationName);
|
String favoritesRemovedMessage(Object stationName);
|
||||||
|
|
||||||
|
/// No description provided for @favoritesFilterAllLabel.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Todas'**
|
||||||
|
String get favoritesFilterAllLabel;
|
||||||
|
|
||||||
|
/// No description provided for @favoriteGroupsChipLabel.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'{groupName} · {count}'**
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count);
|
||||||
|
|
||||||
|
/// No description provided for @favoriteGroupsManage.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Gestionar listas'**
|
||||||
|
String get favoriteGroupsManage;
|
||||||
|
|
||||||
|
/// No description provided for @customStationsAddCta.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Añadir emisora personalizada'**
|
||||||
|
String get customStationsAddCta;
|
||||||
|
|
||||||
/// No description provided for @alarmPostponedCurrentExecution.
|
/// No description provided for @alarmPostponedCurrentExecution.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
|
|||||||
@@ -533,6 +533,20 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
return 'تمت إزالة $stationName من المفضلة';
|
return 'تمت إزالة $stationName من المفضلة';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution => 'تم تأجيل المنبه لهذا التشغيل.';
|
String get alarmPostponedCurrentExecution => 'تم تأجيل المنبه لهذا التشغيل.';
|
||||||
|
|
||||||
|
|||||||
@@ -536,6 +536,20 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
return '$stationName প্রিয় থেকে সরানো হয়েছে';
|
return '$stationName প্রিয় থেকে সরানো হয়েছে';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'এই চালনার জন্য অ্যালার্ম পিছিয়ে দেওয়া হয়েছে।';
|
'এই চালনার জন্য অ্যালার্ম পিছিয়ে দেওয়া হয়েছে।';
|
||||||
|
|||||||
@@ -539,6 +539,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return '$stationName aus Favoriten entfernt';
|
return '$stationName aus Favoriten entfernt';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'Alarm für diese Ausführung verschoben.';
|
'Alarm für diese Ausführung verschoben.';
|
||||||
|
|||||||
@@ -533,6 +533,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '$stationName removed from favorites';
|
return '$stationName removed from favorites';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Manage lists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Add custom station';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'Alarm postponed for this occurrence.';
|
'Alarm postponed for this occurrence.';
|
||||||
|
|||||||
@@ -537,6 +537,20 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return '$stationName eliminada de favoritos';
|
return '$stationName eliminada de favoritos';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'Alarma pospuesta para esta ejecución.';
|
'Alarma pospuesta para esta ejecución.';
|
||||||
|
|||||||
@@ -541,6 +541,20 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return '$stationName retirée des favoris';
|
return '$stationName retirée des favoris';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'Alarme reportée pour cette exécution.';
|
'Alarme reportée pour cette exécution.';
|
||||||
|
|||||||
@@ -534,6 +534,20 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return '$stationName पसंदीदा से हटाया गया';
|
return '$stationName पसंदीदा से हटाया गया';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'इस चाल के लिए अलार्म स्थगित किया गया।';
|
'इस चाल के लिए अलार्म स्थगित किया गया।';
|
||||||
|
|||||||
@@ -535,6 +535,20 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return '$stationName dihapus dari favorit';
|
return '$stationName dihapus dari favorit';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'Alarm ditunda untuk eksekusi ini.';
|
'Alarm ditunda untuk eksekusi ini.';
|
||||||
|
|||||||
@@ -537,6 +537,20 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
return '$stationName rimossa dai preferiti';
|
return '$stationName rimossa dai preferiti';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'Sveglia posticipata per questa esecuzione.';
|
'Sveglia posticipata per questa esecuzione.';
|
||||||
|
|||||||
@@ -518,6 +518,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return '$stationName をお気に入りから削除しました';
|
return '$stationName をお気に入りから削除しました';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution => 'この実行のアラームを延期しました。';
|
String get alarmPostponedCurrentExecution => 'この実行のアラームを延期しました。';
|
||||||
|
|
||||||
|
|||||||
@@ -536,6 +536,20 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return '$stationName removida dos favoritos';
|
return '$stationName removida dos favoritos';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'Alarme adiado para esta execução.';
|
'Alarme adiado para esta execução.';
|
||||||
|
|||||||
@@ -537,6 +537,20 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return '$stationName удалена из избранного';
|
return '$stationName удалена из избранного';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution =>
|
String get alarmPostponedCurrentExecution =>
|
||||||
'Будильник отложен для этого запуска.';
|
'Будильник отложен для этого запуска.';
|
||||||
|
|||||||
@@ -516,6 +516,20 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '$stationName 已从收藏中移除';
|
return '$stationName 已从收藏中移除';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesFilterAllLabel => 'Todas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||||
|
return '$groupName · $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteGroupsManage => 'Gestionar listas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmPostponedCurrentExecution => '本次闹钟已推迟。';
|
String get alarmPostponedCurrentExecution => '本次闹钟已推迟。';
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import '../../widgets/pluri_layout.dart';
|
|||||||
import '../../widgets/pluri_push_scaffold.dart';
|
import '../../widgets/pluri_push_scaffold.dart';
|
||||||
|
|
||||||
/// EMISORAS group · "Emisoras personalizadas" (design ADR-3). Body moved
|
/// EMISORAS group · "Emisoras personalizadas" (design ADR-3). Body moved
|
||||||
/// verbatim from the former `_SeccionEmisoras` + `_FormularioEmisora` in
|
/// verbatim from the former `_SeccionEmisoras` + `FormularioEmisoraPersonalizada` in
|
||||||
/// `pantalla_ajustes.dart` — the panel header's icon, title and spacer were
|
/// `pantalla_ajustes.dart` — the panel header's icon, title and spacer were
|
||||||
/// removed (the pushed screen's title now carries them); the "Add" action,
|
/// removed (the pushed screen's title now carries them); the "Add" action,
|
||||||
/// being a real capability rather than decorative chrome, stays in the body,
|
/// being a real capability rather than decorative chrome, stays in the body,
|
||||||
@@ -110,19 +110,21 @@ class _CuerpoEmisorasPersonalizadas extends StatelessWidget {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
useSafeArea: true,
|
useSafeArea: true,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
builder: (ctx) => const _FormularioEmisora(),
|
builder: (ctx) => const FormularioEmisoraPersonalizada(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FormularioEmisora extends StatefulWidget {
|
class FormularioEmisoraPersonalizada extends StatefulWidget {
|
||||||
const _FormularioEmisora();
|
const FormularioEmisoraPersonalizada({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_FormularioEmisora> createState() => _FormularioEmisoraState();
|
State<FormularioEmisoraPersonalizada> createState() =>
|
||||||
|
FormularioEmisoraPersonalizadaState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
class FormularioEmisoraPersonalizadaState
|
||||||
|
extends State<FormularioEmisoraPersonalizada> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _nombreCtrl = TextEditingController();
|
final _nombreCtrl = TextEditingController();
|
||||||
final _urlCtrl = TextEditingController();
|
final _urlCtrl = TextEditingController();
|
||||||
|
|||||||
@@ -6,24 +6,91 @@ 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 '../modelos/grupo_favoritos.dart';
|
import '../modelos/grupo_favoritos.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';
|
||||||
import '../widgets/pluri_premium_widgets.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
|
import '../widgets/pluri_push_scaffold.dart';
|
||||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||||
|
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
|
||||||
|
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||||
|
|
||||||
import 'reproducir_minimizado.dart';
|
import 'reproducir_minimizado.dart';
|
||||||
|
|
||||||
class PantallaFavoritos extends StatelessWidget {
|
/// WU4, `favorites-organization` spec: a chip-filtered flat list replacing
|
||||||
|
/// the previous stacked per-group panels, with drag-to-reorder, a `swap_vert`
|
||||||
|
/// sort action, group management, and the custom-station CTA all reachable
|
||||||
|
/// from this root screen. Favoritos is the one root that keeps its bottom
|
||||||
|
/// tab bar (design ADR-2's documented exemption) — this file constructs no
|
||||||
|
/// `PluriPushScaffold` and stays a plain body widget for that reason.
|
||||||
|
class PantallaFavoritos extends StatefulWidget {
|
||||||
const PantallaFavoritos({super.key});
|
const PantallaFavoritos({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PantallaFavoritos> createState() => _PantallaFavoritosState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
||||||
|
/// Ephemeral UI state only (design's "State is for ephemeral UI only"
|
||||||
|
/// ruling) — null means the "All" chip is active.
|
||||||
|
String? _grupoSeleccionadoId;
|
||||||
|
|
||||||
|
Future<void> _abrirFormularioEmisoraPersonalizada() async {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (ctx) => const FormularioEmisoraPersonalizada(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _abrirGestionDeListas() {
|
||||||
|
PluriPushScaffold.push(
|
||||||
|
context,
|
||||||
|
(_) => const PantallaAjustesGruposFavoritos(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _elegirOrden(OrdenEmisoras criterio) =>
|
||||||
|
context.read<EstadoRadio>().ordenarFavoritos(criterio);
|
||||||
|
|
||||||
|
/// Translates a drag within the currently FILTERED view into the absolute
|
||||||
|
/// global position [EstadoRadio.reordenarFavorito] expects, so a drag
|
||||||
|
/// while a group chip is active still produces a coherent global order
|
||||||
|
/// (other groups' relative order is left untouched).
|
||||||
|
void _onReorder(
|
||||||
|
List<Emisora> filtrados,
|
||||||
|
List<Emisora> favoritos,
|
||||||
|
int oldIndex,
|
||||||
|
int newIndex,
|
||||||
|
) {
|
||||||
|
final movido = filtrados[oldIndex];
|
||||||
|
final restantes = List<Emisora>.from(filtrados)..removeAt(oldIndex);
|
||||||
|
final int nuevoIndiceGlobal;
|
||||||
|
if (restantes.isEmpty) {
|
||||||
|
nuevoIndiceGlobal = favoritos.length - 1;
|
||||||
|
} else if (newIndex >= restantes.length) {
|
||||||
|
nuevoIndiceGlobal = favoritos.indexWhere(
|
||||||
|
(e) => e.uuid == restantes.last.uuid,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
nuevoIndiceGlobal = favoritos.indexWhere(
|
||||||
|
(e) => e.uuid == restantes[newIndex].uuid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
context.read<EstadoRadio>().reordenarFavorito(
|
||||||
|
movido.uuid,
|
||||||
|
nuevoIndiceGlobal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// S4-R5: no root watch — select only the fields this screen reads. The
|
// S4-R5: no root watch — select only the fields this screen reads. The
|
||||||
// getters are identity-memoized, so playback notifications that do not
|
// getters are identity-memoized, so playback notifications that do not
|
||||||
// change favorites/groups no longer rebuild the screen.
|
// change favorites/groups no longer rebuild the screen.
|
||||||
final favoritos = context.select<EstadoRadio, List<Emisora>>(
|
final favoritos = context.select<EstadoRadio, List<Emisora>>(
|
||||||
(e) => e.listaFavoritos,
|
(e) => e.listaFavoritosManual,
|
||||||
);
|
);
|
||||||
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
|
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
|
||||||
(e) => e.gruposFavoritos,
|
(e) => e.gruposFavoritos,
|
||||||
@@ -51,6 +118,12 @@ class PantallaFavoritos extends StatelessWidget {
|
|||||||
subtitle: l10n.favoritesEmptySubtitle,
|
subtitle: l10n.favoritesEmptySubtitle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Padding(
|
||||||
|
padding: PluriLayout.pageContentPadding,
|
||||||
|
child: _CtaEmisoraPersonalizada(
|
||||||
|
onTap: _abrirFormularioEmisoraPersonalizada,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,10 +140,35 @@ class PantallaFavoritos extends StatelessWidget {
|
|||||||
]
|
]
|
||||||
: grupos;
|
: grupos;
|
||||||
|
|
||||||
return CustomScrollView(
|
// Defensive: a group selected before it was deleted elsewhere (e.g. via
|
||||||
slivers: [
|
// the pushed management screen) falls back to "All" instead of showing
|
||||||
SliverToBoxAdapter(
|
// an empty list with no chip highlighted.
|
||||||
child: PluriScreenHeader(
|
final seleccionEfectiva =
|
||||||
|
gruposVisibles.any((g) => g.id == _grupoSeleccionadoId)
|
||||||
|
? _grupoSeleccionadoId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final filtrados =
|
||||||
|
seleccionEfectiva == null
|
||||||
|
? favoritos
|
||||||
|
: favoritos
|
||||||
|
.where((e) => e.grupoFavoritosId == seleccionEfectiva)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ReorderableListView(
|
||||||
|
buildDefaultDragHandles: false,
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
PluriLayout.horizontal,
|
||||||
|
4,
|
||||||
|
PluriLayout.horizontal,
|
||||||
|
PluriLayout.bottomChromeInset,
|
||||||
|
),
|
||||||
|
header: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
PluriScreenHeader(
|
||||||
title: l10n.favoritesTitle,
|
title: l10n.favoritesTitle,
|
||||||
subtitle: l10n.favoritesHeaderSubtitle,
|
subtitle: l10n.favoritesHeaderSubtitle,
|
||||||
glyph: PluriIconGlyph.favorites,
|
glyph: PluriIconGlyph.favorites,
|
||||||
@@ -79,28 +177,59 @@ class PantallaFavoritos extends StatelessWidget {
|
|||||||
label: l10n.favoritesSavedCount(favoritos.length),
|
label: l10n.favoritesSavedCount(favoritos.length),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
SliverPadding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
PluriLayout.horizontal,
|
|
||||||
4,
|
|
||||||
PluriLayout.horizontal,
|
|
||||||
PluriLayout.bottomChromeInset,
|
|
||||||
),
|
|
||||||
sliver: SliverList(
|
|
||||||
delegate: SliverChildListDelegate([
|
|
||||||
for (final grupo in gruposVisibles) ...[
|
|
||||||
_GrupoFavoritosPanel(
|
|
||||||
grupo: grupo,
|
|
||||||
grupos: gruposVisibles,
|
|
||||||
emisoras:
|
|
||||||
favoritos
|
|
||||||
.where((e) => e.grupoFavoritosId == grupo.id)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _FilaChipsGrupos(
|
||||||
|
grupos: gruposVisibles,
|
||||||
|
favoritos: favoritos,
|
||||||
|
seleccionado: seleccionEfectiva,
|
||||||
|
onSeleccionar:
|
||||||
|
(id) => setState(() => _grupoSeleccionadoId = id),
|
||||||
|
onGestionar: _abrirGestionDeListas,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<OrdenEmisoras>(
|
||||||
|
icon: const Icon(Icons.swap_vert_rounded),
|
||||||
|
tooltip: l10n.stationOrderTitle,
|
||||||
|
onSelected: _elegirOrden,
|
||||||
|
itemBuilder:
|
||||||
|
(context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: OrdenEmisoras.nombre,
|
||||||
|
child: Text(l10n.stationOrderByName),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: OrdenEmisoras.calidad,
|
||||||
|
child: Text(l10n.stationOrderByQuality),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
]),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
footer: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: _CtaEmisoraPersonalizada(
|
||||||
|
onTap: _abrirFormularioEmisoraPersonalizada,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onReorderItem:
|
||||||
|
(oldIndex, newIndex) =>
|
||||||
|
_onReorder(filtrados, favoritos, oldIndex, newIndex),
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < filtrados.length; i++)
|
||||||
|
_FilaFavorito(
|
||||||
|
key: ValueKey(filtrados[i].uuid),
|
||||||
|
index: i,
|
||||||
|
emisora: filtrados[i],
|
||||||
|
grupos: gruposVisibles,
|
||||||
|
grupoActual: gruposVisibles.firstWhere(
|
||||||
|
(g) => g.id == filtrados[i].grupoFavoritosId,
|
||||||
|
orElse: () => gruposVisibles.first,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -108,16 +237,20 @@ class PantallaFavoritos extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GrupoFavoritosPanel extends StatelessWidget {
|
class _FilaChipsGrupos extends StatelessWidget {
|
||||||
const _GrupoFavoritosPanel({
|
const _FilaChipsGrupos({
|
||||||
required this.grupo,
|
|
||||||
required this.grupos,
|
required this.grupos,
|
||||||
required this.emisoras,
|
required this.favoritos,
|
||||||
|
required this.seleccionado,
|
||||||
|
required this.onSeleccionar,
|
||||||
|
required this.onGestionar,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GrupoFavoritos grupo;
|
|
||||||
final List<GrupoFavoritos> grupos;
|
final List<GrupoFavoritos> grupos;
|
||||||
final List<Emisora> emisoras;
|
final List<Emisora> favoritos;
|
||||||
|
final String? seleccionado;
|
||||||
|
final ValueChanged<String?> onSeleccionar;
|
||||||
|
final VoidCallback onGestionar;
|
||||||
|
|
||||||
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
|
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
|
||||||
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
|
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
|
||||||
@@ -125,61 +258,61 @@ class _GrupoFavoritosPanel extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
return SizedBox(
|
||||||
return PluriGlassSurface(
|
height: 40,
|
||||||
padding: const EdgeInsets.all(10),
|
child: ListView(
|
||||||
child: Column(
|
scrollDirection: Axis.horizontal,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
_nombreVisible(l10n, grupo),
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// S5-R5: proper plural message, not a bare number.
|
|
||||||
Text(l10n.stationCount(emisoras.length)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (emisoras.isEmpty)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: Text(
|
child: ChoiceChip(
|
||||||
l10n.favoritesEmptyTitle,
|
label: Text(
|
||||||
style: theme.textTheme.bodySmall,
|
l10n.favoriteGroupsChipLabel(
|
||||||
|
l10n.favoritesFilterAllLabel,
|
||||||
|
favoritos.length,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
else
|
|
||||||
for (var i = 0; i < emisoras.length; i++) ...[
|
|
||||||
_FavoritoItem(
|
|
||||||
emisora: emisoras[i],
|
|
||||||
grupos: grupos,
|
|
||||||
grupoActual: grupo,
|
|
||||||
),
|
),
|
||||||
if (i < emisoras.length - 1) const SizedBox(height: 8),
|
selected: seleccionado == null,
|
||||||
],
|
onSelected: (_) => onSeleccionar(null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final grupo in grupos)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(
|
||||||
|
l10n.favoriteGroupsChipLabel(
|
||||||
|
_nombreVisible(l10n, grupo),
|
||||||
|
favoritos
|
||||||
|
.where((e) => e.grupoFavoritosId == grupo.id)
|
||||||
|
.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
selected: seleccionado == grupo.id,
|
||||||
|
onSelected: (_) => onSeleccionar(grupo.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ActionChip(
|
||||||
|
avatar: const Icon(Icons.add_rounded, size: 18),
|
||||||
|
label: Text(l10n.favoriteGroupsManage),
|
||||||
|
onPressed: onGestionar,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FavoritoItem extends StatelessWidget {
|
class _FilaFavorito extends StatelessWidget {
|
||||||
const _FavoritoItem({
|
const _FilaFavorito({
|
||||||
|
super.key,
|
||||||
|
required this.index,
|
||||||
required this.emisora,
|
required this.emisora,
|
||||||
required this.grupos,
|
required this.grupos,
|
||||||
required this.grupoActual,
|
required this.grupoActual,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final int index;
|
||||||
final Emisora emisora;
|
final Emisora emisora;
|
||||||
final List<GrupoFavoritos> grupos;
|
final List<GrupoFavoritos> grupos;
|
||||||
final GrupoFavoritos grupoActual;
|
final GrupoFavoritos grupoActual;
|
||||||
@@ -253,8 +386,17 @@ class _FavoritoItem extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return Row(
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
ReorderableDragStartListener(
|
||||||
|
index: index,
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.only(right: 4),
|
||||||
|
child: Icon(Icons.drag_handle_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TarjetaEmisora(
|
child: TarjetaEmisora(
|
||||||
key: Key(emisora.uuid),
|
key: Key(emisora.uuid),
|
||||||
@@ -282,6 +424,85 @@ class _FavoritoItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The dashed "Añadir emisora personalizada" CTA (favorites-organization
|
||||||
|
/// spec, "Custom-Station CTA Preserved") — opens the SAME add-station form
|
||||||
|
/// used from Settings' Emisoras personalizadas screen
|
||||||
|
/// ([FormularioEmisoraPersonalizada]), not a duplicate.
|
||||||
|
class _CtaEmisoraPersonalizada extends StatelessWidget {
|
||||||
|
const _CtaEmisoraPersonalizada({required this.onTap});
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final color = Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.5);
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _DashedBorderPainter(color: color),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.add_circle_outline_rounded, color: color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(l10n.customStationsAddCta, style: TextStyle(color: color)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashedBorderPainter extends CustomPainter {
|
||||||
|
const _DashedBorderPainter({required this.color});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
static const _radius = 16.0;
|
||||||
|
static const _dashWidth = 6.0;
|
||||||
|
static const _gapWidth = 4.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final rrect = RRect.fromRectAndRadius(
|
||||||
|
Offset.zero & size,
|
||||||
|
const Radius.circular(_radius),
|
||||||
|
);
|
||||||
|
final path = Path()..addRRect(rrect);
|
||||||
|
final paint =
|
||||||
|
Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.5;
|
||||||
|
for (final metric in path.computeMetrics()) {
|
||||||
|
var distance = 0.0;
|
||||||
|
while (distance < metric.length) {
|
||||||
|
final next = distance + _dashWidth;
|
||||||
|
canvas.drawPath(
|
||||||
|
metric.extractPath(distance, next.clamp(0.0, metric.length)),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
distance = next + _gapWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) =>
|
||||||
|
oldDelegate.color != color;
|
||||||
|
}
|
||||||
|
|||||||
@@ -284,19 +284,61 @@ both kept it below forecast). Same "move-only diff" justification as WU3a.
|
|||||||
**Depends on**: WU1
|
**Depends on**: WU1
|
||||||
**Spec refs**: `favorites-organization` — Chip-Filtered Flat List, Drag-to-Reorder Within the Active Filter, Sort
|
**Spec refs**: `favorites-organization` — Chip-Filtered Flat List, Drag-to-Reorder Within the Active Filter, Sort
|
||||||
Action Using Existing Criteria, Group Management Reachable from Favoritos, Custom-Station CTA Preserved
|
Action Using Existing Criteria, Group Management Reachable from Favoritos, Custom-Station CTA Preserved
|
||||||
**Verify**: `flutter test test/pantallas/pantalla_favoritos_plural_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_favoritos_test.dart test/pantallas/pantalla_favoritos_plural_test.dart test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')`
|
||||||
**Modified tests**: `test/pantallas/pantalla_favoritos_plural_test.dart` (+ new reorder scenario)
|
**New tests**: `test/pantallas/pantalla_favoritos_test.dart` (see 4.1's note — this WU's actual new test file, not
|
||||||
|
`pantalla_favoritos_plural_test.dart`).
|
||||||
|
**Modified tests**: `test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart` (comment only, no
|
||||||
|
scenario change — see 4.4's note).
|
||||||
|
|
||||||
- [ ] 4.1 RED — update the test file: chip filter narrows the list ("Todas · N" + one chip per group); drag-to-reorder
|
- [x] 4.1 RED — **corrected at apply time**: `pantalla_favoritos_plural_test.dart` (the file this task originally
|
||||||
persists across a simulated restart; `swap_vert` applies `OrdenEmisoras`; create-group action reachable;
|
named) never imports `PantallaFavoritos` — it only exercises `stationCount`'s ARB plural formatting via
|
||||||
custom-station CTA reachable.
|
`AppLocalizations` directly, with no widget-level coverage of this screen at all. Left it untouched (still a
|
||||||
- [ ] 4.2 GREEN — replace `_GrupoFavoritosPanel` / `_FavoritoItem` with a horizontally-scrollable chip row + a single
|
valid, unrelated regression guard) and created **`test/pantallas/pantalla_favoritos_test.dart`** instead, with:
|
||||||
`ReorderableListView` filtered by the active chip.
|
3 state-layer tests for the new `EstadoRadio` surface (`listaFavoritosManual`, `reordenarFavorito`,
|
||||||
- [ ] 4.3 GREEN — wire persistence for the new order and the `swap_vert` action to `OrdenEmisoras`.
|
`ordenarFavoritos` — see 4.3's note) plus 6 widget-level scenarios (empty-state CTA, chip filter narrows the
|
||||||
- [ ] 4.4 GREEN — surface the create-group action; keep the dashed custom-station CTA.
|
list, drag-reorder persists across a simulated restart, `swap_vert` sort, create-group action + new-group-chip
|
||||||
- [ ] 4.5 REFACTOR — extract the chip row / reorderable row into private widgets if the file grows unwieldy; confirm
|
reactivity, custom-station CTA).
|
||||||
push-chrome is **not** applied here (Favoritos keeps its tab bar — the one exemption).
|
- [x] 4.2 GREEN — replaced `_GrupoFavoritosPanel` with `_FilaChipsGrupos` (horizontally-scrollable
|
||||||
- [ ] 4.6 Verify — reorder-persists scenario green after simulated restart; sort scenario green.
|
`ChoiceChip` row, "{name} · {count}" label, new ARB keys `favoriteGroupsChipLabel`/`favoritesFilterAllLabel`)
|
||||||
|
and a `ReorderableListView` (`header`/`footer` hold the screen header, chip row, sort action and the CTA;
|
||||||
|
`buildDefaultDragHandles: false` + `ReorderableDragStartListener` per row, matching the spec's "via a drag
|
||||||
|
handle" wording rather than whole-row long-press). Used the modern `onReorderItem` callback, not the
|
||||||
|
now-`@Deprecated` `onReorder` (Flutter 3.44 marks it obsolete — using it would have added a new
|
||||||
|
`deprecated_member_use` warning above the 1-issue baseline).
|
||||||
|
- [x] 4.3 GREEN — **design decision, not specified by any ADR (WU4 has none)**: `EstadoRadio.listaFavoritos` already
|
||||||
|
unconditionally re-sorts by the GLOBAL `ordenListas` setting on every read, which would silently discard any
|
||||||
|
drag-to-reorder the instant anything reloads favorites. Added `listaFavoritosManual` (a new memoized getter
|
||||||
|
returning the stored `orden`-column sequence, untouched by `ordenListas`) instead of changing `listaFavoritos`
|
||||||
|
itself — `navegacion_auto.dart`'s Android Auto tree and the future Escuchar grid (WU5) both read
|
||||||
|
`listaFavoritos` and are correctly unaffected by Favoritos' own manual order. Added `reordenarFavorito(uuid,
|
||||||
|
nuevoIndice)` (thin wrapper over the ALREADY-EXISTING `ServicioFavoritos.reordenar` + `FakeServicioFavoritos`
|
||||||
|
test double — both pre-dated this WU, unused until now) and `ordenarFavoritos(criterio)` (applies
|
||||||
|
`OrdenEmisoras` via the existing `ordenarEmisoras()` function, then PERSISTS the result as the new manual order
|
||||||
|
via the same `reordenar` primitive in a loop — chosen so the sort action's result also survives a restart,
|
||||||
|
consistent with the drag-reorder persistence contract, even though the spec's own sort scenario only asserts
|
||||||
|
the immediate re-render).
|
||||||
|
- [x] 4.4 GREEN — create-group action: an `ActionChip` ("Manage lists", new ARB key `favoriteGroupsManage`) at the
|
||||||
|
end of the chip row pushes the EXISTING `PantallaAjustesGruposFavoritos` (Settings' screen, reused rather than
|
||||||
|
duplicated) — satisfies "Group Management Reachable... in addition to its existing entry point in Settings" as
|
||||||
|
a second entry point to the SAME screen. Custom-station CTA: a new dashed-bordered card (`_DashedBorderPainter`,
|
||||||
|
a small self-contained `CustomPainter` — no dashed-border package was available or added) opens the add-station
|
||||||
|
form directly. That form (`_FormularioEmisora`) was **renamed to public `FormularioEmisoraPersonalizada`** in
|
||||||
|
`pantalla_ajustes_emisoras_personalizadas.dart` so both screens share the one form instead of duplicating it;
|
||||||
|
its one existing test file only referenced the old name in a comment, updated, no scenario changed. New ARB key
|
||||||
|
`customStationsAddCta` ("Add custom station" / "Añadir emisora personalizada", matching the spec's quoted
|
||||||
|
Spanish text exactly).
|
||||||
|
- [x] 4.5 REFACTOR — extracted `_FilaChipsGrupos`, `_FilaFavorito` (the reorderable row, now carrying a leading drag
|
||||||
|
handle plus the pre-existing assign/remove actions), `_CtaEmisoraPersonalizada`, `_DashedBorderPainter` as
|
||||||
|
private widgets. Confirmed `PantallaFavoritos` still constructs zero `Scaffold` (`pluri_push_scaffold_test.dart`
|
||||||
|
"The 5 root screens build zero Scaffold when mounted bare" still passes for it) — push-chrome is not applied
|
||||||
|
here, Favoritos keeps its tab bar.
|
||||||
|
- [x] 4.6 Verify — scoped suite green: 9/9 new + 2/2 `pantalla_favoritos_plural_test.dart` + 2/2
|
||||||
|
`pantalla_ajustes_emisoras_personalizadas_test.dart`. Reorder-persists scenario green (verified via a simulated
|
||||||
|
restart — `cargarFavoritos()` re-fetch from the fake service reflects the new order). Sort scenario green (both
|
||||||
|
state persistence and the visual re-render order). Full suite: 614/614 green (2 skipped, unchanged), up from
|
||||||
|
605. `flutter analyze`: 1 issue, identical to baseline (3 new warnings + 1 new info surfaced during REFACTOR —
|
||||||
|
unused import, 3 unused optional painter parameters, missing `super.key` on the newly-public form widget — all
|
||||||
|
fixed before this count).
|
||||||
|
|
||||||
## WU5 — Escuchar restructure
|
## WU5 — Escuchar restructure
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||||
|
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
||||||
|
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../helpers/fakes.dart';
|
||||||
|
import '../helpers/fakes_alarmas.dart';
|
||||||
|
|
||||||
|
/// WU4, `favorites-organization` spec: chip-filtered flat list replacing the
|
||||||
|
/// stacked per-group panels, drag-to-reorder, the `swap_vert` sort action,
|
||||||
|
/// group management, and the custom-station CTA — all reachable from
|
||||||
|
/// Favoritos.
|
||||||
|
///
|
||||||
|
/// This screen previously had NO widget-level test coverage —
|
||||||
|
/// `pantalla_favoritos_plural_test.dart` only exercises `stationCount`'s ARB
|
||||||
|
/// plural formatting via `AppLocalizations` directly and never imports
|
||||||
|
/// `PantallaFavoritos`. That file is left untouched (it stays a valid,
|
||||||
|
/// unrelated regression guard); this new file covers the screen itself.
|
||||||
|
///
|
||||||
|
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||||
|
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||||
|
/// Flutter flags as a warning-level assertion, not a correctness bug.
|
||||||
|
void _suppressListTileInkAssertion() {
|
||||||
|
final original = FlutterError.onError;
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
if (details.exceptionAsString().contains(
|
||||||
|
'ListTile background color or ink splashes may be invisible',
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
original?.call(details);
|
||||||
|
};
|
||||||
|
addTearDown(() => FlutterError.onError = original);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<File> archivoCustomVacio() async => File(
|
||||||
|
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<EstadoRadio> crearEstadoVacio() async {
|
||||||
|
return EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: FakeServicioFavoritos(),
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||||
|
resolverArchivoCustom: archivoCustomVacio,
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seeds 3 favorites (A, B unassigned won't apply — A is unassigned; B and
|
||||||
|
/// C are in a "Rock" group) added in A, B, C order (so the natural/manual
|
||||||
|
/// order is A, B, C).
|
||||||
|
Future<EstadoRadio> crearEstadoConFavoritos() async {
|
||||||
|
final estado = await crearEstadoVacio();
|
||||||
|
final favoritos = estado.favoritos as FakeServicioFavoritos;
|
||||||
|
final rock = await favoritos.crearGrupo('Rock');
|
||||||
|
await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'Station A'));
|
||||||
|
await favoritos.agregar(emisoraDemo(uuid: 'b', nombre: 'Station B'));
|
||||||
|
await favoritos.agregar(emisoraDemo(uuid: 'c', nombre: 'Station C'));
|
||||||
|
await favoritos.asignarGrupo('b', rock.id);
|
||||||
|
await favoritos.asignarGrupo('c', rock.id);
|
||||||
|
await estado.cargarFavoritos();
|
||||||
|
await estado.cargarGruposFavoritos();
|
||||||
|
return estado;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildScreen(EstadoRadio estado) {
|
||||||
|
// Wrapped in a bare Scaffold, matching pantalla_ajustes_test.dart's
|
||||||
|
// convention: in the real app, _PaginaPrincipal's own Scaffold is what
|
||||||
|
// gives root screens (which construct zero Scaffold themselves, per
|
||||||
|
// ADR-2) a Material ancestor. Without it, Material components like
|
||||||
|
// ChoiceChip/PopupMenuButton/ActionChip fail to find one.
|
||||||
|
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||||
|
value: estado,
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const Scaffold(body: PantallaFavoritos()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpStable(WidgetTester tester) async {
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EstadoRadio: new state surface (state-layer, not widget-layer) ───────
|
||||||
|
|
||||||
|
test('listaFavoritosManual returns favorites in stored order, NOT re-sorted '
|
||||||
|
'by the global ordenListas setting (unlike listaFavoritos)', () async {
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
// Natural insertion order is A, B, C; ordenListas defaults to calidad,
|
||||||
|
// which would NOT necessarily preserve that order if applied.
|
||||||
|
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
'c',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reordenarFavorito persists the new manual order', () async {
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await estado.reordenarFavorito('c', 0);
|
||||||
|
|
||||||
|
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||||
|
'c',
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
]);
|
||||||
|
// Persists across a simulated restart: reload from the (fake) service.
|
||||||
|
await estado.cargarFavoritos();
|
||||||
|
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||||
|
'c',
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordenarFavoritos applies an existing OrdenEmisoras criterion and '
|
||||||
|
'persists the result as the new manual order', () async {
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
// Put them out of alphabetical order first.
|
||||||
|
await estado.reordenarFavorito('c', 0);
|
||||||
|
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||||
|
'c',
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await estado.ordenarFavoritos(OrdenEmisoras.nombre);
|
||||||
|
|
||||||
|
expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [
|
||||||
|
'Station A',
|
||||||
|
'Station B',
|
||||||
|
'Station C',
|
||||||
|
]);
|
||||||
|
// Persists: a simulated restart still shows the sorted order, not the
|
||||||
|
// pre-sort manual order.
|
||||||
|
await estado.cargarFavoritos();
|
||||||
|
expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [
|
||||||
|
'Station A',
|
||||||
|
'Station B',
|
||||||
|
'Station C',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Widget-level: PantallaFavoritos ───────────────────────────────────────
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'shows the empty state with the custom-station CTA when there are no '
|
||||||
|
'favorites',
|
||||||
|
(tester) async {
|
||||||
|
final estado = await crearEstadoVacio();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
await estado.cargarFavoritos();
|
||||||
|
await estado.cargarGruposFavoritos();
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('No favorites yet'), findsOneWidget);
|
||||||
|
expect(find.text('Add custom station'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'chip filter narrows the list to the selected group ("All · N" plus '
|
||||||
|
'one chip per group)',
|
||||||
|
(tester) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('All · 3'), findsOneWidget);
|
||||||
|
expect(find.text('Unassigned · 1'), findsOneWidget);
|
||||||
|
expect(find.text('Rock · 2'), findsOneWidget);
|
||||||
|
expect(find.text('Station A'), findsOneWidget);
|
||||||
|
expect(find.text('Station B'), findsOneWidget);
|
||||||
|
expect(find.text('Station C'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Rock · 2'));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('Station A'), findsNothing);
|
||||||
|
expect(find.text('Station B'), findsOneWidget);
|
||||||
|
expect(find.text('Station C'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('dragging the 3rd item to the 1st position persists across a '
|
||||||
|
'simulated restart', (tester) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
final lista = tester.widget<ReorderableListView>(
|
||||||
|
find.byType(ReorderableListView),
|
||||||
|
);
|
||||||
|
// Drag the 3rd row (index 2, "Station C") to the 1st position (index
|
||||||
|
// 0) — exercised via the real onReorderItem callback the widget wires
|
||||||
|
// up. onReorderItem (not the deprecated onReorder) already adjusts
|
||||||
|
// newIndex for the removed item, so no manual index math here.
|
||||||
|
lista.onReorderItem!(2, 0);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||||
|
'c',
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Persists across a simulated restart.
|
||||||
|
await estado.cargarFavoritos();
|
||||||
|
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||||
|
'c',
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'swap_vert sort action applies OrdenEmisoras.nombre and re-renders '
|
||||||
|
'alphabetically',
|
||||||
|
(tester) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
await estado.reordenarFavorito('c', 0); // out of alphabetical order
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.swap_vert_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('By name'));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [
|
||||||
|
'Station A',
|
||||||
|
'Station B',
|
||||||
|
'Station C',
|
||||||
|
]);
|
||||||
|
// The list re-renders in the new order too, not just the state.
|
||||||
|
expect(
|
||||||
|
tester.getCenter(find.text('Station A')).dy <
|
||||||
|
tester.getCenter(find.text('Station B')).dy,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'the create-group action opens group management, and a newly created '
|
||||||
|
'group appears as a new filter chip',
|
||||||
|
(tester) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Manage lists'));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
// "Group Management Reachable from Favoritos": the SAME screen
|
||||||
|
// Settings uses, reused rather than duplicated.
|
||||||
|
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||||
|
expect(find.byType(PantallaAjustesGruposFavoritos), findsOneWidget);
|
||||||
|
|
||||||
|
// Deliberately does NOT interact with _editarGrupo's create-group
|
||||||
|
// bottom sheet here: it has a pre-existing, out-of-scope
|
||||||
|
// TextEditingController dispose-race (Engram
|
||||||
|
// sdd/rediseno-funcional/controller-dispose-bugfix, tracked
|
||||||
|
// separately, not to be fixed in this WU) already independently
|
||||||
|
// exercised by pantalla_ajustes_grupos_favoritos_test.dart. Popping
|
||||||
|
// back to Favoritos and calling the same EstadoRadio method that
|
||||||
|
// form calls proves the part that's actually NEW here — the chip
|
||||||
|
// row's reactivity to a newly created group — without depending on
|
||||||
|
// that unrelated bug's timing.
|
||||||
|
//
|
||||||
|
// PluriPushScaffold uses a plain IconButton for its back affordance,
|
||||||
|
// not a semantic BackButtonIcon/CupertinoNavigationBarBackButton —
|
||||||
|
// tester.pageBack() looks for those and finds neither.
|
||||||
|
await tester.tap(find.byIcon(Icons.arrow_back_rounded));
|
||||||
|
// pumpAndSettle, not pumpStable: the pop transition's default
|
||||||
|
// animation needs more than a bounded 100ms pump to fully finish.
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(PantallaAjustesGruposFavoritos), findsNothing);
|
||||||
|
expect(find.text('Manage lists'), findsOneWidget);
|
||||||
|
|
||||||
|
await estado.crearGrupoFavoritos('Road trip');
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('Road trip · 0'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('custom-station CTA opens the add-station flow', (tester) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
// The 3200px test surface already fits header + chips + 3 rows +
|
||||||
|
// footer, so no scroll is needed to reach the CTA.
|
||||||
|
await tester.tap(find.text('Add custom station'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.widgetWithText(TextFormField, 'Name *'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLargeSurface(WidgetTester tester) {
|
||||||
|
tester.view.physicalSize = const Size(1440, 3200);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user