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:
@@ -0,0 +1,354 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// WU4, `favorites-organization` spec: chip-filtered flat list replacing the
|
||||
/// stacked per-group panels, drag-to-reorder, the `swap_vert` sort action,
|
||||
/// group management, and the custom-station CTA — all reachable from
|
||||
/// Favoritos.
|
||||
///
|
||||
/// This screen previously had NO widget-level test coverage —
|
||||
/// `pantalla_favoritos_plural_test.dart` only exercises `stationCount`'s ARB
|
||||
/// plural formatting via `AppLocalizations` directly and never imports
|
||||
/// `PantallaFavoritos`. That file is left untouched (it stays a valid,
|
||||
/// unrelated regression guard); this new file covers the screen itself.
|
||||
///
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
/// Flutter flags as a warning-level assertion, not a correctness bug.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<File> archivoCustomVacio() async => File(
|
||||
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
|
||||
);
|
||||
|
||||
Future<EstadoRadio> crearEstadoVacio() async {
|
||||
return EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Seeds 3 favorites (A, B unassigned won't apply — A is unassigned; B and
|
||||
/// C are in a "Rock" group) added in A, B, C order (so the natural/manual
|
||||
/// order is A, B, C).
|
||||
Future<EstadoRadio> crearEstadoConFavoritos() async {
|
||||
final estado = await crearEstadoVacio();
|
||||
final favoritos = estado.favoritos as FakeServicioFavoritos;
|
||||
final rock = await favoritos.crearGrupo('Rock');
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'Station A'));
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'b', nombre: 'Station B'));
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'c', nombre: 'Station C'));
|
||||
await favoritos.asignarGrupo('b', rock.id);
|
||||
await favoritos.asignarGrupo('c', rock.id);
|
||||
await estado.cargarFavoritos();
|
||||
await estado.cargarGruposFavoritos();
|
||||
return estado;
|
||||
}
|
||||
|
||||
Widget buildScreen(EstadoRadio estado) {
|
||||
// Wrapped in a bare Scaffold, matching pantalla_ajustes_test.dart's
|
||||
// convention: in the real app, _PaginaPrincipal's own Scaffold is what
|
||||
// gives root screens (which construct zero Scaffold themselves, per
|
||||
// ADR-2) a Material ancestor. Without it, Material components like
|
||||
// ChoiceChip/PopupMenuButton/ActionChip fail to find one.
|
||||
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: PantallaFavoritos()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpStable(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// ── EstadoRadio: new state surface (state-layer, not widget-layer) ───────
|
||||
|
||||
test('listaFavoritosManual returns favorites in stored order, NOT re-sorted '
|
||||
'by the global ordenListas setting (unlike listaFavoritos)', () async {
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
// Natural insertion order is A, B, C; ordenListas defaults to calidad,
|
||||
// which would NOT necessarily preserve that order if applied.
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
test('reordenarFavorito persists the new manual order', () async {
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.reordenarFavorito('c', 0);
|
||||
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
// Persists across a simulated restart: reload from the (fake) service.
|
||||
await estado.cargarFavoritos();
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ordenarFavoritos applies an existing OrdenEmisoras criterion and '
|
||||
'persists the result as the new manual order', () async {
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
// Put them out of alphabetical order first.
|
||||
await estado.reordenarFavorito('c', 0);
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
|
||||
await estado.ordenarFavoritos(OrdenEmisoras.nombre);
|
||||
|
||||
expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [
|
||||
'Station A',
|
||||
'Station B',
|
||||
'Station C',
|
||||
]);
|
||||
// Persists: a simulated restart still shows the sorted order, not the
|
||||
// pre-sort manual order.
|
||||
await estado.cargarFavoritos();
|
||||
expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [
|
||||
'Station A',
|
||||
'Station B',
|
||||
'Station C',
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Widget-level: PantallaFavoritos ───────────────────────────────────────
|
||||
|
||||
testWidgets(
|
||||
'shows the empty state with the custom-station CTA when there are no '
|
||||
'favorites',
|
||||
(tester) async {
|
||||
final estado = await crearEstadoVacio();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.cargarFavoritos();
|
||||
await estado.cargarGruposFavoritos();
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('No favorites yet'), findsOneWidget);
|
||||
expect(find.text('Add custom station'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'chip filter narrows the list to the selected group ("All · N" plus '
|
||||
'one chip per group)',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('All · 3'), findsOneWidget);
|
||||
expect(find.text('Unassigned · 1'), findsOneWidget);
|
||||
expect(find.text('Rock · 2'), findsOneWidget);
|
||||
expect(find.text('Station A'), findsOneWidget);
|
||||
expect(find.text('Station B'), findsOneWidget);
|
||||
expect(find.text('Station C'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Rock · 2'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('Station A'), findsNothing);
|
||||
expect(find.text('Station B'), findsOneWidget);
|
||||
expect(find.text('Station C'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('dragging the 3rd item to the 1st position persists across a '
|
||||
'simulated restart', (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
final lista = tester.widget<ReorderableListView>(
|
||||
find.byType(ReorderableListView),
|
||||
);
|
||||
// Drag the 3rd row (index 2, "Station C") to the 1st position (index
|
||||
// 0) — exercised via the real onReorderItem callback the widget wires
|
||||
// up. onReorderItem (not the deprecated onReorder) already adjusts
|
||||
// newIndex for the removed item, so no manual index math here.
|
||||
lista.onReorderItem!(2, 0);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
|
||||
// Persists across a simulated restart.
|
||||
await estado.cargarFavoritos();
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'swap_vert sort action applies OrdenEmisoras.nombre and re-renders '
|
||||
'alphabetically',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.reordenarFavorito('c', 0); // out of alphabetical order
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.swap_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('By name'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [
|
||||
'Station A',
|
||||
'Station B',
|
||||
'Station C',
|
||||
]);
|
||||
// The list re-renders in the new order too, not just the state.
|
||||
expect(
|
||||
tester.getCenter(find.text('Station A')).dy <
|
||||
tester.getCenter(find.text('Station B')).dy,
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'the create-group action opens group management, and a newly created '
|
||||
'group appears as a new filter chip',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.text('Manage lists'));
|
||||
await pumpStable(tester);
|
||||
|
||||
// "Group Management Reachable from Favoritos": the SAME screen
|
||||
// Settings uses, reused rather than duplicated.
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.byType(PantallaAjustesGruposFavoritos), findsOneWidget);
|
||||
|
||||
// Deliberately does NOT interact with _editarGrupo's create-group
|
||||
// bottom sheet here: it has a pre-existing, out-of-scope
|
||||
// TextEditingController dispose-race (Engram
|
||||
// sdd/rediseno-funcional/controller-dispose-bugfix, tracked
|
||||
// separately, not to be fixed in this WU) already independently
|
||||
// exercised by pantalla_ajustes_grupos_favoritos_test.dart. Popping
|
||||
// back to Favoritos and calling the same EstadoRadio method that
|
||||
// form calls proves the part that's actually NEW here — the chip
|
||||
// row's reactivity to a newly created group — without depending on
|
||||
// that unrelated bug's timing.
|
||||
//
|
||||
// PluriPushScaffold uses a plain IconButton for its back affordance,
|
||||
// not a semantic BackButtonIcon/CupertinoNavigationBarBackButton —
|
||||
// tester.pageBack() looks for those and finds neither.
|
||||
await tester.tap(find.byIcon(Icons.arrow_back_rounded));
|
||||
// pumpAndSettle, not pumpStable: the pop transition's default
|
||||
// animation needs more than a bounded 100ms pump to fully finish.
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAjustesGruposFavoritos), findsNothing);
|
||||
expect(find.text('Manage lists'), findsOneWidget);
|
||||
|
||||
await estado.crearGrupoFavoritos('Road trip');
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('Road trip · 0'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('custom-station CTA opens the add-station flow', (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
// The 3200px test surface already fits header + chips + 3 rows +
|
||||
// footer, so no scroll is needed to reach the CTA.
|
||||
await tester.tap(find.text('Add custom station'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(TextFormField, 'Name *'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
void setLargeSurface(WidgetTester tester) {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
}
|
||||
Reference in New Issue
Block a user