diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index 0add68ad..52a106bc 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -8,8 +8,10 @@ import 'package:elastic_dashboard/services/shuffleboard_nt_listener.dart'; import 'package:elastic_dashboard/services/update_checker.dart'; import 'package:elastic_dashboard/widgets/custom_appbar.dart'; import 'package:elastic_dashboard/widgets/dashboard_grid.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/layout_drag_tile.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_layout_container.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_nt4_widget_container.dart'; import 'package:elastic_dashboard/widgets/draggable_dialog.dart'; -import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; import 'package:elastic_dashboard/widgets/editable_tab_bar.dart'; import 'package:elastic_dashboard/widgets/network_tree/network_table_tree.dart'; import 'package:elastic_dashboard/widgets/settings_dialog.dart'; @@ -72,8 +74,14 @@ class _DashboardPageState extends State with WindowListener { ]); grids.addAll([ - DashboardGrid(key: GlobalKey(), jsonData: const {}), - DashboardGrid(key: GlobalKey(), jsonData: const {}), + DashboardGrid( + key: GlobalKey(), + onAddWidgetPressed: displayAddWidgetDialog, + ), + DashboardGrid( + key: GlobalKey(), + onAddWidgetPressed: displayAddWidgetDialog, + ), ]); } @@ -131,7 +139,10 @@ class _DashboardPageState extends State with WindowListener { if (!tabNamesList.contains(tabName)) { tabData.add(TabData(name: tabName)); - grids.add(DashboardGrid(key: GlobalKey(), jsonData: const {})); + grids.add(DashboardGrid( + key: GlobalKey(), + onAddWidgetPressed: displayAddWidgetDialog, + )); tabNamesList.add(tabName); } @@ -897,13 +908,21 @@ class _DashboardPageState extends State with WindowListener { tabViews: grids, ), AddWidgetDialog( + grid: grids[currentTabIndex], visible: addWidgetDialogVisible, - onDragUpdate: (globalPosition, widget) { + onNT4DragUpdate: (globalPosition, widget) { grids[currentTabIndex] - .addDragInWidget(widget, globalPosition); + .addNT4DragInWidget(widget, globalPosition); }, - onDragEnd: (widget) { - grids[currentTabIndex].placeDragInWidget(widget); + onNT4DragEnd: (widget) { + grids[currentTabIndex].placeNT4DragInWidget(widget); + }, + onLayoutDragUpdate: (globalPosition, widget) { + grids[currentTabIndex] + .addLayoutDragInWidget(widget, globalPosition); + }, + onLayoutDragEnd: (widget) { + grids[currentTabIndex].placeLayoutDragInWidget(widget); }, onClose: () { setState(() => addWidgetDialogVisible = false); @@ -956,17 +975,27 @@ class _DashboardPageState extends State with WindowListener { } class AddWidgetDialog extends StatelessWidget { + final DashboardGrid grid; final bool visible; - final Function(Offset globalPosition, WidgetContainer widget)? onDragUpdate; - final Function(WidgetContainer widget)? onDragEnd; + + final Function(Offset globalPosition, DraggableNT4WidgetContainer widget)? + onNT4DragUpdate; + final Function(DraggableNT4WidgetContainer widget)? onNT4DragEnd; + + final Function(Offset globalPosition, DraggableLayoutContainer widget)? + onLayoutDragUpdate; + final Function(DraggableLayoutContainer widget)? onLayoutDragEnd; final Function()? onClose; const AddWidgetDialog({ super.key, + required this.grid, required this.visible, - this.onDragUpdate, - this.onDragEnd, + this.onNT4DragUpdate, + this.onNT4DragEnd, + this.onLayoutDragUpdate, + this.onLayoutDragEnd, this.onClose, }); @@ -986,31 +1015,55 @@ class AddWidgetDialog extends StatelessWidget { ]), child: Card( margin: const EdgeInsets.all(10.0), - child: Column( - children: [ - const Icon(Icons.drag_handle, color: Colors.grey), - const SizedBox(height: 10), - Text('Add Widget', - style: Theme.of(context).textTheme.titleMedium), - const Divider(), - Expanded( - child: NetworkTableTree( - onDragUpdate: onDragUpdate, - onDragEnd: onDragEnd, + child: DefaultTabController( + length: 2, + child: Column( + children: [ + const Icon(Icons.drag_handle, color: Colors.grey), + const SizedBox(height: 10), + Text('Add Widget', + style: Theme.of(context).textTheme.titleMedium), + const TabBar( + tabs: [ + Tab(text: 'Network Tables'), + Tab(text: 'Layouts'), + ], ), - ), - Row( - children: [ - const Spacer(), - TextButton( - onPressed: () { - onClose?.call(); - }, - child: const Text('Close'), + const SizedBox(height: 5), + Expanded( + child: TabBarView( + children: [ + NetworkTableTree( + onDragUpdate: onNT4DragUpdate, + onDragEnd: onNT4DragEnd, + widgetContainerBuilder: grid.createNT4WidgetContainer, + ), + ListView( + children: [ + LayoutDragTile( + title: 'List Layout', + layoutBuilder: () => grid.createListLayout(), + onDragUpdate: onLayoutDragUpdate, + onDragEnd: onLayoutDragEnd, + ), + ], + ), + ], ), - ], - ), - ], + ), + Row( + children: [ + const Spacer(), + TextButton( + onPressed: () { + onClose?.call(); + }, + child: const Text('Close'), + ), + ], + ), + ], + ), ), ), ), diff --git a/lib/services/nt4_connection.dart b/lib/services/nt4_connection.dart index 8be5651c..3ca06a77 100644 --- a/lib/services/nt4_connection.dart +++ b/lib/services/nt4_connection.dart @@ -68,7 +68,9 @@ class NT4Connection { onDisconnectedListeners.add(callback); } - Future? subscribeAndRetrieveData(String topic, [period = 0.1]) async { + Future? subscribeAndRetrieveData(String topic, + {period = 0.1, + timeout = const Duration(seconds: 2, milliseconds: 500)}) async { NT4Subscription subscription = subscribe(topic, period); T? value; @@ -76,7 +78,7 @@ class NT4Connection { value = await subscription .periodicStream() .firstWhere((element) => element != null && element is T) - .timeout(const Duration(seconds: 2, milliseconds: 500)) as T?; + .timeout(timeout) as T?; } catch (e) { value = null; } diff --git a/lib/services/shuffleboard_nt_listener.dart b/lib/services/shuffleboard_nt_listener.dart index fadab08a..dfe05b2b 100644 --- a/lib/services/shuffleboard_nt_listener.dart +++ b/lib/services/shuffleboard_nt_listener.dart @@ -1,6 +1,8 @@ +import 'package:dot_cast/dot_cast.dart'; import 'package:elastic_dashboard/services/globals.dart'; import 'package:elastic_dashboard/services/nt4.dart'; import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; import 'package:elastic_dashboard/widgets/network_tree/tree_row.dart'; import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; @@ -48,99 +50,207 @@ class ShuffleboardNTListener { createRows(topic); if (topic.name.contains(metadataTable)) { - Future(() async => _handleMetadata(topic)); + Future(() async => _metadataChanged(topic)); } if (!topic.name.contains(metadataTable) && !topic.name.contains('$shuffleboardTableRoot/.recording') && !topic.name.contains(RegExp( '${r'\'}$shuffleboardTableRoot${r'\/([^\/]+\/){1}\.type'}'))) { - Future(() async => _handleWidgetTopicAnnounced(topic)); + Future(() async => _topicAnnounced(topic)); } }); } - Future _handleMetadata(NT4Topic topic) async { - List tables = topic.name.substring(1).split('/'); + Future _metadataChanged(NT4Topic topic) async { + String name = topic.name; - if (tables.length < 5) { + List metaHierarchy = getHierarchy(name); + + if (metaHierarchy.length < 5) { return; } - String tabName = tables[2]; - String widgetName = tables[3]; - String property = tables[4]; + List realHierarchy = getHierarchy(_realPath(name)); - String jsonTopic = '$tabName/$widgetName'; + // Properties + if (name.contains('/Properties')) { + String propertyTopic = metaHierarchy[metaHierarchy.length - 1]; - currentJsonData.putIfAbsent(jsonTopic, () => {}); + Object? subProperty = + await nt4Connection.subscribeAndRetrieveData(propertyTopic); - switch (property) { - case 'Size': - List rawSize = - await nt4Connection.subscribeAndRetrieveData(topic.name) ?? []; + if (subProperty == null) { + return; + } - List size = rawSize.whereType().toList(); + String real = realHierarchy[realHierarchy.length - 1]; + List realTopics = real.split('/'); + bool inLayout = real.split('/').length > 6; - if (size.length != 2) { - break; - } + String tabName = (inLayout) + ? realTopics[realTopics.length - 5] + : realTopics[realTopics.length - 4]; + String componentName = (inLayout) + ? realTopics[realTopics.length - 4] + : realTopics[realTopics.length - 3]; + String widgetName = + (inLayout) ? realTopics[realTopics.length - 3] : componentName; + String propertyName = realTopics[realTopics.length - 1]; + String jsonKey = '$tabName/$componentName'; - currentJsonData[jsonTopic]!['width'] = - size[0] * Globals.gridSize.toDouble(); - currentJsonData[jsonTopic]!['height'] = - size[1] * Globals.gridSize.toDouble(); - break; - case 'Position': - List rawPosition = - await nt4Connection.subscribeAndRetrieveData(topic.name) ?? []; + if (inLayout) { + currentJsonData[jsonKey]!['layout'] = true; - List position = rawPosition.whereType().toList(); + currentJsonData[jsonKey]! + .putIfAbsent('children', () => >[]); - if (position.length != 2) { - break; - } + Map child = _createOrGetChild(jsonKey, widgetName); - currentJsonData[jsonTopic]!['x'] = position[0] * Globals.gridSize; - currentJsonData[jsonTopic]!['y'] = position[1] * Globals.gridSize; - break; - case 'PreferredComponent': - String? component = - await nt4Connection.subscribeAndRetrieveData(topic.name); + child.putIfAbsent('properties', () => {}); + child['properties']!.putIfAbsent(propertyName, () => subProperty); + } else { + currentJsonData[jsonKey]!.putIfAbsent('layout', () => false); - if (component == null) { - break; - } + currentJsonData[jsonKey]!.putIfAbsent('title', () => widgetName); - currentJsonData[jsonTopic]!['type'] = component; - break; - case 'Properties': - String subPropertyName = tables[5]; - Object? subProperty = - await nt4Connection.subscribeAndRetrieveData(topic.name); + currentJsonData[jsonKey]! + .putIfAbsent('properties', () => {}); + currentJsonData[jsonKey]!['properties'] + .putIfAbsent(propertyName, () => subProperty); + } - if (subProperty == null) { - break; - } + return; + } - currentJsonData[jsonTopic]! - .putIfAbsent('properties', () => {}); + String real = realHierarchy[realHierarchy.length - 2]; + List realTopics = real.split('/'); + bool inLayout = real.split('/').length > 4; - currentJsonData[jsonTopic]!['properties'][subPropertyName] = - subProperty; - break; + String tabName = (inLayout) + ? realTopics[realTopics.length - 3] + : realTopics[realTopics.length - 2]; + String componentName = (inLayout) + ? realTopics[realTopics.length - 2] + : realTopics[realTopics.length - 1]; + String widgetName = + (inLayout) ? realTopics[realTopics.length - 1] : componentName; + String jsonKey = '$tabName/$componentName'; + + currentJsonData.putIfAbsent(jsonKey, () => {}); + + if (inLayout) { + currentJsonData[jsonKey]!['layout'] = true; + + currentJsonData[jsonKey]! + .putIfAbsent('children', () => >[]); + + _createOrGetChild(jsonKey, widgetName); + } else { + currentJsonData[jsonKey]!.putIfAbsent('layout', () => false); + + currentJsonData[jsonKey]!.putIfAbsent('title', () => widgetName); + } + + // Type + if (name.endsWith('/PreferredComponent')) { + String componentTopic = metaHierarchy[metaHierarchy.length - 1]; + + String? type = + await nt4Connection.subscribeAndRetrieveData(componentTopic); + + if (type == null) { + return; + } + + if (inLayout) { + Map child = _createOrGetChild(jsonKey, widgetName); + + child.putIfAbsent('type', () => type); + } else { + currentJsonData[jsonKey]!.putIfAbsent('type', () => type); + } + } + + // Size + if (name.endsWith('/Size')) { + String sizeTopic = metaHierarchy[metaHierarchy.length - 1]; + + List sizeRaw = + await nt4Connection.subscribeAndRetrieveData(sizeTopic) ?? []; + List size = sizeRaw.whereType().toList(); + + if (size.length < 2) { + return; + } + + if (inLayout) { + Map child = _createOrGetChild(jsonKey, widgetName); + + child.putIfAbsent('width', () => size[0] * Globals.gridSize); + child.putIfAbsent('height', () => size[1] * Globals.gridSize); + } else { + currentJsonData[jsonKey]! + .putIfAbsent('width', () => size[0] * Globals.gridSize); + currentJsonData[jsonKey]! + .putIfAbsent('height', () => size[1] * Globals.gridSize); + } } + + // Position + if (name.endsWith('/Position')) { + String positionTopic = metaHierarchy[metaHierarchy.length - 1]; + + List positionRaw = + await nt4Connection.subscribeAndRetrieveData(positionTopic) ?? []; + List position = positionRaw.whereType().toList(); + + if (position.length < 2) { + return; + } + + if (inLayout) { + Map child = _createOrGetChild(jsonKey, widgetName); + + child.putIfAbsent('x', () => position[0] * Globals.gridSize); + child.putIfAbsent('y', () => position[1] * Globals.gridSize); + } else { + currentJsonData[jsonKey]! + .putIfAbsent('x', () => position[0] * Globals.gridSize); + currentJsonData[jsonKey]! + .putIfAbsent('y', () => position[1] * Globals.gridSize); + } + } + } + + Map _createOrGetChild(String jsonKey, String title) { + List> children = currentJsonData[jsonKey]!['children']; + + return children.firstWhere( + (element) => element.containsKey('title') && element['title'] == title, + orElse: () { + final newMap = {'title': title}; + children.add(newMap); + + return newMap; + }, + ); } - void _handleWidgetTopicAnnounced(NT4Topic topic) async { - List tables = topic.name.substring(1).split('/'); + Future _topicAnnounced(NT4Topic topic) async { + String name = topic.name; - if (tables.length < 3) { + List hierarchy = getHierarchy(name); + List tables = name.substring(1).split('/'); + + if (hierarchy.length < 3) { return; } String tabName = tables[1]; - String widgetName = tables[2]; + String componentName = tables[2]; + + String jsonKey = '$tabName/$componentName'; if (!shuffleboardTreeRoot.hasRow(shuffleboardTableRoot.substring(1))) { return; @@ -153,33 +263,56 @@ class ShuffleboardNTListener { } TreeRow tabRow = shuffleboardRootRow.getRow(tabName); - if (!tabRow.hasRow(widgetName)) { + if (!tabRow.hasRow(componentName)) { return; } - TreeRow widgetRow = tabRow.getRow(widgetName); + TreeRow widgetRow = tabRow.getRow(componentName); bool isCameraStream = topic.name.endsWith('/.ShuffleboardURI'); + if (widgetRow.hasRow('.type')) { + String typeTopic = widgetRow.getRow('.type').topic; + + String? type = await nt4Connection.subscribeAndRetrieveData(typeTopic, + timeout: const Duration(seconds: 3)); + + if (type == null) { + return; + } + + if (type == 'ShuffleboardLayout') { + currentJsonData[jsonKey]!['layout'] = true; + } + } + + // Prevents multi-topic widgets from being published twice // If there's a topic like .controllable that gets published before the // type topic, don't delay everything else from being processed if (widgetRow.children.isNotEmpty && - !topic.name.endsWith('/.type') && + !name.endsWith('/.type') && !isCameraStream) { return; } + bool isLayout = currentJsonData[jsonKey]!['layout']; + + if (isLayout) { + handleLayoutTopicAnnounce(topic, widgetRow); + return; + } + + WidgetContainer? widgetContainer; NT4Widget? widget; if (!isCameraStream) { - widget = await widgetRow.getPrimaryWidget(); + widgetContainer = await widgetRow.toWidgetContainer(); + widget = tryCast(widgetContainer?.child); - if (widget == null) { + if (widgetContainer == null || widget == null) { return; } } - String jsonTopic = '$tabName/$widgetName'; - if (isCameraStream) { String? cameraStream = await nt4Connection.subscribeAndRetrieveData(topic.name); @@ -190,39 +323,135 @@ class ShuffleboardNTListener { String cameraName = cameraStream.substring(16); - currentJsonData.putIfAbsent(jsonTopic, () => {}); - currentJsonData[jsonTopic]! + currentJsonData.putIfAbsent(jsonKey, () => {}); + currentJsonData[jsonKey]! .putIfAbsent('properties', () => {}); - currentJsonData[jsonTopic]!['properties']['topic'] = + currentJsonData[jsonKey]!['properties']['topic'] = '/CameraPublisher/$cameraName'; } await Future.delayed(const Duration(seconds: 2, milliseconds: 750), () { - currentJsonData.putIfAbsent(jsonTopic, () => {}); - - currentJsonData[jsonTopic]!.putIfAbsent('title', () => widgetName); - currentJsonData[jsonTopic]!.putIfAbsent('x', () => 0.0); - currentJsonData[jsonTopic]!.putIfAbsent('y', () => 0.0); - currentJsonData[jsonTopic]! - .putIfAbsent('width', () => Globals.gridSize.toDouble()); - currentJsonData[jsonTopic]! - .putIfAbsent('height', () => Globals.gridSize.toDouble()); - currentJsonData[jsonTopic]!.putIfAbsent('tab', () => tabName); - currentJsonData[jsonTopic]!.putIfAbsent( + currentJsonData.putIfAbsent(jsonKey, () => {}); + + currentJsonData[jsonKey]!.putIfAbsent('title', () => componentName); + currentJsonData[jsonKey]!.putIfAbsent('x', () => 0.0); + currentJsonData[jsonKey]!.putIfAbsent('y', () => 0.0); + currentJsonData[jsonKey]!.putIfAbsent( + 'width', + () => (!isCameraStream) + ? widgetContainer!.width + : Globals.gridSize * 2); + currentJsonData[jsonKey]!.putIfAbsent( + 'height', + () => (!isCameraStream) + ? widgetContainer!.height + : Globals.gridSize * 2); + currentJsonData[jsonKey]!.putIfAbsent('tab', () => tabName); + currentJsonData[jsonKey]!.putIfAbsent( 'type', () => (!isCameraStream) ? widget!.type : 'Camera Stream'); - currentJsonData[jsonTopic]! + currentJsonData[jsonKey]! .putIfAbsent('properties', () => {}); - currentJsonData[jsonTopic]!['properties'].putIfAbsent( - 'topic', () => '$shuffleboardTableRoot/$tabName/$widgetName'); - currentJsonData[jsonTopic]!['properties'] + currentJsonData[jsonKey]!['properties'] + .putIfAbsent('topic', () => widgetRow.topic); + currentJsonData[jsonKey]!['properties'] .putIfAbsent('period', () => Globals.defaultPeriod); - onWidgetAdded?.call(currentJsonData[jsonTopic]!); + onWidgetAdded?.call(currentJsonData[jsonKey]!); widget?.unSubscribe(); widget?.dispose(); + }); + } + + Future handleLayoutTopicAnnounce( + NT4Topic topic, TreeRow widgetRow) async { + String name = topic.name; + + List tables = name.substring(1).split('/'); + + String tabName = tables[1]; + String componentName = tables[2]; + + String jsonKey = '$tabName/$componentName'; + + await Future.delayed(const Duration(seconds: 2, milliseconds: 750), + () async { + currentJsonData.putIfAbsent(jsonKey, () => {}); + + currentJsonData[jsonKey]!.putIfAbsent('title', () => componentName); + currentJsonData[jsonKey]!.putIfAbsent('x', () => 0.0); + currentJsonData[jsonKey]!.putIfAbsent('y', () => 0.0); + currentJsonData[jsonKey]! + .putIfAbsent('width', () => Globals.gridSize.toDouble()); + currentJsonData[jsonKey]! + .putIfAbsent('height', () => Globals.gridSize.toDouble()); + currentJsonData[jsonKey]!.putIfAbsent('type', () => 'List Layout'); + currentJsonData[jsonKey]!.putIfAbsent('tab', () => tabName); + currentJsonData[jsonKey]! + .putIfAbsent('children', () => >[]); + + Iterable childrenNames = widgetRow.children + .where((element) => !element.rowName.startsWith('.')) + .map((e) => e.rowName); + + for (String childName in childrenNames) { + _createOrGetChild(jsonKey, childName); + } + + for (Map child + in currentJsonData[jsonKey]!['children']) { + child.putIfAbsent('properties', () => {}); + + if (!widgetRow.hasRow(child['title'])) { + continue; + } + TreeRow childRow = widgetRow.getRow(child['title']); + + WidgetContainer? widgetContainer = await childRow.toWidgetContainer(); + NT4Widget? widget = tryCast(widgetContainer?.child); + + bool isCameraStream = childRow.hasRow('.ShuffleboardURI'); + + if (!isCameraStream && (widget == null || widgetContainer == null)) { + continue; + } + + if (isCameraStream) { + String? cameraStream = await nt4Connection.subscribeAndRetrieveData( + childRow.getRow('.ShuffleboardURI').topic); + + if (cameraStream == null) { + continue; + } + + String cameraName = cameraStream.substring(16); - // currentJsonData[jsonTopic]!.clear(); + child['properties']['topic'] = '/CameraPublisher/$cameraName'; + } + + child.putIfAbsent( + 'type', () => (!isCameraStream) ? widget!.type : 'Camera Stream'); + child.putIfAbsent('x', () => 0.0); + child.putIfAbsent('y', () => 0.0); + child.putIfAbsent( + 'width', + () => (!isCameraStream) + ? widgetContainer!.width + : Globals.gridSize * 2); + child.putIfAbsent( + 'height', + () => (!isCameraStream) + ? widgetContainer!.height + : Globals.gridSize * 2); + + child['properties']!.putIfAbsent('topic', () => childRow.topic); + child['properties']!.putIfAbsent('period', () => Globals.defaultPeriod); + + widget?.unSubscribe(); + widget?.dispose(); + } + + onWidgetAdded?.call(currentJsonData[jsonKey]!); }); } @@ -230,6 +459,29 @@ class ShuffleboardNTListener { onTabChanged?.call(newTab); } + String _realPath(String path) { + return path.replaceFirst('/Shuffleboard/.metadata/', '/Shuffleboard/'); + } + + List getHierarchy(String path) { + final String normal = path; + List hierarchy = []; + if (normal.length == 1) { + hierarchy.add(normal); + return hierarchy; + } + + for (int i = 1;; i = normal.indexOf('/', i + 1)) { + if (i == -1) { + hierarchy.add(normal); + break; + } else { + hierarchy.add(normal.substring(0, i)); + } + } + return hierarchy; + } + void createRows(NT4Topic nt4Topic) { String topic = nt4Topic.name; diff --git a/lib/widgets/dashboard_grid.dart b/lib/widgets/dashboard_grid.dart index d247fff8..1a47bf3f 100644 --- a/lib/widgets/dashboard_grid.dart +++ b/lib/widgets/dashboard_grid.dart @@ -1,6 +1,8 @@ import 'package:contextmenu/contextmenu.dart'; import 'package:elastic_dashboard/services/globals.dart'; import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_layout_container.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_list_layout.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/draggable_nt4_widget_container.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; @@ -16,75 +18,129 @@ class DashboardGridModel extends ChangeNotifier { } class DashboardGrid extends StatelessWidget { - final Map? jsonData; + final List _widgetContainers = []; - final List _widgetContainers = []; - final List _draggingContainers = []; - - MapEntry? _containerDraggingIn; + MapEntry? _containerDraggingIn; final VoidCallback? onAddWidgetPressed; DashboardGridModel? model; - DashboardGrid({super.key, this.jsonData, this.onAddWidgetPressed}) { - init(); - } + DashboardGrid({super.key, this.onAddWidgetPressed}); - DashboardGrid.fromJson( - {super.key, required this.jsonData, this.onAddWidgetPressed}) { - init(); - } + DashboardGrid.fromJson({ + super.key, + required Map jsonData, + this.onAddWidgetPressed, + }) { + if (jsonData['containers'] != null) { + loadContainersFromJson(jsonData); + } - void init() { - if (jsonData != null && jsonData!['containers'] != null) { - loadFromJson(jsonData!); + if (jsonData['layouts'] != null) { + loadLayoutsFromJson(jsonData); } } - void loadFromJson(Map jsonData) { + void loadContainersFromJson(Map jsonData) { for (Map containerData in jsonData['containers']) { - _widgetContainers.add(DraggableNT4WidgetContainer.fromJson( - key: UniqueKey(), - enabled: nt4Connection.isNT4Connected, - validMoveLocation: isValidMoveLocation, - jsonData: containerData, - onUpdate: (widget) { - refresh(); - }, - onDragBegin: (widget) { - _draggingContainers.add(widget); - refresh(); - }, - onDragEnd: (widget) { - _draggingContainers.toSet().lookup(widget)?.child?.dispose(); - _draggingContainers.remove(widget); - refresh(); - }, - onResizeBegin: (widget) { - _draggingContainers.add(widget); - refresh(); - }, - onResizeEnd: (widget) { - _draggingContainers.toSet().lookup(widget)?.child?.dispose(); - _draggingContainers.remove(widget); - refresh(); - }, - )); + _widgetContainers.add( + DraggableNT4WidgetContainer.fromJson( + key: UniqueKey(), + dashboardGrid: this, + enabled: nt4Connection.isNT4Connected, + jsonData: containerData, + onUpdate: _nt4ContainerOnUpdate, + onDragBegin: _nt4ContainerOnDragBegin, + onDragEnd: _nt4ContainerOnDragEnd, + onResizeBegin: _nt4ContainerOnResizeBegin, + onResizeEnd: _nt4ContainerOnResizeEnd, + ), + ); + } + } + + void loadLayoutsFromJson(Map jsonData) { + for (Map layoutData in jsonData['layouts']) { + if (layoutData['type'] == null) { + continue; + } + + late DraggableWidgetContainer widget; + + switch (layoutData['type']) { + case 'List Layout': + widget = DraggableListLayout.fromJson( + key: UniqueKey(), + dashboardGrid: this, + enabled: nt4Connection.isNT4Connected, + jsonData: layoutData, + nt4ContainerBuilder: (Map jsonData) { + return DraggableNT4WidgetContainer.fromJson( + key: UniqueKey(), + dashboardGrid: this, + enabled: nt4Connection.isNT4Connected, + jsonData: jsonData, + onUpdate: _nt4ContainerOnUpdate, + onDragBegin: _nt4ContainerOnDragBegin, + onDragEnd: _nt4ContainerOnDragEnd, + onResizeBegin: _nt4ContainerOnResizeBegin, + onResizeEnd: _nt4ContainerOnResizeEnd, + ); + }, + onUpdate: _layoutContainerOnUpdate, + onDragBegin: _layoutContainerOnDragBegin, + onDragEnd: _layoutContainerOnDragEnd, + onResizeBegin: _layoutContainerOnResizeBegin, + onResizeEnd: _layoutContainerOnResizeEnd, + ); + default: + continue; + } + + _widgetContainers.add(widget); } } Map toJson() { var containers = []; - for (DraggableNT4WidgetContainer container in _widgetContainers) { - containers.add(container.toJson()); + var layouts = []; + for (DraggableWidgetContainer container in _widgetContainers) { + if (container is DraggableNT4WidgetContainer) { + containers.add(container.toJson()); + } else { + layouts.add(container.toJson()); + } } return { + 'layouts': layouts, 'containers': containers, }; } + Offset getLocalPosition(Offset globalPosition) { + BuildContext? context = (key as GlobalKey).currentContext; + + if (context == null) { + return Offset.zero; + } + + RenderBox? ancestor = context.findAncestorRenderObjectOfType(); + + Offset localPosition = ancestor!.globalToLocal(globalPosition); + + if (localPosition.dy < 0) { + localPosition = Offset(localPosition.dx, 0); + } + + if (localPosition.dx < 0) { + localPosition = Offset(0, localPosition.dy); + } + + return localPosition; + } + /// Returns weather `widget` is able to be moved to `location` without overlapping anything else. /// /// This only applies to widgets that already have a place on the grid @@ -95,7 +151,7 @@ class DashboardGrid extends StatelessWidget { gridSize = MediaQuery.of(context).size; } - for (DraggableNT4WidgetContainer container in _widgetContainers) { + for (DraggableWidgetContainer container in _widgetContainers) { if (container.displayRect.overlaps(location) && widget != container) { return false; } else if (gridSize != null && @@ -107,6 +163,10 @@ class DashboardGrid extends StatelessWidget { return true; } + bool isValidLayoutLocation(Offset globalPosition) { + return getLayoutAtLocation(globalPosition) != null; + } + /// Returns weather `location` will overlap with widgets already on the dashboard bool isValidLocation(Rect location) { for (DraggableWidgetContainer container in _widgetContainers) { @@ -117,11 +177,174 @@ class DashboardGrid extends StatelessWidget { return true; } + DraggableLayoutContainer? getLayoutAtLocation(Offset globalPosition) { + Offset localPosition = getLocalPosition(globalPosition); + for (DraggableLayoutContainer container + in _widgetContainers.whereType()) { + if (container.displayRect.contains(localPosition)) { + return container; + } + } + + return null; + } + + void onWidgetResizeEnd(DraggableWidgetContainer widget) { + if (widget.validLocation) { + widget.draggablePositionRect = widget.previewRect; + } else { + widget.draggablePositionRect = widget.dragStartLocation; + } + + widget.displayRect = widget.draggablePositionRect; + + widget.previewRect = widget.draggablePositionRect; + widget.previewVisible = false; + widget.validLocation = true; + } + + void onWidgetDragEnd(DraggableWidgetContainer widget) { + if (widget.validLocation) { + widget.draggablePositionRect = widget.previewRect; + } else { + widget.draggablePositionRect = widget.dragStartLocation; + } + + widget.displayRect = widget.draggablePositionRect; + + widget.previewRect = widget.draggablePositionRect; + widget.previewVisible = false; + widget.validLocation = true; + } + + void onWidgetUpdate(DraggableWidgetContainer widget, Rect newRect) { + double newX = DraggableWidgetContainer.snapToGrid(newRect.left); + double newY = DraggableWidgetContainer.snapToGrid(newRect.top); + + double newWidth = DraggableWidgetContainer.snapToGrid(newRect.width); + double newHeight = DraggableWidgetContainer.snapToGrid(newRect.height); + + if (newWidth < Globals.gridSize) { + newWidth = Globals.gridSize.toDouble(); + } + + if (newHeight < Globals.gridSize) { + newHeight = Globals.gridSize.toDouble(); + } + + Rect preview = + Rect.fromLTWH(newX, newY, newWidth.toDouble(), newHeight.toDouble()); + widget.draggablePositionRect = newRect; + + widget.previewRect = preview; + widget.previewVisible = true; + + bool validLocation = isValidMoveLocation(widget, preview); + + if (validLocation) { + widget.validLocation = true; + + widget.draggingIntoLayout = false; + } else { + validLocation = isValidLayoutLocation(widget.cursorGlobalLocation) && + widget is! DraggableLayoutContainer && + !widget.resizing; + + widget.draggingIntoLayout = validLocation; + + widget.validLocation = validLocation; + } + } + + void _nt4ContainerOnUpdate(dynamic widget, Rect newRect) { + onWidgetUpdate(widget, newRect); + + refresh(); + } + + void _nt4ContainerOnDragBegin(dynamic widget) { + refresh(); + } + + void _nt4ContainerOnDragEnd(dynamic widget, Rect releaseRect, + {Offset? globalPosition}) { + onWidgetDragEnd(widget); + + DraggableNT4WidgetContainer nt4Container = + widget as DraggableNT4WidgetContainer; + + if (widget.draggingIntoLayout && globalPosition != null) { + DraggableLayoutContainer? layoutContainer = + getLayoutAtLocation(globalPosition); + + if (layoutContainer != null) { + layoutContainer.addWidget(nt4Container); + _widgetContainers.remove(nt4Container); + } + } + + refresh(); + } + + void _nt4ContainerOnResizeBegin(dynamic widget) { + refresh(); + } + + void _nt4ContainerOnResizeEnd(dynamic widget, Rect releaseRect) { + onWidgetResizeEnd(widget); + + refresh(); + } + + void _layoutContainerOnUpdate(dynamic widget, Rect newRect) { + onWidgetUpdate(widget, newRect); + + refresh(); + } + + void _layoutContainerOnDragBegin(dynamic widget) { + refresh(); + } + + void _layoutContainerOnDragEnd(dynamic widget, Rect releaseRect, + {Offset? globalPosition}) { + onWidgetDragEnd(widget); + + refresh(); + } + + void _layoutContainerOnResizeBegin(dynamic widget) { + refresh(); + } + + void _layoutContainerOnResizeEnd(dynamic widget, Rect releaseRect) { + onWidgetResizeEnd(widget); + + refresh(); + } + + void layoutDragOutEnd(DraggableWidgetContainer widget) { + if (widget is DraggableNT4WidgetContainer) { + placeNT4DragInWidget(widget, true); + } + } + + void layoutDragOutUpdate( + DraggableWidgetContainer widget, Offset globalPosition) { + Offset localPosition = getLocalPosition(globalPosition); + widget.draggablePositionRect = Rect.fromLTWH( + localPosition.dx, + localPosition.dy, + widget.draggablePositionRect.width, + widget.draggablePositionRect.height, + ); + _containerDraggingIn = MapEntry(widget, globalPosition); + refresh(); + } + void onNTConnect() { for (DraggableWidgetContainer container in _widgetContainers) { - container.enabled = true; - - container.refresh(); + container.setEnabled(true); } refresh(); @@ -129,69 +352,123 @@ class DashboardGrid extends StatelessWidget { void onNTDisconnect() { for (DraggableWidgetContainer container in _widgetContainers) { - container.enabled = false; - - container.refresh(); + container.setEnabled(false); } refresh(); } - void addDragInWidget(WidgetContainer widget, Offset globalOffset) { - _containerDraggingIn = MapEntry(widget, globalOffset); + void addLayoutDragInWidget( + DraggableLayoutContainer widget, Offset globalPosition) { + Offset localPosition = getLocalPosition(globalPosition); + widget.draggablePositionRect = Rect.fromLTWH( + localPosition.dx, + localPosition.dy, + widget.draggablePositionRect.width, + widget.draggablePositionRect.height, + ); + _containerDraggingIn = MapEntry(widget, globalPosition); refresh(); } - void placeDragInWidget(WidgetContainer widget) { + void placeLayoutDragInWidget(DraggableLayoutContainer widget) { if (_containerDraggingIn == null) { return; } Offset globalPosition = _containerDraggingIn!.value; - BuildContext? context = (key as GlobalKey).currentContext; + Offset localPosition = getLocalPosition(globalPosition); - if (context == null) { + double previewX = DraggableWidgetContainer.snapToGrid(localPosition.dx); + double previewY = DraggableWidgetContainer.snapToGrid(localPosition.dy); + + Rect previewLocation = Rect.fromLTWH(previewX, previewY, + widget.displayRect.width, widget.displayRect.height); + + if (!isValidLocation(previewLocation)) { + _containerDraggingIn = null; + + refresh(); return; } - RenderBox? ancestor = context.findAncestorRenderObjectOfType(); + double width = widget.displayRect.width; + double height = widget.displayRect.height; - Offset localPosition = ancestor!.globalToLocal(globalPosition); + widget.displayRect = Rect.fromLTWH(previewX, previewY, width, height); + widget.draggablePositionRect = + Rect.fromLTWH(previewX, previewY, width, height); - if (localPosition.dy < 0) { - localPosition = Offset(localPosition.dx, 0); - } + addWidget(widget); + _containerDraggingIn = null; - if (localPosition.dx < 0) { - localPosition = Offset(0, localPosition.dy); + refresh(); + } + + void addNT4DragInWidget( + DraggableNT4WidgetContainer widget, Offset globalPosition) { + Offset localPosition = getLocalPosition(globalPosition); + widget.draggablePositionRect = Rect.fromLTWH( + localPosition.dx, + localPosition.dy, + widget.draggablePositionRect.width, + widget.draggablePositionRect.height, + ); + _containerDraggingIn = MapEntry(widget, globalPosition); + refresh(); + } + + void placeNT4DragInWidget(DraggableNT4WidgetContainer widget, + [bool fromLayout = false]) { + if (_containerDraggingIn == null) { + return; } + Offset globalPosition = _containerDraggingIn!.value; + + Offset localPosition = getLocalPosition(globalPosition); + double previewX = DraggableWidgetContainer.snapToGrid(localPosition.dx); double previewY = DraggableWidgetContainer.snapToGrid(localPosition.dy); - Rect previewLocation = - Rect.fromLTWH(previewX, previewY, widget.width, widget.height); + double width = widget.displayRect.width; + double height = widget.displayRect.height; - if (!isValidLocation(previewLocation)) { + Rect previewLocation = Rect.fromLTWH(previewX, previewY, width, height); + + if (isValidLayoutLocation(widget.cursorGlobalLocation)) { + DraggableLayoutContainer layoutContainer = + getLayoutAtLocation(widget.cursorGlobalLocation)!; + + if (layoutContainer.willAcceptWidget(widget)) { + layoutContainer.addWidget(widget); + } + } else if (!isValidLocation(previewLocation)) { _containerDraggingIn = null; - if (widget.child is NT4Widget) { - (widget.child as NT4Widget) - ..dispose() - ..unSubscribe(); + if (!fromLayout) { + if (widget.child is NT4Widget) { + (widget.child as NT4Widget) + ..dispose() + ..unSubscribe(); + } } refresh(); return; + } else { + widget.displayRect = previewLocation; + widget.draggablePositionRect = + Rect.fromLTWH(previewX, previewY, width, height); + + addNT4Widget( + widget, + Rect.fromLTWH(previewLocation.left, previewLocation.top, + previewLocation.width, previewLocation.height), + enabled: nt4Connection.isNT4Connected); } - addWidget( - widget, - Rect.fromLTWH(previewLocation.left, previewLocation.top, - previewLocation.width, previewLocation.height), - enabled: nt4Connection.isNT4Connected); - _containerDraggingIn = null; if (widget.child is NT4Widget) { @@ -201,87 +478,149 @@ class DashboardGrid extends StatelessWidget { refresh(); } - void addWidget(WidgetContainer widget, Rect initialPosition, + DraggableNT4WidgetContainer? createNT4WidgetContainer( + WidgetContainer? widget) { + if (widget == null || widget.child == null) { + return null; + } + + if (widget.child is! NT4Widget) { + return null; + } + + return DraggableNT4WidgetContainer( + key: UniqueKey(), + dashboardGrid: this, + title: widget.title, + enabled: nt4Connection.isNT4Connected, + initialPosition: Rect.fromLTWH( + 0.0, + 0.0, + widget.width, + widget.height, + ), + onUpdate: _nt4ContainerOnUpdate, + onDragBegin: _nt4ContainerOnDragBegin, + onDragEnd: _nt4ContainerOnDragEnd, + onResizeBegin: _nt4ContainerOnResizeBegin, + onResizeEnd: _nt4ContainerOnResizeEnd, + child: widget.child as NT4Widget, + ); + } + + DraggableListLayout createListLayout() { + return DraggableListLayout( + key: UniqueKey(), + dashboardGrid: this, + title: 'List Layout', + initialPosition: Rect.fromLTWH( + 0.0, + 0.0, + Globals.gridSize.toDouble(), + Globals.gridSize.toDouble(), + ), + enabled: nt4Connection.isNT4Connected, + onUpdate: _layoutContainerOnUpdate, + onDragBegin: _layoutContainerOnDragBegin, + onDragEnd: _layoutContainerOnDragEnd, + onResizeBegin: _layoutContainerOnResizeBegin, + onResizeEnd: _layoutContainerOnResizeEnd, + ); + } + + void addWidget(DraggableWidgetContainer widget) { + _widgetContainers.add(widget); + } + + void addNT4Widget(DraggableNT4WidgetContainer widget, Rect initialPosition, {bool enabled = true}) { - _widgetContainers.add(DraggableNT4WidgetContainer( - key: UniqueKey(), - title: widget.title, - initialPosition: initialPosition, - validMoveLocation: isValidMoveLocation, - enabled: enabled, - onUpdate: (widget) { - refresh(); - }, - onDragBegin: (widget) { - _draggingContainers.add(widget); - refresh(); - }, - onDragEnd: (widget) { - _draggingContainers.remove(widget); - refresh(); - }, - onResizeBegin: (widget) { - _draggingContainers.add(widget); - refresh(); - }, - onResizeEnd: (widget) { - _draggingContainers.remove(widget); - refresh(); - }, - child: widget.child! as NT4Widget)); + _widgetContainers.add(widget); } void addWidgetFromTabJson(Map widgetData) { // If the widget is already in the tab, don't add it - for (DraggableNT4WidgetContainer container - in _widgetContainers.whereType()) { - String? title = container.title; - String? type = container.child?.type; - String? topic = container.child?.topic; + if (!widgetData['layout']) { + for (DraggableNT4WidgetContainer container + in _widgetContainers.whereType()) { + String? title = container.title; + String? type = container.child?.type; + String? topic = container.child?.topic; + + if (title == null || type == null || topic == null) { + continue; + } - if (title == null || type == null || topic == null) { - continue; + if (title == widgetData['title'] && + type == widgetData['type'] && + topic == widgetData['properties']['topic']) { + return; + } } + } else { + for (DraggableLayoutContainer container + in _widgetContainers.whereType()) { + String? title = container.title; + String type = container.type; + + if (title == null) { + continue; + } - if (title == widgetData['title'] && - type == widgetData['type'] && - topic == widgetData['properties']['topic']) { - return; + if (title == widgetData['title'] && type == widgetData['type']) { + return; + } } } - _widgetContainers.add(DraggableNT4WidgetContainer.fromJson( - key: UniqueKey(), - enabled: nt4Connection.isNT4Connected, - validMoveLocation: isValidMoveLocation, - jsonData: widgetData, - onUpdate: (widget) { - refresh(); - }, - onDragBegin: (widget) { - _draggingContainers.add(widget); - refresh(); - }, - onDragEnd: (widget) { - _draggingContainers.toSet().lookup(widget)?.child?.dispose(); - _draggingContainers.remove(widget); - refresh(); - }, - onResizeBegin: (widget) { - _draggingContainers.add(widget); - refresh(); - }, - onResizeEnd: (widget) { - _draggingContainers.toSet().lookup(widget)?.child?.dispose(); - _draggingContainers.remove(widget); - refresh(); - }, - )); + if (widgetData['layout']) { + switch (widgetData['type']) { + case 'List Layout': + _widgetContainers.add( + DraggableListLayout.fromJson( + key: UniqueKey(), + dashboardGrid: this, + enabled: nt4Connection.isNT4Connected, + nt4ContainerBuilder: (Map jsonData) { + return DraggableNT4WidgetContainer.fromJson( + key: UniqueKey(), + dashboardGrid: this, + enabled: nt4Connection.isNT4Connected, + jsonData: jsonData, + onUpdate: _nt4ContainerOnUpdate, + onDragBegin: _nt4ContainerOnDragBegin, + onDragEnd: _nt4ContainerOnDragEnd, + onResizeBegin: _nt4ContainerOnResizeBegin, + onResizeEnd: _nt4ContainerOnResizeEnd, + ); + }, + jsonData: widgetData, + onUpdate: _layoutContainerOnUpdate, + onDragBegin: _layoutContainerOnDragBegin, + onDragEnd: _layoutContainerOnDragEnd, + onResizeBegin: _layoutContainerOnResizeBegin, + onResizeEnd: _layoutContainerOnResizeEnd, + ), + ); + break; + } + } else { + _widgetContainers.add(DraggableNT4WidgetContainer.fromJson( + key: UniqueKey(), + dashboardGrid: this, + enabled: nt4Connection.isNT4Connected, + jsonData: widgetData, + onUpdate: _nt4ContainerOnUpdate, + onDragBegin: _nt4ContainerOnDragBegin, + onDragEnd: _nt4ContainerOnDragEnd, + onResizeBegin: _nt4ContainerOnResizeBegin, + onResizeEnd: _nt4ContainerOnResizeEnd, + )); + } refresh(); } - void removeWidget(DraggableNT4WidgetContainer widget) { + void removeWidget(DraggableWidgetContainer widget) { widget.dispose(); widget.unSubscribe(); _widgetContainers.remove(widget); @@ -289,8 +628,7 @@ class DashboardGrid extends StatelessWidget { } void clearWidgets() { - for (DraggableNT4WidgetContainer container - in _widgetContainers.whereType()) { + for (DraggableWidgetContainer container in _widgetContainers) { container.dispose(); container.unSubscribe(); } @@ -299,8 +637,7 @@ class DashboardGrid extends StatelessWidget { } void onDestroy() { - for (DraggableNT4WidgetContainer container - in _widgetContainers.whereType()) { + for (DraggableWidgetContainer container in _widgetContainers) { container.dispose(); container.unSubscribe(); } @@ -330,50 +667,51 @@ class DashboardGrid extends StatelessWidget { List draggingInWidgets = []; List previewOutlines = []; - for (DraggableNT4WidgetContainer container in _draggingContainers) { - // Add the widget container above the others - draggingWidgets.add( - Positioned( - left: container.draggablePositionRect.left, - top: container.draggablePositionRect.top, - child: WidgetContainer( - title: container.title, - width: container.draggablePositionRect.width, - height: container.draggablePositionRect.height, - opacity: (container.model?.previewVisible ?? false) ? 0.80 : 1.00, - child: container.child, + for (DraggableWidgetContainer container in _widgetContainers) { + if (container.dragging) { + draggingWidgets.add( + Positioned( + left: container.draggablePositionRect.left, + top: container.draggablePositionRect.top, + child: container.getDraggingWidgetContainer(context), ), - ), - ); + ); - // Display the outline so it doesn't get covered - previewOutlines.add( - Positioned( - left: container.model?.preview.left, - top: container.model?.preview.top, - width: container.model?.preview.width, - height: container.model?.preview.height, - child: Visibility( - visible: container.model?.previewVisible ?? false, - child: Container( - decoration: BoxDecoration( - color: (container.model?.validLocation ?? false) - ? Colors.white.withOpacity(0.25) - : Colors.black.withOpacity(0.1), - borderRadius: BorderRadius.circular(Globals.cornerRadius), - border: Border.all( - color: (container.model?.validLocation ?? false) - ? Colors.lightGreenAccent.shade400 - : Colors.red, - width: 5.0), + if (!container.draggingIntoLayout) { + previewOutlines.add( + container.getDefaultPreview(), + ); + } else { + DraggableLayoutContainer? layoutContainer = + getLayoutAtLocation(container.cursorGlobalLocation); + + if (layoutContainer == null) { + previewOutlines.add( + container.getDefaultPreview(), + ); + } else { + previewOutlines.add( + Positioned( + left: layoutContainer.displayRect.left, + top: layoutContainer.displayRect.top, + width: layoutContainer.displayRect.width, + height: layoutContainer.displayRect.height, + child: Visibility( + visible: container.previewVisible, + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.25), + borderRadius: BorderRadius.circular(Globals.cornerRadius), + border: Border.all(color: Colors.yellow, width: 5.0), + ), + ), + ), ), - ), - ), - ), - ); - } + ); + } + } + } - for (DraggableNT4WidgetContainer container in _widgetContainers) { dashboardWidgets.add( ContextMenuArea( builder: (context) => [ @@ -417,34 +755,38 @@ class DashboardGrid extends StatelessWidget { // Also render any containers that are being dragged into the grid if (_containerDraggingIn != null) { - WidgetContainer container = _containerDraggingIn!.key; - Offset globalOffset = _containerDraggingIn!.value; + DraggableWidgetContainer container = _containerDraggingIn!.key; + + draggingWidgets.add( + Positioned( + left: container.draggablePositionRect.left, + top: container.draggablePositionRect.top, + child: container.getWidgetContainer(context), + ), + ); - RenderBox? ancestor = context.findAncestorRenderObjectOfType(); + double previewX = DraggableWidgetContainer.snapToGrid( + container.draggablePositionRect.left); + double previewY = DraggableWidgetContainer.snapToGrid( + container.draggablePositionRect.top); - Offset localPosition = ancestor!.globalToLocal(globalOffset); + Rect previewLocation = Rect.fromLTWH(previewX, previewY, + container.displayRect.width, container.displayRect.height); - if (localPosition.dx < 0) { - localPosition = Offset(0, localPosition.dy); - } + bool validLocation = isValidLocation(previewLocation) || + isValidLayoutLocation(container.cursorGlobalLocation); - if (localPosition.dy < 0) { - localPosition = Offset(localPosition.dx, 0); - } + Color borderColor = + (validLocation) ? Colors.lightGreenAccent.shade400 : Colors.red; - draggingInWidgets.add( - Positioned( - left: localPosition.dx, - top: localPosition.dy, - child: container, - ), - ); + if (isValidLayoutLocation(container.cursorGlobalLocation)) { + DraggableLayoutContainer layoutContainer = + getLayoutAtLocation(container.cursorGlobalLocation)!; - double previewX = DraggableWidgetContainer.snapToGrid(localPosition.dx); - double previewY = DraggableWidgetContainer.snapToGrid(localPosition.dy); + previewLocation = layoutContainer.displayRect; - Rect previewLocation = - Rect.fromLTWH(previewX, previewY, container.width, container.height); + borderColor = Colors.yellow; + } previewOutlines.add( Positioned( @@ -454,15 +796,11 @@ class DashboardGrid extends StatelessWidget { height: previewLocation.height, child: Container( decoration: BoxDecoration( - color: (isValidLocation(previewLocation)) + color: (validLocation) ? Colors.white.withOpacity(0.25) : Colors.black.withOpacity(0.1), borderRadius: BorderRadius.circular(Globals.cornerRadius), - border: Border.all( - color: (isValidLocation(previewLocation)) - ? Colors.lightGreenAccent.shade400 - : Colors.red, - width: 5.0), + border: Border.all(color: borderColor, width: 5.0), ), ), ), diff --git a/lib/widgets/dialog_widgets/layout_drag_tile.dart b/lib/widgets/dialog_widgets/layout_drag_tile.dart new file mode 100644 index 00000000..b0703c4e --- /dev/null +++ b/lib/widgets/dialog_widgets/layout_drag_tile.dart @@ -0,0 +1,77 @@ +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_layout_container.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class LayoutDragTile extends StatelessWidget { + final String title; + + DraggableLayoutContainer Function()? layoutBuilder; + + DraggableLayoutContainer? draggingWidget; + + final Function(Offset globalPosition, DraggableLayoutContainer widget)? + onDragUpdate; + final Function(DraggableLayoutContainer widget)? onDragEnd; + + LayoutDragTile({ + super.key, + required this.title, + this.layoutBuilder, + this.onDragUpdate, + this.onDragEnd, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () {}, + child: GestureDetector( + onPanStart: (details) { + if (draggingWidget != null) { + return; + } + + // Prevents 2 finger drags from dragging a widget + if (details.kind != null && + details.kind! == PointerDeviceKind.trackpad) { + draggingWidget = null; + return; + } + + draggingWidget = layoutBuilder?.call(); + }, + onPanUpdate: (details) { + if (draggingWidget == null) { + return; + } + + onDragUpdate?.call( + details.globalPosition - + Offset(draggingWidget!.displayRect.width, + draggingWidget!.displayRect.height) / + 2, + draggingWidget!); + }, + onPanEnd: (details) { + if (draggingWidget == null) { + return; + } + + onDragEnd?.call(draggingWidget!); + + draggingWidget = null; + }, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 16.0), + child: ListTile( + style: ListTileStyle.drawer, + dense: true, + contentPadding: const EdgeInsets.only(right: 20.0), + leading: const SizedBox(width: 16.0), + title: Text(title), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/draggable_containers/draggable_layout_container.dart b/lib/widgets/draggable_containers/draggable_layout_container.dart new file mode 100644 index 00000000..aed754af --- /dev/null +++ b/lib/widgets/draggable_containers/draggable_layout_container.dart @@ -0,0 +1,50 @@ +import 'dart:ui'; + +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_nt4_widget_container.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; + +abstract class DraggableLayoutContainer extends DraggableWidgetContainer { + String get type; + + DraggableNT4WidgetContainer Function(Map jsonData)? + nt4ContainerBuilder; + + DraggableLayoutContainer({ + super.key, + required super.dashboardGrid, + required super.title, + required super.initialPosition, + super.enabled = false, + super.onUpdate, + super.onDragBegin, + super.onDragEnd, + super.onResizeBegin, + super.onResizeEnd, + }) : super(); + + DraggableLayoutContainer.fromJson({ + super.key, + required super.dashboardGrid, + required super.jsonData, + required this.nt4ContainerBuilder, + super.enabled = false, + super.onUpdate, + super.onDragBegin, + super.onDragEnd, + super.onResizeBegin, + super.onResizeEnd, + }) : super.fromJson(); + + @override + Map toJson() { + return { + ...super.toJson(), + 'type': type, + }; + } + + bool willAcceptWidget(DraggableWidgetContainer widget, + {Offset? globalPosition}); + + void addWidget(DraggableNT4WidgetContainer widget); +} diff --git a/lib/widgets/draggable_containers/draggable_list_layout.dart b/lib/widgets/draggable_containers/draggable_list_layout.dart new file mode 100644 index 00000000..cb031cb1 --- /dev/null +++ b/lib/widgets/draggable_containers/draggable_list_layout.dart @@ -0,0 +1,400 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_layout_container.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_nt4_widget_container.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; +import 'package:flutter/material.dart'; + +class DraggableListLayout extends DraggableLayoutContainer { + @override + String type = 'List Layout'; + + List children = []; + + DraggableListLayout({ + super.key, + required super.dashboardGrid, + required super.title, + required super.initialPosition, + super.enabled = false, + super.onUpdate, + super.onDragBegin, + super.onDragEnd, + super.onResizeBegin, + super.onResizeEnd, + }) : super(); + + DraggableListLayout.fromJson({ + super.key, + required super.dashboardGrid, + required super.jsonData, + required super.nt4ContainerBuilder, + super.enabled = false, + super.onUpdate, + super.onDragBegin, + super.onDragEnd, + super.onResizeBegin, + super.onResizeEnd, + }) : super.fromJson(); + + @override + void showEditProperties(BuildContext context) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Edit Properties'), + content: SizedBox( + width: 353, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...getContainerEditProperties(), + const Divider(), + if (children.isNotEmpty) + Container( + constraints: const BoxConstraints( + maxHeight: 350, + ), + child: ReorderableListView( + header: const Text('Children Order & Properties'), + children: children + .map( + (container) => Padding( + key: UniqueKey(), + padding: const EdgeInsets.only(right: 8.0), + child: ExpansionTile( + title: Text(container.title ?? ''), + subtitle: Text( + container.child?.type ?? 'NT4Widget'), + controlAffinity: + ListTileControlAffinity.leading, + trailing: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + children.remove(container); + + container.unSubscribe(); + container.dispose(); + + Future(() async { + Navigator.of(context).pop(); + showEditProperties(context); + + refresh(); + }); + }), + childrenPadding: const EdgeInsets.only( + left: 16.0, + top: 8.0, + right: 32.0, + bottom: 8.0, + ), + expandedCrossAxisAlignment: + CrossAxisAlignment.start, + children: getChildEditProperties( + context, container), + ), + ), + ) + .toList(), + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) { + newIndex--; + } + var temp = children[newIndex]; + children[newIndex] = children[oldIndex]; + children[oldIndex] = temp; + + Future(() async { + Navigator.of(context).pop(); + showEditProperties(context); + + refresh(); + }); + }, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + List getChildEditProperties( + BuildContext context, DraggableNT4WidgetContainer container) { + List containerEditProperties = [ + // Settings for the widget container + const Text('Container Settings'), + const SizedBox(height: 5), + DialogTextInput( + onSubmit: (value) { + container.title = value; + + container.refresh(); + + refresh(); + }, + label: 'Title', + initialText: container.title, + ), + ]; + + List childEditProperties = + container.child!.getEditProperties(context); + + return [ + ...containerEditProperties, + const Divider(), + if (childEditProperties.isNotEmpty) ...[ + ...childEditProperties, + const Divider() + ], + ...container.getNT4EditProperties(), + ]; + } + + @override + Map toJson() { + return { + ...super.toJson(), + ...getChildrenJson(), + }; + } + + @override + void fromJson(Map jsonData) { + super.fromJson(jsonData); + + for (Map childData in jsonData['children']) { + children.add(nt4ContainerBuilder?.call(childData) ?? + DraggableNT4WidgetContainer.fromJson( + dashboardGrid: dashboardGrid, + jsonData: childData, + )); + } + } + + Map getChildrenJson() { + var childrenJson = []; + + for (DraggableWidgetContainer childContainer in children) { + childrenJson.add(childContainer.toJson()); + } + + return { + 'children': childrenJson, + }; + } + + @override + void dispose() { + super.dispose(); + + for (var child in children) { + child.dispose(); + } + } + + @override + void unSubscribe() { + super.unSubscribe(); + + for (var child in children) { + child.unSubscribe(); + } + } + + @override + void setEnabled(bool enabled) { + for (DraggableNT4WidgetContainer container in children) { + container.setEnabled(enabled); + } + + super.setEnabled(enabled); + } + + @override + bool willAcceptWidget(DraggableWidgetContainer widget, + {Offset? globalPosition}) { + return widget is DraggableNT4WidgetContainer; + } + + @override + void addWidget(DraggableNT4WidgetContainer widget) { + children.add(widget); + + refresh(); + } + + List getListColumn() { + List column = []; + + for (DraggableNT4WidgetContainer widget in children) { + column.add( + // Detectors for dragging so widgets can be dragged out of list layout + GestureDetector( + supportedDevices: PointerDeviceKind.values + .whereNot((element) => element == PointerDeviceKind.trackpad) + .toSet(), + onPanDown: (details) { + if (dragging) { + dragging = false; + refresh(); + } + Future.delayed(Duration.zero, () => model?.setDraggable(false)); + + widget.cursorGlobalLocation = details.globalPosition; + }, + onPanUpdate: (details) { + widget.cursorGlobalLocation = details.globalPosition; + + Offset location = details.globalPosition - + Offset(widget.displayRect.width, widget.displayRect.height) / 2; + + dashboardGrid.layoutDragOutUpdate(widget, location); + }, + onPanEnd: (details) { + Future.delayed(Duration.zero, () => model?.setDraggable(true)); + + Rect previewLocation = Rect.fromLTWH( + DraggableWidgetContainer.snapToGrid( + widget.draggablePositionRect.left), + DraggableWidgetContainer.snapToGrid( + widget.draggablePositionRect.top), + widget.draggablePositionRect.width, + widget.draggablePositionRect.height, + ); + + if (dashboardGrid.isValidLocation(previewLocation) || + dashboardGrid + .isValidLayoutLocation(widget.cursorGlobalLocation)) { + children.remove(widget); + } + + dashboardGrid.layoutDragOutEnd(widget); + }, + onPanCancel: () { + Future.delayed(Duration.zero, () => model?.setDraggable(true)); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + padding: const EdgeInsets.all(8.0), + constraints: BoxConstraints( + minHeight: 96, + // maxWidth: widget.displayRect.width, + maxHeight: widget.displayRect.height - 32, + ), + decoration: BoxDecoration( + color: const Color.fromARGB(255, 45, 45, 45), + borderRadius: BorderRadius.circular(Globals.cornerRadius), + boxShadow: const [ + BoxShadow( + offset: Offset(2, 2), + blurRadius: 10.5, + spreadRadius: 0, + color: Colors.black, + ), + ], + ), + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text(widget.title ?? ''), + ), + const SizedBox(height: 5), + Flexible( + child: AbsorbPointer( + absorbing: !enabled, + child: widget.child!, + ), + ), + ], + ), + ), + ), + ), + ); + column.add(const Divider(height: 5)); + } + + if (column.isNotEmpty) { + column.removeLast(); + } + + return column; + } + + @override + WidgetContainer getDraggingWidgetContainer(BuildContext context) { + return WidgetContainer( + title: title, + width: draggablePositionRect.width, + height: draggablePositionRect.height, + opacity: 0.80, + child: ClipRRect( + child: Wrap( + children: [ + ...getListColumn(), + ], + ), + ), + ); + } + + @override + WidgetContainer getWidgetContainer(BuildContext context) { + return WidgetContainer( + title: title, + width: displayRect.width, + height: displayRect.height, + opacity: (previewVisible) ? 0.25 : 1.00, + child: Opacity( + opacity: (enabled) ? 1.00 : 0.50, + child: SingleChildScrollView( + child: ClipRRect( + child: Wrap( + children: [ + ...getListColumn(), + ], + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return Stack( + children: [ + Positioned( + left: displayRect.left, + top: displayRect.top, + child: getWidgetContainer(context), + ), + ...super.getStackChildren(model!), + ], + ); + } +} diff --git a/lib/widgets/draggable_containers/draggable_nt4_widget_container.dart b/lib/widgets/draggable_containers/draggable_nt4_widget_container.dart index 63dd38fd..ed58b5f8 100644 --- a/lib/widgets/draggable_containers/draggable_nt4_widget_container.dart +++ b/lib/widgets/draggable_containers/draggable_nt4_widget_container.dart @@ -38,11 +38,11 @@ class DraggableNT4WidgetContainer extends DraggableWidgetContainer { DraggableNT4WidgetContainer({ super.key, + required super.dashboardGrid, required super.title, + required super.initialPosition, required this.child, - required super.validMoveLocation, super.enabled = false, - super.initialPosition, super.onUpdate, super.onDragBegin, super.onDragEnd, @@ -52,7 +52,7 @@ class DraggableNT4WidgetContainer extends DraggableWidgetContainer { DraggableNT4WidgetContainer.fromJson({ super.key, - required super.validMoveLocation, + required super.dashboardGrid, required super.jsonData, super.enabled = false, super.onUpdate, @@ -137,11 +137,17 @@ class DraggableNT4WidgetContainer extends DraggableWidgetContainer { refresh(); } + @override void dispose() { + super.dispose(); + child?.dispose(); } + @override void unSubscribe() { + super.unSubscribe(); + child?.unSubscribe(); } @@ -161,18 +167,7 @@ class DraggableNT4WidgetContainer extends DraggableWidgetContainer { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Settings for the widget container - const Text('Container Settings'), - const SizedBox(height: 5), - DialogTextInput( - onSubmit: (value) { - title = value; - - refresh(); - }, - label: 'Title', - initialText: title, - ), + ...getContainerEditProperties(), const SizedBox(height: 5), Column( mainAxisSize: MainAxisSize.min, @@ -203,44 +198,7 @@ class DraggableNT4WidgetContainer extends DraggableWidgetContainer { const Divider(), ], // Settings for the NT4 Connection - const Text('Network Tables Settings (Advanced)'), - const SizedBox(height: 5), - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - // Topic - Flexible( - child: DialogTextInput( - onSubmit: (value) { - child?.topic = value; - child?.resetSubscription(); - }, - label: 'Topic', - initialText: child?.topic, - ), - ), - const SizedBox(width: 5), - // Period - Flexible( - child: DialogTextInput( - onSubmit: (value) { - double? newPeriod = double.tryParse(value); - if (newPeriod == null) { - return; - } - - child?.period = newPeriod; - child?.resetSubscription(); - }, - formatter: FilteringTextInputFormatter.allow( - RegExp(r"[0-9.]")), - label: 'Period', - initialText: child!.period.toString(), - ), - ), - ], - ), + ...getNT4EditProperties(), ], ), ), @@ -259,6 +217,48 @@ class DraggableNT4WidgetContainer extends DraggableWidgetContainer { ); } + List getNT4EditProperties() { + return [ + const Text('Network Tables Settings (Advanced)'), + const SizedBox(height: 5), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // Topic + Flexible( + child: DialogTextInput( + onSubmit: (value) { + child?.topic = value; + child?.resetSubscription(); + }, + label: 'Topic', + initialText: child?.topic, + ), + ), + const SizedBox(width: 5), + // Period + Flexible( + child: DialogTextInput( + onSubmit: (value) { + double? newPeriod = double.tryParse(value); + if (newPeriod == null) { + return; + } + + child!.period = newPeriod; + child!.resetSubscription(); + }, + formatter: FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")), + label: 'Period', + initialText: child!.period.toString(), + ), + ), + ], + ), + ]; + } + @override Map toJson() { return { @@ -275,7 +275,7 @@ class DraggableNT4WidgetContainer extends DraggableWidgetContainer { child = createChildFromJson(jsonData); } - NT4Widget? createChildFromJson(Map jsonData) { + NT4Widget createChildFromJson(Map jsonData) { switch (jsonData['type']) { case 'Boolean Box': return BooleanBox.fromJson( @@ -419,6 +419,37 @@ class DraggableNT4WidgetContainer extends DraggableWidgetContainer { return child!.toJson(); } + @override + WidgetContainer getDraggingWidgetContainer(BuildContext context) { + return WidgetContainer( + title: title, + width: draggablePositionRect.width, + height: draggablePositionRect.height, + opacity: 0.80, + child: child, + ); + } + + @override + WidgetContainer getWidgetContainer(BuildContext context) { + return WidgetContainer( + title: title, + width: displayRect.width, + height: displayRect.height, + opacity: (previewVisible) ? 0.25 : 1.00, + child: Opacity( + opacity: (enabled) ? 1.00 : 0.50, + child: AbsorbPointer( + absorbing: !enabled, + child: ChangeNotifierProvider( + create: (context) => NT4WidgetNotifier(), + child: child, + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { super.build(context); @@ -428,22 +459,7 @@ class DraggableNT4WidgetContainer extends DraggableWidgetContainer { Positioned( left: displayRect.left, top: displayRect.top, - child: WidgetContainer( - title: title, - width: displayRect.width, - height: displayRect.height, - opacity: (model!.previewVisible) ? 0.25 : 1.00, - child: Opacity( - opacity: (enabled) ? 1.00 : 0.50, - child: AbsorbPointer( - absorbing: !enabled, - child: ChangeNotifierProvider( - create: (context) => NT4WidgetNotifier(), - child: child, - ), - ), - ), - ), + child: getWidgetContainer(context), ), ...super.getStackChildren(model!), ], diff --git a/lib/widgets/draggable_containers/draggable_widget_container.dart b/lib/widgets/draggable_containers/draggable_widget_container.dart index 52cfb0c1..d1001057 100644 --- a/lib/widgets/draggable_containers/draggable_widget_container.dart +++ b/lib/widgets/draggable_containers/draggable_widget_container.dart @@ -1,34 +1,16 @@ +import 'package:dot_cast/dot_cast.dart'; import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/widgets/dashboard_grid.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:flutter/material.dart'; import 'package:flutter_box_transform/flutter_box_transform.dart'; import 'package:provider/provider.dart'; class WidgetContainerModel extends ChangeNotifier { - Rect rect = Rect.fromLTWH( - 0, 0, Globals.gridSize.toDouble(), Globals.gridSize.toDouble()); - Rect preview = Rect.fromLTWH( - 0, 0, Globals.gridSize.toDouble(), Globals.gridSize.toDouble()); - bool previewVisible = false; - bool validLocation = true; - - void setDraggableRect(Rect newRect) { - rect = newRect; - notifyListeners(); - } - - void setPreview(Rect newPreview) { - preview = newPreview; - notifyListeners(); - } - - void setPreviewVisible(bool visible) { - previewVisible = visible; - notifyListeners(); - } + bool draggable = true; - void setValidLocation(bool valid) { - validLocation = valid; + void setDraggable(bool draggable) { + this.draggable = draggable; notifyListeners(); } @@ -38,52 +20,60 @@ class WidgetContainerModel extends ChangeNotifier { } class DraggableWidgetContainer extends StatelessWidget { - String? title; + final DashboardGrid dashboardGrid; - Rect? initialPosition; + String? title; Rect draggablePositionRect = Rect.fromLTWH( 0, 0, Globals.gridSize.toDouble(), Globals.gridSize.toDouble()); + Offset cursorGlobalLocation = const Offset(double.nan, double.nan); + Rect displayRect = Rect.fromLTWH( 0, 0, Globals.gridSize.toDouble(), Globals.gridSize.toDouble()); + Rect previewRect = Rect.fromLTWH( + 0, 0, Globals.gridSize.toDouble(), Globals.gridSize.toDouble()); + late Rect dragStartLocation; bool enabled = false; bool dragging = false; + bool resizing = false; + bool draggingIntoLayout = false; + bool previewVisible = false; + bool validLocation = true; - Map? jsonData = {}; - - bool Function(DraggableWidgetContainer widget, Rect location) - validMoveLocation; - Function(dynamic widget)? onUpdate; + Function(dynamic widget, Rect newRect)? onUpdate; Function(dynamic widget)? onDragBegin; - Function(dynamic widget)? onDragEnd; + Function(dynamic widget, Rect releaseRect, {Offset? globalPosition})? + onDragEnd; Function(dynamic widget)? onResizeBegin; - Function(dynamic widget)? onResizeEnd; + Function(dynamic widget, Rect releaseRect)? onResizeEnd; WidgetContainerModel? model; DraggableWidgetContainer({ super.key, + required this.dashboardGrid, required this.title, - required this.validMoveLocation, + required Rect initialPosition, this.enabled = false, - this.initialPosition, this.onUpdate, this.onDragBegin, this.onDragEnd, this.onResizeBegin, this.onResizeEnd, }) { + displayRect = initialPosition; + init(); } DraggableWidgetContainer.fromJson({ super.key, - required this.validMoveLocation, - required this.jsonData, + required this.dashboardGrid, + required Map jsonData, this.enabled = false, this.onUpdate, this.onDragBegin, @@ -91,6 +81,8 @@ class DraggableWidgetContainer extends StatelessWidget { this.onResizeBegin, this.onResizeEnd, }) { + fromJson(jsonData); + init(); } @@ -120,20 +112,7 @@ class DraggableWidgetContainer extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Settings for the widget container - const Text('Container Settings'), - const SizedBox(height: 5), - DialogTextInput( - onSubmit: (value) { - title = value; - - refresh(); - }, - label: 'Title', - initialText: title, - ), - ], + children: getContainerEditProperties(), ), ), ), @@ -150,6 +129,23 @@ class DraggableWidgetContainer extends StatelessWidget { ); } + List getContainerEditProperties() { + return [ + // Settings for the widget container + const Text('Container Settings'), + const SizedBox(height: 5), + DialogTextInput( + onSubmit: (value) { + title = value; + + refresh(); + }, + label: 'Title', + initialText: title, + ), + ]; + } + Map toJson() { return { 'title': title, @@ -161,30 +157,55 @@ class DraggableWidgetContainer extends StatelessWidget { } void init() { - if (title == null) { - fromJson(jsonData!); - } else { - displayRect = initialPosition!; - } - draggablePositionRect = displayRect; dragStartLocation = displayRect; } + @mustCallSuper void fromJson(Map jsonData) { - title = jsonData['title']; + title = tryCast(jsonData['title']) ?? ''; - double x = jsonData['x']; + double x = tryCast(jsonData['x']) ?? 0.0; - double y = jsonData['y']; + double y = tryCast(jsonData['y']) ?? 0.0; - double width = jsonData['width']; + double width = tryCast(jsonData['width']) ?? Globals.gridSize.toDouble(); - double height = jsonData['height']; + double height = tryCast(jsonData['height']) ?? Globals.gridSize.toDouble(); displayRect = Rect.fromLTWH(x, y, width, height); } + void dispose() {} + + void unSubscribe() {} + + @mustCallSuper + void setEnabled(bool enabled) { + this.enabled = enabled; + + refresh(); + } + + WidgetContainer getDraggingWidgetContainer(BuildContext context) { + return WidgetContainer( + title: title, + width: draggablePositionRect.width, + height: draggablePositionRect.height, + opacity: 0.80, + child: Container(), + ); + } + + WidgetContainer getWidgetContainer(BuildContext context) { + return WidgetContainer( + title: title, + width: displayRect.width, + height: displayRect.height, + child: Container(), + ); + } + List getStackChildren(WidgetContainerModel model) { return [ TransformableBox( @@ -198,7 +219,9 @@ class DraggableWidgetContainer extends StatelessWidget { rect: draggablePositionRect, resizeModeResolver: () => ResizeMode.freeform, allowFlippingWhileResizing: false, + handleTapSize: 12, visibleHandles: const {}, + draggable: model.draggable, contentBuilder: (BuildContext context, Rect rect, Flip flip) { return Container(); }, @@ -209,105 +232,78 @@ class DraggableWidgetContainer extends StatelessWidget { }, onResizeStart: (handle, event) { dragging = true; + resizing = true; dragStartLocation = displayRect; onResizeBegin?.call(this); }, onChanged: (result, event) { - Rect newRect = result.rect; + cursorGlobalLocation = event.globalPosition; - double newX = snapToGrid(newRect.left); - double newY = snapToGrid(newRect.top); + onUpdate?.call(this, result.rect); - double newWidth = snapToGrid(newRect.width); - double newHeight = snapToGrid(newRect.height); - - if (newWidth < Globals.gridSize) { - newWidth = Globals.gridSize.toDouble(); - } - - if (newHeight < Globals.gridSize) { - newHeight = Globals.gridSize.toDouble(); - } - - Rect preview = Rect.fromLTWH( - newX, newY, newWidth.toDouble(), newHeight.toDouble()); - draggablePositionRect = result.rect; - - model.setPreview(preview); - model.setDraggableRect(draggablePositionRect); - model.setPreviewVisible(true); - model.setValidLocation(validMoveLocation.call(this, preview)); - - onUpdate?.call(this); + refresh(); }, onDragEnd: (event) { dragging = false; - if (model.validLocation) { - draggablePositionRect = model.preview; - } else { - draggablePositionRect = dragStartLocation; - } - - displayRect = draggablePositionRect; - model.setPreview(draggablePositionRect); - model.setPreviewVisible(false); - model.setValidLocation(true); + onDragEnd?.call(this, draggablePositionRect, + globalPosition: cursorGlobalLocation); - onDragEnd?.call(this); + refresh(); }, onDragCancel: () { dragging = false; - if (model.validLocation) { - draggablePositionRect = model.preview; - } else { - draggablePositionRect = dragStartLocation; - } - displayRect = draggablePositionRect; + onDragEnd?.call(this, draggablePositionRect, + globalPosition: cursorGlobalLocation); - model.setPreview(draggablePositionRect); - model.setPreviewVisible(false); - model.setValidLocation(true); - - onDragEnd?.call(this); + refresh(); }, onResizeEnd: (handle, event) { dragging = false; - if (model.validLocation) { - draggablePositionRect = model.preview; - } else { - draggablePositionRect = dragStartLocation; - } - - displayRect = draggablePositionRect; + resizing = false; - model.setPreview(draggablePositionRect); - model.setPreviewVisible(false); - model.setValidLocation(true); + onResizeEnd?.call(this, draggablePositionRect); - onResizeEnd?.call(this); + refresh(); }, onResizeCancel: (handle) { dragging = false; - if (model.validLocation) { - draggablePositionRect = model.preview; - } else { - draggablePositionRect = dragStartLocation; - } + resizing = false; - displayRect = draggablePositionRect; + onResizeEnd?.call(this, draggablePositionRect); - model.setPreview(draggablePositionRect); - model.setPreviewVisible(false); - model.setValidLocation(true); - - onResizeEnd?.call(this); + refresh(); }, ), ]; } + Widget getDefaultPreview() { + return Positioned( + left: previewRect.left, + top: previewRect.top, + width: previewRect.width, + height: previewRect.height, + child: Visibility( + visible: previewVisible, + child: Container( + decoration: BoxDecoration( + color: (validLocation) + ? Colors.white.withOpacity(0.25) + : Colors.black.withOpacity(0.1), + borderRadius: BorderRadius.circular(25.0), + border: Border.all( + color: (validLocation) + ? Colors.lightGreenAccent.shade400 + : Colors.red, + width: 5.0), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { WidgetContainerModel model = context.watch(); diff --git a/lib/widgets/network_tree/network_table_tree.dart b/lib/widgets/network_tree/network_table_tree.dart index 0c6fd125..ce689339 100644 --- a/lib/widgets/network_tree/network_table_tree.dart +++ b/lib/widgets/network_tree/network_table_tree.dart @@ -3,16 +3,24 @@ import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:elastic_dashboard/services/nt4.dart'; import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_nt4_widget_container.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; import 'package:elastic_dashboard/widgets/network_tree/tree_row.dart'; import 'package:flutter/material.dart'; import 'package:flutter_fancy_tree_view/flutter_fancy_tree_view.dart'; class NetworkTableTree extends StatefulWidget { - final Function(Offset globalPosition, WidgetContainer widget)? onDragUpdate; - final Function(WidgetContainer widget)? onDragEnd; + final Function(Offset globalPosition, DraggableNT4WidgetContainer widget)? + onDragUpdate; + final Function(DraggableNT4WidgetContainer widget)? onDragEnd; + final DraggableNT4WidgetContainer? Function(WidgetContainer? widget)? + widgetContainerBuilder; - const NetworkTableTree({super.key, this.onDragUpdate, this.onDragEnd}); + const NetworkTableTree( + {super.key, + this.onDragUpdate, + this.onDragEnd, + this.widgetContainerBuilder}); @override State createState() => _NetworkTableTreeState(); @@ -22,9 +30,13 @@ class _NetworkTableTreeState extends State { final TreeRow root = TreeRow(topic: '/', rowName: ''); late final TreeController treeController; - late final Function(Offset globalPosition, WidgetContainer widget)? - onDragUpdate; - late final Function(WidgetContainer widget)? onDragEnd; + late final Function( + Offset globalPosition, DraggableNT4WidgetContainer widget)? + onDragUpdate = widget.onDragUpdate; + late final Function(DraggableNT4WidgetContainer widget)? onDragEnd = + widget.onDragEnd; + late final DraggableNT4WidgetContainer? Function(WidgetContainer? widget)? + widgetContainerBuilder = widget.widgetContainerBuilder; late final Function(NT4Topic topic) onNewTopicAnnounced; @@ -35,9 +47,6 @@ class _NetworkTableTreeState extends State { treeController = TreeController( roots: root.children, childrenProvider: (node) => node.children); - onDragUpdate = widget.onDragUpdate; - onDragEnd = widget.onDragEnd; - nt4Connection.nt4Client .addTopicAnnounceListener(onNewTopicAnnounced = (topic) { setState(() { @@ -113,6 +122,7 @@ class _NetworkTableTreeState extends State { entry: entry, onDragUpdate: onDragUpdate, onDragEnd: onDragEnd, + widgetContainerBuilder: widgetContainerBuilder, onTap: () { setState(() => treeController.toggleExpansion(entry.node)); }, @@ -129,14 +139,18 @@ class TreeTile extends StatelessWidget { required this.onTap, this.onDragUpdate, this.onDragEnd, + this.widgetContainerBuilder, }); final TreeEntry entry; final VoidCallback onTap; - final Function(Offset globalPosition, WidgetContainer widget)? onDragUpdate; - final Function(WidgetContainer widget)? onDragEnd; + final Function(Offset globalPosition, DraggableNT4WidgetContainer widget)? + onDragUpdate; + final Function(DraggableNT4WidgetContainer widget)? onDragEnd; + final DraggableNT4WidgetContainer? Function(WidgetContainer? widget)? + widgetContainerBuilder; - WidgetContainer? draggingWidget; + DraggableNT4WidgetContainer? draggingWidget; @override Widget build(BuildContext context) { @@ -156,17 +170,24 @@ class TreeTile extends StatelessWidget { return; } - draggingWidget = await entry.node.toWidgetContainer(); + draggingWidget = widgetContainerBuilder + ?.call(await entry.node.toWidgetContainer()); }, onPanUpdate: (details) { if (draggingWidget == null) { return; } - onDragUpdate?.call( - details.globalPosition - - Offset(draggingWidget!.width, draggingWidget!.height) / 2, - draggingWidget!); + 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) { diff --git a/test/pages/dashboard_page_test.dart b/test/pages/dashboard_page_test.dart index b00861f6..76114844 100644 --- a/test/pages/dashboard_page_test.dart +++ b/test/pages/dashboard_page_test.dart @@ -8,6 +8,7 @@ import 'package:elastic_dashboard/services/nt4_connection.dart'; import 'package:elastic_dashboard/widgets/custom_appbar.dart'; import 'package:elastic_dashboard/widgets/dashboard_grid.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_list_layout.dart'; import 'package:elastic_dashboard/widgets/draggable_dialog.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; import 'package:elastic_dashboard/widgets/editable_tab_bar.dart'; @@ -175,13 +176,15 @@ void main() { expect(testValueTile, findsOneWidget); expect(find.widgetWithText(TreeTile, 'Test Value 2'), findsOneWidget); - await widgetTester.drag(testValueTile, const Offset(100, 100)); + await widgetTester.drag(testValueTile, const Offset(100, 100), + kind: PointerDeviceKind.mouse); await widgetTester.pumpAndSettle(); expect(testValueContainer, findsNothing); - await widgetTester.drag(testValueTile, const Offset(300, -150)); - await widgetTester.pumpAndSettle(const Duration(seconds: 5)); + await widgetTester.drag(testValueTile, const Offset(300, -150), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); expect(testValueContainer, findsOneWidget); @@ -221,12 +224,28 @@ void main() { when(mockNT4Connection.subscribeAndRetrieveData>( '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Position')) - .thenAnswer((realInvocation) => Future.value([1.0, 1.0])); + .thenAnswer((realInvocation) => Future.value([2.0, 0.0])); when(mockNT4Connection.subscribeAndRetrieveData>( '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Size')) .thenAnswer((realInvocation) => Future.value([2.0, 2.0])); + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position')) + .thenAnswer((realInvocation) => Future.value([0.0, 0.0])); + + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size')) + .thenAnswer((realInvocation) => Future.value([2.0, 3.0])); + + when(mockNT4Connection.subscribeAndRetrieveData( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent')) + .thenAnswer((realInvocation) => Future.value('List Layout')); + + when(mockNT4Connection.subscribeAndRetrieveData( + '/Shuffleboard/Test-Tab/Shuffleboard Test Layout/.type')) + .thenAnswer((realInvocation) => Future.value('ShuffleboardLayout')); + NT4Connection.instance = mockNT4Connection; await widgetTester.pumpWidget( @@ -258,6 +277,30 @@ void main() { type: NT4TypeStr.kInt, properties: {}, )); + + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent', + type: NT4TypeStr.kString, + properties: {}, + )); + callback.call(NT4Topic( + name: '/Shuffleboard/Test-Tab/Shuffleboard Test Layout', + type: NT4TypeStr.kInt, + properties: {}, + )); } // Gives enough time for the widgets to be placed automatically @@ -272,6 +315,12 @@ void main() { find.widgetWithText(WidgetContainer, 'Shuffleboard Test Number', skipOffstage: false), findsOneWidget); + expect( + find.widgetWithText(WidgetContainer, 'Shuffleboard Test Layout', + skipOffstage: false), + findsOneWidget); + expect(find.bySubtype(skipOffstage: false), + findsOneWidget); }); testWidgets('About dialog', (widgetTester) async { diff --git a/test/widgets/dashboard_grid_test.dart b/test/widgets/dashboard_grid_test.dart index a9c215d0..3aab33d5 100644 --- a/test/widgets/dashboard_grid_test.dart +++ b/test/widgets/dashboard_grid_test.dart @@ -5,6 +5,7 @@ import 'package:elastic_dashboard/services/field_images.dart'; import 'package:elastic_dashboard/widgets/dashboard_grid.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_dropdown_chooser.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; +import 'package:elastic_dashboard/widgets/draggable_containers/draggable_list_layout.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/draggable_nt4_widget_container.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/camera_stream.dart'; @@ -19,6 +20,7 @@ import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/power_distribu import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/robot_preferences.dart'; import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/split_button_chooser.dart'; import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/subsystem_widget.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/swerve_drive.dart'; import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; import 'package:elastic_dashboard/widgets/nt4_widgets/single_topic/boolean_box.dart'; import 'package:elastic_dashboard/widgets/nt4_widgets/single_topic/graph.dart'; @@ -82,11 +84,12 @@ void main() async { await widgetTester.pump(Duration.zero); - expect(find.bySubtype(), findsNWidgets(9)); - expect(find.bySubtype(), findsNWidgets(9)); + expect(find.bySubtype(), findsNWidgets(10)); + expect(find.bySubtype(), findsNWidgets(11)); + expect(find.bySubtype(), findsNWidgets(12)); expect(find.bySubtype(), findsOneWidget); - expect(find.bySubtype(), findsOneWidget); + expect(find.bySubtype(), findsNWidgets(2)); expect(find.bySubtype(), findsOneWidget); expect(find.bySubtype(), findsOneWidget); expect(find.bySubtype(), findsOneWidget); @@ -94,6 +97,9 @@ void main() async { expect(find.bySubtype(), findsOneWidget); expect(find.bySubtype(), findsOneWidget); expect(find.bySubtype(), findsOneWidget); + expect(find.bySubtype(), findsOneWidget); + + expect(find.bySubtype(), findsOneWidget); }); testWidgets('Dashboard grid loading (2nd Tab)', (widgetTester) async { diff --git a/test_resources/test-layout.json b/test_resources/test-layout.json index c98988f5..e94c4acf 100644 --- a/test_resources/test-layout.json +++ b/test_resources/test-layout.json @@ -1 +1 @@ -{"tabs":[{"name":"Teleoperated","grid_layout":{"containers":[{"title":"Test Number","x":0.0,"y":0.0,"width":128.0,"height":128.0,"type":"Text Display","properties":{"topic":"/SmartDashboard/Testing","period":0.033}},{"title":"Test Boolean","x":128.0,"y":0.0,"width":128.0,"height":128.0,"type":"Boolean Box","properties":{"topic":"/SmartDashboard/Test Boolean","period":0.033,"true_color":4283215696,"false_color":4294198070}},{"title":"Field","x":256.0,"y":384.0,"width":384.0,"height":256.0,"type":"Field","properties":{"topic":"/SmartDashboard/Field","period":0.033,"field_game":"Charged Up","robot_width":0.82,"robot_length":1.0,"show_other_objects":true,"show_trajectories":true}},{"title":"Test Gyro","x":0.0,"y":128.0,"width":256.0,"height":256.0,"type":"Gyro","properties":{"topic":"/SmartDashboard/Test Gyro","period":0.033,"counter_clockwise_positive":false}},{"title":"Test Distribution","x":640.0,"y":0.0,"width":384.0,"height":512.0,"type":"PowerDistribution","properties":{"topic":"/SmartDashboard/Test Distribution","period":0.033}},{"title":"Test PID Controller","x":1024.0,"y":0.0,"width":256.0,"height":384.0,"type":"PIDController","properties":{"topic":"/SmartDashboard/Test PID Controller","period":0.033,"kp_topic":"/SmartDashboard/Test PID Controller/p","ki_topic":"/SmartDashboard/Test PID Controller/i","kd_topic":"/SmartDashboard/Test PID Controller/d","setpoint_topic":"/SmartDashboard/Test PID Controller/setpoint"}},{"title":"USB Camera 0","x":0.0,"y":384.0,"width":128.0,"height":128.0,"type":"Camera Stream","properties":{"topic":"/CameraPublisher/USB Camera 0","period":0.033}},{"title":"FMSInfo","x":256.0,"y":256.0,"width":384.0,"height":128.0,"type":"FMSInfo","properties":{"topic":"/FMSInfo","period":0.033}},{"title":"Match Time","x":128.0,"y":384.0,"width":128.0,"height":128.0,"type":"Match Time","properties":{"topic":"/SmartDashboard/Match Time","period":0.033}}]}},{"name":"Autonomous","grid_layout":{"containers":[{"title":"Test Chooser","x":0.0,"y":0.0,"width":256.0,"height":128.0,"type":"ComboBox Chooser","properties":{"topic":"/SmartDashboard/Test Chooser","period":0.033}},{"title":"Command Scheduler","x":256.0,"y":0.0,"width":256.0,"height":384.0,"type":"Scheduler","properties":{"topic":"/SmartDashboard/Command Scheduler","period":0.033}},{"title":"Drive System","x":512.0,"y":128.0,"width":256.0,"height":128.0,"type":"Subsystem","properties":{"topic":"/SmartDashboard/Drive System","period":0.033}},{"title":"Rainbow Color Array","x":0.0,"y":256.0,"width":256.0,"height":128.0,"type":"Multi Color View","properties":{"topic":"/SmartDashboard/Rainbow Color Array","period":0.033}},{"title":"Voltage","x":0.0,"y":384.0,"width":256.0,"height":128.0,"type":"Voltage View","properties":{"topic":"/SmartDashboard/Voltage","period":0.033,"min_value":4.0,"max_value":13.0,"divisions":5,"inverted":false,"orientation":"horizontal"}},{"title":"Auto Path","x":512.0,"y":0.0,"width":256.0,"height":128.0,"type":"Command","properties":{"topic":"/SmartDashboard/Auto Path","period":0.033}},{"title":"Test Boolean","x":512.0,"y":256.0,"width":128.0,"height":128.0,"type":"Toggle Switch","properties":{"topic":"/SmartDashboard/Test Boolean","period":0.033}},{"title":"Test Boolean","x":640.0,"y":256.0,"width":128.0,"height":128.0,"type":"Toggle Button","properties":{"topic":"/SmartDashboard/Test Boolean","period":0.033}},{"title":"Preferences","x":768.0,"y":0.0,"width":256.0,"height":384.0,"type":"RobotPreferences","properties":{"topic":"/Preferences","period":0.033}},{"title":"Auto Chooser","x":0.0,"y":128.0,"width":256.0,"height":128.0,"type":"Split Button Chooser","properties":{"topic":"/SmartDashboard/Auto Chooser","period":0.033}},{"title":"Rainbow Color","x":256.0,"y":384.0,"width":128.0,"height":128.0,"type":"Single Color View","properties":{"topic":"/SmartDashboard/Rainbow Color","period":0.033}},{"title":"Test Number","x":384.0,"y":384.0,"width":128.0,"height":128.0,"type":"Number Bar","properties":{"topic":"/SmartDashboard/Test Number","period":0.033,"min_value":-1.0,"max_value":1.0,"divisions":5,"inverted":false,"orientation":"horizontal"}},{"title":"Test Number","x":512.0,"y":384.0,"width":256.0,"height":128.0,"type":"Number Slider","properties":{"topic":"/SmartDashboard/Test Number","period":0.033,"min_value":-1.0,"max_value":1.0,"divisions":5}},{"title":"Voltage","x":768.0,"y":384.0,"width":256.0,"height":128.0,"type":"Graph","properties":{"topic":"/SmartDashboard/Voltage","period":0.033,"time_displayed":5.0,"min_value":null,"max_value":null,"color":4278238420}}]}}]} \ No newline at end of file +{"tabs":[{"name":"Teleoperated","grid_layout":{"layouts":[{"title":"List Layout","x":1280.0,"y":0.0,"width":256.0,"height":384.0,"type":"List Layout","children":[{"title":"Differential Drive","x":0.0,"y":0.0,"width":384.0,"height":256.0,"type":"DifferentialDrive","properties":{"topic":"/SmartDashboard/Differential Drive","period":0.033}},{"title":"Test Boolean","x":0.0,"y":0.0,"width":128.0,"height":128.0,"type":"Boolean Box","properties":{"topic":"/SmartDashboard/Test Boolean","period":0.033,"true_color":4283215696,"false_color":4294198070}}]}],"containers":[{"title":"Test Number","x":0.0,"y":0.0,"width":128.0,"height":128.0,"type":"Text Display","properties":{"topic":"/SmartDashboard/Testing","period":0.033}},{"title":"Test Boolean","x":128.0,"y":0.0,"width":128.0,"height":128.0,"type":"Boolean Box","properties":{"topic":"/SmartDashboard/Test Boolean","period":0.033,"true_color":4283215696,"false_color":4294198070}},{"title":"Field","x":256.0,"y":384.0,"width":384.0,"height":256.0,"type":"Field","properties":{"topic":"/SmartDashboard/Field","period":0.033,"field_game":"Charged Up","robot_width":0.82,"robot_length":1.0,"show_other_objects":true,"show_trajectories":true}},{"title":"Test Gyro","x":0.0,"y":128.0,"width":256.0,"height":256.0,"type":"Gyro","properties":{"topic":"/SmartDashboard/Test Gyro","period":0.033,"counter_clockwise_positive":false}},{"title":"Test Distribution","x":640.0,"y":0.0,"width":384.0,"height":512.0,"type":"PowerDistribution","properties":{"topic":"/SmartDashboard/Test Distribution","period":0.033}},{"title":"Test PID Controller","x":1024.0,"y":0.0,"width":256.0,"height":384.0,"type":"PIDController","properties":{"topic":"/SmartDashboard/Test PID Controller","period":0.033,"kp_topic":"/SmartDashboard/Test PID Controller/p","ki_topic":"/SmartDashboard/Test PID Controller/i","kd_topic":"/SmartDashboard/Test PID Controller/d","setpoint_topic":"/SmartDashboard/Test PID Controller/setpoint"}},{"title":"USB Camera 0","x":0.0,"y":384.0,"width":128.0,"height":128.0,"type":"Camera Stream","properties":{"topic":"/CameraPublisher/USB Camera 0","period":0.033}},{"title":"FMSInfo","x":256.0,"y":256.0,"width":384.0,"height":128.0,"type":"FMSInfo","properties":{"topic":"/FMSInfo","period":0.033}},{"title":"Match Time","x":128.0,"y":384.0,"width":128.0,"height":128.0,"type":"Match Time","properties":{"topic":"/SmartDashboard/Match Time","period":0.033}},{"title":"Swerve Drive","x":1024.0,"y":384.0,"width":256.0,"height":256.0,"type":"SwerveDrive","properties":{"topic":"/SmartDashboard/Swerve Drive","period":0.033,"show_robot_rotation":true}}]}},{"name":"Autonomous","grid_layout":{"layouts":[],"containers":[{"title":"Test Chooser","x":0.0,"y":0.0,"width":256.0,"height":128.0,"type":"ComboBox Chooser","properties":{"topic":"/SmartDashboard/Test Chooser","period":0.033}},{"title":"Command Scheduler","x":256.0,"y":0.0,"width":256.0,"height":384.0,"type":"Scheduler","properties":{"topic":"/SmartDashboard/Command Scheduler","period":0.033}},{"title":"Drive System","x":512.0,"y":128.0,"width":256.0,"height":128.0,"type":"Subsystem","properties":{"topic":"/SmartDashboard/Drive System","period":0.033}},{"title":"Rainbow Color Array","x":0.0,"y":256.0,"width":256.0,"height":128.0,"type":"Multi Color View","properties":{"topic":"/SmartDashboard/Rainbow Color Array","period":0.033}},{"title":"Voltage","x":0.0,"y":384.0,"width":256.0,"height":128.0,"type":"Voltage View","properties":{"topic":"/SmartDashboard/Voltage","period":0.033,"min_value":4.0,"max_value":13.0,"divisions":5,"inverted":false,"orientation":"horizontal"}},{"title":"Auto Path","x":512.0,"y":0.0,"width":256.0,"height":128.0,"type":"Command","properties":{"topic":"/SmartDashboard/Auto Path","period":0.033}},{"title":"Test Boolean","x":512.0,"y":256.0,"width":128.0,"height":128.0,"type":"Toggle Switch","properties":{"topic":"/SmartDashboard/Test Boolean","period":0.033}},{"title":"Test Boolean","x":640.0,"y":256.0,"width":128.0,"height":128.0,"type":"Toggle Button","properties":{"topic":"/SmartDashboard/Test Boolean","period":0.033}},{"title":"Preferences","x":768.0,"y":0.0,"width":256.0,"height":384.0,"type":"RobotPreferences","properties":{"topic":"/Preferences","period":0.033}},{"title":"Auto Chooser","x":0.0,"y":128.0,"width":256.0,"height":128.0,"type":"Split Button Chooser","properties":{"topic":"/SmartDashboard/Auto Chooser","period":0.033}},{"title":"Rainbow Color","x":256.0,"y":384.0,"width":128.0,"height":128.0,"type":"Single Color View","properties":{"topic":"/SmartDashboard/Rainbow Color","period":0.033}},{"title":"Test Number","x":384.0,"y":384.0,"width":128.0,"height":128.0,"type":"Number Bar","properties":{"topic":"/SmartDashboard/Test Number","period":0.033,"min_value":-1.0,"max_value":1.0,"divisions":5,"inverted":false,"orientation":"horizontal"}},{"title":"Test Number","x":512.0,"y":384.0,"width":256.0,"height":128.0,"type":"Number Slider","properties":{"topic":"/SmartDashboard/Test Number","period":0.033,"min_value":-1.0,"max_value":1.0,"divisions":5}},{"title":"Voltage","x":768.0,"y":384.0,"width":256.0,"height":128.0,"type":"Graph","properties":{"topic":"/SmartDashboard/Voltage","period":0.033,"time_displayed":5.0,"min_value":null,"max_value":null,"color":4278238420}}]}}]} \ No newline at end of file