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
@@ -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;
}