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:
2026-07-28 23:49:06 +02:00
parent ebdde7df01
commit 504a13641f
21 changed files with 1011 additions and 126 deletions
+36
View File
@@ -164,6 +164,7 @@ class EstadoRadio extends ChangeNotifier {
final _memoPopulares = MemoLista<Emisora>();
final _memoTendencias = MemoLista<Emisora>();
final _memoFavoritos = MemoLista<Emisora>();
final _memoFavoritosManual = MemoLista<Emisora>();
final _memoGrupos = MemoLista<GrupoFavoritos>();
final _memoCustom = MemoLista<Emisora>();
final _memoInicio = MemoLista<Emisora>();
@@ -200,6 +201,17 @@ class EstadoRadio extends ChangeNotifier {
_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([
_gruposFavoritos,
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
@@ -371,6 +383,30 @@ class EstadoRadio extends ChangeNotifier {
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 {
_emisoraPreferidaUuid = emisora?.uuid;
final prefs = await _resolverPrefs();
+12
View File
@@ -282,6 +282,18 @@
"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.",
"searchScreenTitle": "Search signal",
"searchScreenSubtitle": "Find stations by name, country, or language with fast filters and high contrast.",
+12
View File
@@ -282,6 +282,18 @@
"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.",
"searchScreenTitle": "Buscar señal",
"searchScreenSubtitle": "Encontrá radios por nombre, país o idioma con filtros rápidos y alto contraste.",
+24
View File
@@ -1036,6 +1036,30 @@ abstract class AppLocalizations {
/// **'{stationName} eliminada de favoritos'**
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.
///
/// In es, this message translates to:
+14
View File
@@ -533,6 +533,20 @@ class AppLocalizationsAr extends AppLocalizations {
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
String get alarmPostponedCurrentExecution => 'تم تأجيل المنبه لهذا التشغيل.';
+14
View File
@@ -536,6 +536,20 @@ class AppLocalizationsBn extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'এই চালনার জন্য অ্যালার্ম পিছিয়ে দেওয়া হয়েছে।';
+14
View File
@@ -539,6 +539,20 @@ class AppLocalizationsDe extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'Alarm für diese Ausführung verschoben.';
+14
View File
@@ -533,6 +533,20 @@ class AppLocalizationsEn extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'Alarm postponed for this occurrence.';
+14
View File
@@ -537,6 +537,20 @@ class AppLocalizationsEs extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'Alarma pospuesta para esta ejecución.';
+14
View File
@@ -541,6 +541,20 @@ class AppLocalizationsFr extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'Alarme reportée pour cette exécution.';
+14
View File
@@ -534,6 +534,20 @@ class AppLocalizationsHi extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'इस चाल के लिए अलार्म स्थगित किया गया।';
+14
View File
@@ -535,6 +535,20 @@ class AppLocalizationsId extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'Alarm ditunda untuk eksekusi ini.';
+14
View File
@@ -537,6 +537,20 @@ class AppLocalizationsIt extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'Sveglia posticipata per questa esecuzione.';
+14
View File
@@ -518,6 +518,20 @@ class AppLocalizationsJa extends AppLocalizations {
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
String get alarmPostponedCurrentExecution => 'この実行のアラームを延期しました。';
+14
View File
@@ -536,6 +536,20 @@ class AppLocalizationsPt extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'Alarme adiado para esta execução.';
+14
View File
@@ -537,6 +537,20 @@ class AppLocalizationsRu extends AppLocalizations {
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
String get alarmPostponedCurrentExecution =>
'Будильник отложен для этого запуска.';
+14
View File
@@ -516,6 +516,20 @@ class AppLocalizationsZh extends AppLocalizations {
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
String get alarmPostponedCurrentExecution => '本次闹钟已推迟。';
@@ -11,7 +11,7 @@ import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// 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
/// removed (the pushed screen's title now carries them); the "Add" action,
/// being a real capability rather than decorative chrome, stays in the body,
@@ -110,19 +110,21 @@ class _CuerpoEmisorasPersonalizadas extends StatelessWidget {
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (ctx) => const _FormularioEmisora(),
builder: (ctx) => const FormularioEmisoraPersonalizada(),
);
}
}
class _FormularioEmisora extends StatefulWidget {
const _FormularioEmisora();
class FormularioEmisoraPersonalizada extends StatefulWidget {
const FormularioEmisoraPersonalizada({super.key});
@override
State<_FormularioEmisora> createState() => _FormularioEmisoraState();
State<FormularioEmisoraPersonalizada> createState() =>
FormularioEmisoraPersonalizadaState();
}
class _FormularioEmisoraState extends State<_FormularioEmisora> {
class FormularioEmisoraPersonalizadaState
extends State<FormularioEmisoraPersonalizada> {
final _formKey = GlobalKey<FormState>();
final _nombreCtrl = TextEditingController();
final _urlCtrl = TextEditingController();
+329 -108
View File
@@ -6,24 +6,91 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.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';
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});
@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
Widget build(BuildContext context) {
// S4-R5: no root watch — select only the fields this screen reads. The
// getters are identity-memoized, so playback notifications that do not
// change favorites/groups no longer rebuild the screen.
final favoritos = context.select<EstadoRadio, List<Emisora>>(
(e) => e.listaFavoritos,
(e) => e.listaFavoritosManual,
);
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
(e) => e.gruposFavoritos,
@@ -51,6 +118,12 @@ class PantallaFavoritos extends StatelessWidget {
subtitle: l10n.favoritesEmptySubtitle,
),
),
Padding(
padding: PluriLayout.pageContentPadding,
child: _CtaEmisoraPersonalizada(
onTap: _abrirFormularioEmisoraPersonalizada,
),
),
],
);
}
@@ -67,57 +140,117 @@ class PantallaFavoritos extends StatelessWidget {
]
: grupos;
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: PluriScreenHeader(
title: l10n.favoritesTitle,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.library_music_rounded,
label: l10n.favoritesSavedCount(favoritos.length),
// Defensive: a group selected before it was deleted elsewhere (e.g. via
// the pushed management screen) falls back to "All" instead of showing
// an empty list with no chip highlighted.
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,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.library_music_rounded,
label: l10n.favoritesSavedCount(favoritos.length),
),
),
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,
),
),
),
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),
],
]),
),
),
],
);
}
}
class _GrupoFavoritosPanel extends StatelessWidget {
const _GrupoFavoritosPanel({
required this.grupo,
class _FilaChipsGrupos extends StatelessWidget {
const _FilaChipsGrupos({
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<Emisora> emisoras;
final List<Emisora> favoritos;
final String? seleccionado;
final ValueChanged<String?> onSeleccionar;
final VoidCallback onGestionar;
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
@@ -125,61 +258,61 @@ class _GrupoFavoritosPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return PluriGlassSurface(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
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,
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(
l10n.favoriteGroupsChipLabel(
l10n.favoritesFilterAllLabel,
favoritos.length,
),
),
// S5-R5: proper plural message, not a bare number.
Text(l10n.stationCount(emisoras.length)),
],
selected: seleccionado == null,
onSelected: (_) => onSeleccionar(null),
),
),
const SizedBox(height: 8),
if (emisoras.isEmpty)
for (final grupo in grupos)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
l10n.favoritesEmptyTitle,
style: theme.textTheme.bodySmall,
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),
),
)
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),
],
),
ActionChip(
avatar: const Icon(Icons.add_rounded, size: 18),
label: Text(l10n.favoriteGroupsManage),
onPressed: onGestionar,
),
],
),
);
}
}
class _FavoritoItem extends StatelessWidget {
const _FavoritoItem({
class _FilaFavorito extends StatelessWidget {
const _FilaFavorito({
super.key,
required this.index,
required this.emisora,
required this.grupos,
required this.grupoActual,
});
final int index;
final Emisora emisora;
final List<GrupoFavoritos> grupos;
final GrupoFavoritos grupoActual;
@@ -253,35 +386,123 @@ class _FavoritoItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Row(
children: [
Expanded(
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => reproducirMinimizado(context, emisora),
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.drag_handle_rounded),
),
),
),
const SizedBox(width: 6),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.favoriteGroupsAssignSubtitle(
_nombreVisible(l10n, grupoActual),
Expanded(
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => reproducirMinimizado(context, emisora),
),
),
const SizedBox(width: 6),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.favoriteGroupsAssignSubtitle(
_nombreVisible(l10n, grupoActual),
),
icon: const Icon(Icons.drive_file_move_rounded),
onPressed: () => _asignar(context),
),
icon: const Icon(Icons.drive_file_move_rounded),
onPressed: () => _asignar(context),
),
IconButton.filledTonal(
tooltip: l10n.favoritesRemoveTooltip,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminar(context),
),
],
),
],
IconButton.filledTonal(
tooltip: l10n.favoritesRemoveTooltip,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminar(context),
),
],
),
],
),
);
}
}
/// 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;
}
+54 -12
View File
@@ -284,19 +284,61 @@ both kept it below forecast). Same "move-only diff" justification as WU3a.
**Depends on**: WU1
**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
**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')`
**Modified tests**: `test/pantallas/pantalla_favoritos_plural_test.dart` (+ new reorder scenario)
**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')`
**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
persists across a simulated restart; `swap_vert` applies `OrdenEmisoras`; create-group action reachable;
custom-station CTA reachable.
- [ ] 4.2 GREEN — replace `_GrupoFavoritosPanel` / `_FavoritoItem` with a horizontally-scrollable chip row + a single
`ReorderableListView` filtered by the active chip.
- [ ] 4.3 GREEN — wire persistence for the new order and the `swap_vert` action to `OrdenEmisoras`.
- [ ] 4.4 GREEN — surface the create-group action; keep the dashed custom-station CTA.
- [ ] 4.5 REFACTOR — extract the chip row / reorderable row into private widgets if the file grows unwieldy; confirm
push-chrome is **not** applied here (Favoritos keeps its tab bar — the one exemption).
- [ ] 4.6 Verify — reorder-persists scenario green after simulated restart; sort scenario green.
- [x] 4.1 RED — **corrected at apply time**: `pantalla_favoritos_plural_test.dart` (the file this task originally
named) never imports `PantallaFavoritos` — it only exercises `stationCount`'s ARB plural formatting via
`AppLocalizations` directly, with no widget-level coverage of this screen at all. Left it untouched (still a
valid, unrelated regression guard) and created **`test/pantallas/pantalla_favoritos_test.dart`** instead, with:
3 state-layer tests for the new `EstadoRadio` surface (`listaFavoritosManual`, `reordenarFavorito`,
`ordenarFavoritos` — see 4.3's note) plus 6 widget-level scenarios (empty-state CTA, chip filter narrows the
list, drag-reorder persists across a simulated restart, `swap_vert` sort, create-group action + new-group-chip
reactivity, custom-station CTA).
- [x] 4.2 GREEN — replaced `_GrupoFavoritosPanel` with `_FilaChipsGrupos` (horizontally-scrollable
`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
+354
View File
@@ -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);
}