diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index 422a6db2..aa858f31 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -28,6 +28,7 @@ import 'package:elastic_dashboard/services/shuffleboard_nt_listener.dart'; import 'package:elastic_dashboard/services/update_checker.dart'; import 'package:elastic_dashboard/util/tab_data.dart'; import 'package:elastic_dashboard/widgets/custom_appbar.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/layout_drag_tile.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/models/widget_container_model.dart'; @@ -1765,6 +1766,8 @@ class _AddWidgetDialog extends StatefulWidget { class _AddWidgetDialogState extends State<_AddWidgetDialog> { bool _hideMetadata = true; + String _searchQuery = ''; + @override Widget build(BuildContext context) { return Visibility( @@ -1802,6 +1805,7 @@ class _AddWidgetDialogState extends State<_AddWidgetDialog> { NetworkTableTree( ntConnection: widget.ntConnection, preferences: widget.preferences, + searchQuery: _searchQuery, listLayoutBuilder: ( {required title, required children}) { return widget._grid().createListLayout( @@ -1859,7 +1863,18 @@ class _AddWidgetDialogState extends State<_AddWidgetDialog> { }, ); }), - const Spacer(), + Expanded( + child: SizedBox( + height: 40.0, + child: DialogTextInput( + onSubmit: (value) => + setState(() => _searchQuery = value), + initialText: _searchQuery, + allowEmptySubmission: true, + label: "Search", + ), + ), + ), TextButton( onPressed: () { widget._onClose?.call(); diff --git a/lib/widgets/dialog_widgets/dialog_text_input.dart b/lib/widgets/dialog_widgets/dialog_text_input.dart index ae8401c8..ef26fed8 100644 --- a/lib/widgets/dialog_widgets/dialog_text_input.dart +++ b/lib/widgets/dialog_widgets/dialog_text_input.dart @@ -7,6 +7,7 @@ class DialogTextInput extends StatefulWidget { final String? label; final String? initialText; final bool allowEmptySubmission; + final bool autoFocus; final bool enabled; final TextEditingController? textEditingController; @@ -20,6 +21,7 @@ class DialogTextInput extends StatefulWidget { this.enabled = true, this.formatter, this.textEditingController, + this.autoFocus = false, }); @override @@ -56,6 +58,7 @@ class _DialogTextInputState extends State { focused = value; }, child: TextField( + autofocus: widget.autoFocus, enabled: widget.enabled, onSubmitted: (value) { if (value.isNotEmpty || widget.allowEmptySubmission) { diff --git a/lib/widgets/network_tree/networktables_tree.dart b/lib/widgets/network_tree/networktables_tree.dart index ec16d970..653739c0 100644 --- a/lib/widgets/network_tree/networktables_tree.dart +++ b/lib/widgets/network_tree/networktables_tree.dart @@ -26,7 +26,7 @@ class NetworkTableTree extends StatefulWidget { final Function(Offset globalPosition, WidgetContainerModel widget)? onDragUpdate; final Function(WidgetContainerModel widget)? onDragEnd; - + final String searchQuery; final bool hideMetadata; const NetworkTableTree({ @@ -37,6 +37,7 @@ class NetworkTableTree extends StatefulWidget { required this.hideMetadata, this.onDragUpdate, this.onDragEnd, + this.searchQuery = "", }); @override @@ -65,22 +66,55 @@ class _NetworkTableTreeState extends State { treeController = TreeController( roots: root.children, childrenProvider: (node) { - if (widget.hideMetadata) { - return node.children - .whereNot((element) => element.rowName.startsWith('.')); + List nodes = node.children; + + // Apply the filter to the children + List filteredChildren = _filterChildren(nodes); + + // If there are any filtered children, include the parent node + if (filteredChildren.isNotEmpty || _matchesFilter(node)) { + if (widget.hideMetadata) { + return filteredChildren + .whereNot((element) => element.rowName.startsWith('.')) + .toList(); + } else { + return filteredChildren; + } } else { - return node.children; + return []; } }, ); widget.ntConnection.addTopicAnnounceListener(onNewTopicAnnounced = (topic) { setState(() { + treeController.roots = _filterChildren(root.children); treeController.rebuild(); }); }); } + List _filterChildren( + List children) { + // Apply the filter to each child + return children.where((child) { + if (_matchesFilter(child)) { + return true; + } + // Recursively check if any descendant matches the filter + return _filterChildren(child.children).isNotEmpty; + }).toList(); + } + + bool _matchesFilter(NetworkTableTreeRow node) { + // Don't filter if there isn't a search + if (widget.searchQuery.isEmpty) { + return true; + } + // Check if the node matches the filter + return node.topic.toLowerCase().contains(widget.searchQuery.toLowerCase()); + } + @override void dispose() { widget.ntConnection.removeTopicAnnounceListener(onNewTopicAnnounced); @@ -90,7 +124,9 @@ class _NetworkTableTreeState extends State { @override void didUpdateWidget(NetworkTableTree oldWidget) { - if (widget.hideMetadata != oldWidget.hideMetadata) { + if (widget.hideMetadata != oldWidget.hideMetadata || + widget.searchQuery != oldWidget.searchQuery) { + treeController.roots = _filterChildren(root.children); treeController.rebuild(); } super.didUpdateWidget(oldWidget); @@ -148,6 +184,8 @@ class _NetworkTableTreeState extends State { root.sort(); + treeController.roots = _filterChildren(root.children); + return TreeView( treeController: treeController, nodeBuilder: @@ -198,77 +236,80 @@ class TreeTile extends StatelessWidget { Widget build(BuildContext context) { TextStyle trailingStyle = Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.grey); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - child: GestureDetector( - supportedDevices: PointerDeviceKind.values - .whereNot((element) => element == PointerDeviceKind.trackpad) - .toSet(), - onPanStart: (details) async { - if (draggingWidget != null) { - return; - } - - draggingWidget = await entry.node - .toWidgetContainerModel(listLayoutBuilder: listLayoutBuilder); - }, - onPanUpdate: (details) { - if (draggingWidget == null) { - return; - } - - draggingWidget!.cursorGlobalLocation = details.globalPosition; - - Offset position = details.globalPosition - - Offset( - draggingWidget!.displayRect.width, - draggingWidget!.displayRect.height, - ) / - 2; - - onDragUpdate?.call(position, draggingWidget!); - }, - onPanEnd: (details) { - if (draggingWidget == null) { - return; - } - - onDragEnd?.call(draggingWidget!); - - draggingWidget = null; - }, - child: Padding( - padding: EdgeInsetsDirectional.only(start: entry.level * 16.0), - child: Column( - children: [ - ListTile( - dense: true, - contentPadding: const EdgeInsets.only(right: 20.0), - leading: - (entry.hasChildren || entry.node.containsOnlyMetadata()) - ? FolderButton( - openedIcon: const Icon(Icons.arrow_drop_down), - closedIcon: const Icon(Icons.arrow_right), - iconSize: 24, - isOpen: entry.hasChildren && entry.isExpanded, - onPressed: entry.hasChildren ? onTap : null, - ) - : const SizedBox(width: 8.0), - title: Text(entry.node.rowName), - trailing: (entry.node.ntTopic != null) - ? Text(entry.node.ntTopic!.type, style: trailingStyle) - : null, - ), - ], + // I have absolutely no idea why Material is needed, but otherwise the tiles start bleeding all over the place, it makes zero sense + return Material( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: GestureDetector( + supportedDevices: PointerDeviceKind.values + .whereNot((element) => element == PointerDeviceKind.trackpad) + .toSet(), + onPanStart: (details) async { + if (draggingWidget != null) { + return; + } + + draggingWidget = await entry.node.toWidgetContainerModel( + listLayoutBuilder: listLayoutBuilder); + }, + onPanUpdate: (details) { + if (draggingWidget == null) { + return; + } + + draggingWidget!.cursorGlobalLocation = details.globalPosition; + + Offset position = details.globalPosition - + Offset( + draggingWidget!.displayRect.width, + draggingWidget!.displayRect.height, + ) / + 2; + + onDragUpdate?.call(position, draggingWidget!); + }, + onPanEnd: (details) { + if (draggingWidget == null) { + return; + } + + onDragEnd?.call(draggingWidget!); + + draggingWidget = null; + }, + child: Padding( + padding: EdgeInsetsDirectional.only(start: entry.level * 16.0), + child: Column( + children: [ + ListTile( + dense: true, + contentPadding: const EdgeInsets.only(right: 20.0), + leading: (entry.hasChildren || + entry.node.containsOnlyMetadata()) + ? FolderButton( + openedIcon: const Icon(Icons.arrow_drop_down), + closedIcon: const Icon(Icons.arrow_right), + iconSize: 24, + isOpen: entry.hasChildren && entry.isExpanded, + onPressed: entry.hasChildren ? onTap : null, + ) + : const SizedBox(width: 8.0), + title: Text(entry.node.rowName), + trailing: (entry.node.ntTopic != null) + ? Text(entry.node.ntTopic!.type, style: trailingStyle) + : null, + ), + ], + ), ), ), ), - ), - const Divider(height: 0), - ], + const Divider(height: 0), + ], + ), ); } } diff --git a/test/pages/dashboard_page_test.dart b/test/pages/dashboard_page_test.dart index 3f465c4e..4e7d2947 100644 --- a/test/pages/dashboard_page_test.dart +++ b/test/pages/dashboard_page_test.dart @@ -163,6 +163,102 @@ void main() { expect(jsonString, preferences.getString(PrefKeys.layout)); }); + testWidgets('Add widget dialog search', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + + // widgetTester.tap() doesn't work :shrug: + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; + + addWidgetButton.onPressed?.call(); + + await widgetTester.pumpAndSettle(); + + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + + final smartDashboardTile = find.widgetWithText(TreeTile, 'SmartDashboard'); + + expect(smartDashboardTile, findsOneWidget); + + await widgetTester.tap(smartDashboardTile); + await widgetTester.pumpAndSettle(); + + final searchQuery = find.widgetWithText(DialogTextInput, 'Search'); + expect(searchQuery, findsOneWidget); + + final testValueOne = find.widgetWithText(TreeTile, 'Test Value 1'); + final testValueTwo = find.widgetWithText(TreeTile, 'Test Value 2'); + + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); + + // Both match + await widgetTester.enterText(searchQuery, 'Test Value'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + + await widgetTester.pumpAndSettle(); + + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); + + // One match + await widgetTester.enterText(searchQuery, 'Test Value 1'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + + await widgetTester.pumpAndSettle(); + + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsNothing); + expect(smartDashboardTile, findsOneWidget); + + // No matches + await widgetTester.enterText(searchQuery, 'no match'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + + await widgetTester.pumpAndSettle(); + + expect(testValueOne, findsNothing); + expect(testValueTwo, findsNothing); + expect(smartDashboardTile, findsNothing); + + // Match only smart dashboard tile (all should show) + await widgetTester.enterText(searchQuery, 'Smart'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + + await widgetTester.pumpAndSettle(); + + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); + expect(smartDashboardTile, findsOneWidget); + + // Empty text (both should be visible) + await widgetTester.enterText(searchQuery, ''); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + + await widgetTester.pumpAndSettle(); + + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); + expect(smartDashboardTile, findsOneWidget); + }); + testWidgets('Add widget dialog (widgets)', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; createMockOnlineNT4();