import "package:flutter/widgets.dart"; import "package:flutter_lucide/flutter_lucide.dart"; import "package:flutter_test/flutter_test.dart"; import "package:garage_ui/garage_ui.dart"; import "support/test_scheme.dart"; Widget _host(Widget child) => Directionality( textDirection: TextDirection.ltr, // WidgetsLocalizations, which ReorderableList wants for its drag // semantics. A WidgetsApp brings them; this host isnt one, so it says // so itself rather than the widget quietly shadowing an app's locale. child: Localizations( locale: const Locale("en", "US"), delegates: const [DefaultWidgetsLocalizations.delegate], child: GarageTheme( data: ThemeData(colorScheme: testScheme, density: const Density()), // deliberately NO Material and no MaterialApp: garage's app shell is // a WidgetsApp, so anything material PropertiesList needs it has to // bring itself. The Overlay is the one thing a WidgetsApp really does // provide (through its Navigator), so the host provides it too. child: Overlay(initialEntries: [OverlayEntry(builder: (_) => child)]), ), ), ); PropertiesEntry _entry(String id, {PropertiesPin? pin, bool movable = true}) { return PropertiesEntry( id: id, pin: pin, movable: movable, child: Builder( builder: (context) => PropertiesSection( title: id, scheme: GarageTheme.of(context).colorScheme, collapsed: true, onToggle: () {}, rows: const [], ), ), ); } void main() { testWidgets("every section is movable by default", (tester) async { await tester.pumpWidget( _host( PropertiesList(entries: [_entry("a"), _entry("b")], onReorder: (_) {}), ), ); // one grab handle each - the default is that things move expect(find.byIcon(LucideIcons.grip_horizontal), findsNWidgets(2)); }); testWidgets("an unmovable section has no handle", (tester) async { await tester.pumpWidget( _host( PropertiesList( entries: [_entry("a"), _entry("b", movable: false)], onReorder: (_) {}, ), ), ); expect(find.byIcon(LucideIcons.grip_horizontal), findsOneWidget); }); testWidgets("a pinned section sits above the list, out of its reach", ( tester, ) async { await tester.pumpWidget( _host( PropertiesList( // handed over LAST, and it still comes out first entries: [ _entry("a"), _entry("b"), _entry("top", pin: PropertiesPin.top), ], onReorder: (_) {}, ), ), ); final top = tester.getTopLeft(find.text("top")).dy; expect(top, lessThan(tester.getTopLeft(find.text("a")).dy)); // two movable, so two handles - the pinned one has none expect(find.byIcon(LucideIcons.grip_horizontal), findsNWidgets(2)); // and it isnt IN the reorderable list at all, which is what stops the // list shifting it out of the way and then bouncing the drop back. expect( find.descendant( of: find.byType(ReorderableList), matching: find.text("top"), ), findsNothing, ); expect( find.descendant( of: find.byType(ReorderableList), matching: find.text("a"), ), findsOneWidget, ); }); testWidgets("with nothing pinned there is no stray list", (tester) async { await tester.pumpWidget( _host( PropertiesList(entries: [_entry("a"), _entry("b")], onReorder: (_) {}), ), ); expect(find.byType(ReorderableList), findsOneWidget); }); test("a section is not rebuilt just because its position moved", () { // the whole reason the index lives in a mutable slot. Notifying on it // rebuilt every section in the list each time the gap shifted mid-drag - // rows, text fields and all - which is what made dragging feel like it // was catching. final slot = PropertiesReorderSlot(0); const child = SizedBox(); final before = PropertiesReorderScope( slot: slot, draggable: true, child: child, ); slot.index = 3; final after = PropertiesReorderScope( slot: slot, draggable: true, child: child, ); expect(after.updateShouldNotify(before), isFalse); // but whether it can be dragged at all still is worth a rebuild - thats // the handle appearing or going. final locked = PropertiesReorderScope( slot: slot, draggable: false, child: child, ); expect(locked.updateShouldNotify(after), isTrue); }); testWidgets("a section on its own has no handle at all", (tester) async { await tester.pumpWidget( _host( Builder( builder: (context) => PropertiesSection( title: "alone", scheme: GarageTheme.of(context).colorScheme, collapsed: true, onToggle: () {}, rows: const [], ), ), ), ); expect(find.byIcon(LucideIcons.grip_horizontal), findsNothing); }); }