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
+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);
}