From 5ddce9410e1190a1eebdc07b8b2111606e8ddd8e Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sun, 15 Dec 2024 11:20:21 -0500 Subject: [PATCH] Remote Layout Download from Robot (#153) Depends on https://github.com/wpilibsuite/allwpilib/pull/7527 - Adds the ability to download and merge a layout from a robot via HTTP - Deprecates the Shuffleboard API support as remote downloading is much more reliable and easier to maintain Resolves #134 --- lib/pages/dashboard_page.dart | 247 +++++++++++++++++--- lib/services/elastic_layout_downloader.dart | 45 ++++ lib/widgets/tab_grid.dart | 189 ++++++++------- 3 files changed, 362 insertions(+), 119 deletions(-) create mode 100644 lib/services/elastic_layout_downloader.dart diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index c3a99a41..6d4a5133 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -20,6 +20,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; import 'package:elastic_dashboard/services/app_distributor.dart'; +import 'package:elastic_dashboard/services/elastic_layout_downloader.dart'; import 'package:elastic_dashboard/services/hotkey_manager.dart'; import 'package:elastic_dashboard/services/ip_address_util.dart'; import 'package:elastic_dashboard/services/log.dart'; @@ -66,6 +67,9 @@ class DashboardPage extends StatefulWidget { class _DashboardPageState extends State with WindowListener { late final SharedPreferences preferences = widget.preferences; late final RobotNotificationsListener _robotNotificationListener; + late final ElasticLayoutDownloader _layoutDownloader; + + bool _seenShuffleboardWarning = false; final List _tabData = []; @@ -164,6 +168,7 @@ class _DashboardPageState extends State with WindowListener { }); }, onTabCreated: (tab) { + _showShuffleboardWarningMessage(); if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; @@ -185,43 +190,47 @@ class _DashboardPageState extends State with WindowListener { )); }, onWidgetAdded: (widgetData) { + _showShuffleboardWarningMessage(); if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; } - // Needs to be done in case if widget data gets erased by the listener - Map widgetDataCopy = {}; - - widgetData.forEach( - (key, value) => widgetDataCopy.putIfAbsent(key, () => value)); + // Needs to be converted into the tab json format + Map tabJson = {}; - List tabNamesList = _tabData.map((data) => data.name).toList(); + String tabName = widgetData['tab']; + tabJson.addAll({'containers': >[]}); + tabJson.addAll({'layouts': >[]}); - String tabName = widgetDataCopy['tab']; + if (!(widgetData.containsKey('layout') && widgetData['layout'])) { + tabJson['containers']!.add(widgetData); + } else { + tabJson['layouts']!.add(widgetData); + } - if (!tabNamesList.contains(tabName)) { + if (!_tabData.any((tab) => tab.name == tabName)) { _tabData.add( TabData( name: tabName, - tabGrid: TabGridModel( + tabGrid: TabGridModel.fromJson( ntConnection: widget.ntConnection, preferences: widget.preferences, + jsonData: tabJson, + onJsonLoadingWarning: _showJsonLoadingWarning, onAddWidgetPressed: _displayAddWidgetDialog, ), ), ); - - tabNamesList.add(tabName); - } - - int tabIndex = tabNamesList.indexOf(tabName); - - if (tabIndex == -1) { - return; + } else { + _tabData + .firstWhere((tab) => tab.name == tabName) + .tabGrid + .mergeFromJson( + jsonData: tabJson, + onJsonLoadingWarning: _showJsonLoadingWarning, + ); } - _tabData[tabIndex].tabGrid.addWidgetFromTabJson(widgetDataCopy); - setState(() {}); }, ); @@ -268,6 +277,8 @@ class _DashboardPageState extends State with WindowListener { }); }); _robotNotificationListener.listen(); + + _layoutDownloader = ElasticLayoutDownloader(); } @override @@ -421,11 +432,13 @@ class _DashboardPageState extends State with WindowListener { await launchUrl(url); } }, - child: Text('Update', - style: textTheme.bodyMedium!.copyWith( - color: buttonTheme.colorScheme?.primary, - fontWeight: FontWeight.bold, - )), + child: Text( + 'Update', + style: textTheme.bodyMedium!.copyWith( + color: buttonTheme.colorScheme?.primary, + fontWeight: FontWeight.bold, + ), + ), ), ); @@ -609,6 +622,77 @@ class _DashboardPageState extends State with WindowListener { } } + bool _mergeLayoutFromJsonData(String jsonString) { + logger.info('Merging layout from json'); + + Map? jsonData = tryCast(jsonDecode(jsonString)); + + if (!_validateJsonData(jsonData)) { + return false; + } + + for (Map tabJson in jsonData!['tabs']) { + String tabName = tabJson['name']; + if (!_tabData.any((tab) => tab.name == tabName)) { + _tabData.add( + TabData( + name: tabName, + tabGrid: TabGridModel.fromJson( + ntConnection: widget.ntConnection, + preferences: widget.preferences, + jsonData: tabJson['grid_layout'], + onAddWidgetPressed: _displayAddWidgetDialog, + onJsonLoadingWarning: _showJsonLoadingWarning, + ), + ), + ); + } else { + TabGridModel existingTab = + _tabData.firstWhere((tab) => tab.name == tabName).tabGrid; + existingTab.mergeFromJson( + jsonData: tabJson['grid_layout'], + onJsonLoadingWarning: _showJsonLoadingWarning, + ); + } + } + + _showNotification( + title: 'Successfully Downloaded Layout', + message: 'Remote layout has been successfully downloaded and merged!', + color: const Color(0xff01CB67), + icon: const Icon(Icons.error, color: Color(0xff01CB67)), + width: 350, + ); + + setState(() {}); + + return true; + } + + void _loadLayoutFromRobot() async { + if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { + return; + } + + LayoutDownloadResponse response = await _layoutDownloader.downloadLayout( + ntConnection: widget.ntConnection, + preferences: preferences, + ); + + if (!response.successful) { + _showNotification( + title: 'Failed to Download Layout', + message: response.data, + color: const Color(0xffFE355C), + icon: const Icon(Icons.error, color: Color(0xffFE355C)), + width: 400, + ); + return; + } + + _mergeLayoutFromJsonData(response.data); + } + void _createDefaultTabs() { if (_tabData.isEmpty) { logger.info('Creating default Teleoperated and Autonomous tabs'); @@ -635,6 +719,58 @@ class _DashboardPageState extends State with WindowListener { } } + void _showShuffleboardWarningMessage() { + if (_seenShuffleboardWarning) { + return; + } + ColorScheme colorScheme = Theme.of(context).colorScheme; + TextTheme textTheme = Theme.of(context).textTheme; + ButtonThemeData buttonTheme = ButtonTheme.of(context); + + ElegantNotification notification = ElegantNotification( + autoDismiss: false, + background: colorScheme.surface, + showProgressIndicator: false, + width: 450, + height: 160, + position: Alignment.bottomRight, + icon: const Icon(Icons.warning, color: Colors.yellow), + action: TextButton( + onPressed: () async { + Uri url = Uri.parse( + 'https://frc-elastic.gitbook.io/docs/additional-features-and-references/shuffleboard-api-integration'); + + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + }, + child: Text( + 'Documentation', + style: textTheme.bodyMedium!.copyWith( + color: buttonTheme.colorScheme?.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + title: Text( + 'Shuffleboard API Deprecation', + style: textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + description: const Text( + 'Support for the Shuffleboard API is deprecated in favor of remote layout downloading and will be removed after the 2025 season. See the documentation for more details about migration.', + overflow: TextOverflow.ellipsis, + maxLines: 4, + ), + ); + + if (mounted) { + notification.show(context); + } + _seenShuffleboardWarning = true; + } + void _showJsonLoadingError(String errorMessage) { logger.error(errorMessage); Future(() { @@ -728,6 +864,21 @@ class _DashboardPageState extends State with WindowListener { ), callback: _exportLayout, ); + // Download from robot (Ctrl + D) + hotKeyManager.register( + HotKey( + LogicalKeyboardKey.keyD, + modifiers: [KeyModifier.control], + ), + callback: () { + if (preferences.getBool(PrefKeys.layoutLocked) ?? + Defaults.layoutLocked) { + return; + } + + _loadLayoutFromRobot(); + }, + ); // Switch to Tab (Ctrl + Tab #) for (int i = 1; i <= 9; i++) { hotKeyManager.register( @@ -1457,18 +1608,42 @@ class _DashboardPageState extends State with WindowListener { ), // Export layout MenuItemButton( - style: menuButtonStyle, - onPressed: _exportLayout, - shortcut: const SingleActivator(LogicalKeyboardKey.keyS, - shift: true, control: true), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.save_as_outlined), - SizedBox(width: 8), - Text('Save As'), - ], - )), + style: menuButtonStyle, + onPressed: _exportLayout, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyS, + shift: true, + control: true, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.save_as_outlined), + SizedBox(width: 8), + Text('Save As'), + ], + ), + ), + // Download layout + MenuItemButton( + style: menuButtonStyle, + onPressed: !(preferences.getBool(PrefKeys.layoutLocked) ?? + Defaults.layoutLocked) + ? _loadLayoutFromRobot + : null, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyD, + control: true, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.download), + SizedBox(width: 8), + Text('Download From Robot'), + ], + ), + ), ], child: const Text( 'File', diff --git a/lib/services/elastic_layout_downloader.dart b/lib/services/elastic_layout_downloader.dart new file mode 100644 index 00000000..c1daf7cd --- /dev/null +++ b/lib/services/elastic_layout_downloader.dart @@ -0,0 +1,45 @@ +import 'package:http/http.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:elastic_dashboard/services/nt_connection.dart'; +import 'package:elastic_dashboard/services/settings.dart'; + +typedef LayoutDownloadResponse = ({bool successful, String data}); + +class ElasticLayoutDownloader { + final Client client = Client(); + + Future downloadLayout({ + required NTConnection ntConnection, + required SharedPreferences preferences, + }) async { + if (!ntConnection.isNT4Connected) { + return ( + successful: false, + data: + 'Cannot download a remote layout while disconnected from the robot.' + ); + } + String robotIP = + preferences.getString(PrefKeys.ipAddress) ?? Defaults.ipAddress; + Uri robotUri = Uri.parse( + 'http://$robotIP:5800/elastic-layout.json', + ); + Response response; + try { + response = await client.get(robotUri); + } on ClientException catch (e) { + return (successful: false, data: e.message); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + String errorMessage = switch (response.statusCode) { + 404 => + 'File "elastic-layout.json" was not found, ensure that you have deployed a file named "elastic_layout.json" in the deploy directory', + _ => 'Request returned status code ${response.statusCode}', + }; + + return (successful: false, data: errorMessage); + } + return (successful: true, data: response.body); + } +} diff --git a/lib/widgets/tab_grid.dart b/lib/widgets/tab_grid.dart index 8ece0484..9d641292 100644 --- a/lib/widgets/tab_grid.dart +++ b/lib/widgets/tab_grid.dart @@ -34,10 +34,11 @@ class TabGridModel extends ChangeNotifier { final VoidCallback onAddWidgetPressed; - TabGridModel( - {required this.ntConnection, - required this.preferences, - required this.onAddWidgetPressed}); + TabGridModel({ + required this.ntConnection, + required this.preferences, + required this.onAddWidgetPressed, + }); TabGridModel.fromJson({ required this.ntConnection, @@ -47,8 +48,10 @@ class TabGridModel extends ChangeNotifier { Function(String message)? onJsonLoadingWarning, }) { if (jsonData['containers'] != null) { - loadContainersFromJson(jsonData, - onJsonLoadingWarning: onJsonLoadingWarning); + loadContainersFromJson( + jsonData, + onJsonLoadingWarning: onJsonLoadingWarning, + ); } if (jsonData['layouts'] != null) { @@ -60,6 +63,103 @@ class TabGridModel extends ChangeNotifier { } } + void mergeFromJson({ + required Map jsonData, + Function(String message)? onJsonLoadingWarning, + }) { + if (jsonData['containers'] != null) { + for (Map widgetData in jsonData['containers']) { + Rect newWidgetLocation = Rect.fromLTWH( + tryCast(widgetData['x']) ?? 0.0, + tryCast(widgetData['y']) ?? 0.0, + tryCast(widgetData['width']) ?? 0.0, + tryCast(widgetData['height']) ?? 0.0, + ); + + bool valid = true; + + for (NTWidgetContainerModel container + in _widgetModels.whereType()) { + String? title = container.title; + String? type = container.childModel.type; + String? topic = container.childModel.topic; + bool validLocation = isValidLocation(newWidgetLocation); + + if (title == widgetData['title'] && + type == widgetData['type'] && + topic == widgetData['properties']['topic'] || + !validLocation) { + valid = false; + break; + } + } + + if (valid) { + addWidget( + NTWidgetContainerModel.fromJson( + ntConnection: ntConnection, + jsonData: widgetData, + preferences: preferences, + enabled: ntConnection.isNT4Connected, + onJsonLoadingWarning: onJsonLoadingWarning, + ), + ); + } + } + } + + if (jsonData['layouts'] != null) { + for (Map widgetData in jsonData['layouts']) { + Rect newWidgetLocation = Rect.fromLTWH( + tryCast(widgetData['x']) ?? 0.0, + tryCast(widgetData['y']) ?? 0.0, + tryCast(widgetData['width']) ?? 0.0, + tryCast(widgetData['height']) ?? 0.0, + ); + + bool valid = true; + + for (LayoutContainerModel container + in _widgetModels.whereType()) { + String? title = container.title; + String type = container.type; + bool validLocation = isValidLocation(newWidgetLocation); + + if (title == widgetData['title'] && type == widgetData['type'] || + !validLocation) { + valid = false; + break; + } + } + + if (valid && widgetData['type'] == 'List Layout') { + addWidget( + ListLayoutModel.fromJson( + jsonData: widgetData, + preferences: preferences, + ntWidgetBuilder: (preferences, jsonData, enabled, + {onJsonLoadingWarning}) => + NTWidgetContainerModel.fromJson( + ntConnection: ntConnection, + jsonData: jsonData, + preferences: preferences, + onJsonLoadingWarning: onJsonLoadingWarning, + ), + enabled: ntConnection.isNT4Connected, + dragOutFunctions: ( + dragOutUpdate: layoutDragOutUpdate, + dragOutEnd: layoutDragOutEnd, + ), + onDragCancel: _layoutContainerOnDragCancel, + minWidth: 128.0 * 2, + minHeight: 128.0 * 2, + ), + ); + } + } + } + } + void loadContainersFromJson(Map jsonData, {Function(String message)? onJsonLoadingWarning}) { for (Map containerData in jsonData['containers']) { @@ -560,83 +660,6 @@ class TabGridModel extends ChangeNotifier { notifyListeners(); } - void addWidgetFromTabJson(Map widgetData) { - Rect newWidgetLocation = Rect.fromLTWH( - tryCast(widgetData['x']) ?? 0.0, - tryCast(widgetData['y']) ?? 0.0, - tryCast(widgetData['width']) ?? 0.0, - tryCast(widgetData['height']) ?? 0.0, - ); - // If the widget is already in the tab, don't add it - if (!(widgetData.containsKey('layout') && widgetData['layout'])) { - for (NTWidgetContainerModel container - in _widgetModels.whereType()) { - String? title = container.title; - String? type = container.childModel.type; - String? topic = container.childModel.topic; - bool validLocation = isValidLocation(newWidgetLocation); - - if (title == widgetData['title'] && - type == widgetData['type'] && - topic == widgetData['properties']['topic'] && - !validLocation) { - return; - } - } - } else { - for (LayoutContainerModel container - in _widgetModels.whereType()) { - String? title = container.title; - String type = container.type; - bool validLocation = isValidLocation(newWidgetLocation); - - if (title == widgetData['title'] && - type == widgetData['type'] && - !validLocation) { - return; - } - } - } - - if (widgetData.containsKey('layout') && widgetData['layout']) { - switch (widgetData['type']) { - case 'List Layout': - addWidget( - ListLayoutModel.fromJson( - preferences: preferences, - jsonData: widgetData, - ntWidgetBuilder: (preferences, jsonData, enabled, - {onJsonLoadingWarning}) => - NTWidgetContainerModel.fromJson( - ntConnection: ntConnection, - jsonData: jsonData, - preferences: preferences, - onJsonLoadingWarning: onJsonLoadingWarning, - ), - enabled: ntConnection.isNT4Connected, - dragOutFunctions: ( - dragOutUpdate: layoutDragOutUpdate, - dragOutEnd: layoutDragOutEnd, - ), - onDragCancel: _layoutContainerOnDragCancel, - minWidth: 128.0 * 2, - minHeight: 128.0 * 2, - ), - ); - break; - } - } else { - addWidget( - NTWidgetContainerModel.fromJson( - ntConnection: ntConnection, - preferences: preferences, - enabled: ntConnection.isNT4Connected, - jsonData: widgetData, - ), - ); - } - } - void removeWidget(WidgetContainerModel widget) { widget.removeListener(notifyListeners); widget.disposeModel(deleting: true);