diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index dfc374e..e0acc0f 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -164,6 +164,7 @@ class EstadoRadio extends ChangeNotifier { final _memoPopulares = MemoLista(); final _memoTendencias = MemoLista(); final _memoFavoritos = MemoLista(); + final _memoFavoritosManual = MemoLista(); final _memoGrupos = MemoLista(); final _memoCustom = MemoLista(); final _memoInicio = MemoLista(); @@ -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 get listaFavoritosManual => _memoFavoritosManual.obtener([ + _listaFavoritos, + ], () => List.unmodifiable(_listaFavoritos)); List get gruposFavoritos => _memoGrupos.obtener([ _gruposFavoritos, ], () => List.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 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 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 cambiarEmisoraPreferida(Emisora? emisora) async { _emisoraPreferidaUuid = emisora?.uuid; final prefs = await _resolverPrefs(); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a97ee57..2a18b00 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 011593b..d9e2fcc 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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.", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 4800560..4e68b24 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -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: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index ed03cdd..3feadb0 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -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 => 'تم تأجيل المنبه لهذا التشغيل.'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 2d5f303..ebf602e 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -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 => 'এই চালনার জন্য অ্যালার্ম পিছিয়ে দেওয়া হয়েছে।'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 81d31ab..94cb468 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -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.'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 46c72ef..91f4db6 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -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.'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 5a3fc6a..99de31a 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -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.'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 7731edb..a05701f 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -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.'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 1c4cdce..b30073c 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -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 => 'इस चाल के लिए अलार्म स्थगित किया गया।'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index f2dea3a..f73b288 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -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.'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 88474da..6318d77 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -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.'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index ff9f164..ce6c526 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -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 => 'この実行のアラームを延期しました。'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index beaf3d6..35c914c 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -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.'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index fe4580a..e92b817 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -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 => 'Будильник отложен для этого запуска.'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index d74ff6d..8e24189 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -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 => '本次闹钟已推迟。'; diff --git a/lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart b/lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart index fcb5beb..6b1b841 100644 --- a/lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart +++ b/lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart @@ -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 createState() => + FormularioEmisoraPersonalizadaState(); } -class _FormularioEmisoraState extends State<_FormularioEmisora> { +class FormularioEmisoraPersonalizadaState + extends State { final _formKey = GlobalKey(); final _nombreCtrl = TextEditingController(); final _urlCtrl = TextEditingController(); diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index d2e12a9..ec3feb6 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -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 createState() => _PantallaFavoritosState(); +} + +class _PantallaFavoritosState extends State { + /// Ephemeral UI state only (design's "State is for ephemeral UI only" + /// ruling) — null means the "All" chip is active. + String? _grupoSeleccionadoId; + + Future _abrirFormularioEmisoraPersonalizada() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (ctx) => const FormularioEmisoraPersonalizada(), + ); + } + + void _abrirGestionDeListas() { + PluriPushScaffold.push( + context, + (_) => const PantallaAjustesGruposFavoritos(), + ); + } + + Future _elegirOrden(OrdenEmisoras criterio) => + context.read().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 filtrados, + List favoritos, + int oldIndex, + int newIndex, + ) { + final movido = filtrados[oldIndex]; + final restantes = List.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().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>( - (e) => e.listaFavoritos, + (e) => e.listaFavoritosManual, ); final grupos = context.select>( (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( + 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 grupos; - final List emisoras; + final List favoritos; + final String? seleccionado; + final ValueChanged 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 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; +} diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index f7198e3..08109ce 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -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 diff --git a/test/pantallas/pantalla_favoritos_test.dart b/test/pantallas/pantalla_favoritos_test.dart new file mode 100644 index 0000000..86d8d8b --- /dev/null +++ b/test/pantallas/pantalla_favoritos_test.dart @@ -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 archivoCustomVacio() async => File( + '${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json', + ); + + Future 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 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.value( + value: estado, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: PantallaFavoritos()), + ), + ); + } + + Future 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( + 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); +}