From 39af1a9a00ec516f139bfb58cd5d968e49c60dba Mon Sep 17 00:00:00 2001 From: Isaac S <60453194+WindingMotor@users.noreply.github.com> Date: Mon, 30 Sep 2024 19:24:13 -0400 Subject: [PATCH] UI Improvement and Features (#705) --- .vscode/settings.json | 4 + images/icon.ico | Bin 0 -> 93062 bytes lib/pages/home_page.dart | 339 ++++---- lib/pages/project/project_item_card.dart | 2 +- lib/pages/project/project_page.dart | 803 +++++++++--------- lib/pages/telemetry_page.dart | 258 ++++-- lib/util/path_painter_util.dart | 87 +- lib/util/prefs.dart | 4 + lib/widgets/dialogs/management_dialog.dart | 4 +- lib/widgets/editor/info_card.dart | 26 + lib/widgets/editor/path_painter.dart | 41 +- lib/widgets/editor/preview_seekbar.dart | 88 +- lib/widgets/editor/runtime_display.dart | 127 +++ lib/widgets/editor/split_auto_editor.dart | 53 +- .../editor/split_choreo_path_editor.dart | 1 + lib/widgets/editor/split_path_editor.dart | 36 +- .../commands/command_group_widget.dart | 2 +- .../commands/duplicate_command_button.dart | 2 +- .../commands/named_command_widget.dart | 10 +- .../commands/path_command_widget.dart | 20 +- .../commands/wait_command_widget.dart | 37 +- .../tree_widgets/constraint_zones_tree.dart | 53 +- .../tree_widgets/editor_settings_tree.dart | 165 ++-- .../tree_widgets/event_markers_tree.dart | 147 ++-- .../tree_widgets/global_constraints_tree.dart | 3 +- .../tree_widgets/goal_end_state_tree.dart | 17 +- .../ideal_starting_state_tree.dart | 12 +- .../editor/tree_widgets/item_count.dart | 13 +- .../tree_widgets/path_optimization_tree.dart | 244 +++--- .../editor/tree_widgets/path_tree.dart | 344 +++++--- .../editor/tree_widgets/reset_odom_tree.dart | 2 +- .../tree_widgets/rotation_targets_tree.dart | 90 +- .../editor/tree_widgets/tree_card_node.dart | 10 +- .../editor/tree_widgets/waypoints_tree.dart | 91 +- lib/widgets/window_buttons.dart | 5 +- linux/flutter/CMakeLists.txt | 2 +- linux/my_application.cc | 3 + pubspec.lock | 4 +- pubspec.yaml | 1 + test/pages/home_page_test.dart | 44 + test/pages/project/project_page_test.dart | 220 ++++- test/pages/telemetry_page_test.dart | 214 ++++- test/widgets/editor/preview_seekbar_test.dart | 94 +- .../editor/split_choreo_path_editor_test.dart | 2 + .../editor/split_path_editor_test.dart | 52 +- .../constraint_zones_tree_test.dart | 3 +- .../editor_settings_tree_test.dart | 99 +-- .../tree_widgets/event_markers_tree_test.dart | 58 +- .../goal_end_state_tree_test.dart | 8 +- .../ideal_starting_state_tree_test.dart | 2 +- .../path_optimization_tree_test.dart | 35 +- .../editor/tree_widgets/path_tree_test.dart | 39 +- .../rotation_targets_tree_test.dart | 63 +- .../tree_widgets/runtime_display_test.dart | 75 ++ .../tree_widgets/waypoints_tree_test.dart | 46 +- 55 files changed, 2815 insertions(+), 1389 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 images/icon.ico create mode 100644 lib/widgets/editor/info_card.dart create mode 100644 lib/widgets/editor/runtime_display.dart create mode 100644 test/pages/home_page_test.dart create mode 100644 test/widgets/editor/tree_widgets/runtime_display_test.dart diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..f8ca22713 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "cmake.ignoreCMakeListsMissing": true, + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/images/icon.ico b/images/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..407d2db865825a031e34abd651b7110d886f890a GIT binary patch literal 93062 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 literal 0 HcmV?d00001 diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 59b3f307c..097c37385 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -17,7 +17,6 @@ import 'package:pathplanner/services/log.dart'; import 'package:pathplanner/services/pplib_telemetry.dart'; import 'package:pathplanner/services/update_checker.dart'; import 'package:pathplanner/util/prefs.dart'; -import 'package:pathplanner/widgets/conditional_widget.dart'; import 'package:pathplanner/widgets/custom_appbar.dart'; import 'package:pathplanner/widgets/field_image.dart'; import 'package:pathplanner/widgets/dialogs/settings_dialog.dart'; @@ -237,11 +236,19 @@ class _HomePageState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { return Scaffold( - key: _key, appBar: CustomAppBar( + leading: Builder( + builder: (BuildContext context) { + return IconButton( + icon: const Icon(Icons.menu), + tooltip: 'Menu', + onPressed: () => Scaffold.of(context).openDrawer(), + ); + }, + ), titleWidget: Text( _projectDir == null ? 'PathPlanner' : basename(_projectDir!.path), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), ), ), drawer: _projectDir == null ? null : _buildDrawer(context), @@ -256,173 +263,183 @@ class _HomePageState extends State with TickerProviderStateMixin { ColorScheme colorScheme = Theme.of(context).colorScheme; return Stack( children: [ - NavigationDrawer( - selectedIndex: _selectedPage, - onDestinationSelected: (idx) { - setState(() { - _selectedPage = idx; - _pageController.animateToPage(_selectedPage, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut); - }); - }, - backgroundColor: colorScheme.surface, - surfaceTintColor: colorScheme.surfaceTint, - children: [ - DrawerHeader( - child: Stack( - children: [ - Align( - alignment: FractionalOffset.bottomLeft, - child: Text( - 'v${widget.appVersion}', - style: TextStyle(color: colorScheme.onSurface), - ), - ), - Align( - alignment: FractionalOffset.bottomRight, - child: StreamBuilder( - stream: widget.telemetry.connectionStatusStream(), - builder: (context, snapshot) { - return ConditionalWidget( - condition: snapshot.data ?? false, - trueChild: const Tooltip( - message: 'Connected to Robot', - child: Icon( - Icons.lan, - size: 20, - color: Colors.green, - ), - ), - falseChild: const Tooltip( - message: 'Not Connected to Robot', - child: Icon( - Icons.lan_outlined, - size: 20, - color: Colors.red, - ), - ), - ); - }), - ), - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - flex: 2, - child: Container(), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - basename(_projectDir!.path), - style: const TextStyle( - fontSize: 20, - ), - ), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - foregroundColor: colorScheme.onPrimaryContainer, - backgroundColor: colorScheme.primaryContainer, - ), - onPressed: () { - _openProjectDialog(context); - }, - child: const Text('Switch Project'), - ), - Expanded( - flex: 4, - child: Container(), - ), - ], - ), - ), - ], + SizedBox( + width: 260, + child: NavigationDrawer( + selectedIndex: _selectedPage, + onDestinationSelected: _handleDestinationSelected, + backgroundColor: colorScheme.surface, + surfaceTintColor: colorScheme.surfaceTint, + children: [ + _buildDrawerHeader(colorScheme), + ..._buildNavigationDestinations(), + ], + ), + ), + _buildBottomButtons(colorScheme), + ], + ); + } + + Widget _buildDrawerHeader(ColorScheme colorScheme) { + return DrawerHeader( + child: Column( + children: [ + Expanded( + child: Center( + child: Text( + basename(_projectDir!.path), + style: const TextStyle(fontSize: 20), ), ), - const NavigationDrawerDestination( - icon: Icon(Icons.folder_outlined), - label: Text('Project Browser'), - ), - const NavigationDrawerDestination( - icon: Icon(Icons.bar_chart), - label: Text('Telemetry'), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'v${widget.appVersion}', + style: TextStyle(color: colorScheme.onSurface), + ), + IconButton( + icon: const Icon(Icons.open_in_new_rounded, size: 20), + tooltip: 'Open Project', + onPressed: () => _openProjectDialog(this.context), + ), + ], + ), + ], + ), + ); + } + + List _buildNavigationDestinations() { + return [ + const NavigationDrawerDestination( + icon: Icon(Icons.folder_rounded), + label: Text('Project Browser'), + ), + const SizedBox(height: 5), + NavigationDrawerDestination( + icon: Icon( + _getConnectedIcon(widget.telemetry.isConnected), + color: _getConnectedIconColor(widget.telemetry.isConnected), + ), + label: const Text('Telemetry'), + ), + const SizedBox(height: 5), + const NavigationDrawerDestination( + icon: Icon(Icons.grid_on_rounded), + label: Text('Navigation Grid'), + ), + ]; + } + + Widget _buildBottomButtons(ColorScheme colorScheme) { + return Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: const EdgeInsets.only(bottom: 12.0, left: 8.0), + child: Row( + children: [ + _buildButton( + onPressed: () => launchUrl(Uri.parse('https://pathplanner.dev')), + icon: const Icon(Icons.description), + label: 'Docs', + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, ), - const NavigationDrawerDestination( - icon: Icon(Icons.grid_on), - label: Text('Navigation Grid'), + const SizedBox(width: 6), + _buildButton( + onPressed: () { + Navigator.pop(this.context); + _showSettingsDialog(); + }, + icon: const Icon(Icons.settings), + label: 'Settings', + backgroundColor: colorScheme.surfaceContainer, + foregroundColor: colorScheme.onSurface, + surfaceTintColor: colorScheme.surfaceTint, ), ], ), - Align( - alignment: Alignment.bottomLeft, - child: Padding( - padding: const EdgeInsets.only(bottom: 12.0, left: 8.0), - child: Row( - children: [ - ElevatedButton.icon( - onPressed: () { - launchUrl(Uri.parse('https://pathplanner.dev')); - }, - icon: const Icon(Icons.description), - label: const Text('Docs'), - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.primaryContainer, - foregroundColor: colorScheme.onPrimaryContainer, - elevation: 4.0, - fixedSize: const Size(141, 56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16)), - ), - ), - const SizedBox(width: 6), - ElevatedButton.icon( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return SettingsDialog( - prefs: widget.prefs, - onTeamColorChanged: widget.onTeamColorChanged, - fieldImages: _fieldImages, - selectedField: _fieldImage ?? FieldImage.defaultField, - onFieldSelected: (FieldImage image) { - setState(() { - _fieldImage = image; - if (!_fieldImages.contains(image)) { - _fieldImages.add(image); - } - widget.prefs - .setString(PrefsKeys.fieldImage, image.name); - }); - }, - onSettingsChanged: _onProjectSettingsChanged, - ); - }, - ); - }, - icon: const Icon(Icons.settings), - label: const Text('Settings'), - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.surfaceContainer, - foregroundColor: colorScheme.onSurface, - surfaceTintColor: colorScheme.surfaceTint, - elevation: 4.0, - fixedSize: const Size(141, 56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16)), - ), - ), - ], - ), - ), - ), - ], + ), + ); + } + + Widget _buildButton({ + required VoidCallback onPressed, + required Widget icon, + required String label, + required Color backgroundColor, + required Color foregroundColor, + Color? surfaceTintColor, + }) { + return ElevatedButton.icon( + onPressed: onPressed, + icon: icon, + label: Text(label, style: const TextStyle(fontSize: 12)), + style: ElevatedButton.styleFrom( + fixedSize: const Size(120, 50), + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + surfaceTintColor: surfaceTintColor, + elevation: 4.0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), ); } + void _handleDestinationSelected(int index) { + setState(() { + _selectedPage = index; + _pageController.animateToPage( + _selectedPage, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + ); + }); + Navigator.pop(this.context); + } + + IconData _getConnectedIcon(bool isConnected) { + return isConnected ? Icons.lan : Icons.lan_outlined; + } + + Color _getConnectedIconColor(bool isConnected) { + return isConnected ? Colors.green : Colors.red; + } + + void _showSettingsDialog() { + showDialog( + context: this.context, + barrierDismissible: true, // Allow dismissing by tapping outside + builder: (BuildContext context) { + return Theme( + data: Theme.of(context), // Use the current theme + child: SettingsDialog( + prefs: widget.prefs, + onTeamColorChanged: widget.onTeamColorChanged, + fieldImages: _fieldImages, + selectedField: _fieldImage ?? FieldImage.defaultField, + onFieldSelected: (FieldImage image) { + setState(() { + _fieldImage = image; + if (!_fieldImages.contains(image)) { + _fieldImages.add(image); + } + widget.prefs.setString(PrefsKeys.fieldImage, image.name); + }); + }, + onSettingsChanged: _onProjectSettingsChanged, + ), + ); + }, + ).then((_) { + // Ensure the app rebuilds correctly after dialog is closed + setState(() {}); + }); + } + Widget _buildBody(BuildContext context) { if (_projectDir != null) { return Stack( diff --git a/lib/pages/project/project_item_card.dart b/lib/pages/project/project_item_card.dart index 0351cc2a6..7502efd65 100644 --- a/lib/pages/project/project_item_card.dart +++ b/lib/pages/project/project_item_card.dart @@ -217,7 +217,7 @@ class _ProjectItemCardState extends State { child: Icon( Icons.warning_amber_rounded, size: widget.compact ? 32 : 48, - color: Colors.yellow, + color: Colors.orange[300]!, shadows: widget.compact ? null : const [ diff --git a/lib/pages/project/project_page.dart b/lib/pages/project/project_page.dart index 34713e2b7..7661f1c56 100644 --- a/lib/pages/project/project_page.dart +++ b/lib/pages/project/project_page.dart @@ -84,6 +84,12 @@ class _ProjectPageState extends State { DirectoryWatcher? _chorWatcher; StreamSubscription? _chorWatcherSub; + String _pathSearchQuery = ''; + String _autoSearchQuery = ''; + + late TextEditingController _pathSearchController; + late TextEditingController _autoSearchController; + bool _loading = true; String? _pathFolder; @@ -98,6 +104,9 @@ class _ProjectPageState extends State { void initState() { super.initState(); + _pathSearchController = TextEditingController(); + _autoSearchController = TextEditingController(); + double leftWeight = widget.prefs.getDouble(PrefsKeys.projectLeftWeight) ?? Defaults.projectLeftWeight; _controller.areas = [ @@ -163,6 +172,8 @@ class _ProjectPageState extends State { void dispose() { _chorWatcherSub?.cancel(); + _pathSearchController.dispose(); + _autoSearchController.dispose(); super.dispose(); } @@ -235,6 +246,10 @@ class _ProjectPageState extends State { Widget build(BuildContext context) { ColorScheme colorScheme = Theme.of(context).colorScheme; + // Update _pathSortValue from shared preferences + _pathSortValue = widget.prefs.getString(PrefsKeys.pathSortOption) ?? + Defaults.pathSortOption; + if (_loading) { return const Center( child: CircularProgressIndicator(), @@ -413,31 +428,18 @@ class _ProjectPageState extends State { child: Card( elevation: 0.0, margin: const EdgeInsets.all(0), - color: colorScheme.primary, - surfaceTintColor: colorScheme.surfaceTint, + color: colorScheme.surface, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - const Padding( - padding: EdgeInsets.only(left: 4.0), - child: Text( - 'Choreo Paths', - style: TextStyle(fontSize: 32), - ), - ), - Expanded(child: Container()), - ], - ), - const Divider(), _buildOptionsRow( sortValue: _pathSortValue, viewValue: _pathsCompact, - onSortChanged: (value) { - widget.prefs.setString(PrefsKeys.pathSortOption, value); + onSortChanged: (value) async { + await widget.prefs + .setString(PrefsKeys.pathSortOption, value); setState(() { _pathSortValue = value; _sortPaths(_pathSortValue); @@ -449,6 +451,48 @@ class _ProjectPageState extends State { _pathsCompact = value; }); }, + onSearchChanged: (value) { + setState(() { + _pathSearchQuery = value; + }); + }, + searchController: _pathSearchController, + onAddFolder: () { + String folderName = 'New Folder'; + while (_pathFolders.contains(folderName)) { + folderName = 'New $folderName'; + } + + setState(() { + _pathFolders.add(folderName); + _sortPaths(_pathSortValue); + }); + widget.prefs + .setStringList(PrefsKeys.pathFolders, _pathFolders); + widget.onFoldersChanged?.call(); + }, + onAddItem: () { + List pathNames = []; + for (PathPlannerPath path in _paths) { + pathNames.add(path.name); + } + String pathName = 'New Path'; + while (pathNames.contains(pathName)) { + pathName = 'New $pathName'; + } + + setState(() { + _paths.add(PathPlannerPath.defaultPath( + pathDir: _pathsDirectory.path, + name: pathName, + fs: fs, + folder: _pathFolder, + constraints: _getDefaultConstraints(), + )); + _sortPaths(_pathSortValue); + }); + }, + isPathsView: true, ), GridView.count( crossAxisCount: _pathGridCount, @@ -509,7 +553,7 @@ class _ProjectPageState extends State { } return Padding( - padding: const EdgeInsets.only(left: 8.0, bottom: 8.0), + padding: const EdgeInsets.only(left: 8.0, top: 8.0), child: Card( elevation: 0.0, margin: const EdgeInsets.all(0), @@ -518,124 +562,7 @@ class _ProjectPageState extends State { padding: const EdgeInsets.all(8.0), child: Column( children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - _pathFolder ?? 'Paths', - style: const TextStyle(fontSize: 32), - ), - ), - Expanded(child: Container()), - ConditionalWidget( - condition: _pathFolder == null, - falseChild: Tooltip( - message: 'Delete path folder', - waitDuration: const Duration(seconds: 1), - child: IconButton.filledTonal( - onPressed: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Delete Folder'), - content: SizedBox( - width: 400, - child: Text( - 'Are you sure you want to delete the folder "$_pathFolder"?\n\nThis will also delete all paths within the folder. This cannot be undone.'), - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: const Text('CANCEL'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - - for (int p = 0; - p < _paths.length; - p++) { - if (_paths[p].folder == _pathFolder) { - _paths[p].deletePath(); - } - } - - setState(() { - _paths.removeWhere((path) => - path.folder == _pathFolder); - _pathFolders.remove(_pathFolder); - _pathFolder = null; - }); - widget.prefs.setStringList( - PrefsKeys.pathFolders, - _pathFolders); - widget.onFoldersChanged?.call(); - }, - child: const Text('DELETE'), - ), - ], - ); - }); - }, - icon: const Icon(Icons.delete_forever), - ), - ), - trueChild: Tooltip( - message: 'Add new path folder', - waitDuration: const Duration(seconds: 1), - child: IconButton.filledTonal( - onPressed: () { - String folderName = 'New Folder'; - while (_pathFolders.contains(folderName)) { - folderName = 'New $folderName'; - } - - setState(() { - _pathFolders.add(folderName); - _sortPaths(_pathSortValue); - }); - widget.prefs.setStringList( - PrefsKeys.pathFolders, _pathFolders); - widget.onFoldersChanged?.call(); - }, - icon: const Icon(Icons.create_new_folder_outlined), - ), - ), - ), - const SizedBox(width: 4), - Tooltip( - message: 'Add new path', - waitDuration: const Duration(seconds: 1), - child: IconButton.filled( - onPressed: () { - List pathNames = []; - for (PathPlannerPath path in _paths) { - pathNames.add(path.name); - } - String pathName = 'New Path'; - while (pathNames.contains(pathName)) { - pathName = 'New $pathName'; - } - - setState(() { - _paths.add(PathPlannerPath.defaultPath( - pathDir: _pathsDirectory.path, - name: pathName, - fs: fs, - folder: _pathFolder, - constraints: _getDefaultConstraints(), - )); - _sortPaths(_pathSortValue); - }); - }, - icon: const Icon(Icons.add), - ), - ), - ], - ), - const Divider(), + const SizedBox(height: 8), _buildOptionsRow( sortValue: _pathSortValue, viewValue: _pathsCompact, @@ -652,6 +579,48 @@ class _ProjectPageState extends State { _pathsCompact = value; }); }, + onSearchChanged: (value) { + setState(() { + _pathSearchQuery = value; + }); + }, + searchController: _pathSearchController, + onAddFolder: () { + String folderName = 'New Folder'; + while (_pathFolders.contains(folderName)) { + folderName = 'New $folderName'; + } + + setState(() { + _pathFolders.add(folderName); + _sortPaths(_pathSortValue); + }); + widget.prefs + .setStringList(PrefsKeys.pathFolders, _pathFolders); + widget.onFoldersChanged?.call(); + }, + onAddItem: () { + List pathNames = []; + for (PathPlannerPath path in _paths) { + pathNames.add(path.name); + } + String pathName = 'New Path'; + while (pathNames.contains(pathName)) { + pathName = 'New $pathName'; + } + + setState(() { + _paths.add(PathPlannerPath.defaultPath( + pathDir: _pathsDirectory.path, + name: pathName, + fs: fs, + folder: _pathFolder, + constraints: _getDefaultConstraints(), + )); + _sortPaths(_pathSortValue); + }); + }, + isPathsView: true, ), Expanded( child: ListView( @@ -887,7 +856,11 @@ class _ProjectPageState extends State { shrinkWrap: true, children: [ for (int i = 0; i < _paths.length; i++) - if (_paths[i].folder == _pathFolder) + if (_paths[i].folder == _pathFolder && + _paths[i] + .name + .toLowerCase() + .contains(_pathSearchQuery.toLowerCase())) _buildPathCard(i, context), ], ), @@ -1095,7 +1068,7 @@ class _ProjectPageState extends State { ColorScheme colorScheme = Theme.of(context).colorScheme; return Padding( - padding: const EdgeInsets.only(right: 8.0, bottom: 8.0), + padding: const EdgeInsets.only(right: 8.0, top: 8.0), child: Card( elevation: 0.0, margin: const EdgeInsets.all(0), @@ -1104,135 +1077,7 @@ class _ProjectPageState extends State { padding: const EdgeInsets.all(8.0), child: Column( children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - _autoFolder ?? 'Autos', - style: const TextStyle(fontSize: 32), - ), - ), - Expanded(child: Container()), - ConditionalWidget( - condition: _autoFolder == null, - falseChild: Tooltip( - message: 'Delete auto folder', - waitDuration: const Duration(seconds: 1), - child: IconButton.filledTonal( - onPressed: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Delete Folder'), - content: SizedBox( - width: 400, - child: Text( - 'Are you sure you want to delete the folder "$_autoFolder"?\n\nThis will also delete all autos within the folder. This cannot be undone.'), - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: const Text('CANCEL'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - - for (int a = 0; - a < _autos.length; - a++) { - if (_autos[a].folder == _autoFolder) { - _autos[a].delete(); - } - } - - setState(() { - _autos.removeWhere((auto) => - auto.folder == _autoFolder); - _autoFolders.remove(_autoFolder); - _autoFolder = null; - }); - widget.prefs.setStringList( - PrefsKeys.autoFolders, - _autoFolders); - widget.onFoldersChanged?.call(); - }, - child: const Text('DELETE'), - ), - ], - ); - }); - }, - icon: const Icon(Icons.delete_forever), - ), - ), - trueChild: Tooltip( - message: 'Add new auto folder', - waitDuration: const Duration(seconds: 1), - child: IconButton.filledTonal( - onPressed: () { - String folderName = 'New Folder'; - while (_autoFolders.contains(folderName)) { - folderName = 'New $folderName'; - } - - setState(() { - _autoFolders.add(folderName); - _sortAutos(_autoSortValue); - }); - widget.prefs.setStringList( - PrefsKeys.autoFolders, _autoFolders); - widget.onFoldersChanged?.call(); - }, - icon: const Icon(Icons.create_new_folder_outlined), - ), - ), - ), - const SizedBox(width: 4), - Tooltip( - message: 'Add new auto', - waitDuration: const Duration(seconds: 1), - child: IconButton.filled( - key: _addAutoKey, - onPressed: () { - if (_choreoPaths.isNotEmpty) { - final RenderBox renderBox = _addAutoKey.currentContext - ?.findRenderObject() as RenderBox; - final Size size = renderBox.size; - final Offset offset = - renderBox.localToGlobal(Offset.zero); - - showMenu( - context: context, - position: RelativeRect.fromLTRB( - offset.dx, - offset.dy + size.height, - offset.dx + size.width, - offset.dy + size.height, - ), - items: [ - PopupMenuItem( - child: const Text('New PathPlanner Auto'), - onTap: () => _createNewAuto(), - ), - PopupMenuItem( - child: const Text('New Choreo Auto'), - onTap: () => _createNewAuto(choreo: true), - ), - ], - ); - } else { - _createNewAuto(); - } - }, - icon: const Icon(Icons.add), - ), - ), - ], - ), - const Divider(), + const SizedBox(height: 8), _buildOptionsRow( sortValue: _autoSortValue, viewValue: _autosCompact, @@ -1249,6 +1094,57 @@ class _ProjectPageState extends State { _autosCompact = value; }); }, + onSearchChanged: (value) { + setState(() { + _autoSearchQuery = value; + }); + }, + searchController: _autoSearchController, + onAddFolder: () { + String folderName = 'New Folder'; + while (_autoFolders.contains(folderName)) { + folderName = 'New $folderName'; + } + + setState(() { + _autoFolders.add(folderName); + _sortAutos(_autoSortValue); + }); + widget.prefs + .setStringList(PrefsKeys.autoFolders, _autoFolders); + widget.onFoldersChanged?.call(); + }, + onAddItem: () { + if (_choreoPaths.isNotEmpty) { + final RenderBox renderBox = _addAutoKey.currentContext + ?.findRenderObject() as RenderBox; + final Size size = renderBox.size; + final Offset offset = renderBox.localToGlobal(Offset.zero); + + showMenu( + context: context, + position: RelativeRect.fromLTRB( + offset.dx, + offset.dy + size.height, + offset.dx + size.width, + offset.dy + size.height, + ), + items: [ + PopupMenuItem( + child: const Text('New PathPlanner Auto'), + onTap: () => _createNewAuto(), + ), + PopupMenuItem( + child: const Text('New Choreo Auto'), + onTap: () => _createNewAuto(choreo: true), + ), + ], + ); + } else { + _createNewAuto(); + } + }, + isPathsView: false, ), Expanded( child: ListView( @@ -1447,7 +1343,11 @@ class _ProjectPageState extends State { shrinkWrap: true, children: [ for (int i = 0; i < _autos.length; i++) - if (_autos[i].folder == _autoFolder) + if (_autos[i].folder == _autoFolder && + _autos[i] + .name + .toLowerCase() + .contains(_autoSearchQuery.toLowerCase())) _buildAutoCard(i, context), ], ), @@ -1461,28 +1361,6 @@ class _ProjectPageState extends State { ); } - void _createNewAuto({bool choreo = false}) { - List autoNames = []; - for (PathPlannerAuto auto in _autos) { - autoNames.add(auto.name); - } - String autoName = 'New Auto'; - while (autoNames.contains(autoName)) { - autoName = 'New $autoName'; - } - - setState(() { - _autos.add(PathPlannerAuto.defaultAuto( - autoDir: _autosDirectory.path, - name: autoName, - fs: fs, - folder: _autoFolder, - choreoAuto: choreo, - )); - _sortAutos(_autoSortValue); - }); - } - Widget _buildAutoCard(int i, BuildContext context) { String? warningMessage; @@ -1583,92 +1461,264 @@ class _ProjectPageState extends State { required bool viewValue, required ValueChanged onSortChanged, required ValueChanged onViewChanged, + required ValueChanged onSearchChanged, + required TextEditingController searchController, + required VoidCallback onAddFolder, + required VoidCallback onAddItem, + required bool isPathsView, }) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Text( - 'Sort:', - style: TextStyle(fontSize: 16), + child: Column(children: [ + Row( + children: [ + _buildViewButton( + viewValue: viewValue, + onViewChanged: onViewChanged, + ), + _buildSortButton( + sortValue: sortValue, + onSortChanged: onSortChanged, + ), + const SizedBox(width: 8), + Expanded( + child: _buildSearchBar( + isPathsView: isPathsView, + onChanged: onSearchChanged, + controller: searchController, ), - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Material( - color: Colors.transparent, - child: PopupMenuButton( - initialValue: sortValue, - tooltip: '', - elevation: 12.0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - onSelected: onSortChanged, - itemBuilder: (context) => _sortOptions(), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _sortLabel(sortValue), - const Icon(Icons.arrow_drop_down), - ], + ), + const SizedBox(width: 14), + _buildFolderButton( + isPathsView: isPathsView, + onAddFolder: onAddFolder, + onDeleteFolder: () { + showDialog( + context: this.context, + builder: (context) { + return AlertDialog( + title: const Text('Delete Folder'), + content: SizedBox( + width: 400, + child: Text( + 'Are you sure you want to delete the folder "${isPathsView ? _pathFolder : _autoFolder}"?\n\nThis will also delete all ${isPathsView ? "paths" : "autos"} within the folder. This cannot be undone.', + ), ), - ), - ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('CANCEL'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + + if (isPathsView) { + for (int p = 0; p < _paths.length; p++) { + if (_paths[p].folder == _pathFolder) { + _paths[p].deletePath(); + } + } + + setState(() { + _paths.removeWhere( + (path) => path.folder == _pathFolder); + _pathFolders.remove(_pathFolder); + _pathFolder = null; + }); + widget.prefs.setStringList( + PrefsKeys.pathFolders, _pathFolders); + } else { + for (int a = 0; a < _autos.length; a++) { + if (_autos[a].folder == _autoFolder) { + _autos[a].delete(); + } + } + + setState(() { + _autos.removeWhere( + (auto) => auto.folder == _autoFolder); + _autoFolders.remove(_autoFolder); + _autoFolder = null; + }); + widget.prefs.setStringList( + PrefsKeys.autoFolders, _autoFolders); + } + widget.onFoldersChanged?.call(); + }, + child: const Text('DELETE'), + ), + ], + ); + }, + ); + }, + ), + const SizedBox(width: 8), + _buildAddButton( + isPathsView: isPathsView, + onAddItem: onAddItem, + ), + const SizedBox(width: 8), + ], + ), + const SizedBox(height: 10), + ]), + ); + } + + Widget _buildViewButton({ + required bool viewValue, + required ValueChanged onViewChanged, + }) { + return PopupMenuButton( + initialValue: viewValue, + tooltip: 'View options', + icon: Icon(viewValue ? Icons.view_list_rounded : Icons.grid_view_rounded), + itemBuilder: (context) => const [ + PopupMenuItem(value: false, child: Text('Default')), + PopupMenuItem(value: true, child: Text('Compact')), + ], + onSelected: onViewChanged, + ); + } + + Widget _buildSortButton({ + required String sortValue, + required ValueChanged onSortChanged, + }) { + return PopupMenuButton( + initialValue: sortValue, + tooltip: 'Sort options', + icon: const Icon(Icons.sort_rounded), + itemBuilder: (context) => _sortOptions(), + onSelected: onSortChanged, + ); + } + + Widget _buildFolderButton({ + required bool isPathsView, + required VoidCallback onAddFolder, + required VoidCallback onDeleteFolder, + }) { + final bool isRootFolder = + isPathsView ? _pathFolder == null : _autoFolder == null; + + return IconButton.filledTonal( + icon: Icon(isRootFolder + ? Icons.create_new_folder_outlined + : Icons.delete_forever_rounded), + tooltip: isRootFolder + ? 'Add new folder' + : isPathsView + ? 'Delete path folder' + : 'Delete auto folder', + onPressed: () { + if (isRootFolder) { + onAddFolder(); + } else { + onDeleteFolder(); + } + }, + ); + } + + Widget _buildAddButton({ + required bool isPathsView, + required VoidCallback onAddItem, + }) { + if (!isPathsView) { + return Tooltip( + message: 'Add new auto', + waitDuration: const Duration(seconds: 1), + child: IconButton.filled( + key: _addAutoKey, + onPressed: () { + if (_choreoPaths.isNotEmpty) { + final RenderBox renderBox = + _addAutoKey.currentContext?.findRenderObject() as RenderBox; + final Size size = renderBox.size; + final Offset offset = renderBox.localToGlobal(Offset.zero); + showMenu( + context: this.context, + position: RelativeRect.fromLTRB( + offset.dx, + offset.dy + size.height, + offset.dx + size.width, + offset.dy + size.height, ), - ), - ], - ), - Row( - children: [ - const Text( - 'View:', - style: TextStyle(fontSize: 16), - ), - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Material( - color: Colors.transparent, - child: PopupMenuButton( - initialValue: viewValue, - tooltip: '', - elevation: 12.0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - onSelected: onViewChanged, - itemBuilder: (context) => const [ - PopupMenuItem( - value: false, - child: Text('Default'), - ), - PopupMenuItem( - value: true, - child: Text('Compact'), - ), - ], - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(viewValue ? 'Compact' : 'Default', - style: const TextStyle(fontSize: 16)), - const Icon(Icons.arrow_drop_down), - ], - ), - ), + items: [ + PopupMenuItem( + child: const Text('New PathPlanner Auto'), + onTap: () => _createNewAuto(), ), - ), - ), - ], - ), - ], + PopupMenuItem( + child: const Text('New Choreo Auto'), + onTap: () => _createNewAuto(choreo: true), + ), + ], + ); + } else { + _createNewAuto(); + } + }, + icon: const Icon(Icons.add_rounded), + ), + ); + } else { + return IconButton.filled( + tooltip: 'Add new path', + icon: const Icon(Icons.add_rounded), + onPressed: onAddItem, + ); + } + } + + void _createNewAuto({bool choreo = false}) { + List autoNames = []; + for (PathPlannerAuto auto in _autos) { + autoNames.add(auto.name); + } + String autoName = 'New Auto'; + while (autoNames.contains(autoName)) { + autoName = 'New $autoName'; + } + + setState(() { + _autos.add(PathPlannerAuto.defaultAuto( + autoDir: _autosDirectory.path, + name: autoName, + fs: fs, + folder: _autoFolder, + choreoAuto: choreo, + )); + _sortAutos(_autoSortValue); + }); + } + + Widget _buildSearchBar({ + required bool isPathsView, + required ValueChanged onChanged, + required TextEditingController controller, + }) { + return TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'Search for ${isPathsView ? "paths..." : "autos..."}', + prefixIcon: const Icon(Icons.search_rounded), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), ), + onChanged: (value) { + // Debounce the search to avoid freezing + Future.delayed(const Duration(milliseconds: 300), () { + if (value == controller.text) { + onChanged(value); + } + }); + }, ); } @@ -1726,7 +1776,12 @@ class _ProjectPageState extends State { } void _sortPaths(String sortOption) { - switch (sortOption) { + // Get the latest sort option from shared preferences + String latestSortOption = + widget.prefs.getString(PrefsKeys.pathSortOption) ?? + Defaults.pathSortOption; + + switch (latestSortOption) { case 'recent': _paths.sort((a, b) => b.lastModified.compareTo(a.lastModified)); _pathFolders.sort((a, b) => a.compareTo(b)); @@ -1780,16 +1835,6 @@ class _ProjectPageState extends State { ]; } - Widget _sortLabel(String optionValue) { - return switch (optionValue) { - 'recent' => const Text('Recent', style: TextStyle(fontSize: 16)), - 'nameDesc' => - const Text('Name Descending', style: TextStyle(fontSize: 16)), - 'nameAsc' => const Text('Name Ascending', style: TextStyle(fontSize: 16)), - _ => throw FormatException('Invalid sort value', optionValue), - }; - } - PathConstraints _getDefaultConstraints() { return PathConstraints( maxVelocity: widget.prefs.getDouble(PrefsKeys.defaultMaxVel) ?? diff --git a/lib/pages/telemetry_page.dart b/lib/pages/telemetry_page.dart index 35e6186c3..4d5c54372 100644 --- a/lib/pages/telemetry_page.dart +++ b/lib/pages/telemetry_page.dart @@ -115,18 +115,55 @@ class _TelemetryPageState extends State { }); } + Widget _buildConnectionTip(String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, size: 20, color: Colors.grey[600]), + const SizedBox(width: 8), + Text(text, style: TextStyle(fontSize: 14, color: Colors.grey[600])), + ], + ), + ); + } + @override Widget build(BuildContext context) { if (!_connected) { - return const Center( + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Attempting to connect to robot...'), + const CircularProgressIndicator() + .animate(onPlay: (controller) => controller.repeat()) + .rotate(duration: 1.5.seconds, curve: Curves.easeInOut), + const SizedBox(height: 24), + const Text( + 'Attempting to connect to robot...', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 16), + Text( + 'Please ensure that:', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + _buildConnectionTip('The robot is powered on'), + _buildConnectionTip('You are connected to the correct network'), + _buildConnectionTip('The robot code is running'), + const SizedBox(height: 16), + Text( + 'Current Server Address: ${widget.telemetry.getServerAddress()}', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + const SizedBox(height: 24), ], - ), + ).animate().fadeIn(duration: 500.ms, curve: Curves.easeInOut), ); } @@ -280,23 +317,20 @@ class _TelemetryPageState extends State { duration: const Duration(milliseconds: 0), ), ), - Align( - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: Text( - title, - style: const TextStyle(fontSize: 18), - ), + Positioned( + top: 10, + left: 12, + child: Text( + title, + style: + const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ), if (legend != null) - Align( - alignment: Alignment.bottomLeft, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: legend, - ), + Positioned( + top: 8, + right: 8, + child: legend, ), ], ), @@ -318,16 +352,45 @@ class _TelemetryPageState extends State { show: true, drawVerticalLine: true, drawHorizontalLine: true, - verticalInterval: 1, - horizontalInterval: horizontalInterval, + getDrawingVerticalLine: (value) => FlLine( + color: Colors.grey.withOpacity(0.1), + strokeWidth: 0.5, + ), + getDrawingHorizontalLine: (value) => FlLine( + color: Colors.grey.withOpacity(0.1), + strokeWidth: 0.5, + ), ), - lineTouchData: const LineTouchData(enabled: false), - titlesData: const FlTitlesData( - show: false, + lineTouchData: LineTouchData( + enabled: true, + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (touchedSpots) { + return touchedSpots.map((LineBarSpot touchedSpot) { + final textStyle = TextStyle( + color: touchedSpot.bar.gradient?.colors.first ?? + touchedSpot.bar.color ?? + Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ); + return LineTooltipItem( + '${touchedSpot.x.toStringAsFixed(2)}, ${touchedSpot.y.toStringAsFixed(2)}', + textStyle, + ); + }).toList(); + }, + getTooltipColor: (LineBarSpot touchedSpot) { + return Colors.black.withOpacity(0.5); + }, + ), ), - borderData: FlBorderData( - show: false, + titlesData: const FlTitlesData( + bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), ), + borderData: FlBorderData(show: false), minX: 0, maxX: 5, minY: minY, @@ -336,66 +399,91 @@ class _TelemetryPageState extends State { for (int i = 0; i < spots.length; i++) LineChartBarData( spots: spots[i], - shadow: const Shadow(offset: Offset(0, 5), blurRadius: 5), isCurved: true, gradient: lineGradients[i], - barWidth: 5, + barWidth: 3, isStrokeCapRound: true, dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + lineGradients[i].colors.first.withOpacity(0.3), + lineGradients[i].colors.last.withOpacity(0.0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), ), + // Add moving average line + LineChartBarData( + spots: _calculateMovingAverage(spots.first, 5), + isCurved: true, + color: Colors.white.withOpacity(0.5), + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), ], ); } + List _calculateMovingAverage(List data, int period) { + if (data.length < period) { + return data; + } + + List movingAverages = []; + for (int i = period - 1; i < data.length; i++) { + double sum = 0; + for (int j = 0; j < period; j++) { + sum += data[i - j].y; + } + movingAverages.add(FlSpot(data[i].x, sum / period)); + } + return movingAverages; + } + Widget _buildLegend(Color actualColor, Color commandedColor) { return Container( + padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.circular(4), ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: actualColor, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(width: 4), - const Text('Actual'), - ], - ), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: commandedColor, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(width: 4), - const Text('Commanded'), - ], - ), - ], - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildLegendItem('Actual', actualColor), + const SizedBox(width: 8), + _buildLegendItem('Commanded', commandedColor), + ], ), ); } + + Widget _buildLegendItem(String label, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ], + ); + } } class TelemetryPainter extends CustomPainter { @@ -441,24 +529,26 @@ class TelemetryPainter extends CustomPainter { if (targetPose != null) { PathPainterUtil.paintRobotOutline( - targetPose!.position, - targetPose!.rotation, - fieldImage, - robotSize, - scale, - canvas, - Colors.grey[600]!.withOpacity(0.75)); + targetPose!.position, + targetPose!.rotation, + fieldImage, + robotSize, + scale, + canvas, + Colors.grey[600]!.withOpacity(0.75), + ); } if (currentPose != null) { PathPainterUtil.paintRobotOutline( - currentPose!.position, - currentPose!.rotation, - fieldImage, - robotSize, - scale, - canvas, - Colors.grey[400]!); + currentPose!.position, + currentPose!.rotation, + fieldImage, + robotSize, + scale, + canvas, + Colors.grey[400]!, + ); } } diff --git a/lib/util/path_painter_util.dart b/lib/util/path_painter_util.dart index adaf44270..173381cb0 100644 --- a/lib/util/path_painter_util.dart +++ b/lib/util/path_painter_util.dart @@ -41,7 +41,8 @@ class PathPainterUtil { Size robotSize, double scale, Canvas canvas, - Color color) { + Color color, + {bool showDetails = false}) { var paint = Paint() ..style = PaintingStyle.stroke ..color = color @@ -65,8 +66,11 @@ class PathPainterUtil { Rect.fromCenter(center: center, width: length, height: width), const Radius.circular(5)), paint); - paint.style = PaintingStyle.fill; + Offset frontMiddle = center + Offset(length / 2, 0); + + // Draw the dot + paint.style = PaintingStyle.fill; canvas.drawCircle(frontMiddle, PathPainterUtil.uiPointSizeToPixels(15, scale, fieldImage), paint); paint.style = PaintingStyle.stroke; @@ -74,18 +78,79 @@ class PathPainterUtil { paint.color = Colors.black; canvas.drawCircle(frontMiddle, PathPainterUtil.uiPointSizeToPixels(15, scale, fieldImage), paint); + + if (showDetails) { + String angleText = '${rotationDegrees.toStringAsFixed(1)}°'; + String coordText = + '(${position.x.toStringAsFixed(2)}, ${position.y.toStringAsFixed(2)})'; + String displayText = '$angleText\n$coordText'; + + double textSize = min(width, length) * 0.175; + + TextPainter textPainter = TextPainter( + textDirection: TextDirection.ltr, + text: TextSpan( + text: displayText, + style: TextStyle( + fontSize: textSize, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ); + textPainter.layout(); + + Offset textPosition = center + Offset(-length * 0.4, -width * 0.2); + + canvas.save(); + + canvas.translate(textPosition.dx, textPosition.dy); + + final bgRect = Rect.fromCenter( + center: Offset(textPainter.width / 2, textPainter.height / 2), + width: textPainter.width + 8, + height: textPainter.height + 6, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(bgRect, const Radius.circular(3)), + Paint()..color = Colors.black.withOpacity(0.6), + ); + + TextPainter outlinePainter = TextPainter( + textDirection: TextDirection.ltr, + text: TextSpan( + text: displayText, + style: TextStyle( + fontSize: textSize, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = Colors.black, + fontWeight: FontWeight.bold, + ), + ), + ); + outlinePainter.layout(); + outlinePainter.paint(canvas, Offset.zero); + + textPainter.paint(canvas, Offset.zero); + + canvas.restore(); + } + canvas.restore(); } - static void paintMarker(Canvas canvas, Offset location, Color color) { - const IconData markerIcon = Icons.location_on; + static void paintMarker( + Canvas canvas, Offset location, Color color, Color strokeColor) { + const IconData markerIcon = Icons.location_on_rounded; TextPainter textPainter = TextPainter( textDirection: TextDirection.ltr, text: TextSpan( text: String.fromCharCode(markerIcon.codePoint), style: TextStyle( - fontSize: 40, + fontSize: 35, // Set the font size to 35 color: color, fontFamily: markerIcon.fontFamily, ), @@ -97,12 +162,12 @@ class PathPainterUtil { text: TextSpan( text: String.fromCharCode(markerIcon.codePoint), style: TextStyle( - fontSize: 40, + fontSize: 35, // Set the font size to 35 fontFamily: markerIcon.fontFamily, foreground: Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 1 - ..color = Colors.black, + ..strokeWidth = 2 + ..color = strokeColor, ), ), ); @@ -110,8 +175,10 @@ class PathPainterUtil { textPainter.layout(); textStrokePainter.layout(); - textPainter.paint(canvas, location - const Offset(20, 37)); - textStrokePainter.paint(canvas, location - const Offset(20, 37)); + textPainter.paint( + canvas, location - const Offset(17.5, 27.5)); // Adjust the offset + textStrokePainter.paint( + canvas, location - const Offset(17.5, 27.5)); // Adjust the offset } static Offset pointToPixelOffset( diff --git a/lib/util/prefs.dart b/lib/util/prefs.dart index 1a24913aa..d0da643bb 100644 --- a/lib/util/prefs.dart +++ b/lib/util/prefs.dart @@ -36,6 +36,8 @@ class PrefsKeys { static const String driveCurrentLimit = 'driveCurrentLimit'; static const String wheelCOF = 'wheelCOF'; static const String showStates = 'showStates'; + static const String showRobotDetails = 'showRobotDetails'; + static const String showGrid = 'showGrid'; } class Defaults { @@ -73,4 +75,6 @@ class Defaults { static const double driveCurrentLimit = 60.0; static const double wheelCOF = 1.2; static const bool showStates = false; + static const bool showRobotDetails = false; + static const bool showGrid = false; } diff --git a/lib/widgets/dialogs/management_dialog.dart b/lib/widgets/dialogs/management_dialog.dart index 1030a1770..1b766c2ba 100644 --- a/lib/widgets/dialogs/management_dialog.dart +++ b/lib/widgets/dialogs/management_dialog.dart @@ -155,7 +155,7 @@ class _ManagementDialogState extends State { )); }, icon: Icon( - Icons.close_rounded, + Icons.delete_forever_rounded, color: colorScheme.error, ), ), @@ -224,7 +224,7 @@ class _ManagementDialogState extends State { )); }, icon: Icon( - Icons.close_rounded, + Icons.delete_forever_rounded, color: colorScheme.error, ), ), diff --git a/lib/widgets/editor/info_card.dart b/lib/widgets/editor/info_card.dart new file mode 100644 index 000000000..601911cf3 --- /dev/null +++ b/lib/widgets/editor/info_card.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class InfoCard extends StatelessWidget { + final String value; + + const InfoCard({super.key, required this.value}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color.fromARGB(36, 0, 0, 0), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + value, + style: const TextStyle( + fontWeight: FontWeight.normal, + color: Colors.white, + fontSize: 12, + ), + ), + ); + } +} diff --git a/lib/widgets/editor/path_painter.dart b/lib/widgets/editor/path_painter.dart index 5cf8c2bb6..bd4106609 100644 --- a/lib/widgets/editor/path_painter.dart +++ b/lib/widgets/editor/path_painter.dart @@ -14,6 +14,7 @@ import 'package:pathplanner/util/path_painter_util.dart'; import 'package:shared_preferences/shared_preferences.dart'; class PathPainter extends CustomPainter { + final ColorScheme colorScheme; final List paths; final List choreoPaths; final FieldImage fieldImage; @@ -43,6 +44,7 @@ class PathPainter extends CustomPainter { static double scale = 1; PathPainter({ + required this.colorScheme, required this.paths, this.choreoPaths = const [], required this.fieldImage, @@ -90,6 +92,9 @@ class PathPainter extends CustomPainter { void paint(Canvas canvas, Size size) { scale = size.width / fieldImage.defaultSize.width; + _paintGrid( + canvas, size, prefs.getBool(PrefsKeys.showGrid) ?? Defaults.showGrid); + for (int i = 0; i < paths.length; i++) { if (hideOtherPathsOnHover && hoveredPath != null && @@ -135,7 +140,7 @@ class PathPainter extends CustomPainter { choreoPaths[i].trajectory, canvas, (hoveredPath == choreoPaths[i].name) - ? Colors.orange + ? colorScheme.primary : Colors.grey[300]!); _paintChoreoWaypoint( choreoPaths[i].trajectory.states.first, canvas, Colors.green, scale); @@ -203,7 +208,9 @@ class PathPainter extends CustomPainter { robotSize, scale, canvas, - previewColor ?? Colors.grey); + previewColor ?? Colors.grey, + showDetails: prefs.getBool(PrefsKeys.showRobotDetails) ?? + Defaults.showRobotDetails); } } @@ -339,7 +346,8 @@ class PathPainter extends CustomPainter { if (selectedZone != null) { paint.color = Colors.orange; - paint.strokeWidth = 4; + paint.strokeWidth = + 6; // Thicker stroke width for selected constraint zone p.reset(); num startPos = path.constraintZones[selectedZone!].minWaypointRelativePos; @@ -361,7 +369,7 @@ class PathPainter extends CustomPainter { if (hoveredZone != null && selectedZone != hoveredZone) { paint.color = Colors.deepPurpleAccent; - paint.strokeWidth = 4; + paint.strokeWidth = 6; // Thicker stroke width for hovered constraint zone p.reset(); num startPos = path.constraintZones[hoveredZone!].minWaypointRelativePos; @@ -387,6 +395,7 @@ class PathPainter extends CustomPainter { var position = path.samplePath(path.eventMarkers[i].waypointRelativePos); Color markerColor = Colors.grey[700]!; + Color markerStrokeColor = Colors.black; if (selectedMarker == i) { markerColor = Colors.orange; } else if (hoveredMarker == i) { @@ -396,7 +405,8 @@ class PathPainter extends CustomPainter { Offset markerPos = PathPainterUtil.pointToPixelOffset(position, scale, fieldImage); - PathPainterUtil.paintMarker(canvas, markerPos, markerColor); + PathPainterUtil.paintMarker( + canvas, markerPos, markerColor, markerStrokeColor); } } @@ -406,7 +416,8 @@ class PathPainter extends CustomPainter { Offset markerPos = PathPainterUtil.pointToPixelOffset( Point(s.pose.translation.x, s.pose.translation.y), scale, fieldImage); - PathPainterUtil.paintMarker(canvas, markerPos, Colors.grey[700]!); + PathPainterUtil.paintMarker( + canvas, markerPos, Colors.grey[700]!, Colors.black); } } @@ -644,4 +655,22 @@ class PathPainter extends CustomPainter { } } } + + void _paintGrid(Canvas canvas, Size size, bool showGrid) { + if (!showGrid) return; + + final paint = Paint() + ..color = Colors.grey.withOpacity(0.2) // More transparent + ..strokeWidth = 1; + + double gridSpacing = PathPainterUtil.metersToPixels(0.5, scale, fieldImage); + + for (double x = 0; x <= size.width; x += gridSpacing) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + + for (double y = 0; y <= size.height; y += gridSpacing) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + } } diff --git a/lib/widgets/editor/preview_seekbar.dart b/lib/widgets/editor/preview_seekbar.dart index 3fdd10b6f..47446f5e4 100644 --- a/lib/widgets/editor/preview_seekbar.dart +++ b/lib/widgets/editor/preview_seekbar.dart @@ -28,9 +28,12 @@ class _PreviewSeekbarState extends State { child: Card( color: colorScheme.surface, surfaceTintColor: colorScheme.surfaceTint, - elevation: 2.0, + elevation: 4.0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), child: SizedBox( - height: 32, + height: 40, child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ @@ -47,43 +50,64 @@ class _PreviewSeekbarState extends State { } }); }, - icon: widget.previewController.isAnimating - ? const Icon(Icons.pause) - : const Icon(Icons.play_arrow), + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: widget.previewController.isAnimating + ? const Icon(Icons.pause) + : const Icon(Icons.play_arrow), + ), visualDensity: VisualDensity.compact, + tooltip: + widget.previewController.isAnimating ? 'Pause' : 'Play', + ), + IconButton( + onPressed: () { + setState(() { + widget.previewController.reset(); + widget.previewController + .repeat(); // Start playing again after reset + widget.onPauseStateChanged?.call(false); + }); + }, + icon: const Icon(Icons.replay), + visualDensity: VisualDensity.compact, + tooltip: 'Reset', ), Expanded( child: AnimatedBuilder( - animation: widget.previewController.view, - builder: (context, _) { - return Theme( - data: Theme.of(context).copyWith( - sliderTheme: const SliderThemeData( - showValueIndicator: ShowValueIndicator.always, + animation: widget.previewController.view, + builder: (context, _) { + return Theme( + data: Theme.of(context).copyWith( + sliderTheme: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: 8, ), - ), - child: Slider( - value: widget.previewController.view.value, - label: (widget.previewController.view.value * - widget.totalPathTime) - .toStringAsFixed(2), - focusNode: FocusNode( - skipTraversal: true, - canRequestFocus: false, + overlayShape: RoundSliderOverlayShape( + overlayRadius: 16, ), - onChanged: (value) { - if (widget.previewController.isAnimating) { - setState(() { - widget.previewController.stop(); - }); - widget.onPauseStateChanged?.call(true); - } - - widget.previewController.value = value; - }, ), - ); - }), + ), + child: Slider( + value: widget.previewController.value, + label: (widget.previewController.value * + widget.totalPathTime) + .toStringAsFixed(2), + onChanged: (value) { + if (widget.previewController.isAnimating) { + setState(() { + widget.previewController.stop(); + }); + widget.onPauseStateChanged?.call(true); + } + + widget.previewController.value = value; + }, + ), + ); + }, + ), ), ], ), diff --git a/lib/widgets/editor/runtime_display.dart b/lib/widgets/editor/runtime_display.dart new file mode 100644 index 000000000..8630b0651 --- /dev/null +++ b/lib/widgets/editor/runtime_display.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; + +class RuntimeDisplay extends StatelessWidget { + final num? currentRuntime; + final num? previousRuntime; + + const RuntimeDisplay({ + super.key, + required this.currentRuntime, + required this.previousRuntime, + }); + + @override + Widget build(BuildContext context) { + final runtime = currentRuntime ?? 0; + final prevRuntime = previousRuntime ?? runtime; + final difference = runtime - prevRuntime; + final isShortened = difference < 0; + final isSignificantIncrease = difference > 0.15; + final isMinorIncrease = difference > 0.05 && !isSignificantIncrease; + final isNoSignificantChange = difference.abs() <= 0.05; + + return Tooltip( + message: isShortened + ? 'Path time decreased by ~${difference.abs().toStringAsFixed(2)}s' + : isNoSignificantChange + ? 'Path time changed by less than 0.05s' + : isMinorIncrease + ? 'Path time slightly increased by ~${difference.abs().toStringAsFixed(2)}s' + : 'Path time increased by ~${difference.abs().toStringAsFixed(2)}s', + child: AnimatedContainer( + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + decoration: BoxDecoration( + color: isNoSignificantChange + ? Colors.grey[100] + : isShortened + ? Colors.green[100] + : isMinorIncrease + ? Colors.orange[100] + : Colors.red[100], + borderRadius: BorderRadius.circular(4.0), + border: Border.all( + color: isNoSignificantChange + ? Colors.grey[300]! + : isShortened + ? Colors.green[200]! + : isMinorIncrease + ? Colors.orange[200]! + : Colors.red[200]!, + width: 1.0, + ), + ), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: isNoSignificantChange ? 0.7 : 1.0, + child: AnimatedScale( + duration: const Duration(milliseconds: 500), + scale: isNoSignificantChange ? 1.0 : 1.05, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isNoSignificantChange) const SizedBox(width: 4), + Text( + '~${runtime.toStringAsFixed(2)}s', + style: TextStyle( + fontSize: 14, + color: isNoSignificantChange + ? Colors.grey[800] + : isShortened + ? Colors.green[800] + : isMinorIncrease + ? Colors.orange[800] + : Colors.red[800], + fontWeight: FontWeight.w600, + ), + ), + if (difference.abs() > 0.05) ...[ + const SizedBox(width: 4), + Text( + '(${isShortened ? '-' : '+'}${difference.abs().toStringAsFixed(2)}s)', + style: TextStyle( + fontSize: 12, + color: isShortened + ? Colors.green[700] + : isMinorIncrease + ? Colors.orange[700] + : Colors.red[700], + ), + ), + ], + if (!isNoSignificantChange) + AnimatedSlide( + duration: const Duration(milliseconds: 500), + curve: isShortened + ? Curves.easeOut // Slide down + : isMinorIncrease + ? Curves.easeInOut // Slide right + : Curves.bounceOut, // Bounce up + offset: isShortened + ? const Offset(0, 0.1) // Slide down + : isMinorIncrease + ? const Offset(0.1, 0) // Slide right + : const Offset(0, -0.1), // Bounce up + child: Icon( + isShortened + ? Icons.arrow_downward + : isMinorIncrease + ? Icons.arrow_forward + : Icons.arrow_upward, + color: isShortened + ? Colors.green[700] + : isMinorIncrease + ? Colors.orange[700] + : Colors.red[700], + size: 16.0, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/editor/split_auto_editor.dart b/lib/widgets/editor/split_auto_editor.dart index 0763d5443..4d47693f5 100644 --- a/lib/widgets/editor/split_auto_editor.dart +++ b/lib/widgets/editor/split_auto_editor.dart @@ -102,21 +102,20 @@ class _SplitAutoEditorState extends State widget.fieldImage.getWidget(), Positioned.fill( child: CustomPaint( - painter: PathPainter( - paths: widget.autoPaths, - choreoPaths: widget.autoChoreoPaths, - simple: true, - hideOtherPathsOnHover: - widget.prefs.getBool(PrefsKeys.hidePathsOnHover) ?? + painter: PathPainter( + colorScheme: colorScheme, + paths: widget.autoPaths, + choreoPaths: widget.autoChoreoPaths, + simple: true, + hideOtherPathsOnHover: widget.prefs + .getBool(PrefsKeys.hidePathsOnHover) ?? Defaults.hidePathsOnHover, - hoveredPath: _hoveredPath, - fieldImage: widget.fieldImage, - simulatedPath: _simPath, - animation: _previewController.view, - previewColor: colorScheme.primary, - prefs: widget.prefs, - ), - ), + hoveredPath: _hoveredPath, + fieldImage: widget.fieldImage, + simulatedPath: _simPath, + animation: _previewController.view, + previewColor: colorScheme.primary, + prefs: widget.prefs)), ), ], ), @@ -316,10 +315,28 @@ class _SplitAutoEditorState extends State // Trajectory failed to generate. Notify the user Log.warning( 'Failed to generate trajectory for auto: ${widget.auto.name}'); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text( - 'Failed to generate trajectory. Please open an issue on the pathplanner github and include the failing path file.'), - )); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to generate trajectory for ${widget.auto.name}. Please open an issue on the pathplanner github and include the failing path file.', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer), + ), + backgroundColor: Theme.of(context).colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + action: SnackBarAction( + label: 'Dismiss', + textColor: Theme.of(context).colorScheme.onErrorContainer, + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + ), + ); } } } diff --git a/lib/widgets/editor/split_choreo_path_editor.dart b/lib/widgets/editor/split_choreo_path_editor.dart index 0f6d9bc6c..cd7b44174 100644 --- a/lib/widgets/editor/split_choreo_path_editor.dart +++ b/lib/widgets/editor/split_choreo_path_editor.dart @@ -84,6 +84,7 @@ class _SplitChoreoPathEditorState extends State Positioned.fill( child: CustomPaint( painter: PathPainter( + colorScheme: colorScheme, paths: [], choreoPaths: [widget.path], fieldImage: widget.fieldImage, diff --git a/lib/widgets/editor/split_path_editor.dart b/lib/widgets/editor/split_path_editor.dart index bda5564cb..98156c5df 100644 --- a/lib/widgets/editor/split_path_editor.dart +++ b/lib/widgets/editor/split_path_editor.dart @@ -16,6 +16,7 @@ import 'package:pathplanner/trajectory/trajectory.dart'; import 'package:pathplanner/util/prefs.dart'; import 'package:pathplanner/widgets/editor/path_painter.dart'; import 'package:pathplanner/widgets/editor/preview_seekbar.dart'; +import 'package:pathplanner/widgets/editor/runtime_display.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/path_tree.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/waypoints_tree.dart'; import 'package:pathplanner/widgets/field_image.dart'; @@ -79,6 +80,8 @@ class _SplitPathEditorState extends State List get waypoints => widget.path.waypoints; + RuntimeDisplay? _runtimeDisplay; + @override void initState() { super.initState(); @@ -430,6 +433,7 @@ class _SplitPathEditorState extends State Positioned.fill( child: CustomPaint( painter: PathPainter( + colorScheme: colorScheme, paths: [widget.path], simple: false, fieldImage: widget.fieldImage, @@ -501,6 +505,7 @@ class _SplitPathEditorState extends State child: PathTree( path: widget.path, pathRuntime: _simTraj?.getTotalTimeSeconds(), + runtimeDisplay: _runtimeDisplay, initiallySelectedWaypoint: _selectedWaypoint, initiallySelectedZone: _selectedZone, initiallySelectedRotTarget: _selectedRotTarget, @@ -686,6 +691,12 @@ class _SplitPathEditorState extends State if (!(_simTraj?.getTotalTimeSeconds().isFinite ?? false)) { _simTraj = null; } + + // Update the RuntimeDisplay widget + _runtimeDisplay = RuntimeDisplay( + currentRuntime: _simTraj?.states.last.timeSeconds, + previousRuntime: _runtimeDisplay?.currentRuntime, + ); }); if (!_paused) { @@ -701,10 +712,27 @@ class _SplitPathEditorState extends State // Trajectory failed to generate. Notify the user Log.warning( 'Failed to generate trajectory for path: ${widget.path.name}'); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text( - 'Failed to generate trajectory. Please open an issue on the pathplanner github and include this path file.'), - )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to generate trajectory. Please open an issue on the pathplanner github and include this path file', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer), + ), + backgroundColor: Theme.of(context).colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + action: SnackBarAction( + label: 'Dismiss', + textColor: Theme.of(context).colorScheme.onErrorContainer, + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + ), + ); } } } diff --git a/lib/widgets/editor/tree_widgets/commands/command_group_widget.dart b/lib/widgets/editor/tree_widgets/commands/command_group_widget.dart index 39da73300..ac92d00a0 100644 --- a/lib/widgets/editor/tree_widgets/commands/command_group_widget.dart +++ b/lib/widgets/editor/tree_widgets/commands/command_group_widget.dart @@ -130,7 +130,7 @@ class CommandGroupWidget extends StatelessWidget { visualDensity: const VisualDensity( horizontal: VisualDensity.minimumDensity, vertical: VisualDensity.minimumDensity), - icon: Icon(Icons.close, color: colorScheme.error), + icon: Icon(Icons.delete, color: colorScheme.error), ), ), ), diff --git a/lib/widgets/editor/tree_widgets/commands/duplicate_command_button.dart b/lib/widgets/editor/tree_widgets/commands/duplicate_command_button.dart index bca1eaa9d..1f56ce99e 100644 --- a/lib/widgets/editor/tree_widgets/commands/duplicate_command_button.dart +++ b/lib/widgets/editor/tree_widgets/commands/duplicate_command_button.dart @@ -12,6 +12,6 @@ class DuplicateCommandButton extends StatelessWidget { return IconButton( onPressed: onPressed, tooltip: 'Duplicate', - icon: Icon(Icons.copy, color: colorScheme.primary)); + icon: Icon(Icons.copy_all_rounded, color: colorScheme.primary)); } } diff --git a/lib/widgets/editor/tree_widgets/commands/named_command_widget.dart b/lib/widgets/editor/tree_widgets/commands/named_command_widget.dart index 57a8f0101..e78f6c063 100644 --- a/lib/widgets/editor/tree_widgets/commands/named_command_widget.dart +++ b/lib/widgets/editor/tree_widgets/commands/named_command_widget.dart @@ -171,15 +171,15 @@ class _NamedCommandWidgetState extends State { ), ), ), - const SizedBox(width: 8), + const SizedBox(width: 12), Visibility( visible: widget.command.name == null, - child: const Tooltip( + child: Tooltip( message: 'Missing command name', child: Icon( Icons.warning_amber_rounded, - color: Colors.yellow, - size: 32, + color: Colors.orange[300]!, + size: 24, ), ), ), @@ -194,7 +194,7 @@ class _NamedCommandWidgetState extends State { visualDensity: const VisualDensity( horizontal: VisualDensity.minimumDensity, vertical: VisualDensity.minimumDensity), - icon: Icon(Icons.close, color: colorScheme.error), + icon: Icon(Icons.delete, color: colorScheme.error), ), ), ], diff --git a/lib/widgets/editor/tree_widgets/commands/path_command_widget.dart b/lib/widgets/editor/tree_widgets/commands/path_command_widget.dart index da193f7da..a3bc583de 100644 --- a/lib/widgets/editor/tree_widgets/commands/path_command_widget.dart +++ b/lib/widgets/editor/tree_widgets/commands/path_command_widget.dart @@ -53,11 +53,15 @@ class _PathCommandWidgetState extends State { widget.allPathNames.length, (index) => DropdownMenuItem( value: widget.allPathNames[index], - child: Text( - widget.allPathNames[index], - style: TextStyle( - fontWeight: FontWeight.normal, - color: colorScheme.onPrimaryContainer, + child: Tooltip( + message: widget.allPathNames[index], + child: Text( + widget.allPathNames[index], + style: TextStyle( + fontWeight: FontWeight.normal, + color: colorScheme.onPrimaryContainer, + ), + overflow: TextOverflow.ellipsis, ), ), ), @@ -148,11 +152,11 @@ class _PathCommandWidgetState extends State { const SizedBox(width: 8), Visibility( visible: widget.command.pathName == null, - child: const Tooltip( + child: Tooltip( message: 'Missing path name', child: Icon( Icons.warning_amber_rounded, - color: Colors.yellow, + color: Colors.orange[300]!, size: 32, ), ), @@ -168,7 +172,7 @@ class _PathCommandWidgetState extends State { visualDensity: const VisualDensity( horizontal: VisualDensity.minimumDensity, vertical: VisualDensity.minimumDensity), - icon: Icon(Icons.close, color: colorScheme.error), + icon: Icon(Icons.delete, color: colorScheme.error), ), ), ], diff --git a/lib/widgets/editor/tree_widgets/commands/wait_command_widget.dart b/lib/widgets/editor/tree_widgets/commands/wait_command_widget.dart index c807fb2a2..1886d1cca 100644 --- a/lib/widgets/editor/tree_widgets/commands/wait_command_widget.dart +++ b/lib/widgets/editor/tree_widgets/commands/wait_command_widget.dart @@ -20,37 +20,46 @@ class WaitCommandWidget extends StatelessWidget { this.onDuplicateCommand, }); + void _updateWaitTime(double newValue) { + if (newValue >= 0) { + undoStack.add(Change( + command.waitTime, + () { + command.waitTime = newValue; + onUpdated?.call(); + }, + (oldValue) { + command.waitTime = oldValue; + onUpdated?.call(); + }, + )); + } + } + @override Widget build(BuildContext context) { ColorScheme colorScheme = Theme.of(context).colorScheme; return Row( children: [ + const SizedBox(width: 8), Expanded( child: NumberTextField( initialText: command.waitTime.toStringAsFixed(2), label: 'Wait Time (S)', onSubmitted: (value) { - if (value != null && value >= 0) { - undoStack.add(Change( - command.waitTime, - () { - command.waitTime = value; - onUpdated?.call(); - }, - (oldValue) { - command.waitTime = oldValue; - onUpdated?.call(); - }, - )); + double? parsedValue = double.tryParse(value.toString()); + if (parsedValue != null && parsedValue >= 0) { + _updateWaitTime(parsedValue); } }, + arrowKeyIncrement: 0.1, ), ), + const SizedBox(width: 12), DuplicateCommandButton( onPressed: onDuplicateCommand, ), - const SizedBox(width: 8), Tooltip( message: 'Remove Command', waitDuration: const Duration(milliseconds: 500), @@ -59,7 +68,7 @@ class WaitCommandWidget extends StatelessWidget { visualDensity: const VisualDensity( horizontal: VisualDensity.minimumDensity, vertical: VisualDensity.minimumDensity), - icon: Icon(Icons.close, color: colorScheme.error), + icon: Icon(Icons.delete, color: colorScheme.error), ), ), ], diff --git a/lib/widgets/editor/tree_widgets/constraint_zones_tree.dart b/lib/widgets/editor/tree_widgets/constraint_zones_tree.dart index 0e687fd84..65fe6691c 100644 --- a/lib/widgets/editor/tree_widgets/constraint_zones_tree.dart +++ b/lib/widgets/editor/tree_widgets/constraint_zones_tree.dart @@ -55,32 +55,12 @@ class _ConstraintZonesTreeState extends State { Widget build(BuildContext context) { return TreeCardNode( title: const Text('Constraint Zones'), - trailing: ItemCount(count: widget.path.constraintZones.length), - initiallyExpanded: widget.path.constraintZonesExpanded, - onExpansionChanged: (value) { - if (value != null) { - widget.path.constraintZonesExpanded = value; - if (value == false) { - _selectedZone = null; - widget.onZoneSelected?.call(null); - } - } - }, - elevation: 1.0, - children: [ - const Center( - child: Text('Zones at the top of the list have higher priority'), - ), - const SizedBox(height: 6), - for (int i = 0; i < constraintZones.length; i++) _buildZoneCard(i), - const SizedBox(height: 12), - Center( - child: ElevatedButton.icon( - icon: const Icon(Icons.add), - style: ElevatedButton.styleFrom( - elevation: 4.0, - ), - label: const Text('Add New Zone'), + leading: const Icon(Icons.speed_rounded), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add, size: 20), onPressed: () { widget.undoStack.add(Change( PathPlannerPath.cloneConstraintZones(constraintZones), @@ -100,8 +80,29 @@ class _ConstraintZonesTreeState extends State { }, )); }, + tooltip: 'Add New Constraint Zone', ), + const SizedBox(width: 8), + ItemCount(count: widget.path.constraintZones.length), + ], + ), + initiallyExpanded: widget.path.constraintZonesExpanded, + onExpansionChanged: (value) { + if (value != null) { + widget.path.constraintZonesExpanded = value; + if (value == false) { + _selectedZone = null; + widget.onZoneSelected?.call(null); + } + } + }, + elevation: 1.0, + children: [ + const Center( + child: Text('Zones at the top of the list have higher priority'), ), + const SizedBox(height: 6), + for (int i = 0; i < constraintZones.length; i++) _buildZoneCard(i), ], ); } diff --git a/lib/widgets/editor/tree_widgets/editor_settings_tree.dart b/lib/widgets/editor/tree_widgets/editor_settings_tree.dart index 2e045143f..dffcf07d3 100644 --- a/lib/widgets/editor/tree_widgets/editor_settings_tree.dart +++ b/lib/widgets/editor/tree_widgets/editor_settings_tree.dart @@ -20,6 +20,8 @@ class _EditorSettingsTreeState extends State { bool _snapToGuidelines = Defaults.snapToGuidelines; bool _hidePathsOnHover = Defaults.hidePathsOnHover; bool _showStates = Defaults.showStates; + bool _showRobotDetails = Defaults.showRobotDetails; + bool _showGrid = Defaults.showGrid; @override void initState() { @@ -34,6 +36,9 @@ class _EditorSettingsTreeState extends State { Defaults.hidePathsOnHover; _showStates = _prefs.getBool(PrefsKeys.showStates) ?? Defaults.showStates; + _showRobotDetails = _prefs.getBool(PrefsKeys.showRobotDetails) ?? + Defaults.showRobotDetails; + _showGrid = _prefs.getBool(PrefsKeys.showGrid) ?? Defaults.showGrid; }); }); } @@ -43,84 +48,100 @@ class _EditorSettingsTreeState extends State { return TreeCardNode( initiallyExpanded: widget.initiallyExpanded, title: const Text('Editor Settings'), + leading: const Icon(Icons.settings), elevation: 1.0, children: [ - Row( - children: [ - Checkbox( - value: _snapToGuidelines, - onChanged: (val) { - if (val != null) { - setState(() { - _snapToGuidelines = val; - _prefs.setBool(PrefsKeys.snapToGuidelines, val); - }); - } - }, - ), - const Padding( - padding: EdgeInsets.only( - bottom: 3.0, - left: 4.0, - ), - child: Text( - 'Snap To Guidelines', - style: TextStyle(fontSize: 15), - ), - ), - ], + _buildCheckboxRow( + 'Snap To Guidelines', + _snapToGuidelines, + _updateSnapToGuidelines, + 'Enable or disable snapping to guidelines.', ), - Row( - children: [ - Checkbox( - value: _hidePathsOnHover, - onChanged: (val) { - if (val != null) { - setState(() { - _hidePathsOnHover = val; - _prefs.setBool(PrefsKeys.hidePathsOnHover, val); - }); - } - }, - ), - const Padding( - padding: EdgeInsets.only( - bottom: 3.0, - left: 4.0, - ), - child: Text( - 'Hide Other Paths on Hover', - style: TextStyle(fontSize: 15), - ), - ), - ], + _buildCheckboxRow( + 'Hide Other Paths on Hover', + _hidePathsOnHover, + _updateHidePathsOnHover, + 'Hide other paths when hovering over a specific path.', ), - Row( - children: [ - Checkbox( - value: _showStates, - onChanged: (val) { - if (val != null) { - setState(() { - _showStates = val; - _prefs.setBool(PrefsKeys.showStates, val); - }); - } - }, - ), - const Padding( - padding: EdgeInsets.only( - bottom: 3.0, - left: 4.0, - ), - child: Text( - 'Show Trajectory States', - style: TextStyle(fontSize: 15), - ), - ), - ], + _buildCheckboxRow( + 'Show Trajectory States', + _showStates, + _updateShowStates, + 'Display trajectory states.', + ), + _buildCheckboxRow( + 'Show Robot Details', + _showRobotDetails, + _updateShowRobotDetails, + 'Display additional details about the robot\'s current rotation and position.', + ), + _buildCheckboxRow( + 'Show Grid', + _showGrid, + _updateShowGrid, + 'Toggle the visibility of the grid on the field. Each cell is 0.5M x 0.5M.', + ), + ], + ); + } + + Widget _buildCheckboxRow( + String label, bool value, Function(bool) onChanged, String tooltip) { + return Row( + children: [ + Tooltip( + message: tooltip, + child: Checkbox( + value: value, + onChanged: (val) => onChanged(val!), + ), + ), + Padding( + padding: const EdgeInsets.only( + bottom: 3.0, + left: 4.0, + ), + child: Text( + label, + style: const TextStyle(fontSize: 15), + ), ), ], ); } + + void _updateSnapToGuidelines(bool value) { + setState(() { + _snapToGuidelines = value; + }); + _prefs.setBool(PrefsKeys.snapToGuidelines, _snapToGuidelines); + } + + void _updateHidePathsOnHover(bool value) { + setState(() { + _hidePathsOnHover = value; + }); + _prefs.setBool(PrefsKeys.hidePathsOnHover, _hidePathsOnHover); + } + + void _updateShowStates(bool value) { + setState(() { + _showStates = value; + }); + _prefs.setBool(PrefsKeys.showStates, _showStates); + } + + void _updateShowRobotDetails(bool value) { + setState(() { + _showRobotDetails = value; + }); + _prefs.setBool(PrefsKeys.showRobotDetails, _showRobotDetails); + } + + void _updateShowGrid(bool value) { + setState(() { + _showGrid = value; + }); + _prefs.setBool(PrefsKeys.showGrid, _showGrid); + } } diff --git a/lib/widgets/editor/tree_widgets/event_markers_tree.dart b/lib/widgets/editor/tree_widgets/event_markers_tree.dart index fc534b45f..fd1c4fcf9 100644 --- a/lib/widgets/editor/tree_widgets/event_markers_tree.dart +++ b/lib/widgets/editor/tree_widgets/event_markers_tree.dart @@ -4,9 +4,11 @@ import 'package:pathplanner/commands/command_groups.dart'; import 'package:pathplanner/path/event_marker.dart'; import 'package:pathplanner/path/pathplanner_path.dart'; import 'package:pathplanner/path/waypoint.dart'; +import 'package:pathplanner/widgets/editor/info_card.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/commands/command_group_widget.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/item_count.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/tree_card_node.dart'; +import 'package:pathplanner/widgets/number_text_field.dart'; import 'package:pathplanner/widgets/renamable_title.dart'; import 'package:undo/undo.dart'; @@ -55,28 +57,12 @@ class _EventMarkersTreeState extends State { Widget build(BuildContext context) { return TreeCardNode( title: const Text('Event Markers'), - trailing: ItemCount(count: widget.path.eventMarkers.length), - initiallyExpanded: widget.path.eventMarkersExpanded, - onExpansionChanged: (value) { - if (value != null) { - widget.path.eventMarkersExpanded = value; - if (value == false) { - _selectedMarker = null; - widget.onMarkerSelected?.call(null); - } - } - }, - elevation: 1.0, - children: [ - for (int i = 0; i < markers.length; i++) _buildMarkerCard(i), - const SizedBox(height: 12), - Center( - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - elevation: 4.0, - ), - icon: const Icon(Icons.add), - label: const Text('Add New Marker'), + leading: const Icon(Icons.pin_drop_rounded), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add, size: 20), onPressed: () { widget.undoStack.add(Change( PathPlannerPath.cloneEventMarkers(markers), @@ -94,16 +80,37 @@ class _EventMarkersTreeState extends State { }, )); }, + tooltip: 'Add New Event Marker', ), - ), + const SizedBox(width: 8), + ItemCount(count: widget.path.eventMarkers.length), + ], + ), + initiallyExpanded: widget.path.eventMarkersExpanded, + onExpansionChanged: (value) { + if (value != null) { + widget.path.eventMarkersExpanded = value; + if (value == false) { + _selectedMarker = null; + widget.onMarkerSelected?.call(null); + } + } + }, + elevation: 1.0, + children: [ + for (int i = 0; i < markers.length; i++) _buildMarkerCard(i), ], ); } Widget _buildMarkerCard(int markerIdx) { ColorScheme colorScheme = Theme.of(context).colorScheme; + final TextEditingController timeController = TextEditingController( + text: markers[markerIdx].waypointRelativePos.toStringAsFixed(2), + ); return TreeCardNode( + leading: const Icon(Icons.pin_drop_rounded), controller: _controllers[markerIdx], onHoverStart: () => widget.onMarkerHovered?.call(markerIdx), onHoverEnd: () => widget.onMarkerHovered?.call(null), @@ -140,6 +147,10 @@ class _EventMarkersTreeState extends State { }, ), Expanded(child: Container()), + const SizedBox(width: 12), + InfoCard( + value: + 'Positioned at ${markers[markerIdx].waypointRelativePos.toStringAsFixed(2)}'), Tooltip( message: 'Delete Marker', waitDuration: const Duration(seconds: 1), @@ -171,33 +182,71 @@ class _EventMarkersTreeState extends State { initiallyExpanded: markerIdx == _selectedMarker, elevation: 4.0, children: [ - Slider( - value: markers[markerIdx].waypointRelativePos.toDouble(), - min: 0.0, - max: waypoints.length - 1.0, - divisions: (waypoints.length - 1) * 20, - label: markers[markerIdx].waypointRelativePos.toStringAsFixed(2), - onChangeStart: (value) { - _sliderChangeStart = value; - }, - onChangeEnd: (value) { - widget.undoStack.add(Change( - _sliderChangeStart, - () { - markers[markerIdx].waypointRelativePos = value; - widget.onPathChangedNoSim?.call(); - }, - (oldValue) { - markers[markerIdx].waypointRelativePos = oldValue; - widget.onPathChangedNoSim?.call(); - }, - )); - }, - onChanged: (value) { - markers[markerIdx].waypointRelativePos = value; - widget.onPathChangedNoSim?.call(); - }, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: Row( + children: [ + const SizedBox(width: 8), + SizedBox( + width: 100, + child: NumberTextField( + initialText: + markers[markerIdx].waypointRelativePos.toStringAsFixed(2), + label: 'Location', + arrowKeyIncrement: 0.1, + onSubmitted: (value) { + if (value != null && + value >= 0.0 && + value <= (waypoints.length - 1.0)) { + setState(() { + markers[markerIdx].waypointRelativePos = value; + widget.onPathChangedNoSim?.call(); + }); + } else { + // Optional: Show error message or handle invalid input + } + }, + ), + ), + Expanded( + child: Slider( + value: markers[markerIdx].waypointRelativePos.toDouble(), + min: 0.0, + max: waypoints.length - 1.0, + divisions: (waypoints.length - 1) * 20, + label: + markers[markerIdx].waypointRelativePos.toStringAsFixed(2), + onChangeStart: (value) { + _sliderChangeStart = value; + }, + onChangeEnd: (value) { + widget.undoStack.add(Change( + _sliderChangeStart, + () { + markers[markerIdx].waypointRelativePos = value; + timeController.text = value.toStringAsFixed(2); + widget.onPathChangedNoSim?.call(); + }, + (oldValue) { + markers[markerIdx].waypointRelativePos = oldValue; + timeController.text = oldValue.toStringAsFixed(2); + widget.onPathChangedNoSim?.call(); + }, + )); + }, + onChanged: (value) { + setState(() { + markers[markerIdx].waypointRelativePos = value; + timeController.text = value.toStringAsFixed(2); + widget.onPathChangedNoSim?.call(); + }); + }, + ), + ), + ], + ), ), + const SizedBox(height: 12), _buildCommandCard(markerIdx), ], ); diff --git a/lib/widgets/editor/tree_widgets/global_constraints_tree.dart b/lib/widgets/editor/tree_widgets/global_constraints_tree.dart index 6dc5ba097..adc4ebd2f 100644 --- a/lib/widgets/editor/tree_widgets/global_constraints_tree.dart +++ b/lib/widgets/editor/tree_widgets/global_constraints_tree.dart @@ -23,6 +23,7 @@ class GlobalConstraintsTree extends StatelessWidget { Widget build(BuildContext context) { return TreeCardNode( title: const Text('Global Constraints'), + leading: const Icon(Icons.escalator_rounded), initiallyExpanded: path.globalConstraintsExpanded, onExpansionChanged: (value) { if (value != null) { @@ -135,7 +136,7 @@ class GlobalConstraintsTree extends StatelessWidget { const SizedBox(width: 4), const Text( 'Use Default Constraints', - style: TextStyle(fontSize: 18), + style: TextStyle(fontSize: 15), ), ], ), diff --git a/lib/widgets/editor/tree_widgets/goal_end_state_tree.dart b/lib/widgets/editor/tree_widgets/goal_end_state_tree.dart index 59d76bc90..a3578f72b 100644 --- a/lib/widgets/editor/tree_widgets/goal_end_state_tree.dart +++ b/lib/widgets/editor/tree_widgets/goal_end_state_tree.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:pathplanner/path/pathplanner_path.dart'; +import 'package:pathplanner/widgets/editor/info_card.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/tree_card_node.dart'; import 'package:pathplanner/widgets/number_text_field.dart'; import 'package:undo/undo.dart'; @@ -21,7 +22,16 @@ class GoalEndStateTree extends StatelessWidget { @override Widget build(BuildContext context) { return TreeCardNode( - title: const Text('Goal End State'), + title: Wrap( + alignment: WrapAlignment.spaceBetween, + children: [ + Text('Final State'), + InfoCard( + value: + '${path.goalEndState.rotation.toStringAsFixed(2)}° ending with ${path.goalEndState.velocity.toStringAsFixed(2)} M/S'), + ], + ), + leading: const Icon(Icons.flag_circle_rounded), initiallyExpanded: path.goalEndStateExpanded, onExpansionChanged: (value) { if (value != null) { @@ -35,6 +45,9 @@ class GoalEndStateTree extends StatelessWidget { child: Row( children: [ Expanded( + child: Tooltip( + message: + 'The allowed velocity of the robot at end of the path.', child: NumberTextField( initialText: path.goalEndState.velocity.toStringAsFixed(2), label: 'Velocity (M/S)', @@ -45,7 +58,7 @@ class GoalEndStateTree extends StatelessWidget { } }, ), - ), + )), if (holonomicMode) const SizedBox(width: 8), if (holonomicMode) Expanded( diff --git a/lib/widgets/editor/tree_widgets/ideal_starting_state_tree.dart b/lib/widgets/editor/tree_widgets/ideal_starting_state_tree.dart index 999161cbf..67fb6e34c 100644 --- a/lib/widgets/editor/tree_widgets/ideal_starting_state_tree.dart +++ b/lib/widgets/editor/tree_widgets/ideal_starting_state_tree.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:pathplanner/path/pathplanner_path.dart'; +import 'package:pathplanner/widgets/editor/info_card.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/tree_card_node.dart'; import 'package:pathplanner/widgets/number_text_field.dart'; import 'package:undo/undo.dart'; @@ -21,7 +22,16 @@ class IdealStartingStateTree extends StatelessWidget { @override Widget build(BuildContext context) { return TreeCardNode( - title: const Text('Ideal Starting State'), + title: Wrap( + alignment: WrapAlignment.spaceBetween, + children: [ + Text('Starting State'), + InfoCard( + value: + '${path.idealStartingState.rotation.toStringAsFixed(2)}° starting with ${path.idealStartingState.velocity.toStringAsFixed(2)} M/S'), + ], + ), + leading: const Icon(Icons.start_rounded), initiallyExpanded: path.previewStartingStateExpanded, onExpansionChanged: (value) { if (value != null) { diff --git a/lib/widgets/editor/tree_widgets/item_count.dart b/lib/widgets/editor/tree_widgets/item_count.dart index 111565084..43e9e4ea0 100644 --- a/lib/widgets/editor/tree_widgets/item_count.dart +++ b/lib/widgets/editor/tree_widgets/item_count.dart @@ -12,11 +12,14 @@ class ItemCount extends StatelessWidget { Widget build(BuildContext context) { ColorScheme colorScheme = Theme.of(context).colorScheme; - return Text( - '$count', - style: TextStyle( - fontSize: 18, - color: colorScheme.primary, + return Tooltip( + message: 'Number of items', + child: Text( + '$count', + style: TextStyle( + fontSize: 18, + color: colorScheme.primary, + ), ), ); } diff --git a/lib/widgets/editor/tree_widgets/path_optimization_tree.dart b/lib/widgets/editor/tree_widgets/path_optimization_tree.dart index 06b459ba8..58a5fbcdd 100644 --- a/lib/widgets/editor/tree_widgets/path_optimization_tree.dart +++ b/lib/widgets/editor/tree_widgets/path_optimization_tree.dart @@ -36,6 +36,7 @@ class _PathOptimizationTreeState extends State { return TreeCardNode( title: const Text('Path Optimizer'), + leading: const Icon(Icons.query_stats), initiallyExpanded: widget.path.pathOptimizationExpanded, onExpansionChanged: (value) { if (value != null) { @@ -44,142 +45,131 @@ class _PathOptimizationTreeState extends State { }, elevation: 1.0, children: [ - Center( - child: Text( - 'Optimized Runtime: ${(_currentResult?.runtime ?? 0.0).toStringAsFixed(2)}s', - style: const TextStyle(fontSize: 18), - ), - ), - const SizedBox(height: 8), - SizedBox( - height: 50, - child: Row( + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: ElevatedButton.icon( - label: const Text('Optimize'), - icon: const Icon(Icons.query_stats), - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.primaryContainer, - foregroundColor: colorScheme.onPrimaryContainer, - elevation: 4.0, - minimumSize: const Size(0, 56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16)), - ), - onPressed: _running - ? null - : () async { - RobotConfig config = - RobotConfig.fromPrefs(widget.prefs); - - setState(() { - _running = true; - _currentResult = null; - }); - - widget.onUpdate?.call(_currentResult?.path); - - final result = await PathOptimizer.optimizePath( - widget.path, config, - onUpdate: (result) => setState(() { - _currentResult = result; - widget.onUpdate?.call(_currentResult?.path); - })); - - setState(() { - _running = false; - _currentResult = result; - }); - - widget.onUpdate?.call(_currentResult?.path); - }, - ), + Text( + 'Optimized Runtime: ${(_currentResult?.runtime ?? 0.0).toStringAsFixed(2)}s', + style: Theme.of(context).textTheme.bodyLarge, ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton.icon( - label: const Text('Discard'), - icon: const Icon(Icons.close), - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.errorContainer, - foregroundColor: colorScheme.onErrorContainer, - elevation: 4.0, - minimumSize: const Size(0, 56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + label: const Text('Optimize'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + ), + onPressed: _running ? null : _runOptimization, + ), ), - onPressed: (_running || _currentResult == null) - ? null - : () { - setState(() { - _currentResult = null; - }); - widget.onUpdate?.call(_currentResult?.path); - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton.icon( - label: const Text('Accept'), - icon: const Icon(Icons.check), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green[700], - foregroundColor: colorScheme.onSurface, - elevation: 4.0, - minimumSize: const Size(0, 56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16)), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.close), + label: const Text('Discard'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + ), + onPressed: (_running || _currentResult == null) + ? null + : _discardOptimization, + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.check), + label: const Text('Accept'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[700], + foregroundColor: colorScheme.onSecondaryContainer, + ), + onPressed: (_running || _currentResult == null) + ? null + : _acceptOptimization, + ), ), - onPressed: (_running || _currentResult == null) - ? null - : () { - if (_currentResult == null) { - return; - } - - final points = PathPlannerPath.cloneWaypoints( - _currentResult!.path.waypoints); - - widget.undoStack.add(Change( - PathPlannerPath.cloneWaypoints( - widget.path.waypoints), - () { - setState(() { - _currentResult = null; - }); - widget.onUpdate?.call(_currentResult?.path); - - widget.path.waypoints = points; - widget.onPathChanged?.call(); - }, - (oldValue) { - setState(() { - _currentResult = null; - }); - widget.onUpdate?.call(_currentResult?.path); - - widget.path.waypoints = - PathPlannerPath.cloneWaypoints(oldValue); - widget.onPathChanged?.call(); - }, - )); - }, - ), + ], + ), + const SizedBox(height: 16), + LinearProgressIndicator( + value: (_currentResult?.generation ?? 0) / + PathOptimizer.generations, ), ], ), ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(right: 6.0), - child: LinearProgressIndicator( - value: - (_currentResult?.generation ?? 0) / PathOptimizer.generations, - ), - ), ], ); } + + void _runOptimization() async { + RobotConfig config = RobotConfig.fromPrefs(widget.prefs); + + setState(() { + _running = true; + _currentResult = null; + }); + + widget.onUpdate?.call(_currentResult?.path); + + final result = await PathOptimizer.optimizePath( + widget.path, + config, + onUpdate: (result) => setState(() { + _currentResult = result; + widget.onUpdate?.call(_currentResult?.path); + }), + ); + + setState(() { + _running = false; + _currentResult = result; + }); + + widget.onUpdate?.call(_currentResult?.path); + } + + void _discardOptimization() { + setState(() { + _currentResult = null; + }); + widget.onUpdate?.call(_currentResult?.path); + } + + void _acceptOptimization() { + if (_currentResult == null) return; + + final points = + PathPlannerPath.cloneWaypoints(_currentResult!.path.waypoints); + + widget.undoStack.add(Change( + PathPlannerPath.cloneWaypoints(widget.path.waypoints), + () { + setState(() { + _currentResult = null; + }); + widget.onUpdate?.call(_currentResult?.path); + + widget.path.waypoints = points; + widget.onPathChanged?.call(); + }, + (oldValue) { + setState(() { + _currentResult = null; + }); + widget.onUpdate?.call(_currentResult?.path); + + widget.path.waypoints = PathPlannerPath.cloneWaypoints(oldValue); + widget.onPathChanged?.call(); + }, + )); + } } diff --git a/lib/widgets/editor/tree_widgets/path_tree.dart b/lib/widgets/editor/tree_widgets/path_tree.dart index 5522dd236..728a419ff 100644 --- a/lib/widgets/editor/tree_widgets/path_tree.dart +++ b/lib/widgets/editor/tree_widgets/path_tree.dart @@ -39,6 +39,8 @@ class PathTree extends StatefulWidget { final PathConstraints defaultConstraints; final SharedPreferences prefs; + final Widget? runtimeDisplay; + const PathTree({ super.key, required this.path, @@ -60,6 +62,7 @@ class PathTree extends StatefulWidget { this.onMarkerSelected, this.initiallySelectedMarker, required this.undoStack, + this.runtimeDisplay, this.pathRuntime, this.onPathChangedNoSim, required this.holonomicMode, @@ -76,101 +79,24 @@ class _PathTreeState extends State { Widget build(BuildContext context) { return Column( children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Row( - children: [ - Text( - 'Simulated Driving Time: ~${(widget.pathRuntime ?? 0).toStringAsFixed(2)}s', - style: const TextStyle(fontSize: 18), - ), - Expanded(child: Container()), - Tooltip( - message: 'Move to Other Side', - waitDuration: const Duration(seconds: 1), - child: IconButton( - onPressed: widget.onSideSwapped, - icon: const Icon(Icons.swap_horiz), - ), - ), - ], - ), - ), + _buildHeader(), const SizedBox(height: 4.0), Expanded( child: SingleChildScrollView( child: Column( children: [ - WaypointsTree( - key: ValueKey('waypoints${widget.path.waypoints.length}'), - onWaypointDeleted: widget.onWaypointDeleted, - initialSelectedWaypoint: widget.initiallySelectedWaypoint, - controller: widget.waypointsTreeController, - path: widget.path, - onWaypointHovered: widget.onWaypointHovered, - onWaypointSelected: widget.onWaypointSelected, - onPathChanged: widget.onPathChanged, - undoStack: widget.undoStack, - holonomicMode: widget.holonomicMode, - ), - GlobalConstraintsTree( - path: widget.path, - onPathChanged: widget.onPathChanged, - undoStack: widget.undoStack, - defaultConstraints: widget.defaultConstraints, - ), - IdealStartingStateTree( - path: widget.path, - undoStack: widget.undoStack, - holonomicMode: widget.holonomicMode, - onPathChanged: widget.onPathChanged, - ), - GoalEndStateTree( - path: widget.path, - onPathChanged: widget.onPathChanged, - undoStack: widget.undoStack, - holonomicMode: widget.holonomicMode, - ), - if (widget.holonomicMode) - RotationTargetsTree( - key: ValueKey( - 'rotations${widget.path.rotationTargets.length}'), - path: widget.path, - onPathChanged: widget.onPathChanged, - onPathChangedNoSim: widget.onPathChangedNoSim, - onTargetHovered: widget.onRotTargetHovered, - onTargetSelected: widget.onRotTargetSelected, - initiallySelectedTarget: widget.initiallySelectedRotTarget, - undoStack: widget.undoStack, - ), - EventMarkersTree( - key: ValueKey('markers${widget.path.eventMarkers.length}'), - path: widget.path, - onPathChangedNoSim: widget.onPathChangedNoSim, - onMarkerHovered: widget.onMarkerHovered, - onMarkerSelected: widget.onMarkerSelected, - initiallySelectedMarker: widget.initiallySelectedMarker, - undoStack: widget.undoStack, - ), - ConstraintZonesTree( - key: ValueKey('zones${widget.path.constraintZones.length}'), - path: widget.path, - onPathChanged: widget.onPathChanged, - onPathChangedNoSim: widget.onPathChangedNoSim, - onZoneHovered: widget.onZoneHovered, - onZoneSelected: widget.onZoneSelected, - initiallySelectedZone: widget.initiallySelectedZone, - undoStack: widget.undoStack, - ), - _buildReversedCheckbox(), + _buildWaypointsTree(), + _buildEventMarkersTree(), + if (widget.holonomicMode) _buildRotationTargetsTree(), + const Divider(), + _buildIdealStartingStateTree(), + _buildGoalEndStateTree(), const Divider(), - PathOptimizationTree( - path: widget.path, - onPathChanged: widget.onPathChanged, - onUpdate: widget.onOptimizationUpdate, - undoStack: widget.undoStack, - prefs: widget.prefs, - ), + _buildGlobalConstraintsTree(), + _buildConstraintZonesTree(), + if (!widget.holonomicMode) _buildReversedCheckbox(), + const Divider(), + _buildPathOptimizationTree(), const EditorSettingsTree(), ], ), @@ -180,42 +106,218 @@ class _PathTreeState extends State { ); } - Widget _buildReversedCheckbox() { - return Visibility( - visible: !widget.holonomicMode, - child: Card( - elevation: 1.0, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 12.0), - child: Row( - children: [ - Checkbox( - value: widget.path.reversed, - onChanged: (value) { - bool reversed = value ?? false; - - widget.undoStack.add(Change( - widget.path.reversed, - () { - widget.path.reversed = reversed; - widget.onPathChanged?.call(); - }, - (oldValue) { - widget.path.reversed = oldValue; - widget.onPathChanged?.call(); - }, - )); - }, + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Container()), + if (!widget.holonomicMode) ...[ + _buildReversedButton(), + const SizedBox(width: 8), + Tooltip( + message: + 'Moving ${widget.path.reversed ? 'Reversed' : 'Forward'}', + child: _buildInfoCard( + value: widget.path.reversed ? 'RVD' : 'FWD', ), - const SizedBox(width: 4), - const Text( - 'Reversed', - style: TextStyle(fontSize: 16), - ), - ], + ), + ], + const SizedBox(width: 16), + if (widget.runtimeDisplay != null) widget.runtimeDisplay!, + const SizedBox(width: 16), + Tooltip( + message: 'Move to Other Side', + waitDuration: const Duration(seconds: 1), + child: IconButton( + onPressed: widget.onSideSwapped, + icon: const Icon(Icons.swap_horiz), + ), ), + ], + ), + ); + } + + Widget _buildInfoCard({required String value}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + color: const Color.fromARGB(36, 0, 0, 0), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + value, + style: const TextStyle( + fontWeight: FontWeight.normal, + color: Colors.white, + fontSize: 12, ), ), ); } + + Widget _buildWaypointsTree() { + return WaypointsTree( + key: ValueKey('waypoints${widget.path.waypoints.length}'), + onWaypointDeleted: widget.onWaypointDeleted, + initialSelectedWaypoint: widget.initiallySelectedWaypoint, + controller: widget.waypointsTreeController, + path: widget.path, + onWaypointHovered: widget.onWaypointHovered, + onWaypointSelected: widget.onWaypointSelected, + onPathChanged: widget.onPathChanged, + undoStack: widget.undoStack, + holonomicMode: widget.holonomicMode, + ); + } + + Widget _buildGlobalConstraintsTree() { + return GlobalConstraintsTree( + path: widget.path, + onPathChanged: widget.onPathChanged, + undoStack: widget.undoStack, + defaultConstraints: widget.defaultConstraints, + ); + } + + Widget _buildIdealStartingStateTree() { + return IdealStartingStateTree( + path: widget.path, + undoStack: widget.undoStack, + holonomicMode: widget.holonomicMode, + onPathChanged: widget.onPathChanged, + ); + } + + Widget _buildGoalEndStateTree() { + return GoalEndStateTree( + path: widget.path, + onPathChanged: widget.onPathChanged, + undoStack: widget.undoStack, + holonomicMode: widget.holonomicMode, + ); + } + + Widget _buildRotationTargetsTree() { + return RotationTargetsTree( + key: ValueKey('rotations${widget.path.rotationTargets.length}'), + path: widget.path, + onPathChanged: widget.onPathChanged, + onPathChangedNoSim: widget.onPathChangedNoSim, + onTargetHovered: widget.onRotTargetHovered, + onTargetSelected: widget.onRotTargetSelected, + initiallySelectedTarget: widget.initiallySelectedRotTarget, + undoStack: widget.undoStack, + ); + } + + Widget _buildEventMarkersTree() { + return EventMarkersTree( + key: ValueKey('markers${widget.path.eventMarkers.length}'), + path: widget.path, + onPathChangedNoSim: widget.onPathChangedNoSim, + onMarkerHovered: widget.onMarkerHovered, + onMarkerSelected: widget.onMarkerSelected, + initiallySelectedMarker: widget.initiallySelectedMarker, + undoStack: widget.undoStack, + ); + } + + Widget _buildConstraintZonesTree() { + return ConstraintZonesTree( + key: ValueKey('zones${widget.path.constraintZones.length}'), + path: widget.path, + onPathChanged: widget.onPathChanged, + onPathChangedNoSim: widget.onPathChangedNoSim, + onZoneHovered: widget.onZoneHovered, + onZoneSelected: widget.onZoneSelected, + initiallySelectedZone: widget.initiallySelectedZone, + undoStack: widget.undoStack, + ); + } + + Widget _buildReversedButton() { + return Tooltip( + message: widget.path.reversed ? 'Unreverse Path' : 'Reverse Path', + child: GestureDetector( + onTap: () { + bool newReversed = !widget.path.reversed; + + widget.undoStack.add(Change( + widget.path.reversed, + () { + widget.path.reversed = newReversed; + widget.onPathChanged?.call(); + }, + (oldValue) { + widget.path.reversed = oldValue; + widget.onPathChanged?.call(); + }, + )); + }, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color.fromARGB(36, 0, 0, 0), + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + widget.path.reversed + ? Icons.arrow_forward_rounded + : Icons.arrow_back_rounded, + color: Colors.white, + size: 15, + ), + ), + ), + ); + } + + Widget _buildReversedCheckbox() { + return Card( + elevation: 1.0, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 12.0), + child: Row( + children: [ + Checkbox( + value: widget.path.reversed, + onChanged: (value) { + bool reversed = value ?? false; + + widget.undoStack.add(Change( + widget.path.reversed, + () { + widget.path.reversed = reversed; + widget.onPathChanged?.call(); + }, + (oldValue) { + widget.path.reversed = oldValue; + widget.onPathChanged?.call(); + }, + )); + }, + ), + const SizedBox(width: 4), + const Text( + 'Reversed', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + ); + } + + Widget _buildPathOptimizationTree() { + return PathOptimizationTree( + path: widget.path, + onPathChanged: widget.onPathChanged, + onUpdate: widget.onOptimizationUpdate, + undoStack: widget.undoStack, + prefs: widget.prefs, + ); + } } diff --git a/lib/widgets/editor/tree_widgets/reset_odom_tree.dart b/lib/widgets/editor/tree_widgets/reset_odom_tree.dart index aad668b6d..18eb5545d 100644 --- a/lib/widgets/editor/tree_widgets/reset_odom_tree.dart +++ b/lib/widgets/editor/tree_widgets/reset_odom_tree.dart @@ -49,7 +49,7 @@ class ResetOdomTree extends StatelessWidget { ), child: Text( 'Reset Odometry', - style: TextStyle(fontSize: 18), + style: TextStyle(fontSize: 16), ), ), ], diff --git a/lib/widgets/editor/tree_widgets/rotation_targets_tree.dart b/lib/widgets/editor/tree_widgets/rotation_targets_tree.dart index 1d3b21670..51febb871 100644 --- a/lib/widgets/editor/tree_widgets/rotation_targets_tree.dart +++ b/lib/widgets/editor/tree_widgets/rotation_targets_tree.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pathplanner/path/pathplanner_path.dart'; import 'package:pathplanner/path/rotation_target.dart'; import 'package:pathplanner/path/waypoint.dart'; +import 'package:pathplanner/widgets/editor/info_card.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/item_count.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/tree_card_node.dart'; import 'package:pathplanner/widgets/number_text_field.dart'; @@ -54,28 +55,12 @@ class _RotationTargetsTreeState extends State { Widget build(BuildContext context) { return TreeCardNode( title: const Text('Rotation Targets'), - trailing: ItemCount(count: widget.path.rotationTargets.length), - initiallyExpanded: widget.path.rotationTargetsExpanded, - onExpansionChanged: (value) { - if (value != null) { - widget.path.rotationTargetsExpanded = value; - if (value == false) { - _selectedTarget = null; - widget.onTargetSelected?.call(null); - } - } - }, - elevation: 1.0, - children: [ - for (int i = 0; i < rotations.length; i++) _buildRotationCard(i), - const SizedBox(height: 12), - Center( - child: ElevatedButton.icon( - icon: const Icon(Icons.add), - style: ElevatedButton.styleFrom( - elevation: 4.0, - ), - label: const Text('Add New Rotation Target'), + leading: const Icon(Icons.rotate_90_degrees_cw_rounded), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add, size: 20), onPressed: () { widget.undoStack.add(Change( PathPlannerPath.cloneRotationTargets(rotations), @@ -93,8 +78,25 @@ class _RotationTargetsTreeState extends State { }, )); }, + tooltip: 'Add New Rotation Target', ), - ), + const SizedBox(width: 8), + ItemCount(count: widget.path.rotationTargets.length), + ], + ), + initiallyExpanded: widget.path.rotationTargetsExpanded, + onExpansionChanged: (value) { + if (value != null) { + widget.path.rotationTargetsExpanded = value; + if (value == false) { + _selectedTarget = null; + widget.onTargetSelected?.call(null); + } + } + }, + elevation: 1.0, + children: [ + for (int i = 0; i < rotations.length; i++) _buildRotationCard(i), ], ); } @@ -103,6 +105,7 @@ class _RotationTargetsTreeState extends State { ColorScheme colorScheme = Theme.of(context).colorScheme; return TreeCardNode( + leading: const Icon(Icons.rotate_right_rounded), controller: _controllers[targetIdx], onHoverStart: () => widget.onTargetHovered?.call(targetIdx), onHoverEnd: () => widget.onTargetHovered?.call(null), @@ -122,9 +125,15 @@ class _RotationTargetsTreeState extends State { }, title: Row( children: [ - Text( - 'Rotation Target at ${rotations[targetIdx].waypointRelativePos.toStringAsFixed(2)}'), - Expanded(child: Container()), + Expanded( + child: Text('Rotation Target ${targetIdx + 1}'), + ), + const SizedBox(width: 8), + InfoCard( + value: + '${rotations[targetIdx].rotationDegrees.toStringAsFixed(2)}° at ${rotations[targetIdx].waypointRelativePos.toStringAsFixed(2)}', + ), + const SizedBox(width: 8), Tooltip( message: 'Delete Target', waitDuration: const Duration(seconds: 1), @@ -166,7 +175,7 @@ class _RotationTargetsTreeState extends State { initialText: rotations[targetIdx].rotationDegrees.toStringAsFixed(2), label: 'Rotation (Deg)', - arrowKeyIncrement: 1.0, + arrowKeyIncrement: 45, onSubmitted: (value) { if (value != null) { num rot = value % 360; @@ -190,6 +199,27 @@ class _RotationTargetsTreeState extends State { }, ), ), + const SizedBox(width: 8), + SizedBox( + width: 100, + child: NumberTextField( + initialText: rotations[targetIdx] + .waypointRelativePos + .toStringAsFixed(2), + label: 'Position', + arrowKeyIncrement: 0.1, + onSubmitted: (value) { + if (value != null && + value >= 0.0 && + value <= (waypoints.length - 1.0)) { + setState(() { + rotations[targetIdx].waypointRelativePos = value; + widget.onPathChangedNoSim?.call(); + }); + } + }, + ), + ), ], ), ), @@ -217,8 +247,10 @@ class _RotationTargetsTreeState extends State { )); }, onChanged: (value) { - rotations[targetIdx].waypointRelativePos = value; - widget.onPathChangedNoSim?.call(); + setState(() { + rotations[targetIdx].waypointRelativePos = value; + widget.onPathChangedNoSim?.call(); + }); }, ), ], diff --git a/lib/widgets/editor/tree_widgets/tree_card_node.dart b/lib/widgets/editor/tree_widgets/tree_card_node.dart index c22ac06b9..07c28709a 100644 --- a/lib/widgets/editor/tree_widgets/tree_card_node.dart +++ b/lib/widgets/editor/tree_widgets/tree_card_node.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; class TreeCardNode extends StatelessWidget { final Widget title; + final Widget? leading; final double elevation; final List children; final VoidCallback? onHoverStart; @@ -15,6 +16,7 @@ class TreeCardNode extends StatelessWidget { const TreeCardNode({ super.key, required this.title, + this.leading, this.elevation = 2, required this.children, this.onHoverStart, @@ -41,7 +43,13 @@ class TreeCardNode extends StatelessWidget { onEnter: (event) => onHoverStart?.call(), onExit: (event) => onHoverEnd?.call(), child: ExpansionTile( - title: title, + title: Row( + children: [ + if (leading != null) leading!, + if (leading != null) const SizedBox(width: 8), + Expanded(child: title), + ], + ), trailing: trailing, controller: controller, maintainState: false, diff --git a/lib/widgets/editor/tree_widgets/waypoints_tree.dart b/lib/widgets/editor/tree_widgets/waypoints_tree.dart index 22d1eaf84..77a78ef6f 100644 --- a/lib/widgets/editor/tree_widgets/waypoints_tree.dart +++ b/lib/widgets/editor/tree_widgets/waypoints_tree.dart @@ -67,6 +67,7 @@ class _WaypointsTreeState extends State { Widget build(BuildContext context) { return TreeCardNode( title: const Text('Waypoints'), + leading: const Icon(Icons.location_on_rounded), trailing: ItemCount(count: widget.path.waypoints.length), initiallyExpanded: widget.path.waypointsExpanded, controller: _expansionController, @@ -135,6 +136,13 @@ class _WaypointsTreeState extends State { }, title: Row( children: [ + if (waypointIdx == 0) + const Icon(Icons.start_rounded) + else if (waypointIdx == waypoints.length - 1) + const Icon(Icons.flag_outlined) + else + const Icon(Icons.room), + const SizedBox(width: 8), Text(name), if (waypoint.linkedName != null) Padding( @@ -153,13 +161,30 @@ class _WaypointsTreeState extends State { waitDuration: const Duration(seconds: 1), child: IconButton( onPressed: () { - waypoint.isLocked = !waypoint.isLocked; + setState(() { + waypoint.isLocked = !waypoint.isLocked; + }); widget.onPathChanged?.call(); }, - icon: Icon(waypoint.isLocked ? Icons.lock : Icons.lock_open, - color: colorScheme.onSurface), + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, Animation animation) { + return ScaleTransition(scale: animation, child: child); + }, + child: Icon( + waypoint.isLocked + ? Icons.lock_rounded + : Icons.lock_open_rounded, + key: ValueKey(waypoint.isLocked), + color: waypoint.isLocked + ? colorScheme.primary + : colorScheme.onSurface, + size: 20, + ), + ), ), ), + const SizedBox(width: 8), if (waypoints.length > 2) Tooltip( message: 'Delete Waypoint', @@ -291,9 +316,9 @@ class _WaypointsTreeState extends State { alignment: WrapAlignment.center, children: [ if (widget.holonomicMode) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: ElevatedButton.icon( + Tooltip( + message: 'Add Rotation Target at Waypoint', + child: IconButton( onPressed: () { widget.undoStack.add(Change( PathPlannerPath.cloneRotationTargets( @@ -310,21 +335,13 @@ class _WaypointsTreeState extends State { }, )); }, - icon: const Icon(Icons.replay, size: 20), - style: ElevatedButton.styleFrom( - elevation: 1.0, - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - label: const Text('Add Rotation Target'), + icon: const Icon(Icons.rotate_right_rounded, size: 20), ), ), if (waypointIdx != waypoints.length - 1) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: ElevatedButton.icon( + Tooltip( + message: 'Create New Waypoint After', + child: IconButton( onPressed: () { widget.undoStack.add(Change( [ @@ -363,36 +380,20 @@ class _WaypointsTreeState extends State { )); }, icon: const Icon(Icons.add, size: 20), - style: ElevatedButton.styleFrom( - elevation: 1.0, - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - label: const Text('New Waypoint After'), ), ), if (waypoint.linkedName == null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: ElevatedButton.icon( + Tooltip( + message: 'Link Waypoint', + child: IconButton( onPressed: () => _showLinkedDialog(waypointIdx), - icon: const Icon(Icons.link, size: 20), - style: ElevatedButton.styleFrom( - elevation: 1.0, - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - label: const Text('Link Waypoint'), + icon: const Icon(Icons.add_link_rounded, size: 20), ), ), if (waypoint.linkedName != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: ElevatedButton.icon( + Tooltip( + message: 'Unlink Waypoint', + child: IconButton( onPressed: () { widget.undoStack.add(_waypointChange(waypoint, () { waypoint.linkedName = null; @@ -401,14 +402,6 @@ class _WaypointsTreeState extends State { })); }, icon: const Icon(Icons.link_off, size: 20), - style: ElevatedButton.styleFrom( - elevation: 1.0, - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - label: const Text('Unlink'), ), ), ], diff --git a/lib/widgets/window_buttons.dart b/lib/widgets/window_buttons.dart index 89bb80011..6ad1b5004 100644 --- a/lib/widgets/window_buttons.dart +++ b/lib/widgets/window_buttons.dart @@ -8,14 +8,16 @@ class WindowButton extends StatefulWidget { final VoidCallback onPressed; final IconData icon; final EdgeInsets padding; + final double iconSize; const WindowButton({ - this.buttonWidth = 56, + this.buttonWidth = 45, this.hoverBackgroundColor = const Color(0xFF404040), this.pressedBackgroundColor = const Color(0xFF202020), required this.onPressed, required this.icon, this.padding = const EdgeInsets.all(8), + this.iconSize = 16, super.key, }); @@ -47,6 +49,7 @@ class _WindowButtonState extends State { child: Icon( widget.icon, color: colorScheme.onSurface, + size: widget.iconSize, ), ), ), diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt index a1da1b9e5..6dc970558 100644 --- a/linux/flutter/CMakeLists.txt +++ b/linux/flutter/CMakeLists.txt @@ -82,7 +82,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - linux-x64 ${CMAKE_BUILD_TYPE} + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/linux/my_application.cc b/linux/my_application.cc index b6a620941..2c3225d8f 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -19,6 +19,9 @@ static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + gtk_window_set_icon_from_file(GTK_WINDOW(window), "images/icon.ico", NULL); + // Use a header bar when running in GNOME as this is the common style used // by applications and is the setup most users will be using (e.g. Ubuntu diff --git a/pubspec.lock b/pubspec.lock index 56dcb30c3..29ee8403a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1158,10 +1158,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.2.4" watcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2f31b3d3a..3459f3c65 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,6 +85,7 @@ flutter: - images/field23.png - images/field24.png - images/icon.png + - images/icon.ico - images/choreo.png - resources/default_navgrid.json diff --git a/test/pages/home_page_test.dart b/test/pages/home_page_test.dart new file mode 100644 index 000000000..a2494ca21 --- /dev/null +++ b/test/pages/home_page_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pathplanner/pages/home_page.dart'; +import 'package:pathplanner/widgets/custom_appbar.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:file/memory.dart'; +import 'package:pathplanner/services/pplib_telemetry.dart'; +import 'package:pathplanner/services/update_checker.dart'; +import 'package:undo/undo.dart'; + +void main() { + late SharedPreferences prefs; + late MemoryFileSystem fs; + late ChangeStack undoStack; + late PPLibTelemetry telemetry; + late UpdateChecker updateChecker; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + fs = MemoryFileSystem(); + undoStack = ChangeStack(); + telemetry = PPLibTelemetry(serverBaseAddress: 'localhost'); + updateChecker = UpdateChecker(); + }); + + testWidgets('HomePage initial rendering', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: HomePage( + appVersion: '1.0.0', + prefs: prefs, + onTeamColorChanged: (_) {}, + fs: fs, + undoStack: undoStack, + telemetry: telemetry, + updateChecker: updateChecker, + ), + )); + + expect(find.byType(HomePage), findsOneWidget); + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.text('PathPlanner'), findsOneWidget); + }); +} diff --git a/test/pages/project/project_page_test.dart b/test/pages/project/project_page_test.dart index bd2475a33..838c44cad 100644 --- a/test/pages/project/project_page_test.dart +++ b/test/pages/project/project_page_test.dart @@ -218,6 +218,56 @@ void main() { expect(find.widgetWithText(ProjectItemCard, 'auto2'), findsNothing); }); + testWidgets('add new path', (widgetTester) async { + await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: ProjectPage( + prefs: prefs, + fieldImage: FieldImage.defaultField, + pathplannerDirectory: fs.directory(deployPath), + choreoDirectory: fs.directory(join(deployPath, 'choreo')), + fs: fs, + undoStack: ChangeStack(), + shortcuts: false, + ), + ), + )); + await widgetTester.pumpAndSettle(); + + final addButton = find.byTooltip('Add new path'); + expect(addButton, findsOneWidget); + await widgetTester.tap(addButton); + await widgetTester.pumpAndSettle(); + + expect(find.text('New Path'), findsOneWidget); + }); + + testWidgets('add new auto', (widgetTester) async { + await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: ProjectPage( + prefs: prefs, + fieldImage: FieldImage.defaultField, + pathplannerDirectory: fs.directory(deployPath), + choreoDirectory: fs.directory(join(deployPath, 'choreo')), + fs: fs, + undoStack: ChangeStack(), + shortcuts: false, + ), + ), + )); + await widgetTester.pumpAndSettle(); + + final addButton = find.byTooltip('Add new auto'); + expect(addButton, findsOneWidget); + await widgetTester.tap(addButton); + await widgetTester.pumpAndSettle(); + + expect(find.text('New Auto'), findsOneWidget); + }); + testWidgets('add new path button', (widgetTester) async { await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); @@ -342,6 +392,42 @@ void main() { find.widgetWithText(ProjectItemCard, 'New New Auto'), findsOneWidget); }); + testWidgets('add new auto button', (widgetTester) async { + await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: ProjectPage( + prefs: prefs, + fieldImage: FieldImage.defaultField, + pathplannerDirectory: fs.directory(deployPath), + choreoDirectory: fs.directory(join(deployPath, 'choreo')), + fs: fs, + undoStack: ChangeStack(), + shortcuts: false, + ), + ), + )); + await widgetTester.pumpAndSettle(); + + final addButton = find.byTooltip('Add new auto'); + + expect(addButton, findsOneWidget); + + await widgetTester.tap(addButton); + await widgetTester.pumpAndSettle(); + + expect(find.byType(ProjectItemCard), findsNWidgets(2)); + expect(find.widgetWithText(ProjectItemCard, 'New Auto'), findsOneWidget); + + await widgetTester.tap(addButton); + await widgetTester.pumpAndSettle(); + + expect(find.byType(ProjectItemCard), findsNWidgets(3)); + expect( + find.widgetWithText(ProjectItemCard, 'New New Auto'), findsOneWidget); + }); + testWidgets('duplicate path', (widgetTester) async { await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); @@ -1038,9 +1124,7 @@ void main() { testWidgets('add path folder', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; - await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); - await widgetTester.pumpWidget(MaterialApp( home: Scaffold( body: ProjectPage( @@ -1056,20 +1140,26 @@ void main() { )); await widgetTester.pumpAndSettle(); - expect(find.byTooltip('Add new path folder'), findsOneWidget); + // Find the specific 'Add new folder' button for paths + final addFolderButton = find + .byWidgetPredicate((widget) => + widget is IconButton && + widget.tooltip == 'Add new folder' && + widget.icon is Icon && + (widget.icon as Icon).icon == Icons.create_new_folder_outlined) + .first; - await widgetTester.tap(find.byTooltip('Add new path folder')); - await widgetTester.pump(); + expect(addFolderButton, findsOneWidget); + await widgetTester.tap(addFolderButton); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(DragTarget, 'New Folder'), - findsOneWidget); + // Check if the new folder is added + expect(find.text('New Folder'), findsOneWidget); }); testWidgets('add auto folder', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; - await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); - await widgetTester.pumpWidget(MaterialApp( home: Scaffold( body: ProjectPage( @@ -1085,13 +1175,21 @@ void main() { )); await widgetTester.pumpAndSettle(); - expect(find.byTooltip('Add new auto folder'), findsOneWidget); + // Find the specific 'Add new folder' button for autos + final addAutoFolderButton = find + .byWidgetPredicate((widget) => + widget is IconButton && + widget.tooltip == 'Add new folder' && + widget.icon is Icon && + (widget.icon as Icon).icon == Icons.create_new_folder_outlined) + .last; // Assuming the auto folder button is the second (last) one - await widgetTester.tap(find.byTooltip('Add new auto folder')); - await widgetTester.pump(); + expect(addAutoFolderButton, findsOneWidget); + await widgetTester.tap(addAutoFolderButton); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(DragTarget, 'New Folder'), - findsOneWidget); + // Check if the new folder is added + expect(find.text('New Folder'), findsOneWidget); }); testWidgets('delete path folder', (widgetTester) async { @@ -1669,4 +1767,98 @@ void main() { await widgetTester.tap(confirmBtn); await widgetTester.pumpAndSettle(); }); + + testWidgets('search bar filters paths and autos', (widgetTester) async { + await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); + + await fs.directory(join(deployPath, 'paths')).create(recursive: true); + await fs.directory(join(deployPath, 'autos')).create(recursive: true); + + PathPlannerPath path1 = PathPlannerPath.defaultPath( + pathDir: join(deployPath, 'paths'), + fs: fs, + name: 'Test Path 1', + ); + PathPlannerPath path2 = PathPlannerPath.defaultPath( + pathDir: join(deployPath, 'paths'), + fs: fs, + name: 'Another Path', + ); + + PathPlannerAuto auto1 = PathPlannerAuto.defaultAuto( + autoDir: join(deployPath, 'autos'), + fs: fs, + name: 'Test Auto 1', + ); + PathPlannerAuto auto2 = PathPlannerAuto.defaultAuto( + autoDir: join(deployPath, 'autos'), + fs: fs, + name: 'Another Auto', + ); + + await fs + .file(join(deployPath, 'paths', 'Test Path 1.path')) + .writeAsString(jsonEncode(path1.toJson())); + await fs + .file(join(deployPath, 'paths', 'Another Path.path')) + .writeAsString(jsonEncode(path2.toJson())); + await fs + .file(join(deployPath, 'autos', 'Test Auto 1.auto')) + .writeAsString(jsonEncode(auto1.toJson())); + await fs + .file(join(deployPath, 'autos', 'Another Auto.auto')) + .writeAsString(jsonEncode(auto2.toJson())); + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: ProjectPage( + prefs: prefs, + fieldImage: FieldImage.defaultField, + pathplannerDirectory: fs.directory(deployPath), + choreoDirectory: fs.directory(join(deployPath, 'choreo')), + fs: fs, + undoStack: ChangeStack(), + shortcuts: false, + ), + ), + )); + + await widgetTester.pumpAndSettle(); + + // Verify all items are initially visible + expect(find.text('Test Path 1'), findsOneWidget); + expect(find.text('Another Path'), findsOneWidget); + expect(find.text('Test Auto 1'), findsOneWidget); + expect(find.text('Another Auto'), findsOneWidget); + + // Find and interact with the path search bar + final pathSearchBar = find.widgetWithText(TextField, 'Search for paths...'); + await widgetTester.enterText(pathSearchBar, 'Test'); + await widgetTester.pumpAndSettle(const Duration(milliseconds: 300)); + + // Verify path filtering + expect(find.text('Test Path 1'), findsOneWidget); + expect(find.text('Another Path'), findsNothing); + + // Clear path search + await widgetTester.enterText(pathSearchBar, ''); + await widgetTester.pumpAndSettle(const Duration(milliseconds: 300)); + + // Find and interact with the auto search bar + final autoSearchBar = find.widgetWithText(TextField, 'Search for autos...'); + await widgetTester.enterText(autoSearchBar, 'Another'); + await widgetTester.pumpAndSettle(const Duration(milliseconds: 300)); + + // Verify auto filtering + expect(find.text('Test Auto 1'), findsNothing); + expect(find.text('Another Auto'), findsOneWidget); + + // Test case-insensitivity + await widgetTester.enterText(autoSearchBar, 'auto'); + await widgetTester.pumpAndSettle(const Duration(milliseconds: 300)); + + // Verify case-insensitive filtering + expect(find.text('Test Auto 1'), findsOneWidget); + expect(find.text('Another Auto'), findsOneWidget); + }); } diff --git a/test/pages/telemetry_page_test.dart b/test/pages/telemetry_page_test.dart index d9f4130e2..20cfa4607 100644 --- a/test/pages/telemetry_page_test.dart +++ b/test/pages/telemetry_page_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -22,9 +24,17 @@ void main() { testWidgets('loading when not connected', (widgetTester) async { var telemetry = MockPPLibTelemetry(); - when(telemetry.isConnected).thenReturn(false); + final connectionStatusController = StreamController(); when(telemetry.connectionStatusStream()) - .thenAnswer((_) => Stream.value(false)); + .thenAnswer((_) => connectionStatusController.stream); + when(telemetry.getServerAddress()).thenReturn('localhost:5811'); + when(telemetry.velocitiesStream()).thenAnswer((_) => const Stream.empty()); + when(telemetry.inaccuracyStream()).thenAnswer((_) => const Stream.empty()); + when(telemetry.currentPoseStream()).thenAnswer((_) => Stream.value(null)); + when(telemetry.targetPoseStream()).thenAnswer((_) => Stream.value(null)); + when(telemetry.currentPathStream()).thenAnswer((_) => Stream.value(null)); + + connectionStatusController.add(false); await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); @@ -37,27 +47,43 @@ void main() { ), ), )); - await widgetTester.pump(); + + await widgetTester.pump(const Duration(seconds: 1)); expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Attempting to connect to robot...'), findsOneWidget); + expect(find.text('Current Server Address: localhost:5811'), findsOneWidget); expect(find.byType(LineChart), findsNothing); + + // Clean up + addTearDown(connectionStatusController.close); }); testWidgets('displays data when connected', (widgetTester) async { var telemetry = MockPPLibTelemetry(); - when(telemetry.isConnected).thenReturn(true); + final connectionStatusController = StreamController(); + final velocitiesController = StreamController>(); + final inaccuracyController = StreamController(); + final currentPoseController = StreamController>(); + final targetPoseController = StreamController>(); + final currentPathController = StreamController>(); + + when(telemetry.getServerAddress()).thenReturn('localhost:5811'); when(telemetry.connectionStatusStream()) - .thenAnswer((_) => Stream.value(true)); + .thenAnswer((_) => connectionStatusController.stream); when(telemetry.velocitiesStream()) - .thenAnswer((_) => Stream.value([0, 0, 0, 0])); - when(telemetry.inaccuracyStream()).thenAnswer((_) => Stream.value(0)); + .thenAnswer((_) => velocitiesController.stream); + when(telemetry.inaccuracyStream()) + .thenAnswer((_) => inaccuracyController.stream); when(telemetry.currentPoseStream()) - .thenAnswer((_) => Stream.value([2.1, 2.1, 20])); + .thenAnswer((_) => currentPoseController.stream); when(telemetry.targetPoseStream()) - .thenAnswer((_) => Stream.value([2, 2, 0])); + .thenAnswer((_) => targetPoseController.stream); when(telemetry.currentPathStream()) - .thenAnswer((_) => Stream.value([1, 5, 2, 4, 3, 5])); + .thenAnswer((_) => currentPathController.stream); + + connectionStatusController.add(true); await widgetTester.binding.setSurfaceSize(const Size(1280, 720)); @@ -70,10 +96,176 @@ void main() { ), ), )); + + // Add some data to the streams + velocitiesController.add([1.0, 2.0, 0.5, 1.5]); + inaccuracyController.add(0.25); + currentPoseController.add([2.1, 2.1, 20]); + targetPoseController.add([2, 2, 0]); + currentPathController.add([1, 5, 2, 4, 3, 5]); + + // Allow time for the streams to emit some values + await widgetTester.pump(const Duration(milliseconds: 100)); await widgetTester.pumpAndSettle(); expect(find.byType(CircularProgressIndicator), findsNothing); - expect(find.byType(LineChart), findsWidgets); + expect(find.byType(LineChart), findsNWidgets(3)); // Expect 3 LineCharts expect(find.byType(InteractiveViewer), findsOneWidget); + expect(find.text('Robot Velocity'), findsOneWidget); + expect(find.text('Angular Velocity'), findsOneWidget); + expect(find.text('Path Inaccuracy'), findsOneWidget); + + // Clean up + addTearDown(() { + connectionStatusController.close(); + velocitiesController.close(); + inaccuracyController.close(); + currentPoseController.close(); + targetPoseController.close(); + currentPathController.close(); + }); + }); + + testWidgets('TelemetryPage handles connection status changes', + (WidgetTester tester) async { + var telemetry = MockPPLibTelemetry(); + + final connectionStatusController = StreamController.broadcast(); + final velocitiesController = StreamController>(); + final inaccuracyController = StreamController(); + final currentPoseController = StreamController?>(); + final targetPoseController = StreamController?>(); + final currentPathController = StreamController?>(); + + when(telemetry.connectionStatusStream()) + .thenAnswer((_) => connectionStatusController.stream); + when(telemetry.velocitiesStream()) + .thenAnswer((_) => velocitiesController.stream); + when(telemetry.inaccuracyStream()) + .thenAnswer((_) => inaccuracyController.stream); + when(telemetry.currentPoseStream()) + .thenAnswer((_) => currentPoseController.stream); + when(telemetry.targetPoseStream()) + .thenAnswer((_) => targetPoseController.stream); + when(telemetry.currentPathStream()) + .thenAnswer((_) => currentPathController.stream); + when(telemetry.getServerAddress()).thenReturn('localhost:5811'); + + await tester.binding.setSurfaceSize(const Size(1280, 720)); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: TelemetryPage( + fieldImage: FieldImage.defaultField, + telemetry: telemetry, + prefs: prefs, + ), + ), + )); + + // Initially disconnected + connectionStatusController.add(false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Attempting to connect to robot...'), findsOneWidget); + + // Connect + connectionStatusController.add(true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(LineChart), findsNWidgets(3)); + + // Disconnect again + connectionStatusController.add(false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Attempting to connect to robot...'), findsOneWidget); + + // Clean up + addTearDown(() { + connectionStatusController.close(); + velocitiesController.close(); + inaccuracyController.close(); + currentPoseController.close(); + targetPoseController.close(); + currentPathController.close(); + }); + }); + + testWidgets('TelemetryPage updates inaccuracy graph', + (WidgetTester tester) async { + var telemetry = MockPPLibTelemetry(); + + final connectionStatusController = StreamController.broadcast(); + final velocitiesController = StreamController>(); + final inaccuracyController = StreamController.broadcast(); + final currentPoseController = StreamController?>(); + final targetPoseController = StreamController?>(); + final currentPathController = StreamController?>(); + + when(telemetry.connectionStatusStream()) + .thenAnswer((_) => connectionStatusController.stream); + when(telemetry.velocitiesStream()) + .thenAnswer((_) => velocitiesController.stream); + when(telemetry.inaccuracyStream()) + .thenAnswer((_) => inaccuracyController.stream); + when(telemetry.currentPoseStream()) + .thenAnswer((_) => currentPoseController.stream); + when(telemetry.targetPoseStream()) + .thenAnswer((_) => targetPoseController.stream); + when(telemetry.currentPathStream()) + .thenAnswer((_) => currentPathController.stream); + when(telemetry.getServerAddress()).thenReturn('localhost:5811'); + + await tester.binding.setSurfaceSize(const Size(1280, 720)); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: TelemetryPage( + fieldImage: FieldImage.defaultField, + telemetry: telemetry, + prefs: prefs, + ), + ), + )); + + // Simulate connected state + connectionStatusController.add(true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Add inaccuracy data + inaccuracyController.add(0.5); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify that the inaccuracy graph is updated + final inaccuracyGraph = find.byWidgetPredicate( + (Widget widget) => + widget is LineChart && + widget.data.lineBarsData.isNotEmpty && + widget.data.lineBarsData.first.spots.isNotEmpty, + ); + expect(inaccuracyGraph, findsOneWidget); + + // Verify the inaccuracy value + final lineChart = tester.widget(inaccuracyGraph); + expect(lineChart.data.lineBarsData.first.spots.last.y, 0.5); + + // Clean up + addTearDown(() { + connectionStatusController.close(); + velocitiesController.close(); + inaccuracyController.close(); + currentPoseController.close(); + targetPoseController.close(); + currentPathController.close(); + }); }); } diff --git a/test/widgets/editor/preview_seekbar_test.dart b/test/widgets/editor/preview_seekbar_test.dart index 3a1c01f2b..559638d8d 100644 --- a/test/widgets/editor/preview_seekbar_test.dart +++ b/test/widgets/editor/preview_seekbar_test.dart @@ -6,37 +6,61 @@ void main() { late AnimationController controller; setUp(() { - controller = AnimationController(vsync: const TestVSync()); - controller.duration = const Duration(milliseconds: 1000); + controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(seconds: 1), + ); }); - testWidgets('play/pause button', (widgetTester) async { - await widgetTester.pumpWidget( + tearDown(() { + controller.dispose(); + }); + + testWidgets('play/pause button', (WidgetTester tester) async { + final AnimationController controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(seconds: 1), + ); + + await tester.pumpWidget( MaterialApp( home: Scaffold( body: PreviewSeekbar( previewController: controller, - totalPathTime: 1.0, + totalPathTime: 10, ), ), ), ); - expect(controller.isAnimating, false); + // Find the play/pause button by its tooltip + final Finder playPauseButton = find.byWidgetPredicate( + (Widget widget) => + widget is IconButton && + (widget.tooltip == 'Play' || widget.tooltip == 'Pause'), + ); - final iconButton = find.byType(IconButton); + expect(playPauseButton, findsOneWidget); - expect(iconButton, findsOneWidget); + // Initially, the button should show the play icon + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + expect(find.byIcon(Icons.pause), findsNothing); - await widgetTester.tap(iconButton); - await widgetTester.pump(); + // Tap the play button + await tester.tap(playPauseButton); + await tester.pump(); - expect(controller.isAnimating, true); + // Now it should show the pause icon + expect(find.byIcon(Icons.pause), findsOneWidget); + expect(find.byIcon(Icons.play_arrow), findsNothing); - await widgetTester.tap(iconButton); - await widgetTester.pump(); + // Tap the pause button + await tester.tap(playPauseButton); + await tester.pump(); - expect(controller.isAnimating, false); + // It should show the play icon again + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + expect(find.byIcon(Icons.pause), findsNothing); }); testWidgets('seek slider', (widgetTester) async { @@ -63,4 +87,46 @@ void main() { expect(controller.isAnimating, false); expect(controller.view.value, closeTo(0.5, 0.01)); }); + + testWidgets('restart button', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PreviewSeekbar( + previewController: controller, + totalPathTime: 10, + ), + ), + ), + ); + + // Find the restart button by its tooltip + final Finder restartButton = find.byWidgetPredicate( + (Widget widget) => widget is IconButton && widget.tooltip == 'Reset', + ); + + expect(restartButton, findsOneWidget); + + // Verify the restart icon + expect(find.byIcon(Icons.replay), findsOneWidget); + + // Set the controller to a non-zero value + controller.value = 0.5; + + // Tap the restart button + await tester.tap(restartButton); + await tester.pump(); + + // Verify that the controller has been reset + expect(controller.value, 0.0); + + // Pump a frame to allow animations to start + await tester.pump(); + + // Verify that the controller is animating + expect(controller.isAnimating, true); + + // Stop the animation to prevent it from running after the test + controller.stop(); + }); } diff --git a/test/widgets/editor/split_choreo_path_editor_test.dart b/test/widgets/editor/split_choreo_path_editor_test.dart index c0b943371..3baf1037e 100644 --- a/test/widgets/editor/split_choreo_path_editor_test.dart +++ b/test/widgets/editor/split_choreo_path_editor_test.dart @@ -36,6 +36,8 @@ void main() { PrefsKeys.treeOnRight: true, PrefsKeys.robotWidth: 1.0, PrefsKeys.robotLength: 1.0, + PrefsKeys.showRobotDetails: true, + PrefsKeys.showGrid: true, }); prefs = await SharedPreferences.getInstance(); }); diff --git a/test/widgets/editor/split_path_editor_test.dart b/test/widgets/editor/split_path_editor_test.dart index 3125b5bc6..4b71ebea9 100644 --- a/test/widgets/editor/split_path_editor_test.dart +++ b/test/widgets/editor/split_path_editor_test.dart @@ -13,6 +13,7 @@ import 'package:pathplanner/path/ideal_starting_state.dart'; import 'package:pathplanner/path/rotation_target.dart'; import 'package:pathplanner/util/path_painter_util.dart'; import 'package:pathplanner/util/prefs.dart'; +import 'package:pathplanner/widgets/editor/info_card.dart'; import 'package:pathplanner/widgets/editor/path_painter.dart'; import 'package:pathplanner/widgets/editor/split_path_editor.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/path_tree.dart'; @@ -60,6 +61,8 @@ void main() { PrefsKeys.treeOnRight: true, PrefsKeys.robotWidth: 1.0, PrefsKeys.robotLength: 1.0, + PrefsKeys.showRobotDetails: true, + PrefsKeys.showGrid: true, }); prefs = await SharedPreferences.getInstance(); }); @@ -69,12 +72,16 @@ void main() { await widgetTester.pumpWidget(MaterialApp( home: Scaffold( - body: SplitPathEditor( - prefs: prefs, - path: path, - fieldImage: FieldImage.defaultField, - undoStack: undoStack, - simulate: true, + body: SizedBox( + width: 1280, + height: 720, + child: SplitPathEditor( + prefs: prefs, + path: path, + fieldImage: FieldImage.defaultField, + undoStack: undoStack, + simulate: true, + ), ), ), )); @@ -537,7 +544,7 @@ void main() { final targetCard = find.descendant( of: find.byType(TreeCardNode), - matching: find.widgetWithText(TreeCardNode, 'Rotation Target at 0.50')); + matching: find.widgetWithText(TreeCardNode, 'Rotation Target 1')); await gesture.moveTo(widgetTester.getCenter(targetCard)); await widgetTester.pump(); @@ -548,7 +555,36 @@ void main() { await widgetTester.tap(targetCard); await widgetTester.pumpAndSettle(); - // nothing to test here, just covering the hover/select code + // Verify that the rotation target is selected + expect(find.byType(NumberTextField), findsNWidgets(2)); + expect(find.text('Rotation (Deg)'), findsOneWidget); + expect(find.text('Position'), findsOneWidget); + + // Verify that at least one slider is present + expect(find.byType(Slider), findsAtLeastNWidgets(1)); + + // Find the specific slider for the rotation target + final rotationTargetSlider = find.descendant( + of: find.ancestor( + of: targetCard, + matching: find.byType(TreeCardNode), + ), + matching: find.byType(Slider), + ); + expect(rotationTargetSlider, findsOneWidget); + + // Verify that the InfoCard is present with the correct information + final infoCard = find.descendant( + of: targetCard, + matching: find.byType(InfoCard), + ); + expect(infoCard, findsOneWidget); + + final infoCardText = find.descendant( + of: infoCard, + matching: find.textContaining('° at'), + ); + expect(infoCardText, findsOneWidget); }); testWidgets('hover/select event marker', (widgetTester) async { diff --git a/test/widgets/editor/tree_widgets/constraint_zones_tree_test.dart b/test/widgets/editor/tree_widgets/constraint_zones_tree_test.dart index fc369c668..23e200ce8 100644 --- a/test/widgets/editor/tree_widgets/constraint_zones_tree_test.dart +++ b/test/widgets/editor/tree_widgets/constraint_zones_tree_test.dart @@ -511,7 +511,8 @@ void main() { ), )); - var newZoneButton = find.text('Add New Zone'); + // Find the add new zone button by its tooltip + var newZoneButton = find.byTooltip('Add New Constraint Zone'); expect(newZoneButton, findsOneWidget); diff --git a/test/widgets/editor/tree_widgets/editor_settings_tree_test.dart b/test/widgets/editor/tree_widgets/editor_settings_tree_test.dart index 32c2e9a49..dec894181 100644 --- a/test/widgets/editor/tree_widgets/editor_settings_tree_test.dart +++ b/test/widgets/editor/tree_widgets/editor_settings_tree_test.dart @@ -15,7 +15,7 @@ void main() { prefs = await SharedPreferences.getInstance(); }); - testWidgets('snap to guidelines check', (widgetTester) async { + testWidgets('Editor Settings Tree checks', (widgetTester) async { await widgetTester.pumpWidget(const MaterialApp( home: Scaffold( body: EditorSettingsTree( @@ -25,81 +25,44 @@ void main() { )); await widgetTester.pump(); - final snapRow = find.widgetWithText(Row, 'Snap To Guidelines'); + // List of all settings to test + final settings = [ + ('Snap To Guidelines', PrefsKeys.snapToGuidelines), + ('Hide Other Paths on Hover', PrefsKeys.hidePathsOnHover), + ('Show Trajectory States', PrefsKeys.showStates), + ('Show Robot Details', PrefsKeys.showRobotDetails), + ('Show Grid', PrefsKeys.showGrid), + ]; - expect(snapRow, findsOneWidget); + for (final setting in settings) { + final label = setting.$1; + final prefKey = setting.$2; - final snapCheck = - find.descendant(of: snapRow, matching: find.byType(Checkbox)); + final row = find + .ancestor( + of: find.text(label), + matching: find.byType(Row), + ) + .first; - expect(snapCheck, findsOneWidget); + expect(row, findsOneWidget); - await widgetTester.tap(snapCheck); - await widgetTester.pumpAndSettle(); + final check = find.descendant( + of: row, + matching: find.byType(Checkbox), + ); - expect(prefs.getBool(PrefsKeys.snapToGuidelines), true); + expect(check, findsOneWidget); - await widgetTester.tap(snapCheck); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(check); + await widgetTester.pumpAndSettle(); - expect(prefs.getBool(PrefsKeys.snapToGuidelines), false); - }); - - testWidgets('hide paths check', (widgetTester) async { - await widgetTester.pumpWidget(const MaterialApp( - home: Scaffold( - body: EditorSettingsTree( - initiallyExpanded: true, - ), - ), - )); - await widgetTester.pump(); - - final row = find.widgetWithText(Row, 'Hide Other Paths on Hover'); - - expect(row, findsOneWidget); - - final check = find.descendant(of: row, matching: find.byType(Checkbox)); - - expect(check, findsOneWidget); - - await widgetTester.tap(check); - await widgetTester.pumpAndSettle(); - - expect(prefs.getBool(PrefsKeys.hidePathsOnHover), true); - - await widgetTester.tap(check); - await widgetTester.pumpAndSettle(); - - expect(prefs.getBool(PrefsKeys.hidePathsOnHover), false); - }); - - testWidgets('show states check', (widgetTester) async { - await widgetTester.pumpWidget(const MaterialApp( - home: Scaffold( - body: EditorSettingsTree( - initiallyExpanded: true, - ), - ), - )); - await widgetTester.pump(); - - final row = find.widgetWithText(Row, 'Show Trajectory States'); - - expect(row, findsOneWidget); - - final check = find.descendant(of: row, matching: find.byType(Checkbox)); - - expect(check, findsOneWidget); - - await widgetTester.tap(check); - await widgetTester.pumpAndSettle(); - - expect(prefs.getBool(PrefsKeys.showStates), true); + expect(prefs.getBool(prefKey), true); - await widgetTester.tap(check); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(check); + await widgetTester.pumpAndSettle(); - expect(prefs.getBool(PrefsKeys.showStates), false); + expect(prefs.getBool(prefKey), false); + } }); } diff --git a/test/widgets/editor/tree_widgets/event_markers_tree_test.dart b/test/widgets/editor/tree_widgets/event_markers_tree_test.dart index 11a1443a1..79c5f2068 100644 --- a/test/widgets/editor/tree_widgets/event_markers_tree_test.dart +++ b/test/widgets/editor/tree_widgets/event_markers_tree_test.dart @@ -8,6 +8,7 @@ import 'package:pathplanner/path/pathplanner_path.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/commands/command_group_widget.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/event_markers_tree.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/tree_card_node.dart'; +import 'package:pathplanner/widgets/number_text_field.dart'; import 'package:pathplanner/widgets/renamable_title.dart'; import 'package:undo/undo.dart'; @@ -53,7 +54,6 @@ void main() { ), )); - // Tree initially collapsed, expect to find nothing expect( find.descendant( of: find.byType(TreeCardNode), matching: find.byType(TreeCardNode)), @@ -64,8 +64,7 @@ void main() { expect(path.eventMarkersExpanded, true); - await widgetTester.tap(find.text( - 'Event Markers')); // Use text so it doesn't tap middle of expanded card + await widgetTester.tap(find.text('Event Markers')); await widgetTester.pumpAndSettle(); expect(path.eventMarkersExpanded, false); }); @@ -200,11 +199,11 @@ void main() { expect(slider, findsOneWidget); - await widgetTester.tap(slider); // will tap the center + await widgetTester.tap(slider); await widgetTester.pump(); expect(pathChanged, true); - expect(path.eventMarkers[0].waypointRelativePos, 0.5); + expect(path.eventMarkers[0].waypointRelativePos, closeTo(0.5, 0.01)); undoStack.undo(); await widgetTester.pump(); @@ -235,7 +234,7 @@ void main() { await widgetTester.tap(typeDropdown); await widgetTester.pumpAndSettle(); - await widgetTester.tap(find.text('Deadline Group')); + await widgetTester.tap(find.text('Deadline Group').last); await widgetTester.pumpAndSettle(); expect(pathChanged, true); @@ -261,7 +260,7 @@ void main() { ), )); - var deleteButtons = find.byTooltip('Delete Marker'); + var deleteButtons = find.byIcon(Icons.delete_forever); expect(deleteButtons, findsNWidgets(2)); @@ -291,7 +290,11 @@ void main() { ), )); - var newMarkerButton = find.text('Add New Marker'); + // Find the specific add button for event markers + var newMarkerButton = find.descendant( + of: find.byType(EventMarkersTree), + matching: find.byIcon(Icons.add).first, + ); expect(newMarkerButton, findsOneWidget); @@ -305,4 +308,43 @@ void main() { await widgetTester.pump(); expect(path.eventMarkers.length, 2); }); + + testWidgets('position text field input', (widgetTester) async { + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: EventMarkersTree( + path: path, + onPathChangedNoSim: () => pathChanged = true, + onMarkerHovered: (value) => hoveredMarker = value, + onMarkerSelected: (value) => selectedMarker = value, + undoStack: undoStack, + initiallySelectedMarker: 0, + ), + ), + )); + + var numberTextField = find.byType(NumberTextField); + expect(numberTextField, findsOneWidget); + + // Verify initial value + expect(path.eventMarkers[0].waypointRelativePos, 0.2); + + // Simulate entering text + await widgetTester.enterText(numberTextField, '0.5'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + // Verify that the path changed and the new value is correct + expect(pathChanged, true); + expect(path.eventMarkers[0].waypointRelativePos, 0.5); + + // Simulate entering another value + await widgetTester.enterText(numberTextField, '0.7'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + // Verify that the path changed again and the new value is correct + expect(pathChanged, true); + expect(path.eventMarkers[0].waypointRelativePos, 0.7); + }); } diff --git a/test/widgets/editor/tree_widgets/goal_end_state_tree_test.dart b/test/widgets/editor/tree_widgets/goal_end_state_tree_test.dart index d7b56dc6b..7236fd284 100644 --- a/test/widgets/editor/tree_widgets/goal_end_state_tree_test.dart +++ b/test/widgets/editor/tree_widgets/goal_end_state_tree_test.dart @@ -42,15 +42,19 @@ void main() { // Tree initially collapsed, expect to find nothing expect(find.byType(NumberTextField), findsNothing); + // Find and tap the GoalEndStateTree widget await widgetTester.tap(find.byType(GoalEndStateTree)); await widgetTester.pumpAndSettle(); expect(path.goalEndStateExpanded, true); + expect(find.byType(NumberTextField), findsWidgets); - await widgetTester.tap(find.text( - 'Goal End State')); // Use text so it doesn't tap middle of expanded card + // Tap the title to collapse + await widgetTester.tap(find.text('Final State')); await widgetTester.pumpAndSettle(); + expect(path.goalEndStateExpanded, false); + expect(find.byType(NumberTextField), findsNothing); }); testWidgets('vel text field', (widgetTester) async { diff --git a/test/widgets/editor/tree_widgets/ideal_starting_state_tree_test.dart b/test/widgets/editor/tree_widgets/ideal_starting_state_tree_test.dart index cc95f37b1..f8fdaa407 100644 --- a/test/widgets/editor/tree_widgets/ideal_starting_state_tree_test.dart +++ b/test/widgets/editor/tree_widgets/ideal_starting_state_tree_test.dart @@ -45,7 +45,7 @@ void main() { expect(find.byType(NumberTextField), findsWidgets); await widgetTester.tap(find.text( - 'Ideal Starting State')); // Use text so it doesn't tap middle of expanded card + 'Starting State')); // Use text so it doesn't tap middle of expanded card await widgetTester.pumpAndSettle(); expect(find.byType(NumberTextField), findsNothing); }); diff --git a/test/widgets/editor/tree_widgets/path_optimization_tree_test.dart b/test/widgets/editor/tree_widgets/path_optimization_tree_test.dart index a0f60a394..2ca51cfe2 100644 --- a/test/widgets/editor/tree_widgets/path_optimization_tree_test.dart +++ b/test/widgets/editor/tree_widgets/path_optimization_tree_test.dart @@ -24,7 +24,7 @@ void main() { }); testWidgets('tapping expands/collapses tree', (widgetTester) async { - path.waypointsExpanded = false; + path.pathOptimizationExpanded = false; await widgetTester.pumpWidget(MaterialApp( home: Scaffold( body: PathOptimizationTree( @@ -35,25 +35,20 @@ void main() { ), )); - // Tree initially collapsed, expect to find nothing - expect( - find.descendant( - of: find.byType(TreeCardNode), matching: find.byType(TreeCardNode)), - findsNothing); + expect(find.byType(TreeCardNode), findsOneWidget); + expect(find.text('Path Optimizer'), findsOneWidget); + expect(find.byIcon(Icons.query_stats), findsOneWidget); - await widgetTester.tap(find.byType(PathOptimizationTree)); + await widgetTester.tap(find.byType(TreeCardNode)); await widgetTester.pumpAndSettle(); expect(path.pathOptimizationExpanded, true); - await widgetTester.tap(find.text( - 'Path Optimizer')); // Use text so it doesn't tap middle of expanded card + await widgetTester.tap(find.text('Path Optimizer')); await widgetTester.pumpAndSettle(); expect(path.pathOptimizationExpanded, false); }); - testWidgets('widget builds', (widgetTester) async { - // Basic test to make sure the widget builds. Can't really - // test the buttons since we can't wait for the isolate from this test + testWidgets('widget builds and displays correctly', (widgetTester) async { path.pathOptimizationExpanded = true; await widgetTester.pumpWidget(MaterialApp( home: Scaffold( @@ -66,11 +61,15 @@ void main() { )); await widgetTester.pumpAndSettle(); - // For some reason flutter thinks elevated button w/ icon is not an elevated button - expect(find.text('Optimize'), findsOne); - expect(find.text('Discard'), findsOne); - expect(find.text('Accept'), findsOne); - - expect(find.byType(LinearProgressIndicator), findsOne); + expect(find.text('Path Optimizer'), findsOneWidget); + expect(find.byIcon(Icons.query_stats), findsOneWidget); + expect(find.text('Optimized Runtime: 0.00s'), findsOneWidget); + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + expect(find.text('Optimize'), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.text('Discard'), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + expect(find.text('Accept'), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); }); } diff --git a/test/widgets/editor/tree_widgets/path_tree_test.dart b/test/widgets/editor/tree_widgets/path_tree_test.dart index 887d74fd9..51b675d72 100644 --- a/test/widgets/editor/tree_widgets/path_tree_test.dart +++ b/test/widgets/editor/tree_widgets/path_tree_test.dart @@ -12,6 +12,7 @@ import 'package:pathplanner/widgets/editor/tree_widgets/path_optimization_tree.d import 'package:pathplanner/widgets/editor/tree_widgets/path_tree.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/rotation_targets_tree.dart'; import 'package:pathplanner/widgets/editor/tree_widgets/waypoints_tree.dart'; +import 'package:pathplanner/widgets/editor/runtime_display.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:undo/undo.dart'; @@ -34,7 +35,11 @@ void main() { prefs = await SharedPreferences.getInstance(); }); - testWidgets('has simulated driving time', (widgetTester) async { + testWidgets('has runtime display', (widgetTester) async { + // Set up a mock SharedPreferences instance + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await widgetTester.pumpWidget(MaterialApp( home: Scaffold( body: PathTree( @@ -42,12 +47,16 @@ void main() { undoStack: ChangeStack(), holonomicMode: true, defaultConstraints: PathConstraints(), - prefs: prefs, + runtimeDisplay: const RuntimeDisplay( + currentRuntime: 5.0, + previousRuntime: null, + ), + prefs: prefs, // Add the required prefs parameter ), ), )); - expect(find.textContaining('Simulated Driving Time'), findsOneWidget); + expect(find.byType(RuntimeDisplay), findsOneWidget); }); testWidgets('swap side button', (widgetTester) async { @@ -185,7 +194,23 @@ void main() { expect(find.byType(PathOptimizationTree), findsOneWidget); }); - testWidgets('Reversed checkbox', (widgetTester) async { + testWidgets('has optimizer tree', (widgetTester) async { + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: PathTree( + path: path, + undoStack: ChangeStack(), + holonomicMode: true, + defaultConstraints: PathConstraints(), + prefs: prefs, + ), + ), + )); + + expect(find.byType(PathOptimizationTree), findsOneWidget); + }); + + testWidgets('Reversed button', (widgetTester) async { final ChangeStack undoStack = ChangeStack(); await widgetTester.pumpWidget(MaterialApp( @@ -200,11 +225,11 @@ void main() { ), )); - final check = find.byType(Checkbox); + final reversedButton = find.byTooltip('Reverse Path'); - expect(check, findsOneWidget); + expect(reversedButton, findsOneWidget); - await widgetTester.tap(check); + await widgetTester.tap(reversedButton); await widgetTester.pump(); expect(path.reversed, true); diff --git a/test/widgets/editor/tree_widgets/rotation_targets_tree_test.dart b/test/widgets/editor/tree_widgets/rotation_targets_tree_test.dart index 08bcf9823..4d29df2db 100644 --- a/test/widgets/editor/tree_widgets/rotation_targets_tree_test.dart +++ b/test/widgets/editor/tree_widgets/rotation_targets_tree_test.dart @@ -101,8 +101,7 @@ void main() { ), )); - expect(find.text('Rotation Target at 0.20'), findsOneWidget); - expect(find.text('Rotation Target at 0.70'), findsOneWidget); + expect(find.text('Rotation Target 1'), findsOneWidget); }); testWidgets('Target card hover', (widgetTester) async { @@ -273,11 +272,21 @@ void main() { ), )); - var newTargetButton = find.text('Add New Rotation Target'); + final addIcon = find.byIcon(Icons.add); + expect(addIcon, findsOneWidget); - expect(newTargetButton, findsOneWidget); + // Find the parent IconButton of the Icon + final addButton = find.ancestor( + of: addIcon, + matching: find.byType(IconButton), + ); + expect(addButton, findsOneWidget); + + // Check the tooltip of the IconButton + expect((widgetTester.widget(addButton) as IconButton).tooltip, + 'Add New Rotation Target'); - await widgetTester.tap(newTargetButton); + await widgetTester.tap(addButton); await widgetTester.pump(); expect(pathChanged, true); @@ -285,6 +294,50 @@ void main() { undoStack.undo(); await widgetTester.pump(); + expect(path.rotationTargets.length, 2); }); + + testWidgets('position text field input', (widgetTester) async { + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: RotationTargetsTree( + path: path, + onPathChangedNoSim: () => pathChanged = true, + onTargetHovered: (value) => hoveredTarget = value, + onTargetSelected: (value) => selectedTarget = value, + undoStack: undoStack, + initiallySelectedTarget: 0, + ), + ), + )); + + var numberTextField = + find.byType(NumberTextField).last; // Get the position text field + expect(numberTextField, findsOneWidget); + + // Verify initial value + expect(path.rotationTargets[0].waypointRelativePos, 0.2); + + // Simulate entering text + await widgetTester.enterText(numberTextField, '0.5'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + // Verify that the path changed and the new value is correct + expect(pathChanged, true); + expect(path.rotationTargets[0].waypointRelativePos, 0.5); + + // Reset pathChanged flag + pathChanged = false; + + // Simulate entering another value + await widgetTester.enterText(numberTextField, '0.7'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + // Verify that the path changed again and the new value is correct + expect(pathChanged, true); + expect(path.rotationTargets[0].waypointRelativePos, 0.7); + }); } diff --git a/test/widgets/editor/tree_widgets/runtime_display_test.dart b/test/widgets/editor/tree_widgets/runtime_display_test.dart new file mode 100644 index 000000000..47ba6a3f0 --- /dev/null +++ b/test/widgets/editor/tree_widgets/runtime_display_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pathplanner/widgets/editor/runtime_display.dart'; + +void main() { + testWidgets('RuntimeDisplay shows current runtime', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RuntimeDisplay( + currentRuntime: 5.0, + previousRuntime: null, + ), + ), + ), + ); + + expect(find.text('~5.00s'), findsOneWidget); + }); + + testWidgets('RuntimeDisplay shows runtime decrease', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RuntimeDisplay( + currentRuntime: 4.5, + previousRuntime: 5.0, + ), + ), + ), + ); + + expect(find.text('~4.50s'), findsOneWidget); + expect(find.text('(-0.50s)'), findsOneWidget); + expect(find.byIcon(Icons.arrow_downward), findsOneWidget); + }); + + testWidgets('RuntimeDisplay shows runtime increase', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RuntimeDisplay( + currentRuntime: 5.5, + previousRuntime: 5.0, + ), + ), + ), + ); + + expect(find.text('~5.50s'), findsOneWidget); + expect(find.text('(+0.50s)'), findsOneWidget); + expect(find.byIcon(Icons.arrow_upward), findsOneWidget); + }); + + testWidgets('RuntimeDisplay shows no significant change', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RuntimeDisplay( + currentRuntime: 5.03, + previousRuntime: 5.0, + ), + ), + ), + ); + + expect(find.text('~5.03s'), findsOneWidget); + expect(find.text('(+0.03s)'), findsNothing); + expect(find.byIcon(Icons.arrow_upward), findsNothing); + }); +} diff --git a/test/widgets/editor/tree_widgets/waypoints_tree_test.dart b/test/widgets/editor/tree_widgets/waypoints_tree_test.dart index 483704d2a..30434294c 100644 --- a/test/widgets/editor/tree_widgets/waypoints_tree_test.dart +++ b/test/widgets/editor/tree_widgets/waypoints_tree_test.dart @@ -398,7 +398,11 @@ void main() { ), )); - var insertButton = find.text('New Waypoint After'); + // Expand the waypoint card first + await widgetTester.tap(find.byType(TreeCardNode).first); + await widgetTester.pumpAndSettle(); + + var insertButton = find.byIcon(Icons.add); expect(insertButton, findsOneWidget); @@ -431,7 +435,11 @@ void main() { ), )); - var button = find.text('Add Rotation Target'); + // Expand the waypoint card first + await widgetTester.tap(find.byType(TreeCardNode).at(1)); + await widgetTester.pumpAndSettle(); + + var button = find.byIcon(Icons.rotate_right_rounded); expect(button, findsOneWidget); @@ -466,11 +474,12 @@ void main() { ), )); - var button = find.text('Link Waypoint'); + // Find the link icon button + var linkButton = find.byIcon(Icons.add_link_rounded); - expect(button, findsOneWidget); + expect(linkButton, findsOneWidget); - await widgetTester.tap(button); + await widgetTester.tap(linkButton); await widgetTester.pumpAndSettle(); final cancelButton = find.text('Cancel'); @@ -481,7 +490,7 @@ void main() { expect(find.byType(AlertDialog), findsNothing); - await widgetTester.tap(button); + await widgetTester.tap(linkButton); await widgetTester.pumpAndSettle(); final dropdown = @@ -508,13 +517,13 @@ void main() { expect(pathChanged, true); expect(path.waypoints[1].linkedName, 'new link'); - final unlinkButton = find.text('Unlink'); + final unlinkButton = find.byIcon(Icons.link_off); await widgetTester.tap(unlinkButton); await widgetTester.pumpAndSettle(); expect(path.waypoints[1].linkedName, null); - await widgetTester.tap(button); + await widgetTester.tap(linkButton); await widgetTester.pumpAndSettle(); await widgetTester.tap(dropdown); @@ -546,8 +555,8 @@ void main() { expect(path.waypoints[1].linkedName, null); }); - testWidgets('Lock waypoint button', (widgetTester) async { - await widgetTester.pumpWidget(MaterialApp( + testWidgets('Lock waypoint button', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( home: Scaffold( body: WaypointsTree( path: path, @@ -561,20 +570,21 @@ void main() { ), )); - var lockButtons = find.byTooltip('Lock'); + final Finder lockButton = find.byType(IconButton).first; - expect(lockButtons, findsNWidgets(2)); + await tester.ensureVisible(lockButton); + await tester.pumpAndSettle(); - await widgetTester.tap(lockButtons.at(1)); - await widgetTester.pump(); + await tester.tap(lockButton); + await tester.pumpAndSettle(); expect(pathChanged, true); - expect(path.waypoints[1].isLocked, true); + expect(path.waypoints[0].isLocked, true); - await widgetTester.tap(lockButtons.at(1)); - await widgetTester.pump(); + await tester.tap(lockButton); + await tester.pumpAndSettle(); - expect(path.waypoints[1].isLocked, false); + expect(path.waypoints[0].isLocked, false); }); testWidgets('Delete waypoint button', (widgetTester) async {